feat(ux): agent templates, chat status indicator, dashboard trend chart

This commit is contained in:
matevip 2026-04-09 20:11:19 +08:00
parent 62b7998975
commit 4cd373dcdd
6 changed files with 278 additions and 7 deletions

View File

@ -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) => {

View 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',

View File

@ -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: '今日',

View File

@ -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>

View File

@ -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);

View File

@ -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 {