优化: UI/UX遗留批(审计/AI命令层/前端组件,会话前基线收尾)

- AuditLog +298(筛选/详情/i18n)+ audit 后端 record/mod

- AI 命令层:generate_image +81 / fetch_url / fetch_search / skills / tool_registry / tools/file / provider / conversation

- 前端组件:AiChat/TopBar/ConversationSidebar/GitChanges/ApprovalPopup/Dashboard/ProjectDetail 等 30+ + composables + i18n

- 诊断文档: aichat历史会话实证诊断-2026-08-04 + project_soft_delete 测试
This commit is contained in:
lxy
2026-08-05 22:15:34 +08:00
parent 28de5d6143
commit e68d283651
49 changed files with 1257 additions and 138 deletions
+3 -3
View File
@@ -513,7 +513,7 @@ function onDragEnd() { isDragging.value = false }
border: 0.5px solid var(--df-accent);
border-left: 3px solid var(--df-accent);
border-radius: var(--df-radius-md);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(123, 111, 240, 0.2);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 0 1px var(--df-accent-soft);
overflow: hidden;
user-select: none;
}
@@ -554,7 +554,7 @@ function onDragEnd() { isDragging.value = false }
background: var(--df-danger);
color: #fff;
font-size: 9px;
font-weight: 700;
font-weight: 500;
line-height: 16px;
text-align: center;
border: 1.5px solid var(--df-bg-card);
@@ -730,7 +730,7 @@ function onDragEnd() { isDragging.value = false }
border-radius: var(--df-radius-xs, 3px);
font-family: var(--df-font-mono);
font-size: 9px;
font-weight: 700;
font-weight: 500;
line-height: 1;
background: rgba(0, 0, 0, 0.18);
opacity: 0.7;
+295 -3
View File
@@ -3,14 +3,28 @@
<header class="page-header">
<h1>{{ t('auditLog.title') }}</h1>
<div class="header-actions">
<button class="btn btn-ghost" @click="reload" :disabled="loading">
<button class="btn btn-ghost" @click="reloadAudit" :disabled="loading" v-if="activeTab === 'audit'">
<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>
<!-- Tab 导航 -->
<nav class="detail-tabs audit-tabs">
<button class="tab-btn" :class="{ 'tab-active': activeTab === 'audit' }" type="button" @click="switchTab('audit')">
<svg 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="tab-btn" :class="{ 'tab-active': activeTab === 'conversations' }" type="button" @click="switchTab('conversations')">
<svg 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">
@@ -95,6 +109,62 @@
<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 }" @click="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">{{ msg.role === 'user' ? '🧑 用户' : msg.role === 'assistant' ? '🤖 助手' : msg.role === 'tool' ? '🔧 工具' : '⚙️ 系统' }}</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>
@@ -104,6 +174,8 @@ 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 type { AiConversationSummary } from '@/api/types'
const { t } = useI18n()
@@ -228,7 +300,78 @@ async function load() {
}
}
function reload() {
// ── 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
}
}
async function toggleConv(id: string) {
if (expandedConv.value === id) {
expandedConv.value = null
convMessages.value = []
return
}
expandedConv.value = id
convLoading.value = true
convError.value = ''
try {
const json = await aiApi.exportConversation(id, 'json')
const parsed = JSON.parse(json) as ConvMessage[] ?? []
convMessages.value = parsed || []
} catch (e) {
convError.value = String(e)
convMessages.value = []
} finally {
convLoading.value = false
}
}
function reloadAudit() {
if (loading.value) return
offset.value = 0
expandedId.value = null
@@ -278,6 +421,7 @@ function decidedClass(d: string): string {
onMounted(() => {
void load()
if (activeTab.value === 'conversations') void loadConversations()
})
</script>
@@ -476,4 +620,152 @@ onMounted(() => {
}
/* empty-state 已提取到 global.css 全局 */
/* ===== Tab 导航(对齐 ProjectDetail 同款交互) ===== */
.audit-tabs {
display: flex;
gap: 4px;
border-bottom: 0.5px solid var(--df-border);
margin-bottom: 16px;
}
.audit-tabs .tab-btn {
padding: 8px 16px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--df-text-dim);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
margin-bottom: -0.5px;
}
.audit-tabs .tab-btn:hover { color: var(--df-text); }
.audit-tabs .tab-btn.tab-active {
color: var(--df-text);
border-bottom-color: var(--df-accent);
}
/* ===== 对话历史 ===== */
.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: 1px 6px;
border-radius: var(--df-radius-xs);
background: rgba(90,99,128,0.2); 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 rgba(240,199,94,0.2);
}
.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>
+1 -9
View File
@@ -11,10 +11,6 @@
<svg v-if="loading" class="dash-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>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
</button>
<button class="btn btn-primary btn-sm" @click="quickCapture">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
{{ $t('dashboard.captureIdea') }}
</button>
</div>
</header>
@@ -58,7 +54,7 @@
</template>
<script setup lang="ts">
// 仪表盘页壳 — 仅保留:Header(refresh/quickCapture) + 错误条 + 布局栅格 + 加载编排(loadAll) +
// 仪表盘页壳 — 仅保留:Header(refresh) + 错误条 + 布局栅格 + 加载编排(loadAll) +
// 底部统计概要行(原独立统计卡 StatCardRow 已下沉为底部一行小字,空间让给项目/灵感面板)。
// 项目面板/灵感面板已抽至 components/dashboard/ 子组件,各自共享 project store(全局单例),无需 props。
import { onMounted, ref } from 'vue'
@@ -101,10 +97,6 @@ async function refresh() {
await loadAll()
}
function quickCapture() {
router.push('/ideas')
}
// 底部统计概要项点击跳转(可点击项:ideas/projects/tasks;drafts 不可点)
function go(link: string) {
router.push(link)
+1 -1
View File
@@ -485,7 +485,7 @@ async function runImport() {
}
.view-toggle-btn:hover { background: var(--df-bg-card); color: var(--df-text); }
.view-toggle-btn.active {
background: rgba(108, 99, 255, 0.12);
background: var(--df-accent-soft);
color: var(--df-accent);
}