feat: restructure workspace navigation (sidebar 11->5, merged homepage)

Router reorganized: /workspace is now the landing page (NewHome.vue). Sidebar reduced from 11 items to 5 groups (Home/Customers/Products&Quotes/Translate/More). Profile sub-navigation updated to /workspace/profile/*. Legacy discovery/marketing/followup routes preserved as hidden.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
wlt
2026-06-17 10:48:13 +08:00
parent 7317fbe012
commit 45e98a9c82
4 changed files with 747 additions and 129 deletions
+26 -17
View File
@@ -16,17 +16,26 @@
:collapse-transition="false" :collapse-transition="false"
@select="showMobileMenu = 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="/workspace"><el-icon><Odometer /></el-icon><span>{{ $t('nav.home') || '首页工作台' }}</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/customers"><el-icon><User /></el-icon><span>{{ $t('nav.customers') }}</span></el-menu-item>
<el-menu-item index="/workspace"><el-icon><Odometer /></el-icon><span>{{ $t('nav.workspace') }}</span></el-menu-item> <el-sub-menu index="products-quotations">
<el-menu-item index="/customers"><el-icon><User /></el-icon><span>{{ $t('nav.customers') }}</span></el-menu-item> <template #title>
<el-menu-item index="/products"><el-icon><Goods /></el-icon><span>{{ $t('nav.products') }}</span></el-menu-item> <el-icon><Goods /></el-icon>
<el-menu-item index="/quotations"><el-icon><DocumentCopy /></el-icon><span>{{ $t('nav.quotations') }}</span></el-menu-item> <span>{{ $t('nav.productsQuotations') || '报价产品' }}</span>
<el-menu-item index="/translate"><el-icon><ChatLineSquare /></el-icon><span>{{ $t('nav.translate') }}</span></el-menu-item> </template>
<el-menu-item index="/marketing"><el-icon><Promotion /></el-icon><span>{{ $t('nav.marketing') }}</span></el-menu-item> <el-menu-item index="/workspace/products"><el-icon><Goods /></el-icon><span>{{ $t('nav.products') }}</span></el-menu-item>
<el-menu-item index="/followup"><el-icon><Message /></el-icon><span>{{ $t('nav.followup') }}</span></el-menu-item> <el-menu-item index="/workspace/quotations"><el-icon><DocumentCopy /></el-icon><span>{{ $t('nav.quotations') }}</span></el-menu-item>
<el-menu-item index="/analytics"><el-icon><DataAnalysis /></el-icon><span>{{ $t('nav.analytics') }}</span></el-menu-item> </el-sub-menu>
<el-menu-item index="/team"><el-icon><UserFilled /></el-icon><span>{{ $t('nav.team') }}</span></el-menu-item> <el-menu-item index="/workspace/translate"><el-icon><ChatLineSquare /></el-icon><span>{{ $t('nav.translate') }}</span></el-menu-item>
<el-sub-menu index="more">
<template #title>
<el-icon><Menu /></el-icon>
<span>{{ $t('nav.more') || '更多' }}</span>
</template>
<el-menu-item index="/workspace/profile"><el-icon><User /></el-icon><span>{{ $t('nav.profile') }}</span></el-menu-item>
<el-menu-item index="/workspace/analytics"><el-icon><DataAnalysis /></el-icon><span>{{ $t('nav.analytics') }}</span></el-menu-item>
<el-menu-item index="/workspace/team"><el-icon><UserFilled /></el-icon><span>{{ $t('nav.team') }}</span></el-menu-item>
</el-sub-menu>
</el-menu> </el-menu>
</aside> </aside>
@@ -36,17 +45,17 @@
<el-icon :size="20"><Expand /></el-icon> <el-icon :size="20"><Expand /></el-icon>
</el-button> </el-button>
<el-breadcrumb separator="/" class="breadcrumb"> <el-breadcrumb separator="/" class="breadcrumb">
<el-breadcrumb-item :to="'/workspace'">{{ $t('nav.workspace') }}</el-breadcrumb-item> <el-breadcrumb-item :to="'/workspace'">{{ $t('nav.home') || '首页工作台' }}</el-breadcrumb-item>
<el-breadcrumb-item v-if="route.meta?.title" :to="route.path">{{ $t('nav.' + route.name?.toLowerCase()) || route.meta.title }}</el-breadcrumb-item> <el-breadcrumb-item v-if="route.meta?.title && route.path !== '/workspace'" :to="route.path">{{ $t('nav.' + route.name?.toLowerCase()) || route.meta.title }}</el-breadcrumb-item>
</el-breadcrumb> </el-breadcrumb>
<div class="topbar-right"> <div class="topbar-right">
<el-button text style="font-size:13px;color:#999" @click="toggleLang">{{ currentLang }}</el-button> <el-button text style="font-size:13px;color:#999" @click="toggleLang">{{ currentLang }}</el-button>
<el-button v-if="creditBalance !== null" text class="credit-btn" @click="$router.push('/credits')"> <el-button v-if="creditBalance !== null" text class="credit-btn" @click="$router.push('/workspace/profile/credits')">
<el-icon><Coin /></el-icon> <el-icon><Coin /></el-icon>
<span class="credit-text">{{ creditBalance }} {{ $t('topbar.credits') }}</span> <span class="credit-text">{{ creditBalance }} {{ $t('topbar.credits') }}</span>
</el-button> </el-button>
<el-badge :value="unread" :hidden="!unread" class="notif-badge"> <el-badge :value="unread" :hidden="!unread" class="notif-badge">
<el-button text style="font-size:18px" @click="$router.push('/notifications')"> <el-button text style="font-size:18px" @click="$router.push('/workspace/profile/notifications')">
<el-icon><Bell /></el-icon> <el-icon><Bell /></el-icon>
</el-button> </el-button>
</el-badge> </el-badge>
@@ -58,8 +67,8 @@
</el-button> </el-button>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item @click="$router.push('/profile')">{{ $t('nav.profile') }}</el-dropdown-item> <el-dropdown-item @click="$router.push('/workspace/profile')">{{ $t('nav.profile') }}</el-dropdown-item>
<el-dropdown-item @click="$router.push('/notifications')">{{ $t('nav.notifications') }}</el-dropdown-item> <el-dropdown-item @click="$router.push('/workspace/profile/notifications')">{{ $t('nav.notifications') }}</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">{{ $t('common.confirm') }}退出</el-dropdown-item> <el-dropdown-item divided @click="handleLogout">{{ $t('common.confirm') }}退出</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
+32 -107
View File
@@ -3,52 +3,49 @@ import { createRouter, createWebHistory } from 'vue-router'
const routes = [ const routes = [
{ path: '/login', redirect: '/' }, { path: '/login', redirect: '/' },
{ path: '/', name: 'Landing', component: () => import('@/views/WorkspaceLanding.vue') }, { 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', path: '/workspace',
component: () => import('@/layouts/UserLayout.vue'), component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
children: [ children: [
{ path: '', name: 'Workspace', component: () => import('@/views/Workspace.vue'), meta: { title: '工作台' } }, { path: '', name: 'Home', component: () => import('@/views/NewHome.vue'), meta: { title: '首页工作台' } },
{ path: 'customers', name: 'Customers', component: () => import('@/views/Customers.vue'), meta: { title: '客户管理' } },
{ path: 'products', name: 'Products', component: () => import('@/views/Products.vue'), meta: { title: '产品库' } },
{ path: 'quotations', name: 'Quotations', component: () => import('@/views/Quotations.vue'), meta: { title: '报价单' } },
{ path: 'translate', name: 'Translate', component: () => import('@/views/Translate.vue'), meta: { title: '智能翻译' } },
{ path: 'analytics', name: 'Analytics', component: () => import('@/views/Analytics.vue'), meta: { title: '数据分析' } },
{ path: 'team', name: 'Team', component: () => import('@/views/Team.vue'), meta: { title: '团队协作' } },
{ path: 'profile', name: 'Profile', component: () => import('@/views/Profile.vue'), meta: { title: '个人中心' } },
{ path: 'profile/credits', name: 'Credits', component: () => import('@/views/Credits.vue'), meta: { title: '购买次数' } },
{ path: 'profile/certification', name: 'Certification', component: () => import('@/views/Certification.vue'), meta: { title: '实名认证' } },
{ path: 'profile/invoice', name: 'Invoice', component: () => import('@/views/Invoice.vue'), meta: { title: '发票管理' } },
{ path: 'profile/notifications', name: 'Notifications', component: () => import('@/views/Notifications.vue'), meta: { title: '通知中心' } },
{ path: 'profile/feedback', name: 'Feedback', component: () => import('@/views/Feedback.vue'), meta: { title: '意见反馈' } },
] ]
}, },
// Redirect old top-level routes to new /workspace children
{ path: '/customers', redirect: '/workspace/customers' },
{ path: '/products', redirect: '/workspace/products' },
{ path: '/quotations', redirect: '/workspace/quotations' },
{ path: '/translate', redirect: '/workspace/translate' },
{ path: '/analytics', redirect: '/workspace/analytics' },
{ path: '/team', redirect: '/workspace/team' },
{ path: '/profile', redirect: '/workspace/profile' },
{ path: '/credits', redirect: '/workspace/profile/credits' },
{ path: '/certification', redirect: '/workspace/profile/certification' },
{ path: '/invoice', redirect: '/workspace/profile/invoice' },
{ path: '/notifications', redirect: '/workspace/profile/notifications' },
{ path: '/feedback', redirect: '/workspace/profile/feedback' },
{ path: '/agent', redirect: '/workspace' },
{ path: '/workspace/home', redirect: '/workspace' },
// Hidden routes still accessible via direct URL but not in sidebar
{ {
path: '/translate', path: '/discovery',
component: () => import('@/layouts/UserLayout.vue'), component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
children: [ children: [
{ path: '', name: 'Translate', component: () => import('@/views/Translate.vue'), meta: { title: '智能翻译' } }, { path: '', name: 'Discovery', component: () => import('@/views/Discovery.vue'), meta: { title: '发现客户' } },
]
},
{
path: '/customers',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Customers', component: () => import('@/views/Customers.vue'), meta: { title: '客户管理' } },
]
},
{
path: '/products',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Products', component: () => import('@/views/Products.vue'), meta: { title: '产品库' } },
]
},
{
path: '/quotations',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Quotations', component: () => import('@/views/Quotations.vue'), meta: { title: '报价单' } },
] ]
}, },
{ {
@@ -59,14 +56,6 @@ const routes = [
{ path: '', name: 'Marketing', component: () => import('@/views/Marketing.vue'), meta: { title: '营销素材' } }, { path: '', name: 'Marketing', component: () => import('@/views/Marketing.vue'), meta: { title: '营销素材' } },
] ]
}, },
{
path: '/discovery',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Discovery', component: () => import('@/views/Discovery.vue'), meta: { title: '发现客户' } },
]
},
{ {
path: '/followup', path: '/followup',
component: () => import('@/layouts/UserLayout.vue'), component: () => import('@/layouts/UserLayout.vue'),
@@ -75,70 +64,6 @@ const routes = [
{ path: '', name: 'Followup', component: () => import('@/views/Followup.vue'), meta: { title: '智能跟进' } }, { path: '', name: 'Followup', component: () => import('@/views/Followup.vue'), meta: { title: '智能跟进' } },
] ]
}, },
{
path: '/analytics',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Analytics', component: () => import('@/views/Analytics.vue'), meta: { title: '数据分析' } },
]
},
{
path: '/team',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Team', component: () => import('@/views/Team.vue'), meta: { title: '团队协作' } },
]
},
{
path: '/notifications',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Notifications', component: () => import('@/views/Notifications.vue'), meta: { title: '通知中心' } },
]
},
{
path: '/profile',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Profile', component: () => import('@/views/Profile.vue'), meta: { title: '个人中心' } },
]
},
{
path: '/credits',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Credits', component: () => import('@/views/Credits.vue'), meta: { title: '购买次数' } },
]
},
{
path: '/certification',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Certification', component: () => import('@/views/Certification.vue'), meta: { title: '实名认证' } },
]
},
{
path: '/invoice',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Invoice', component: () => import('@/views/Invoice.vue'), meta: { title: '发票管理' } },
]
},
{
path: '/feedback',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Feedback', component: () => import('@/views/Feedback.vue'), meta: { title: '意见反馈' } },
]
},
{ path: '/:pathMatch(.*)*', redirect: '/' }, { path: '/:pathMatch(.*)*', redirect: '/' },
] ]
+679
View File
@@ -0,0 +1,679 @@
<template>
<div class="home-dashboard">
<!-- AI Input Bar (Wave 2 placeholder) -->
<div class="ai-input-section">
<div class="ai-input-bar">
<el-icon class="ai-input-icon" :size="20"><MagicStick /></el-icon>
<el-input
v-model="aiInput"
:placeholder="$t('home.aiPlaceholder') || '描述你的需求,AI 会帮你完成...'"
size="large"
clearable
@keyup.enter="handleAiInput"
/>
<el-button type="primary" size="large" @click="handleAiInput">{{ $t('home.send') || '发送' }}</el-button>
</div>
<div class="quick-tasks">
<el-button size="small" round @click="quickTask('find')">
<el-icon><Search /></el-icon>{{ $t('home.findCustomers') || '找客户' }}
</el-button>
<el-button size="small" round @click="quickTask('write')">
<el-icon><Edit /></el-icon>{{ $t('home.writeCopy') || '写文案' }}
</el-button>
<el-button size="small" round @click="quickTask('translate')">
<el-icon><ChatLineSquare /></el-icon>{{ $t('home.translate') || '翻译' }}
</el-button>
<el-button size="small" round @click="quickTask('quote')">
<el-icon><DocumentCopy /></el-icon>{{ $t('home.quote') || '报价' }}
</el-button>
</div>
</div>
<!-- Welcome + Stats -->
<div class="welcome-section">
<div class="welcome-left">
<h2>{{ $t('home.greeting') || '你好' }}{{ auth.user?.username || '用户' }}</h2>
<p class="welcome-desc">{{ $t('home.subtitle') || 'AI 数字员工为你自动挖掘、分析、触达、跟进' }}</p>
</div>
<div class="welcome-right">
<el-button type="primary" size="large" @click="showStartDialog = true" :icon="Plus">
{{ $t('agent.newTask') || '新建任务' }}
</el-button>
</div>
</div>
<!-- Credit Balance -->
<el-card shadow="never" class="credit-card">
<div class="credit-inner">
<div class="credit-left">
<div class="credit-label">{{ $t('home.creditBalance') || '信用余额' }}</div>
<div class="credit-amount">{{ creditBalance ?? '...' }} <small>{{ $t('home.credits') || '次' }}</small></div>
</div>
<div class="credit-right">
<el-button type="primary" @click="$router.push('/workspace/profile/credits')">{{ $t('home.buyCredits') || '购买次数' }} </el-button>
</div>
</div>
</el-card>
<!-- Stats Cards -->
<el-row :gutter="16" class="stats-row">
<el-col :xs="12" :sm="6" v-for="item in stats" :key="item.label">
<el-card shadow="hover" class="stat-card" @click="item.route && $router.push(item.route)">
<div class="stat-value" :style="{ color: item.color }">{{ item.value }}</div>
<div class="stat-label">{{ item.label }}</div>
</el-card>
</el-col>
</el-row>
<!-- Pipeline Stats (from Agent.vue) -->
<el-row :gutter="16" class="agent-stats">
<el-col :xs="12" :sm="3" v-for="s in pipelineStats" :key="s.label">
<el-card shadow="never">
<div class="stat-item">
<div class="stat-value" :style="{ color: s.color }">{{ s.value }}</div>
<div class="stat-label">{{ s.label }}</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- Activity Feed (Wave 2 placeholder) -->
<el-card shadow="never" class="activity-section" v-if="recentActivities.length">
<template #header>
<span class="section-title">{{ $t('home.recentActivity') || '最近活动' }}</span>
</template>
<div class="activity-list">
<div v-for="act in recentActivities" :key="act.id" class="activity-item">
<el-tag :type="act.type" size="small" class="activity-tag">{{ act.tag }}</el-tag>
<span class="activity-text">{{ act.text }}</span>
<span class="activity-time">{{ act.time }}</span>
</div>
</div>
</el-card>
<!-- 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">
{{ $t('agent.foundLeads') || '发现' }}
<strong>{{ p.pipeline_data.summary.total_leads || 0 }}</strong>
{{ $t('agent.leads') || '个线索' }}
{{ $t('agent.highMatch') || '高匹配' }}
<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>
<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>
<el-button size="small" @click="selectedPipeline = null">{{ $t('common.close') || '关闭' }}</el-button>
</div>
</template>
<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>
<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">{{ $t('agent.hot') || '' }}</el-tag>
<el-tag v-else-if="row.match_score >= 50" size="small" type="warning">{{ $t('agent.warm') || '' }}</el-tag>
<el-tag v-else size="small" type="info">{{ $t('agent.cold') || '冷' }}</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>
<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="$t('agent.whatsapp') || '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="$t('agent.email') || 'Email'">
<div class="outreach-email">
<div v-if="outreachData.subject" class="outreach-subject"><strong>{{ $t('agent.subject') || '主题' }}</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 { useAuthStore } from '@/stores/auth'
import { useI18n } from 'vue-i18n'
import { Plus, MagicStick, Search, Edit, ChatLineSquare, DocumentCopy, Loading, CircleCheck, CircleClose } from '@element-plus/icons-vue'
import {
startAgentPipeline,
listAgentPipelines,
getAgentPipeline,
createCustomer,
getCreditBalance,
getAnalyticsOverview,
} from '@/api'
const router = useRouter()
const { t } = useI18n()
const auth = useAuthStore()
// AI Input
const aiInput = ref('')
function handleAiInput() {
if (!aiInput.value.trim()) return
const text = aiInput.value.trim()
// Route to Agent pipeline or translate based on intent
if (text.includes('翻译') || text.includes('translate')) {
router.push('/workspace/translate')
} else {
// Default: treat as Agent task intent
showStartDialog.value = true
form.value.product_name = text
}
aiInput.value = ''
}
function quickTask(type) {
const prompts = {
find: '帮我找____市场的____产品买家',
write: '为产品____写一篇____风格的____',
translate: '',
quote: '根据客户____创建报价单',
}
if (type === 'translate') {
router.push('/workspace/translate')
} else {
showStartDialog.value = true
form.value.product_name = prompts[type] || ''
}
}
// Credit balance
const creditBalance = ref(null)
// Stats from analytics
const stats = ref([])
// Pipeline 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 pipelineStats = computed(() => {
const all = pipelines.value
const completedCount = all.filter(p => p.status === 'completed')
const totalLeads = completedCount.reduce((sum, p) => sum + (p.pipeline_data?.summary?.total_leads || 0), 0)
const saved = completedCount.reduce((sum, p) => sum + (p.pipeline_data?.summary?.customers_saved || 0), 0)
return [
{ value: all.length, label: t('agent.totalTasks') || '总任务', color: '#303133' },
{ value: completedCount.length, label: t('agent.completed') || '已完成', color: '#67c23a' },
{ value: totalLeads, label: t('agent.totalLeads') || '累计线索', color: '#e6a23c' },
{ value: saved, label: t('agent.savedCustomers') || '已保存客户', color: '#409eff' },
]
})
// Recent activities (from pipelines)
const recentActivities = computed(() => {
const acts = []
const recent = [...pipelines.value].sort((a, b) => new Date(b.created_at) - new Date(a.created_at)).slice(0, 5)
for (const p of recent) {
if (p.status === 'completed') {
acts.push({
id: `p-${p.id}`,
type: 'success',
tag: 'Agent',
text: `${p.product_name}${p.target_market},发现 ${p.pipeline_data?.summary?.total_leads || 0} 个线索`,
time: formatTime(p.created_at),
})
} else if (p.status === 'running') {
acts.push({
id: `p-${p.id}`,
type: 'warning',
tag: 'Agent',
text: `${p.product_name}${p.target_market},执行中 ${p.progress || 0}%`,
time: formatTime(p.created_at),
})
}
}
return acts
})
// 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('/workspace/customers')
} catch (e) {
console.error('Failed to add customer', e)
}
}
function openUrl(url) {
window.open(url, '_blank', 'noopener')
}
onMounted(async () => {
loadPipelines()
try {
const balance = await getCreditBalance()
creditBalance.value = balance.balance
} catch { creditBalance.value = null }
try {
const res = await getAnalyticsOverview()
const d = res?.data || res || {}
stats.value = [
{ value: d.customers?.total || 0, label: t('home.totalCustomers') || '客户总数', color: '#1890ff', route: '/workspace/customers' },
{ value: d.translations?.today || 0, label: t('home.todayTranslate') || '今日翻译', color: '#52c41a', route: '/workspace/translate' },
{ value: d.quotations?.total || 0, label: t('home.totalQuotations') || '报价单数', color: '#faad14', route: '/workspace/quotations' },
{ value: pipelines.value.filter(p => p.status === 'running').length, label: t('home.runningTasks') || '执行中', color: '#e6a23c' },
]
} catch { /* ignore */ }
})
</script>
<style scoped>
.home-dashboard { max-width: 1200px; margin: 0 auto; }
/* AI Input Bar */
.ai-input-section { margin-bottom: 24px; }
.ai-input-bar { display: flex; gap: 12px; align-items: center; background: #fff; padding: 16px 20px; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
.ai-input-icon { color: #722ed1; flex-shrink: 0; }
.ai-input-bar :deep(.el-input) { flex: 1; }
.ai-input-bar :deep(.el-input__wrapper) { box-shadow: none !important; border: 1px solid #e8e8e8; border-radius: 8px; }
.ai-input-bar :deep(.el-input__wrapper:hover) { border-color: #722ed1; }
.quick-tasks { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; padding: 0 4px; }
.quick-tasks .el-button { color: #666; border-color: #e8e8e8; }
.quick-tasks .el-button:hover { color: #722ed1; border-color: #722ed1; }
/* Welcome */
.welcome-section { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.welcome-left h2 { font-size: 22px; font-weight: 700; color: #333; margin: 0 0 4px; }
.welcome-desc { font-size: 13px; color: #999; margin: 0; }
.welcome-right { display: flex; gap: 12px; flex-shrink: 0; }
/* Credit */
.credit-card { margin-bottom: 16px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; }
.credit-inner { display: flex; align-items: center; justify-content: space-between; padding: 4px 0; }
.credit-label { font-size: 13px; opacity: 0.9; }
.credit-amount { font-size: 28px; font-weight: 700; margin-top: 4px; }
.credit-amount small { font-size: 14px; font-weight: 400; opacity: 0.8; }
/* Stats */
.stats-row { margin-bottom: 20px; }
.stat-card { cursor: pointer; text-align: center; transition: all 0.25s; }
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,0.08); }
.stat-value { font-size: 28px; font-weight: 700; }
.stat-label { font-size: 12px; color: #999; margin-top: 4px; }
.agent-stats { margin-bottom: 24px; }
.stat-item { text-align: center; padding: 8px 0; }
/* Activity */
.activity-section { margin-bottom: 24px; }
.section-title { font-weight: 600; font-size: 15px; }
.activity-list { display: flex; flex-direction: column; gap: 8px; }
.activity-item { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid #f5f5f5; }
.activity-item:last-child { border-bottom: none; }
.activity-tag { flex-shrink: 0; }
.activity-text { flex: 1; font-size: 13px; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.activity-time { font-size: 11px; color: #999; flex-shrink: 0; }
/* Pipeline */
.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; flex-wrap: wrap; gap: 8px; }
.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; }
.welcome-section { flex-direction: column; align-items: flex-start; gap: 12px; }
.ai-input-bar { flex-direction: column; }
.ai-input-bar :deep(.el-input) { width: 100%; }
.welcome-left h2 { font-size: 20px; }
}
</style>
+10 -5
View File
@@ -11,19 +11,22 @@
</div> </div>
<el-divider style="margin:8px 0" /> <el-divider style="margin:8px 0" />
<div class="profile-menu"> <div class="profile-menu">
<div class="menu-item" @click="$router.push('/credits')"> <div class="menu-item" :class="{ active: $route.path === '/workspace/profile' }" @click="$router.push('/workspace/profile')">
<el-icon><User /></el-icon><span>个人资料</span>
</div>
<div class="menu-item" :class="{ active: $route.path === '/workspace/profile/credits' }" @click="$router.push('/workspace/profile/credits')">
<el-icon><Coin /></el-icon><span>购买次数</span> <el-icon><Coin /></el-icon><span>购买次数</span>
</div> </div>
<div class="menu-item" @click="$router.push('/certification')"> <div class="menu-item" :class="{ active: $route.path === '/workspace/profile/certification' }" @click="$router.push('/workspace/profile/certification')">
<el-icon><Stamp /></el-icon><span>实名认证</span> <el-icon><Stamp /></el-icon><span>实名认证</span>
</div> </div>
<div class="menu-item" @click="$router.push('/invoice')"> <div class="menu-item" :class="{ active: $route.path === '/workspace/profile/invoice' }" @click="$router.push('/workspace/profile/invoice')">
<el-icon><List /></el-icon><span>发票管理</span> <el-icon><List /></el-icon><span>发票管理</span>
</div> </div>
<div class="menu-item" @click="$router.push('/notifications')"> <div class="menu-item" :class="{ active: $route.path === '/workspace/profile/notifications' }" @click="$router.push('/workspace/profile/notifications')">
<el-icon><Bell /></el-icon><span>通知中心</span> <el-icon><Bell /></el-icon><span>通知中心</span>
</div> </div>
<div class="menu-item" @click="$router.push('/feedback')"> <div class="menu-item" :class="{ active: $route.path === '/workspace/profile/feedback' }" @click="$router.push('/workspace/profile/feedback')">
<el-icon><ChatDotSquare /></el-icon><span>意见反馈</span> <el-icon><ChatDotSquare /></el-icon><span>意见反馈</span>
</div> </div>
</div> </div>
@@ -70,6 +73,7 @@ import { useAuthStore } from '@/stores/auth'
import { updateProfile, changePassword } from '@/api' import { updateProfile, changePassword } from '@/api'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
const route = useRoute()
const auth = useAuthStore() const auth = useAuthStore()
const saving = ref(false) const saving = ref(false)
const showPassword = ref(false) const showPassword = ref(false)
@@ -115,4 +119,5 @@ async function changePw() {
.profile-menu { padding: 0; } .profile-menu { padding: 0; }
.menu-item { display: flex; align-items: center; gap: 10px; padding: 12px 16px; cursor: pointer; border-radius: 6px; transition: background 0.2s; } .menu-item { display: flex; align-items: center; gap: 10px; padding: 12px 16px; cursor: pointer; border-radius: 6px; transition: background 0.2s; }
.menu-item:hover { background: #f0f5ff; color: #409eff; } .menu-item:hover { background: #f0f5ff; color: #409eff; }
.menu-item.active { background: #e6f7ff; color: #1890ff; font-weight: 500; }
</style> </style>