feat: add AI Digital Employee agent orchestrator with pipeline tracking

- New AgentPipeline model with JSONB pipeline_data for stages/leads/summary
- AgentOrchestrator service chains DiscoveryService search→analyze→outreach→auto-save
- 3 new API endpoints: POST /agent/start, GET /agent/pipelines, GET /agent/{id}
- Full Agent dashboard Vue component with stats, pipeline grid, leads table, outreach preview
- Sidebar redesigned with AI Agent as primary entry point
- Updated PROGRESS.md, AGENTS.md, DATABASE_SCHEMA.md with latest state
This commit is contained in:
wlt
2026-06-16 18:30:56 +08:00
parent 15d172e825
commit 7317fbe012
15 changed files with 1052 additions and 83 deletions
+4
View File
@@ -131,4 +131,8 @@ export function subscribeCreditPlan(planId, payType = 'alipay') {
}
export function cancelCreditSubscription() { return http.post('/credits/cancel-subscription') }
export function startAgentPipeline(data) { return http.post('/agent/start', data, { timeout: 300000 }) }
export function listAgentPipelines(params) { return http.get('/agent/pipelines', { params }) }
export function getAgentPipeline(id) { return http.get(`/agent/${id}`) }
export default http
+1 -7
View File
@@ -16,6 +16,7 @@
:collapse-transition="false"
@select="showMobileMenu = false"
>
<el-menu-item index="/agent"><el-icon><MagicStick /></el-icon><span>{{ $t('nav.agent') || 'AI数字员工' }}</span></el-menu-item>
<el-menu-item index="/discovery"><el-icon><Search /></el-icon><span>{{ $t('nav.discovery') }}</span></el-menu-item>
<el-menu-item index="/workspace"><el-icon><Odometer /></el-icon><span>{{ $t('nav.workspace') }}</span></el-menu-item>
<el-menu-item index="/customers"><el-icon><User /></el-icon><span>{{ $t('nav.customers') }}</span></el-menu-item>
@@ -137,13 +138,6 @@ const beianInfo = computed(() => {
return { icp: '京ICP备2026007249号-1', gongan: '京公网安备11011502039545号', gonganLink: 'https://beian.mps.gov.cn/#/query/webSearch?code=11011502039545', showGongan: true }
})
onMounted(async () => {
try {
const res = await getUnreadCount()
unread.value = res.count || res || 0
} catch { /* ignore */ }
})
function handleLogout() {
auth.logout()
router.push('/')
+8
View File
@@ -3,6 +3,14 @@ import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/login', redirect: '/' },
{ path: '/', name: 'Landing', component: () => import('@/views/WorkspaceLanding.vue') },
{
path: '/agent',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Agent', component: () => import('@/views/Agent.vue'), meta: { title: 'AI数字员工' } },
]
},
{
path: '/workspace',
component: () => import('@/layouts/UserLayout.vue'),
+531
View File
@@ -0,0 +1,531 @@
<template>
<div class="agent-dashboard">
<!-- Header -->
<div class="agent-header">
<div class="agent-header-left">
<h2>{{ $t('agent.title') || 'AI 数字员工' }}</h2>
<p class="agent-subtitle">{{ $t('agent.subtitle') || '智能挖掘 · 分析 · 触达 · 跟进,一站式自动完成' }}</p>
</div>
<el-button type="primary" size="large" @click="showStartDialog = true" :icon="Plus">
{{ $t('agent.newTask') || '新建任务' }}
</el-button>
</div>
<!-- Stats -->
<el-row :gutter="16" class="agent-stats">
<el-col :xs="12" :sm="6">
<el-card shadow="never">
<div class="stat-item">
<div class="stat-value">{{ stats.total }}</div>
<div class="stat-label">{{ $t('agent.totalTasks') || '总任务' }}</div>
</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never">
<div class="stat-item">
<div class="stat-value" style="color:#67c23a">{{ stats.completed }}</div>
<div class="stat-label">{{ $t('agent.completed') || '已完成' }}</div>
</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never">
<div class="stat-item">
<div class="stat-value" style="color:#e6a23c">{{ stats.total_leads }}</div>
<div class="stat-label">{{ $t('agent.totalLeads') || '累计线索' }}</div>
</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="never">
<div class="stat-item">
<div class="stat-value" style="color:#409eff">{{ stats.saved_customers }}</div>
<div class="stat-label">{{ $t('agent.savedCustomers') || '已保存客户' }}</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- Pipeline List -->
<div class="agent-section">
<div class="section-header">
<h3>{{ $t('agent.taskHistory') || '任务历史' }}</h3>
<el-radio-group v-model="statusFilter" size="small">
<el-radio-button value="">{{ $t('agent.all') || '全部' }}</el-radio-button>
<el-radio-button value="running">{{ $t('agent.running') || '进行中' }}</el-radio-button>
<el-radio-button value="completed">{{ $t('agent.done') || '已完成' }}</el-radio-button>
<el-radio-button value="failed">{{ $t('agent.failed') || '失败' }}</el-radio-button>
</el-radio-group>
</div>
<div v-if="loading" style="text-align:center;padding:60px">
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
</div>
<template v-else-if="filteredPipelines.length">
<div class="pipeline-grid">
<el-card
v-for="p in filteredPipelines"
:key="p.id"
shadow="hover"
:class="['pipeline-card', { active: selectedId === p.id }]"
@click="selectPipeline(p)"
>
<div class="pipeline-card-header">
<el-tag :type="statusTag(p.status)" size="small">{{ statusLabel(p.status) }}</el-tag>
<el-tag v-if="p.progress === 100" type="success" size="small" effect="dark">{{ p.progress }}%</el-tag>
<el-tag v-else type="warning" size="small" effect="plain">{{ p.progress || 0 }}%</el-tag>
</div>
<div class="pipeline-card-body">
<h4>{{ p.product_name }}</h4>
<p class="pipeline-market">{{ p.target_market }}</p>
<p v-if="p.pipeline_data?.summary" class="pipeline-summary">
发现 <strong>{{ p.pipeline_data.summary.total_leads || 0 }}</strong> 个线索
高匹配 <strong>{{ p.pipeline_data.summary.high_match || 0 }}</strong>
</p>
</div>
<div class="pipeline-card-footer">
<span class="pipeline-time">{{ formatTime(p.created_at) }}</span>
</div>
<!-- Progress bar for running -->
<el-progress
v-if="p.status === 'running'"
:percentage="p.progress || 0"
:stroke-width="3"
style="margin-top:8px"
/>
</el-card>
</div>
<div v-if="totalPages > 1" class="pagination-wrap">
<el-pagination
background
layout="prev, pager, next"
:total="totalPipelines"
:page-size="pageSize"
v-model:current-page="currentPage"
@current-change="loadPipelines"
/>
</div>
</template>
<el-empty v-else :description="$t('agent.noTasks') || '暂无任务,点击右上角新建'" :image-size="80" />
</div>
<!-- Pipeline Detail -->
<el-card v-if="selectedPipeline" shadow="never" class="pipeline-detail">
<template #header>
<div class="detail-header">
<div>
<strong>{{ selectedPipeline.product_name }}</strong>
<el-tag :type="statusTag(selectedPipeline.status)" size="small" style="margin-left:8px">
{{ statusLabel(selectedPipeline.status) }}
</el-tag>
<span style="color:#999;font-size:12px;margin-left:12px">{{ selectedPipeline.target_market }}</span>
</div>
<div>
<el-button size="small" @click="selectedPipeline = null">{{ $t('common.close') || '关闭' }}</el-button>
</div>
</div>
</template>
<!-- Stage Progress -->
<div class="stage-steps">
<div
v-for="(st, stKey) in selectedPipeline.pipeline_data?.stages || {}"
:key="stKey"
:class="['stage-step', st.status]"
>
<div class="stage-icon">
<el-icon v-if="st.status === 'completed'" color="#67c23a"><CircleCheck /></el-icon>
<el-icon v-else-if="st.status === 'running'" class="is-loading" color="#409eff"><Loading /></el-icon>
<el-icon v-else color="#999"><CircleClose /></el-icon>
</div>
<div class="stage-content">
<div class="stage-name">{{ stageLabel(stKey) }}</div>
<div class="stage-msg">{{ st.message || '' }}</div>
</div>
</div>
</div>
<!-- Leads Table -->
<div v-if="leads.length" class="leads-section">
<h4 style="margin:16px 0 12px">{{ $t('agent.leads') || '客户线索' }} ({{ leads.length }})</h4>
<el-table :data="leads" stripe style="width:100%" @row-click="showLeadDetail">
<el-table-column prop="name" :label="$t('agent.leadName') || '公司名称'" min-width="160">
<template #default="{ row }">
<div class="lead-name-cell">
<span>{{ row.name }}</span>
<el-tag v-if="row.match_score >= 70" size="small" type="success"></el-tag>
<el-tag v-else-if="row.match_score >= 50" size="small" type="warning"></el-tag>
<el-tag v-else size="small" type="info"></el-tag>
</div>
</template>
</el-table-column>
<el-table-column prop="match_score" :label="$t('agent.matchScore') || '匹配度'" width="100" align="center">
<template #default="{ row }">
<el-progress
:percentage="row.match_score || 0"
:stroke-width="10"
:color="scoreColor(row.match_score)"
style="width:80px"
/>
</template>
</el-table-column>
<el-table-column prop="country" :label="$t('agent.country') || '国家'" width="100" />
<el-table-column prop="source" :label="$t('agent.source') || '来源'" width="100" />
<el-table-column :label="$t('agent.outreach') || '触达文案'" min-width="120">
<template #default="{ row }">
<el-button
v-if="row.outreach"
size="small"
type="primary"
link
@click.stop="showOutreach(row)"
>{{ $t('agent.preview') || '预览' }}</el-button>
<span v-else style="color:#999;font-size:12px">{{ $t('agent.noOutreach') || '未生成' }}</span>
</template>
</el-table-column>
<el-table-column :label="$t('agent.actions') || '操作'" width="180" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" link @click.stop="goToCustomers(row)">
{{ $t('agent.addCustomer') || '添加客户' }}
</el-button>
<el-button v-if="row.url && row.url.startsWith('http')" size="small" link @click.stop="openUrl(row.url)">
{{ $t('agent.visit') || '访问' }}
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- Error message -->
<el-alert v-if="selectedPipeline.error_message" :title="selectedPipeline.error_message" type="error" show-icon :closable="false" style="margin-top:12px" />
</el-card>
<!-- Start New Task Dialog -->
<el-dialog
v-model="showStartDialog"
:title="$t('agent.newTask') || '新建 AI 数字员工任务'"
width="520px"
:close-on-click-modal="false"
>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item :label="$t('agent.productName') || '产品名称'" prop="product_name">
<el-input v-model="form.product_name" :placeholder="$t('agent.productNamePlaceholder') || '例如:户外折叠椅'" />
</el-form-item>
<el-form-item :label="$t('agent.productDescription') || '产品描述(选填)'" prop="product_description">
<el-input
v-model="form.product_description"
type="textarea"
:rows="3"
:placeholder="$t('agent.productDescPlaceholder') || '描述产品的材质、尺寸、优势等,可帮助AI更精准匹配'"
/>
</el-form-item>
<el-form-item :label="$t('agent.targetMarket') || '目标市场'" prop="target_market">
<el-input v-model="form.target_market" :placeholder="$t('agent.marketPlaceholder') || '例如:美国、德国、东南亚等'" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showStartDialog = false">{{ $t('common.cancel') || '取消' }}</el-button>
<el-button type="primary" :loading="starting" @click="startTask">
{{ $t('agent.start') || '开始执行' }}
</el-button>
</template>
</el-dialog>
<!-- Outreach Preview Dialog -->
<el-dialog
v-model="showOutreachDialog"
:title="outreachLead?.name"
width="600px"
>
<template v-if="outreachData">
<el-tabs>
<el-tab-pane label="WhatsApp">
<pre class="outreach-text">{{ outreachData.whatsapp_message || '未生成' }}</pre>
</el-tab-pane>
<el-tab-pane label="LinkedIn">
<pre class="outreach-text">{{ outreachData.linkedin_message || '未生成' }}</pre>
</el-tab-pane>
<el-tab-pane label="Email">
<div class="outreach-email">
<div v-if="outreachData.subject" class="outreach-subject"><strong>主题</strong>{{ outreachData.subject }}</div>
<pre class="outreach-text">{{ outreachData.email_body || '未生成' }}</pre>
</div>
</el-tab-pane>
<el-tab-pane :label="$t('agent.tips') || '建议'">
<ul v-if="outreachData.tips?.length">
<li v-for="(t, i) in outreachData.tips" :key="i" style="margin:4px 0">{{ t }}</li>
</ul>
<div v-if="outreachData.key_points?.length" style="margin-top:8px">
<strong>{{ $t('agent.keyPoints') || '关键要点' }}</strong>
<el-tag v-for="(kp, i) in outreachData.key_points" :key="i" size="small" style="margin:2px">{{ kp }}</el-tag>
</div>
</el-tab-pane>
</el-tabs>
</template>
</el-dialog>
<el-dialog
v-model="showLeadDialog"
:title="leadDetail?.name"
width="500px"
>
<template v-if="leadDetail">
<div class="lead-info">
<el-descriptions :column="1" border size="small">
<el-descriptions-item :label="$t('agent.matchScore') || '匹配度'">
<el-progress :percentage="leadDetail.match_score || 0" :stroke-width="14" :color="scoreColor(leadDetail.match_score)" style="width:120px" />
</el-descriptions-item>
<el-descriptions-item :label="$t('agent.matchReason') || '匹配理由'">{{ leadDetail.match_reason || '暂无' }}</el-descriptions-item>
<el-descriptions-item :label="$t('agent.companySummary') || '公司简介'">{{ leadDetail.company_summary || leadDetail.description || '暂无' }}</el-descriptions-item>
<el-descriptions-item :label="$t('agent.productFit') || '产品契合度'">{{ leadDetail.product_fit || '暂无' }}</el-descriptions-item>
<el-descriptions-item :label="$t('agent.country') || '国家'">{{ leadDetail.country || '未知' }}</el-descriptions-item>
<el-descriptions-item :label="$t('agent.source') || '来源'">{{ leadDetail.source || '未知' }}</el-descriptions-item>
<el-descriptions-item v-if="leadDetail.url" :label="'URL'">
<a :href="leadDetail.url" target="_blank" rel="noopener">{{ leadDetail.url.substring(0, 50) }}</a>
</el-descriptions-item>
</el-descriptions>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
Plus, Loading, CircleCheck, CircleClose,
} from '@element-plus/icons-vue'
import {
startAgentPipeline,
listAgentPipelines,
getAgentPipeline,
} from '@/api'
import { createCustomer } from '@/api'
const router = useRouter()
const { t } = useI18n()
// State
const showStartDialog = ref(false)
const showOutreachDialog = ref(false)
const showLeadDialog = ref(false)
const starting = ref(false)
const loading = ref(false)
const pipelines = ref([])
const selectedPipeline = ref(null)
const selectedId = ref(null)
const outreachLead = ref(null)
const outreachData = ref(null)
const leadDetail = ref(null)
const statusFilter = ref('')
const currentPage = ref(1)
const pageSize = 12
const totalPipelines = ref(0)
const form = ref({
product_name: '',
product_description: '',
target_market: '',
})
const rules = {
product_name: [{ required: true, message: '请输入产品名称', trigger: 'blur' }],
target_market: [{ required: true, message: '请输入目标市场', trigger: 'blur' }],
}
// Computed
const totalPages = computed(() => Math.ceil(totalPipelines.value / pageSize))
const filteredPipelines = computed(() => {
if (!statusFilter.value) return pipelines.value
return pipelines.value.filter(p => p.status === statusFilter.value)
})
const leads = computed(() => {
if (!selectedPipeline.value) return []
return selectedPipeline.value.pipeline_data?.leads || []
})
const stats = computed(() => {
const all = pipelines.value
const completed = all.filter(p => p.status === 'completed')
const totalLeads = completed.reduce((sum, p) => sum + (p.pipeline_data?.summary?.total_leads || 0), 0)
const saved = completed.reduce((sum, p) => sum + (p.pipeline_data?.summary?.customers_saved || 0), 0)
return {
total: all.length,
completed: completed.length,
total_leads: totalLeads,
saved_customers: saved,
}
})
// Methods
function statusTag(status) {
return { running: 'warning', completed: 'success', failed: 'danger' }[status] || 'info'
}
function statusLabel(status) {
return { running: '进行中', completed: '已完成', failed: '失败', pending: '等待中' }[status] || status
}
function stageLabel(key) {
return { discover: '客户搜索', analyze: '匹配分析', outreach: '触达文案', complete: '任务完成' }[key] || key
}
function scoreColor(score) {
if (score >= 70) return '#67c23a'
if (score >= 50) return '#e6a23c'
return '#909399'
}
function formatTime(ts) {
if (!ts) return ''
const d = new Date(ts)
const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
async function loadPipelines() {
loading.value = true
try {
const res = await listAgentPipelines({ page: currentPage.value, size: pageSize })
if (res.code === 0) {
pipelines.value = res.data.items || []
totalPipelines.value = res.data.total || 0
}
} catch (e) {
console.error('Failed to load pipelines', e)
} finally {
loading.value = false
}
}
async function selectPipeline(p) {
selectedId.value = p.id
try {
const res = await getAgentPipeline(p.id)
if (res.code === 0) {
selectedPipeline.value = res.data
}
} catch (e) {
console.error('Failed to load pipeline', e)
}
}
async function startTask() {
const formRef = document.querySelector('.el-form')
if (!formRef) return
try {
await formRef.validate?.()
} catch {
return
}
starting.value = true
try {
const res = await startAgentPipeline({
product_name: form.value.product_name,
product_description: form.value.product_description,
target_market: form.value.target_market,
})
if (res.code === 0) {
showStartDialog.value = false
form.value = { product_name: '', product_description: '', target_market: '' }
await loadPipelines()
selectedPipeline.value = res.data
selectedId.value = res.data.id
currentPage.value = 1
}
} catch (e) {
console.error('Failed to start pipeline', e)
} finally {
starting.value = false
}
}
function showOutreach(lead) {
outreachLead.value = lead
outreachData.value = lead.outreach
showOutreachDialog.value = true
}
function showLeadDetail(row) {
leadDetail.value = row
showLeadDialog.value = true
}
async function goToCustomers(lead) {
try {
await createCustomer({
name: lead.name,
company: lead.company_summary || lead.name,
country: lead.country || '',
description: (lead.description || '').substring(0, 500),
status: 'lead',
source: 'ai_agent',
})
router.push('/customers')
} catch (e) {
console.error('Failed to add customer', e)
}
}
function openUrl(url) {
window.open(url, '_blank', 'noopener')
}
onMounted(loadPipelines)
</script>
<style scoped>
.agent-dashboard { max-width: 1200px; margin: 0 auto; }
.agent-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; gap: 16px; flex-wrap: wrap; }
.agent-header-left h2 { margin: 0; font-size: 22px; }
.agent-subtitle { margin: 4px 0 0; color: #999; font-size: 13px; }
.agent-stats { margin-bottom: 24px; }
.stat-item { text-align: center; padding: 8px 0; }
.stat-value { font-size: 28px; font-weight: 700; color: #303133; }
.stat-label { font-size: 12px; color: #999; margin-top: 4px; }
.agent-section { margin-bottom: 24px; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 8px; }
.section-header h3 { margin: 0; font-size: 16px; }
.pipeline-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.pipeline-card { cursor: pointer; transition: all 0.2s; }
.pipeline-card:hover { transform: translateY(-2px); }
.pipeline-card.active { border-color: #409eff; box-shadow: 0 0 0 2px rgba(64,158,255,0.2); }
.pipeline-card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.pipeline-card-body h4 { margin: 0; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pipeline-market { color: #409eff; font-size: 12px; margin: 4px 0; }
.pipeline-summary { color: #666; font-size: 12px; margin: 4px 0; }
.pipeline-card-footer { display: flex; justify-content: space-between; font-size: 11px; color: #ccc; margin-top: 8px; }
.pipeline-time { color: #999; }
.pagination-wrap { display: flex; justify-content: center; margin-top: 20px; }
.pipeline-detail { margin-top: 20px; }
.detail-header { display: flex; justify-content: space-between; align-items: center; }
.stage-steps { display: flex; gap: 8px; padding: 16px 0; flex-wrap: wrap; }
.stage-step { display: flex; align-items: center; gap: 8px; padding: 8px 14px; border-radius: 8px; background: #f5f7fa; flex: 1; min-width: 140px; }
.stage-step.completed { background: #f0f9eb; }
.stage-step.running { background: #ecf5ff; }
.stage-step.pending { opacity: 0.6; }
.stage-icon { flex-shrink: 0; font-size: 20px; }
.stage-content { min-width: 0; }
.stage-name { font-size: 13px; font-weight: 600; }
.stage-msg { font-size: 11px; color: #666; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 180px; }
.lead-name-cell { display: flex; align-items: center; gap: 6px; }
.lead-name-cell span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 140px; display: inline-block; }
.outreach-text { white-space: pre-wrap; word-break: break-word; background: #f5f7fa; padding: 12px; border-radius: 6px; font-size: 13px; line-height: 1.6; max-height: 300px; overflow-y: auto; }
.outreach-email .outreach-subject { margin-bottom: 8px; }
.lead-info { padding: 4px 0; }
@media (max-width: 768px) {
.pipeline-grid { grid-template-columns: 1fr; }
.stage-steps { flex-direction: column; }
.agent-header { flex-direction: column; align-items: flex-start; }
}
</style>