deotalandAi/apps/frontend/src/components/AgentCard.vue

748 lines
17 KiB
Vue

<template>
<div
:class="[
'agent-card',
`agent-card--${agent.status}`,
{ 'agent-card--hovered': isHovered }
]"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
ref="cardRef"
>
<!-- 卡片头部 -->
<div class="agent-card__header">
<div class="agent-avatar-section">
<div class="agent-avatar">
<img
:src="agent.avatar || defaultAvatar"
:alt="agent.name"
class="avatar-image"
@error="handleImageError"
/>
<div
:class="[
'status-indicator',
{ 'status-indicator--online': agent.status === AGENT_STATUS.ACTIVE }
]"
></div>
</div>
<div class="agent-basic-info">
<h3 class="agent-name">{{ agent.name }}</h3>
<div class="agent-role">
<el-tag :type="roleType" size="small">
<el-icon v-if="roleIcon" class="role-icon"><component :is="roleIcon" /></el-icon>
{{ roleLabel }}
</el-tag>
</div>
</div>
</div>
<div class="agent-menu">
<el-dropdown trigger="click" @command="handleMenuCommand">
<el-button size="small" text>
<el-icon><MoreFilled /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon>
{{ $t('agents.card.actions.edit') }}
</el-dropdown-item>
<el-dropdown-item command="duplicate" :disabled="isProcessing">
<el-icon><CopyDocument /></el-icon>
{{ $t('agents.card.actions.duplicate') }}
</el-dropdown-item>
<el-dropdown-item
command="device"
:disabled="isProcessing"
>
<el-icon><Connection /></el-icon>
{{ $t('agents.card.actions.device') }}
</el-dropdown-item>
<el-dropdown-item
command="export"
:disabled="isProcessing"
>
<el-icon><Download /></el-icon>
{{ $t('agents.card.actions.export') }}
</el-dropdown-item>
<el-dropdown-item divided command="delete" class="text-danger">
<el-icon><Delete /></el-icon>
{{ $t('agents.card.actions.delete') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 智能体描述 -->
<div class="agent-card__description">
<p class="description-text" :title="agent.description">
{{ truncatedDescription }}
</p>
</div>
<!-- 设备绑定状态 -->
<div class="device-binding-section">
<div class="device-status">
<el-icon
:class="[
'device-icon',
{ 'device-icon--bound': agent.device.bound }
]"
>
<Phone v-if="agent.device.bound" />
<Monitor v-else />
</el-icon>
<div class="device-info">
<span class="device-status-text">
{{ agent.device.bound ? $t('agents.card.device.bound') : $t('agents.card.device.unbound') }}
</span>
<span v-if="agent.device.bound && agent.device.deviceName" class="device-name">
{{ agent.device.deviceName }}
</span>
</div>
<el-button
v-if="!agent.device.bound"
type="primary"
size="small"
@click="handleBindDevice"
:loading="bindingState.loading"
:disabled="isProcessing"
>
{{ $t('agents.card.device.bind') }}
</el-button>
<el-dropdown v-else trigger="click" @command="handleDeviceMenuCommand">
<el-button size="small" text>
<el-icon><MoreFilled /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view-device">
<el-icon><View /></el-icon>
{{ $t('agents.card.device.viewDetails') }}
</el-dropdown-item>
<el-dropdown-item command="unbind-device">
<el-icon><CloseBold /></el-icon>
{{ $t('agents.card.device.unbind') }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<!-- 最新活动/对话预览 -->
<div v-if="lastActivity" class="activity-preview">
<div class="activity-header">
<el-icon class="activity-icon"><ChatDotRound /></el-icon>
<span class="activity-title">{{ $t('agents.card.activity.title') }}</span>
</div>
<div class="activity-content">
<p class="activity-text">{{ lastActivity.message }}</p>
<span class="activity-time">{{ formatRelativeTime(lastActivity.timestamp) }}</span>
</div>
</div>
<!-- 统计信息 -->
<div class="stats-section">
<div class="stats-grid">
<div class="stat-item">
<span class="stat-value">{{ agent.settings?.temperature || 0.7 }}</span>
<span class="stat-label">{{ $t('agents.card.stats.temperature') }}</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ formatNumber(agent.settings?.maxTokens || 0) }}</span>
<span class="stat-label">{{ $t('agents.card.stats.tokens') }}</span>
</div>
<div class="stat-item">
<span class="stat-value">{{ agent.voice }}</span>
<span class="stat-label">{{ $t('agents.card.stats.voice') }}</span>
</div>
</div>
</div>
<!-- 错误状态显示 -->
<div v-if="error" class="error-section">
<el-alert
:title="error"
type="error"
:closable="false"
show-icon
class="error-alert"
/>
</div>
<!-- 加载状态覆盖 -->
<div v-if="bindingState.loading || isProcessing" class="loading-overlay">
<el-icon class="loading-icon"><Loading /></el-icon>
<span class="loading-text">
{{ bindingState.verifying ? $t('agents.card.device.verifying') : $t('agents.card.loading') }}
</span>
</div>
</div>
</template>
<script>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import {
MoreFilled,
Edit,
CopyDocument,
Connection,
Download,
Delete,
Phone,
Monitor,
ChatDotRound,
View,
CloseBold,
Loading,
User,
Briefcase,
MagicStick,
Reading,
} from '@element-plus/icons-vue'
import { AGENT_STATUS, AGENT_ROLES, VOICE_MODELS } from '../stores/agents.js'
// 默认头像
const defaultAvatar = '/images/default-avatar.png'
export default {
name: 'AgentCard',
components: {
MoreFilled,
Edit,
CopyDocument,
Connection,
Download,
Delete,
Phone,
Monitor,
ChatDotRound,
View,
CloseBold,
Loading,
User,
Briefcase,
MagicStick,
Reading,
},
props: {
agent: {
type: Object,
required: true,
validator: (agent) => {
return agent && typeof agent.id === 'string' && typeof agent.name === 'string'
}
},
isProcessing: {
type: Boolean,
default: false
}
},
emits: [
'edit',
'duplicate',
'delete',
'bind-device',
'unbind-device',
'view-details',
'export'
],
setup(props, { emit, expose }) {
const { t } = useI18n()
const cardRef = ref(null)
const isHovered = ref(false)
const error = ref(null)
// 绑定状态
const bindingState = ref({
loading: false,
error: null,
verifying: false
})
// ==================== 计算属性 ====================
const truncatedDescription = computed(() => {
if (!props.agent.description) return ''
const maxLength = 80
if (props.agent.description.length <= maxLength) {
return props.agent.description
}
return props.agent.description.substring(0, maxLength) + '...'
})
const roleType = computed(() => {
const typeMap = {
[AGENT_ROLES.ASSISTANT]: 'success',
[AGENT_ROLES.CONSULTANT]: 'warning',
[AGENT_ROLES.CREATIVE]: 'info',
[AGENT_ROLES.EDUCATOR]: 'primary',
[AGENT_ROLES.CUSTOM]: 'default'
}
return typeMap[props.agent.role] || 'default'
})
const roleLabel = computed(() => {
const labelMap = {
[AGENT_ROLES.ASSISTANT]: t('agents.card.role.assistant'),
[AGENT_ROLES.CONSULTANT]: t('agents.card.role.consultant'),
[AGENT_ROLES.CREATIVE]: t('agents.card.role.creative'),
[AGENT_ROLES.EDUCATOR]: t('agents.card.role.educator'),
[AGENT_ROLES.CUSTOM]: t('agents.card.role.custom')
}
return labelMap[props.agent.role] || props.agent.role
})
const roleIcon = computed(() => {
const iconMap = {
[AGENT_ROLES.ASSISTANT]: 'User',
[AGENT_ROLES.CONSULTANT]: 'Briefcase',
[AGENT_ROLES.CREATIVE]: 'MagicStick',
[AGENT_ROLES.EDUCATOR]: 'Reading',
[AGENT_ROLES.CUSTOM]: 'Setting'
}
return iconMap[props.agent.role]
})
const lastActivity = computed(() => {
if (!props.agent.lastActive) return null
// 模拟最新对话数据(实际中可能从其他地方获取)
const sampleMessages = [
t('agents.card.activity.messages.greeting'),
t('agents.card.activity.messages.help'),
t('agents.card.activity.messages.conversation'),
t('agents.card.activity.messages.task')
]
return {
message: sampleMessages[Math.floor(Math.random() * sampleMessages.length)],
timestamp: props.agent.lastActive
}
})
// ==================== 事件处理 ====================
const handleMouseEnter = () => {
isHovered.value = true
}
const handleMouseLeave = () => {
isHovered.value = false
}
const handleMenuCommand = (command) => {
error.value = null
switch (command) {
case 'edit':
emit('edit', props.agent)
break
case 'duplicate':
emit('duplicate', props.agent)
break
case 'delete':
emit('delete', props.agent)
break
case 'device':
if (props.agent.device.bound) {
emit('view-details', props.agent)
} else {
emit('bind-device', props.agent)
}
break
case 'export':
emit('export', props.agent)
break
default:
console.warn('Unknown menu command:', command)
}
}
const handleDeviceMenuCommand = (command) => {
error.value = null
switch (command) {
case 'view-device':
emit('view-details', props.agent)
break
case 'unbind-device':
emit('unbind-device', props.agent)
break
default:
console.warn('Unknown device menu command:', command)
}
}
const handleBindDevice = () => {
emit('bind-device', props.agent)
}
// ==================== 工具函数 ====================
const handleImageError = (event) => {
event.target.src = defaultAvatar
}
const formatRelativeTime = (timestamp) => {
const now = new Date()
const time = new Date(timestamp)
const diffMs = now - time
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffMins < 1) return t('agents.card.time.justNow')
if (diffMins < 60) return `${diffMins}${t('agents.card.time.minutesAgo')}`
if (diffHours < 24) return `${diffHours}${t('agents.card.time.hoursAgo')}`
if (diffDays < 7) return `${diffDays}${t('agents.card.time.daysAgo')}`
return time.toLocaleDateString()
}
const formatNumber = (num) => {
if (num >= 1000) {
return `${(num / 1000).toFixed(1)}k`
}
return num.toString()
}
// ==================== 暴露方法 ====================
const updateBindingState = (newState) => {
bindingState.value = { ...bindingState.value, ...newState }
}
const setError = (errorMessage) => {
error.value = errorMessage
}
const clearError = () => {
error.value = null
}
expose({
updateBindingState,
setError,
clearError
})
return {
// 常量
AGENT_STATUS,
defaultAvatar,
// 响应式数据
cardRef,
isHovered,
error,
bindingState,
// 计算属性
truncatedDescription,
roleType,
roleLabel,
roleIcon,
lastActivity,
// 方法
handleMouseEnter,
handleMouseLeave,
handleMenuCommand,
handleDeviceMenuCommand,
handleBindDevice,
handleImageError,
formatRelativeTime,
formatNumber
}
}
}
</script>
<style scoped>
/* 基础样式 */
.agent-card {
@apply relative bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden;
@apply transition-all duration-300 ease-in-out;
min-height: 280px;
}
.agent-card:hover {
@apply shadow-md border-gray-300;
transform: translateY(-2px);
}
.agent-card--hovered {
@apply border-blue-300;
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.1);
}
/* 卡片头部 */
.agent-card__header {
@apply flex items-start justify-between p-4 border-b border-gray-100;
}
.agent-avatar-section {
@apply flex items-center space-x-3;
}
.agent-avatar {
@apply relative;
}
.avatar-image {
@apply w-12 h-12 rounded-full object-cover;
@apply border-2 border-gray-200;
}
.status-indicator {
@apply absolute -bottom-1 -right-1 w-4 h-4 rounded-full border-2 border-white;
@apply bg-gray-400;
}
.status-indicator--online {
@apply bg-green-500;
}
.agent-basic-info {
@apply flex-1 min-w-0;
}
.agent-name {
@apply text-lg font-semibold text-gray-900 mb-1;
@apply truncate;
}
.agent-role {
@apply flex items-center;
}
.role-icon {
@apply mr-1;
}
.agent-menu {
@apply flex-shrink-0;
}
/* 描述区域 */
.agent-card__description {
@apply p-4 border-b border-gray-100;
}
.description-text {
@apply text-sm text-gray-600 leading-relaxed;
@apply line-clamp-2;
}
/* 设备绑定状态 */
.device-binding-section {
@apply p-4 border-b border-gray-100;
}
.device-status {
@apply flex items-center space-x-3;
}
.device-icon {
@apply text-gray-400;
@apply transition-colors duration-200;
}
.device-icon--bound {
@apply text-green-500;
}
.device-info {
@apply flex-1 min-w-0;
}
.device-status-text {
@apply text-sm font-medium text-gray-900;
}
.device-name {
@apply text-xs text-gray-500 block;
@apply truncate;
}
/* 活动预览 */
.activity-preview {
@apply p-4 border-b border-gray-100;
}
.activity-header {
@apply flex items-center space-x-2 mb-2;
}
.activity-icon {
@apply text-blue-500 text-sm;
}
.activity-title {
@apply text-xs font-medium text-gray-500 uppercase tracking-wide;
}
.activity-content {
@apply flex items-center justify-between;
}
.activity-text {
@apply text-sm text-gray-600;
@apply truncate flex-1 mr-2;
}
.activity-time {
@apply text-xs text-gray-400 flex-shrink-0;
}
/* 统计信息 */
.stats-section {
@apply p-4;
}
.stats-grid {
@apply grid grid-cols-3 gap-4;
}
.stat-item {
@apply text-center;
}
.stat-value {
@apply block text-sm font-semibold text-gray-900;
}
.stat-label {
@apply block text-xs text-gray-500 mt-1;
}
/* 错误状态 */
.error-section {
@apply p-2;
}
.error-alert {
@apply mb-0;
}
/* 加载状态 */
.loading-overlay {
@apply absolute inset-0 bg-white bg-opacity-80 flex flex-col items-center justify-center;
@apply space-y-2;
}
.loading-icon {
@apply text-blue-500 text-lg animate-spin;
}
.loading-text {
@apply text-sm text-gray-600;
}
/* 文本截断 */
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 危险操作样式 */
.text-danger {
@apply text-red-600;
}
/* 响应式设计 */
@media (max-width: 768px) {
.agent-card__header {
@apply p-3;
}
.agent-avatar-section {
@apply space-x-2;
}
.avatar-image {
@apply w-10 h-10;
}
.agent-name {
@apply text-base;
}
.device-binding-section,
.activity-preview,
.stats-section {
@apply p-3;
}
.description-text {
@apply text-xs;
}
.stats-grid {
@apply gap-3;
}
}
@media (max-width: 480px) {
.agent-card {
@apply rounded-md;
}
.stats-grid {
@apply grid-cols-2;
}
.device-status {
@apply flex-col items-start space-y-2;
}
}
/* 深色模式支持 */
@media (prefers-color-scheme: dark) {
.agent-card {
@apply bg-gray-800 border-gray-700;
}
.agent-card:hover {
@apply border-gray-600;
}
.agent-card__header {
@apply border-gray-700;
}
.agent-name {
@apply text-gray-100;
}
.description-text,
.activity-text,
.device-status-text {
@apply text-gray-300;
}
.stat-value {
@apply text-gray-100;
}
.activity-icon {
@apply text-blue-400;
}
.loading-overlay {
@apply bg-gray-800 bg-opacity-80;
}
.loading-text {
@apply text-gray-300;
}
}
</style>