mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(ux): agent templates, chat status indicator, dashboard trend chart
This commit is contained in:
parent
62b7998975
commit
4cd373dcdd
@ -82,6 +82,12 @@ export const agentApi = {
|
||||
getState: (id: string | number) => http.get(`/agents/${id}/state`),
|
||||
}
|
||||
|
||||
// ==================== Templates ====================
|
||||
export const templateApi = {
|
||||
list: () => http.get('/templates'),
|
||||
apply: (id: string) => http.post(`/templates/${id}/apply`),
|
||||
}
|
||||
|
||||
// ==================== Chat ====================
|
||||
export const chatApi = {
|
||||
uploadFile: async (conversationId: string, file: File) => {
|
||||
|
||||
@ -33,6 +33,11 @@ export default {
|
||||
disable: 'Disable',
|
||||
},
|
||||
chat: {
|
||||
status: {
|
||||
idle: 'Ready',
|
||||
streaming: 'Generating...',
|
||||
error: 'Disconnected',
|
||||
},
|
||||
thinking: 'Thinking',
|
||||
stopped: 'Generation stopped',
|
||||
failed: 'Generation failed',
|
||||
@ -579,6 +584,12 @@ export default {
|
||||
title: 'Agent Management',
|
||||
desc: 'Create, edit, and manage your AI agents',
|
||||
newAgent: 'New Agent',
|
||||
templates: {
|
||||
title: 'Choose a Template',
|
||||
desc: 'Start with a pre-configured agent, or create from scratch.',
|
||||
skip: 'Start from Scratch',
|
||||
applied: 'Agent created from template',
|
||||
},
|
||||
search: 'Search agents...',
|
||||
tabs: {
|
||||
all: 'All',
|
||||
@ -1507,11 +1518,18 @@ export default {
|
||||
},
|
||||
dashboard: {
|
||||
title: 'Dashboard',
|
||||
kicker: 'Operations Pulse',
|
||||
desc: 'System usage overview and runtime status',
|
||||
conversations: 'Conversations',
|
||||
messages: 'Messages',
|
||||
tokens: 'Token Usage',
|
||||
toolCalls: 'Tool Calls',
|
||||
periodDesc: 'A sharper view of how your system behaves across short, medium, and monthly horizons.',
|
||||
runsDesc: 'Execution history with timing, cost, and outcome at a glance.',
|
||||
trend: {
|
||||
title: '7-Day Trend',
|
||||
subtitle: 'Messages and token consumption over the past week.',
|
||||
},
|
||||
periodComparison: 'Period Comparison',
|
||||
periods: {
|
||||
today: 'Today',
|
||||
|
||||
@ -33,6 +33,11 @@ export default {
|
||||
disable: '停用',
|
||||
},
|
||||
chat: {
|
||||
status: {
|
||||
idle: '就绪',
|
||||
streaming: '生成中...',
|
||||
error: '连接断开',
|
||||
},
|
||||
thinking: '深度思考',
|
||||
stopped: '已停止生成',
|
||||
failed: '生成失败',
|
||||
@ -579,6 +584,12 @@ export default {
|
||||
title: '智能体管理',
|
||||
desc: '创建、编辑和管理你的 AI 智能体',
|
||||
newAgent: '新建智能体',
|
||||
templates: {
|
||||
title: '选择模板',
|
||||
desc: '选择预配置的 Agent 模板快速开始,或从空白创建。',
|
||||
skip: '从空白开始',
|
||||
applied: '已从模板创建 Agent',
|
||||
},
|
||||
search: '搜索智能体...',
|
||||
tabs: {
|
||||
all: '全部',
|
||||
@ -1517,11 +1528,18 @@ export default {
|
||||
},
|
||||
dashboard: {
|
||||
title: '仪表盘',
|
||||
kicker: '运营脉搏',
|
||||
desc: '系统用量概览与运行状态',
|
||||
conversations: '对话数',
|
||||
messages: '消息数',
|
||||
tokens: 'Token 消耗',
|
||||
toolCalls: '工具调用',
|
||||
periodDesc: '从日、周、月三个维度观察系统运行状况。',
|
||||
runsDesc: '定时任务执行记录,包含耗时、消耗和结果。',
|
||||
trend: {
|
||||
title: '7 天趋势',
|
||||
subtitle: '过去一周的消息量和 Token 消耗趋势。',
|
||||
},
|
||||
periodComparison: '周期对比',
|
||||
periods: {
|
||||
today: '今日',
|
||||
|
||||
@ -107,6 +107,49 @@
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Template Selector Modal -->
|
||||
<div v-if="showTemplateSelector" class="modal-overlay" @click.self="showTemplateSelector = false">
|
||||
<div class="modal template-modal">
|
||||
<div class="modal-header">
|
||||
<h2>{{ t('agents.templates.title') }}</h2>
|
||||
<button class="modal-close" @click="showTemplateSelector = false">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="template-desc">{{ t('agents.templates.desc') }}</p>
|
||||
<div class="template-grid">
|
||||
<div
|
||||
v-for="tpl in templates"
|
||||
:key="tpl.id"
|
||||
class="template-card mc-surface-card"
|
||||
:class="{ applying: applyingTemplate }"
|
||||
@click="!applyingTemplate && applyTemplate(tpl.id)"
|
||||
>
|
||||
<div class="template-icon">{{ tpl.icon }}</div>
|
||||
<div class="template-info">
|
||||
<h4 class="template-name">{{ $i18n.locale === 'zh-CN' && tpl.nameZh ? tpl.nameZh : tpl.name }}</h4>
|
||||
<p class="template-detail">{{ $i18n.locale === 'zh-CN' && tpl.descriptionZh ? tpl.descriptionZh : tpl.description }}</p>
|
||||
</div>
|
||||
<div class="template-tags">
|
||||
<span v-for="tag in (tpl.tags || '').split(',').filter(Boolean)" :key="tag" class="tag-chip">{{ tag.trim() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" @click="openBlankCreateModal">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
{{ t('agents.templates.skip') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
@ -235,7 +278,7 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { agentApi, agentBindingApi, skillApi, toolApi } from '@/api/index'
|
||||
import { agentApi, agentBindingApi, skillApi, toolApi, templateApi } from '@/api/index'
|
||||
import type { Agent } from '@/types/index'
|
||||
|
||||
const { t } = useI18n()
|
||||
@ -252,6 +295,11 @@ const availableTools = ref<any[]>([])
|
||||
const selectedSkillIds = ref<number[]>([])
|
||||
const selectedToolNames = ref<string[]>([])
|
||||
|
||||
// Template selector state
|
||||
const showTemplateSelector = ref(false)
|
||||
const templates = ref<any[]>([])
|
||||
const applyingTemplate = ref(false)
|
||||
|
||||
const filterTabs = [
|
||||
{ key: 'agents.tabs.all', value: 'all' },
|
||||
{ key: 'agents.tabs.react', value: 'react' },
|
||||
@ -315,6 +363,13 @@ function formatTime(time?: string): string {
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
// Show template selector first
|
||||
showTemplateSelector.value = true
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
function openBlankCreateModal() {
|
||||
showTemplateSelector.value = false
|
||||
editingAgent.value = null
|
||||
form.value = defaultForm()
|
||||
modalTab.value = 'basic'
|
||||
@ -323,6 +378,30 @@ function openCreateModal() {
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
async function loadTemplates() {
|
||||
try {
|
||||
const res: any = await templateApi.list()
|
||||
templates.value = res.data || []
|
||||
} catch {
|
||||
// Fallback: skip templates, open blank form
|
||||
openBlankCreateModal()
|
||||
}
|
||||
}
|
||||
|
||||
async function applyTemplate(id: string) {
|
||||
applyingTemplate.value = true
|
||||
try {
|
||||
await templateApi.apply(id)
|
||||
ElMessage.success(t('agents.templates.applied'))
|
||||
showTemplateSelector.value = false
|
||||
await loadAgents()
|
||||
} catch {
|
||||
ElMessage.error(t('agents.messages.saveFailed'))
|
||||
} finally {
|
||||
applyingTemplate.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openEditModal(agent: Agent) {
|
||||
editingAgent.value = agent
|
||||
form.value = {
|
||||
@ -551,4 +630,26 @@ async function toggleAgent(agent: Agent) {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Template Selector */
|
||||
.template-modal { max-width: 640px; }
|
||||
.template-desc { font-size: 14px; color: var(--mc-text-secondary); margin: 0 0 18px; }
|
||||
|
||||
.template-grid { display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
.template-card {
|
||||
display: flex; align-items: flex-start; gap: 14px; padding: 16px; cursor: pointer;
|
||||
border: 1px solid var(--mc-border); border-radius: 12px; transition: all 0.15s;
|
||||
}
|
||||
.template-card:hover { border-color: var(--mc-primary); background: var(--mc-primary-bg); }
|
||||
.template-card.applying { opacity: 0.5; pointer-events: none; }
|
||||
|
||||
.template-icon { font-size: 28px; width: 44px; height: 44px; display: flex; align-items: center; justify-content: center; background: var(--mc-bg-muted); border-radius: 10px; flex-shrink: 0; }
|
||||
|
||||
.template-info { flex: 1; min-width: 0; }
|
||||
.template-name { font-size: 15px; font-weight: 600; color: var(--mc-text-primary); margin: 0 0 4px; }
|
||||
.template-detail { font-size: 13px; color: var(--mc-text-secondary); margin: 0; line-height: 1.5; }
|
||||
|
||||
.template-tags { display: flex; flex-wrap: wrap; gap: 4px; align-self: flex-start; margin-top: 2px; }
|
||||
.tag-chip { font-size: 11px; padding: 2px 8px; background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); border-radius: 999px; white-space: nowrap; }
|
||||
</style>
|
||||
|
||||
@ -91,6 +91,7 @@
|
||||
<span class="agent-badge-icon">{{ currentAgent.icon || '🤖' }}</span>
|
||||
<span class="agent-badge-name">{{ currentAgent.name }}</span>
|
||||
<span class="agent-badge-type">{{ currentAgent.agentType === 'react' ? 'ReAct' : 'Plan-Execute' }}</span>
|
||||
<span class="status-dot" :class="connectionStatusClass" :title="connectionStatusLabel"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-agent-hint">{{ $t('chat.selectAgent') }}</div>
|
||||
@ -394,6 +395,18 @@ const {
|
||||
},
|
||||
})
|
||||
|
||||
// ============ 连接状态 ============
|
||||
const connectionStatusClass = computed(() => {
|
||||
if (isGenerating.value) return 'status-streaming'
|
||||
if (streamPhase.value === 'failed') return 'status-error'
|
||||
return 'status-idle'
|
||||
})
|
||||
const connectionStatusLabel = computed(() => {
|
||||
if (isGenerating.value) return t('chat.status.streaming', 'Generating...')
|
||||
if (streamPhase.value === 'failed') return t('chat.status.error', 'Disconnected')
|
||||
return t('chat.status.idle', 'Ready')
|
||||
})
|
||||
|
||||
// ============ 计算属性 ============
|
||||
const currentAgent = computed(() => agents.value.find(a => String(a.id) === String(selectedAgentId.value)))
|
||||
|
||||
@ -1451,6 +1464,15 @@ function handleCodeCopy(e: MouseEvent) {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin-left: 2px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
.status-idle { background: #34d399; box-shadow: 0 0 4px rgba(52, 211, 153, 0.5); }
|
||||
.status-streaming { background: #fbbf24; box-shadow: 0 0 4px rgba(251, 191, 36, 0.5); animation: pulse-dot 1.2s infinite; }
|
||||
.status-error { background: #f87171; box-shadow: 0 0 4px rgba(248, 113, 113, 0.5); }
|
||||
@keyframes pulse-dot { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
|
||||
|
||||
.no-agent-hint {
|
||||
font-size: 13px;
|
||||
color: var(--mc-text-tertiary);
|
||||
|
||||
@ -4,12 +4,12 @@
|
||||
<div class="mc-page-inner dashboard-inner">
|
||||
<div class="mc-page-header">
|
||||
<div>
|
||||
<div class="mc-page-kicker">Operations Pulse</div>
|
||||
<div class="mc-page-kicker">{{ t('dashboard.kicker') }}</div>
|
||||
<h1 class="mc-page-title">{{ t('dashboard.title') }}</h1>
|
||||
<p class="mc-page-desc">{{ t('dashboard.desc') }}</p>
|
||||
</div>
|
||||
<div class="hero-note mc-surface-card">
|
||||
<div class="hero-note__label">Today</div>
|
||||
<div class="hero-note__label">{{ t('dashboard.periods.today') }}</div>
|
||||
<div class="hero-note__value">{{ formatTokens(todayStats.totalTokens) }}</div>
|
||||
<div class="hero-note__meta">{{ t('dashboard.tokens') }} · {{ todayStats.toolCalls }} {{ t('dashboard.toolCalls') }}</div>
|
||||
</div>
|
||||
@ -55,10 +55,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="trendData.length" class="trend-section">
|
||||
<div class="section-head">
|
||||
<h2 class="section-title">{{ t('dashboard.trend.title', '7-Day Trend') }}</h2>
|
||||
<p class="section-subtitle">{{ t('dashboard.trend.subtitle', 'Messages and token consumption over the past week.') }}</p>
|
||||
</div>
|
||||
<div class="trend-chart mc-surface-card">
|
||||
<div ref="chartRef" class="chart-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="comparison-section">
|
||||
<div class="section-head">
|
||||
<h2 class="section-title">{{ t('dashboard.periodComparison') }}</h2>
|
||||
<p class="section-subtitle">A sharper view of how your system behaves across short, medium, and monthly horizons.</p>
|
||||
<p class="section-subtitle">{{ t('dashboard.periodDesc') }}</p>
|
||||
</div>
|
||||
<div class="comparison-grid">
|
||||
<div class="comparison-card mc-surface-card" v-for="(period, key) in overview" :key="key">
|
||||
@ -86,7 +96,7 @@
|
||||
<div class="runs-section">
|
||||
<div class="section-head">
|
||||
<h2 class="section-title">{{ t('dashboard.recentRuns') }}</h2>
|
||||
<p class="section-subtitle">Execution should feel legible. If it runs, you should see its rhythm, cost, and outcome instantly.</p>
|
||||
<p class="section-subtitle">{{ t('dashboard.runsDesc') }}</p>
|
||||
</div>
|
||||
<div class="runs-table-wrapper mc-surface-card">
|
||||
<table v-if="recentRuns.length" class="runs-table">
|
||||
@ -123,15 +133,24 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ref, reactive, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChatDotRound, DataLine, Document, Tools } from '@element-plus/icons-vue'
|
||||
import { dashboardApi } from '@/api'
|
||||
import * as echarts from 'echarts/core'
|
||||
import { LineChart } from 'echarts/charts'
|
||||
import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
|
||||
echarts.use([LineChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer])
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const overview = ref<Record<string, any>>({})
|
||||
const recentRuns = ref<any[]>([])
|
||||
const trendData = ref<any[]>([])
|
||||
const chartRef = ref<HTMLElement | null>(null)
|
||||
let chartInstance: echarts.ECharts | null = null
|
||||
|
||||
const todayStats = reactive({
|
||||
conversations: 0,
|
||||
@ -143,19 +162,102 @@ const todayStats = reactive({
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [overviewRes, runsRes] = await Promise.all([
|
||||
const [overviewRes, runsRes, trendRes] = await Promise.all([
|
||||
dashboardApi.overview(),
|
||||
dashboardApi.recentRuns(10),
|
||||
dashboardApi.trend(7),
|
||||
])
|
||||
overview.value = (overviewRes as any).data || {}
|
||||
const today = overview.value.today || {}
|
||||
Object.assign(todayStats, today)
|
||||
recentRuns.value = (runsRes as any).data || []
|
||||
trendData.value = (trendRes as any).data || []
|
||||
if (trendData.value.length) {
|
||||
await nextTick()
|
||||
renderChart()
|
||||
}
|
||||
} catch {
|
||||
// Dashboard data is non-critical
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
chartInstance?.dispose()
|
||||
})
|
||||
|
||||
function renderChart() {
|
||||
if (!chartRef.value) return
|
||||
chartInstance = echarts.init(chartRef.value)
|
||||
|
||||
const dates = trendData.value.map((d: any) => d.date?.slice(5) || '') // MM-DD
|
||||
const messages = trendData.value.map((d: any) => d.messages || 0)
|
||||
const tokens = trendData.value.map((d: any) => d.totalTokens || 0)
|
||||
|
||||
const style = getComputedStyle(document.documentElement)
|
||||
const textColor = style.getPropertyValue('--mc-text-secondary').trim() || '#999'
|
||||
const borderColor = style.getPropertyValue('--mc-border-light').trim() || '#eee'
|
||||
const primaryColor = style.getPropertyValue('--mc-primary').trim() || '#D97757'
|
||||
|
||||
chartInstance.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
legend: {
|
||||
data: [t('dashboard.messages'), 'Tokens'],
|
||||
textStyle: { color: textColor, fontSize: 12 },
|
||||
bottom: 0,
|
||||
},
|
||||
grid: { top: 10, right: 16, bottom: 36, left: 48, containLabel: false },
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates,
|
||||
axisLabel: { color: textColor, fontSize: 11 },
|
||||
axisLine: { lineStyle: { color: borderColor } },
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
axisLabel: { color: textColor, fontSize: 11 },
|
||||
splitLine: { lineStyle: { color: borderColor, type: 'dashed' } },
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
axisLabel: { color: textColor, fontSize: 11, formatter: (v: number) => v >= 1000 ? (v / 1000).toFixed(0) + 'K' : v },
|
||||
splitLine: { show: false },
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: t('dashboard.messages'),
|
||||
type: 'line',
|
||||
data: messages,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: { width: 2.5, color: primaryColor },
|
||||
itemStyle: { color: primaryColor },
|
||||
areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: primaryColor + '30' },
|
||||
{ offset: 1, color: primaryColor + '05' },
|
||||
])},
|
||||
},
|
||||
{
|
||||
name: 'Tokens',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: tokens,
|
||||
smooth: true,
|
||||
symbol: 'circle',
|
||||
symbolSize: 6,
|
||||
lineStyle: { width: 2, color: '#60a5fa' },
|
||||
itemStyle: { color: '#60a5fa' },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
// Responsive resize
|
||||
const ro = new ResizeObserver(() => chartInstance?.resize())
|
||||
ro.observe(chartRef.value!)
|
||||
}
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (!n) return '0'
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
|
||||
@ -270,6 +372,10 @@ function calcDuration(run: any): string {
|
||||
.stat-value { font-size: 30px; font-weight: 800; color: var(--mc-text-primary); line-height: 1; letter-spacing: -0.05em; }
|
||||
.stat-label { font-size: 12px; color: var(--mc-text-tertiary); margin-top: 6px; text-transform: uppercase; letter-spacing: 0.08em; }
|
||||
|
||||
.trend-section { margin-bottom: 22px; }
|
||||
.trend-chart { padding: 18px; }
|
||||
.chart-container { width: 100%; height: 240px; }
|
||||
|
||||
.comparison-section { margin-bottom: 22px; }
|
||||
.comparison-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
||||
.comparison-card {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user