重构: 跨模块样式DRY收口+审批列表增强+灵感模块优化
- 全局样式收口: page-header/btn/modal/filter-bar/back-link/empty-state 等通用类从12个视图的scoped重复定义提取到global.css/components.css - 审批列表增强: 筛选栏(状态/风险/工具搜索)+行展开查看完整参数结果 +审批耗时显示+刷新spinner+硬编码颜色全部改用CSS变量 - 灵感模块: scoreTier统一评分阈值+score-bar/assessment-badge提取全局 +statusLabelKey消除重复+i18n状态文案对齐+eval-report--muted统一空态 - Dashboard: df-panel/df-link提取全局(修scoped不渗透)+统计卡可点击跳转 +活跃项目面板只显示active状态+灵感列表按分数排序+刷新spinner - 修复: intent.rs tool_type类型对齐(newtype)+ToolCardList补ai-btn定义 +SkillMention/ImageInput样式不渗透修复(提取全局)
This commit is contained in:
+171
-51
@@ -3,16 +3,36 @@
|
||||
<header class="page-header">
|
||||
<h1>{{ t('auditLog.title') }}</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-ghost" @click="reload">{{ t('auditLog.refresh') }}</button>
|
||||
<button class="btn btn-ghost" @click="reload" :disabled="loading">
|
||||
<svg 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>
|
||||
|
||||
<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">{{ t('auditLog.loading') }}</div>
|
||||
<div v-else-if="errorMsg" class="empty-state">{{ errorMsg }}</div>
|
||||
<div v-else-if="records.length === 0" class="empty-state">{{ t('auditLog.empty') }}</div>
|
||||
<div v-if="loading" class="empty-state audit-loading">
|
||||
<svg 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">
|
||||
@@ -29,40 +49,57 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in records" :key="r.id">
|
||||
<td class="col-time">
|
||||
<div class="time-rel">{{ formatRelative(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>
|
||||
<template v-for="r in filteredRecords" :key="r.id">
|
||||
<tr class="audit-row" :class="{ expanded: expandedId === r.id }" @click="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"><code 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="detail-label">{{ t('auditLog.col.args') }}</span>
|
||||
<pre class="detail-pre">{{ r.arguments_brief || '—' }}</pre>
|
||||
</div>
|
||||
<div class="detail-section">
|
||||
<span class="detail-label">{{ t('auditLog.col.result') }}</span>
|
||||
<pre 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" :disabled="offset === 0" @click="prevPage">{{ t('auditLog.pager.prev') }}</button>
|
||||
<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>
|
||||
<button class="btn btn-ghost" :disabled="!hasMore" @click="nextPage">{{ t('auditLog.pager.next') }}</button>
|
||||
<button class="btn btn-ghost btn-sm" :disabled="!hasMore" @click="nextPage">{{ t('auditLog.pager.next') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { formatRelative, formatDate } from '@/utils/time'
|
||||
@@ -91,10 +128,50 @@ const offset = ref(0)
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
// 筛选状态(客户端过滤,数据量轻量)
|
||||
const statusFilter = ref('')
|
||||
const riskFilter = ref('')
|
||||
const toolSearch = ref('')
|
||||
|
||||
// 展开行(查看完整 args/result)
|
||||
const expandedId = ref<string | null>(null)
|
||||
|
||||
const statusKeys = ['pending', 'approved', 'rejected', 'executing', 'completed', 'failed']
|
||||
const riskKeys = ['low', 'medium', 'high']
|
||||
|
||||
const page = computed(() => Math.floor(offset.value / PAGE_SIZE) + 1)
|
||||
// 下一页存在性:本页满 PAGE_SIZE 视为可能还有更多(hasMore);末页不足时点下一页会拉空,自动回退
|
||||
const hasMore = computed(() => records.value.length === PAGE_SIZE)
|
||||
|
||||
// 客户端筛选(状态/风险/工具名)
|
||||
// 后端 list_tool_executions 不支持 WHERE 筛选,数据量小(每页 50),前端 filter 轻量。
|
||||
const filteredRecords = computed(() => {
|
||||
let list = records.value
|
||||
if (statusFilter.value) list = list.filter(r => r.status === statusFilter.value)
|
||||
if (riskFilter.value) list = list.filter(r => r.risk_level === riskFilter.value)
|
||||
const q = toolSearch.value.trim().toLowerCase()
|
||||
if (q) list = list.filter(r => r.tool_name.toLowerCase().includes(q))
|
||||
return list
|
||||
})
|
||||
|
||||
// 筛选变化时关闭展开行(避免展开行被筛掉后残留)
|
||||
watch([statusFilter, riskFilter, toolSearch], () => { expandedId.value = null })
|
||||
|
||||
function toggleExpand(id: string) {
|
||||
expandedId.value = expandedId.value === id ? null : id
|
||||
}
|
||||
|
||||
// 审批→执行 耗时(秒级,毫秒差太精细)
|
||||
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`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
@@ -117,16 +194,20 @@ async function load() {
|
||||
}
|
||||
|
||||
function reload() {
|
||||
if (loading.value) return
|
||||
offset.value = 0
|
||||
expandedId.value = null
|
||||
void load()
|
||||
}
|
||||
function prevPage() {
|
||||
if (offset.value === 0) return
|
||||
offset.value = Math.max(0, offset.value - PAGE_SIZE)
|
||||
expandedId.value = null
|
||||
void load()
|
||||
}
|
||||
function nextPage() {
|
||||
offset.value += PAGE_SIZE
|
||||
expandedId.value = null
|
||||
void load()
|
||||
}
|
||||
|
||||
@@ -169,14 +250,7 @@ onMounted(() => {
|
||||
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-header / btn 等已提取到 global.css 全局 */
|
||||
|
||||
.page-desc {
|
||||
font-size: 12px;
|
||||
@@ -184,6 +258,30 @@ onMounted(() => {
|
||||
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); }
|
||||
.audit-spinner { animation: audit-spin 0.9s linear infinite; }
|
||||
.audit-spinner--lg { color: var(--df-text-dim); }
|
||||
@keyframes audit-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ===== 表格 ===== */
|
||||
.audit-table-wrap {
|
||||
background: var(--df-bg-card);
|
||||
@@ -215,7 +313,9 @@ onMounted(() => {
|
||||
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)); }
|
||||
.audit-table tbody tr:hover td { background: var(--df-bg-hover, rgba(255,255,255,0.03)); }
|
||||
/* 展开/hover 行高亮由 .audit-row:hover/.expanded 管理,避免选择器冲突 */
|
||||
.audit-table tbody tr.audit-row:hover td { background: var(--df-bg-hover, rgba(255,255,255,0.03)); }
|
||||
|
||||
.col-time { min-width: 120px; }
|
||||
.col-tool { min-width: 130px; }
|
||||
@@ -225,14 +325,39 @@ onMounted(() => {
|
||||
.col-args { min-width: 200px; }
|
||||
.col-result { min-width: 220px; }
|
||||
|
||||
/* 可点击行 */
|
||||
.audit-row { cursor: pointer; transition: background 0.12s; }
|
||||
.audit-row:hover td { background: var(--df-bg-hover, rgba(255,255,255,0.03)); }
|
||||
.audit-row.expanded td { background: var(--df-bg-hover, rgba(255,255,255,0.04)); }
|
||||
|
||||
/* 展开详情行 */
|
||||
.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; }
|
||||
.detail-label { font-size: 11px; color: var(--df-text-dim); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.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, rgba(123,111,240,0.08));
|
||||
background: var(--df-accent-bg);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
@@ -254,8 +379,8 @@ onMounted(() => {
|
||||
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); }
|
||||
.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 {
|
||||
@@ -266,12 +391,12 @@ onMounted(() => {
|
||||
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); }
|
||||
.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); }
|
||||
|
||||
/* 决策者 tag */
|
||||
.decided-tag {
|
||||
@@ -281,7 +406,7 @@ onMounted(() => {
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.decided-human { background: rgba(123,111,240,0.2); color: var(--df-accent); }
|
||||
.decided-human { background: var(--df-accent-bg); 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); }
|
||||
|
||||
@@ -299,10 +424,5 @@ onMounted(() => {
|
||||
font-family: var(--df-font-mono);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
font-size: 14px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
/* empty-state 已提取到 global.css 全局 */
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user