Browse Source
- Create AgentDocumentDrawer.vue: right-side drawer with document list + detail view - AgentSidebar: change document NuxtLink to button emitting showDocuments - AgentToolbar: change document NuxtLink to button emitting showDocuments - AgentChatArea: forward showDocuments event from toolbar to page - Chat page: wire showDocumentDrawer state + render AgentDocumentDrawer Drawer features: - List view with type filter (all/document/memory) + search - Detail view with markdown rendering via AgentMarkdown - Slide-in transition + overlay backdrop - Teleport to body for proper z-index stacking Co-authored-by: CodeFree <codefree@chinatelcom.cn>feat/agent-independence
5 changed files with 527 additions and 7 deletions
@ -0,0 +1,508 @@ |
|||
<script setup lang="ts"> |
|||
import { request } from '~/utils/http/factory' |
|||
|
|||
const props = defineProps<{ |
|||
show: boolean |
|||
}>() |
|||
|
|||
const emit = defineEmits<{ |
|||
close: [] |
|||
}>() |
|||
|
|||
const { $toast } = useNuxtApp() |
|||
|
|||
interface AgentDocumentItem { |
|||
id: number |
|||
title: string |
|||
type: 'memory' | 'document' |
|||
summary: string | null |
|||
tags: string[] |
|||
createdAt: string |
|||
updatedAt: string |
|||
} |
|||
|
|||
interface ListResult { |
|||
items: AgentDocumentItem[] |
|||
total: number |
|||
page: number |
|||
pageSize: number |
|||
hasMore: boolean |
|||
} |
|||
|
|||
interface DocDetail { |
|||
id: number |
|||
title: string |
|||
type: 'memory' | 'document' |
|||
content: string |
|||
summary: string | null |
|||
tags: string[] |
|||
createdAt: string |
|||
updatedAt: string |
|||
} |
|||
|
|||
const documents = ref<AgentDocumentItem[]>([]) |
|||
const loading = ref(false) |
|||
const selectedDoc = ref<DocDetail | null>(null) |
|||
const detailLoading = ref(false) |
|||
const typeFilter = ref<'memory' | 'document' | ''>('') |
|||
const searchQuery = ref('') |
|||
|
|||
async function fetchDocuments() { |
|||
loading.value = true |
|||
try { |
|||
const params = new URLSearchParams() |
|||
params.set('page', '1') |
|||
params.set('pageSize', '50') |
|||
if (typeFilter.value) params.set('type', typeFilter.value) |
|||
if (searchQuery.value) params.set('keyword', searchQuery.value) |
|||
|
|||
const raw = await request<{ code: number; data: ListResult }>( |
|||
`/api/agent-documents?${params.toString()}`, |
|||
) |
|||
documents.value = raw.data.items |
|||
} catch { |
|||
$toast.error('文档加载失败') |
|||
} finally { |
|||
loading.value = false |
|||
} |
|||
} |
|||
|
|||
async function openDetail(id: number) { |
|||
detailLoading.value = true |
|||
selectedDoc.value = null |
|||
try { |
|||
const raw = await request<{ code: number; data: DocDetail }>( |
|||
`/api/agent-documents/${id}`, |
|||
) |
|||
selectedDoc.value = raw.data |
|||
} catch { |
|||
$toast.error('文档详情加载失败') |
|||
} finally { |
|||
detailLoading.value = false |
|||
} |
|||
} |
|||
|
|||
function closeDetail() { |
|||
selectedDoc.value = null |
|||
} |
|||
|
|||
function onFilterChange(type: 'memory' | 'document' | '') { |
|||
typeFilter.value = type |
|||
fetchDocuments() |
|||
} |
|||
|
|||
let searchTimer: ReturnType<typeof setTimeout> | null = null |
|||
function onSearchInput() { |
|||
if (searchTimer) clearTimeout(searchTimer) |
|||
searchTimer = setTimeout(() => { |
|||
fetchDocuments() |
|||
}, 400) |
|||
} |
|||
|
|||
watch(() => props.show, (val) => { |
|||
if (val && documents.value.length === 0) { |
|||
fetchDocuments() |
|||
} |
|||
}) |
|||
</script> |
|||
|
|||
<template> |
|||
<ClientOnly> |
|||
<Teleport to="body"> |
|||
<Transition name="doc-drawer-overlay"> |
|||
<div v-if="show" class="doc-drawer-overlay" @click="emit('close')" /> |
|||
</Transition> |
|||
<Transition name="doc-drawer-slide"> |
|||
<aside v-if="show" class="doc-drawer"> |
|||
<!-- List view --> |
|||
<div v-if="!selectedDoc" class="drawer-list-view"> |
|||
<div class="drawer-header"> |
|||
<h2 class="drawer-title">文档</h2> |
|||
<button class="close-btn" @click="emit('close')"> |
|||
<Icon name="lucide:x" /> |
|||
</button> |
|||
</div> |
|||
|
|||
<div class="drawer-filters"> |
|||
<div class="filter-tabs"> |
|||
<button |
|||
class="filter-tab" |
|||
:class="{ active: typeFilter === '' }" |
|||
@click="onFilterChange('')" |
|||
> |
|||
全部 |
|||
</button> |
|||
<button |
|||
class="filter-tab" |
|||
:class="{ active: typeFilter === 'document' }" |
|||
@click="onFilterChange('document')" |
|||
> |
|||
文档 |
|||
</button> |
|||
<button |
|||
class="filter-tab" |
|||
:class="{ active: typeFilter === 'memory' }" |
|||
@click="onFilterChange('memory')" |
|||
> |
|||
记忆 |
|||
</button> |
|||
</div> |
|||
<div class="search-box"> |
|||
<Icon name="lucide:search" class="search-icon" /> |
|||
<input |
|||
v-model="searchQuery" |
|||
type="text" |
|||
placeholder="搜索…" |
|||
class="search-input" |
|||
@input="onSearchInput" |
|||
/> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="drawer-body"> |
|||
<div v-if="loading" class="loading-state">加载中…</div> |
|||
<div v-else-if="documents.length === 0" class="empty-state"> |
|||
<p>暂无文档</p> |
|||
<p class="empty-hint">Agent 创建的文档会显示在这里</p> |
|||
</div> |
|||
<div v-else class="doc-list"> |
|||
<article |
|||
v-for="item in documents" |
|||
:key="item.id" |
|||
class="doc-card" |
|||
@click="openDetail(item.id)" |
|||
> |
|||
<div class="card-meta"> |
|||
<span class="type-badge" :class="item.type"> |
|||
{{ item.type === 'document' ? '文档' : '记忆' }} |
|||
</span> |
|||
<span class="card-date">{{ new Date(item.updatedAt).toLocaleDateString('zh-CN') }}</span> |
|||
</div> |
|||
<h3 class="card-title">{{ item.title }}</h3> |
|||
<p v-if="item.summary" class="card-summary">{{ item.summary }}</p> |
|||
</article> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Detail view --> |
|||
<div v-else class="drawer-detail-view"> |
|||
<div class="drawer-header"> |
|||
<button class="back-btn" @click="closeDetail"> |
|||
<Icon name="lucide:arrow-left" /> |
|||
<span>返回</span> |
|||
</button> |
|||
<button class="close-btn" @click="emit('close')"> |
|||
<Icon name="lucide:x" /> |
|||
</button> |
|||
</div> |
|||
|
|||
<div class="drawer-body"> |
|||
<div v-if="detailLoading" class="loading-state">加载中…</div> |
|||
<div v-else-if="selectedDoc" class="doc-detail"> |
|||
<div class="detail-meta"> |
|||
<span class="type-badge" :class="selectedDoc.type"> |
|||
{{ selectedDoc.type === 'document' ? '文档' : '记忆' }} |
|||
</span> |
|||
<span class="detail-date"> |
|||
{{ new Date(selectedDoc.updatedAt).toLocaleString('zh-CN') }} |
|||
</span> |
|||
</div> |
|||
<h2 class="detail-title">{{ selectedDoc.title }}</h2> |
|||
<AgentMarkdown :text="selectedDoc.content" /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</aside> |
|||
</Transition> |
|||
</Teleport> |
|||
</ClientOnly> |
|||
</template> |
|||
|
|||
<style scoped> |
|||
.doc-drawer-overlay { |
|||
position: fixed; |
|||
inset: 0; |
|||
background: rgba(0, 0, 0, 0.35); |
|||
z-index: 299; |
|||
} |
|||
|
|||
.doc-drawer { |
|||
position: fixed; |
|||
top: 0; |
|||
right: 0; |
|||
bottom: 0; |
|||
width: 440px; |
|||
max-width: 90vw; |
|||
background: var(--color-canvas, #faf9f5); |
|||
z-index: 300; |
|||
box-shadow: -2px 0 16px rgba(0, 0, 0, 0.12); |
|||
display: flex; |
|||
flex-direction: column; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.drawer-list-view, |
|||
.drawer-detail-view { |
|||
display: flex; |
|||
flex-direction: column; |
|||
height: 100%; |
|||
} |
|||
|
|||
.drawer-header { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
padding: 16px 20px; |
|||
border-bottom: 1px solid var(--color-hairline, #e6dfd8); |
|||
flex-shrink: 0; |
|||
} |
|||
|
|||
.drawer-title { |
|||
font-size: 18px; |
|||
font-weight: 600; |
|||
color: var(--color-ink, #141413); |
|||
margin: 0; |
|||
} |
|||
|
|||
.close-btn { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
width: 32px; |
|||
height: 32px; |
|||
border: none; |
|||
background: none; |
|||
border-radius: 8px; |
|||
cursor: pointer; |
|||
color: var(--color-muted, #6c6a64); |
|||
font-size: 18px; |
|||
transition: all 0.15s; |
|||
} |
|||
|
|||
.close-btn:hover { |
|||
background: var(--color-surface-soft, #efe9de); |
|||
color: var(--color-ink, #141413); |
|||
} |
|||
|
|||
.back-btn { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 6px; |
|||
padding: 6px 10px; |
|||
border: none; |
|||
background: none; |
|||
border-radius: 8px; |
|||
cursor: pointer; |
|||
color: var(--color-muted, #6c6a64); |
|||
font-size: 14px; |
|||
transition: all 0.15s; |
|||
} |
|||
|
|||
.back-btn:hover { |
|||
background: var(--color-surface-soft, #efe9de); |
|||
color: var(--color-ink, #141413); |
|||
} |
|||
|
|||
.drawer-filters { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 10px; |
|||
padding: 12px 20px; |
|||
border-bottom: 1px solid var(--color-hairline, #e6dfd8); |
|||
flex-shrink: 0; |
|||
flex-wrap: wrap; |
|||
} |
|||
|
|||
.filter-tabs { |
|||
display: flex; |
|||
gap: 4px; |
|||
background: var(--color-surface-soft, #efe9de); |
|||
border-radius: 8px; |
|||
padding: 3px; |
|||
} |
|||
|
|||
.filter-tab { |
|||
padding: 4px 12px; |
|||
border: none; |
|||
background: none; |
|||
font-size: 12px; |
|||
color: var(--color-muted, #6c6a64); |
|||
border-radius: 6px; |
|||
cursor: pointer; |
|||
transition: all 0.2s; |
|||
} |
|||
|
|||
.filter-tab.active { |
|||
background: #fff; |
|||
color: var(--color-ink, #141413); |
|||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); |
|||
} |
|||
|
|||
.search-box { |
|||
position: relative; |
|||
flex: 1; |
|||
min-width: 100px; |
|||
} |
|||
|
|||
.search-icon { |
|||
position: absolute; |
|||
left: 10px; |
|||
top: 50%; |
|||
transform: translateY(-50%); |
|||
font-size: 14px; |
|||
color: var(--color-muted, #b8b2a6); |
|||
} |
|||
|
|||
.search-input { |
|||
width: 100%; |
|||
padding: 6px 10px 6px 30px; |
|||
border: 1px solid var(--color-hairline, #e6dfd8); |
|||
border-radius: 8px; |
|||
font-size: 13px; |
|||
color: var(--color-body, #3d3d3a); |
|||
background: var(--color-canvas, #faf9f5); |
|||
outline: none; |
|||
transition: border-color 0.2s; |
|||
} |
|||
|
|||
.search-input:focus { |
|||
border-color: var(--color-primary, #cc785c); |
|||
} |
|||
|
|||
.drawer-body { |
|||
flex: 1; |
|||
overflow-y: auto; |
|||
padding: 12px 20px; |
|||
} |
|||
|
|||
.loading-state, |
|||
.empty-state { |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
padding: 60px 0; |
|||
gap: 6px; |
|||
font-size: 14px; |
|||
color: var(--color-muted, #6c6a64); |
|||
} |
|||
|
|||
.empty-hint { |
|||
font-size: 12px; |
|||
color: var(--color-muted, #b8b2a6); |
|||
} |
|||
|
|||
.doc-list { |
|||
display: flex; |
|||
flex-direction: column; |
|||
gap: 8px; |
|||
} |
|||
|
|||
.doc-card { |
|||
padding: 14px 16px; |
|||
background: var(--color-surface-card, #fff); |
|||
border: 1px solid var(--color-hairline, #e6dfd8); |
|||
border-radius: 10px; |
|||
cursor: pointer; |
|||
transition: all 0.15s; |
|||
} |
|||
|
|||
.doc-card:hover { |
|||
border-color: var(--color-primary, #cc785c); |
|||
background: var(--color-canvas, #f5f0e8); |
|||
} |
|||
|
|||
.card-meta { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 8px; |
|||
margin-bottom: 6px; |
|||
} |
|||
|
|||
.type-badge { |
|||
font-size: 10px; |
|||
padding: 2px 6px; |
|||
border-radius: 4px; |
|||
font-weight: 500; |
|||
} |
|||
|
|||
.type-badge.document { |
|||
background: #d4edda; |
|||
color: #2d6a4f; |
|||
} |
|||
|
|||
.type-badge.memory { |
|||
background: #d1e4f0; |
|||
color: #1a5276; |
|||
} |
|||
|
|||
.card-date { |
|||
font-size: 11px; |
|||
color: var(--color-muted, #b8b2a6); |
|||
} |
|||
|
|||
.card-title { |
|||
font-size: 15px; |
|||
font-weight: 500; |
|||
color: var(--color-ink, #141413); |
|||
margin: 0 0 4px; |
|||
line-height: 1.4; |
|||
} |
|||
|
|||
.card-summary { |
|||
font-size: 13px; |
|||
color: var(--color-muted, #6c6a64); |
|||
margin: 0; |
|||
line-height: 1.5; |
|||
display: -webkit-box; |
|||
-webkit-line-clamp: 2; |
|||
-webkit-box-orient: vertical; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
/* Detail view */ |
|||
.doc-detail { |
|||
padding: 4px 0; |
|||
} |
|||
|
|||
.detail-meta { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 10px; |
|||
margin-bottom: 12px; |
|||
} |
|||
|
|||
.detail-date { |
|||
font-size: 12px; |
|||
color: var(--color-muted, #b8b2a6); |
|||
} |
|||
|
|||
.detail-title { |
|||
font-size: 20px; |
|||
font-weight: 600; |
|||
color: var(--color-ink, #141413); |
|||
margin: 0 0 16px; |
|||
line-height: 1.4; |
|||
} |
|||
|
|||
/* Transitions */ |
|||
.doc-drawer-overlay-enter-active, |
|||
.doc-drawer-overlay-leave-active { |
|||
transition: opacity 0.2s ease; |
|||
} |
|||
|
|||
.doc-drawer-overlay-enter-from, |
|||
.doc-drawer-overlay-leave-to { |
|||
opacity: 0; |
|||
} |
|||
|
|||
.doc-drawer-slide-enter-active, |
|||
.doc-drawer-slide-leave-active { |
|||
transition: transform 0.25s ease; |
|||
} |
|||
|
|||
.doc-drawer-slide-enter-from, |
|||
.doc-drawer-slide-leave-to { |
|||
transform: translateX(100%); |
|||
} |
|||
</style> |
|||
Loading…
Reference in new issue