Compare commits

..

5 Commits

Author SHA1 Message Date
TradeMate Dev 2ccaafe470 feat: add user-frontend i18n keys for new nav structure
Add home, customers, biz, agent i18n keys to zh-CN and en locale files

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-21 23:08:00 +08:00
TradeMate Dev 417e79c64d feat: aggressive PC workspace UX redesign (consolidated workspaces, CommandK)
Reduce sidebar from 4-level clutter to 5 clean items. Add global CommandK palette (Ctrl+K). Merge customers/discovery/followup into WorkspaceCustomer. Merge translate/products/quotations/marketing into WorkspaceBiz. Simplify NewHome to dashboard. Redirect old routes.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-21 23:08:00 +08:00
TradeMate Dev cb8faed80f docs: update AGENTS.md with uni-app workspace consolidation structure
Add mobile H5 redesign section: 4-tab layout, workspace-customer/biz pages, updated config.js and pages.json

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-21 23:08:00 +08:00
TradeMate Dev 771b70843f feat: add AI agent and quotation routing options to admin config
Add agent (数字员工), followup (智能跟进), outreach (开发信), competitor (竞品分析) to ai_routing field labels in admin Config.vue

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-21 23:06:16 +08:00
TradeMate Dev b1ffb78629 feat: redesign uni-app mobile with consolidated 4-tab workspace
Replace 5-tab layout (首页/客户/营销/报价/我的) with 4-tab (首页/客户/业务/我的). Add workspace-customer (客户列表/挖掘新客/智能跟进) and workspace-biz (翻译/产品库/报价单/营销素材) pages. Simplify index to dashboard, update profile nav.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-21 23:05:29 +08:00
16 changed files with 3295 additions and 677 deletions
+12
View File
@@ -1,5 +1,17 @@
# TradeMate (外贸小助手) — Agent Guide # TradeMate (外贸小助手) — Agent Guide
## uni-app 移动端 H5 (改版后)
- **Tab Bar**: 4 项底部导航 — 首页 / 客户 / 业务 / 我的
- **主页** `pages/index/index.vue` — 简化仪表盘 (统计卡片 + 快捷入口矩阵 + 待办提醒)
- **客户工作台** `pages/workspace-customer/workspace-customer.vue`**NEW** 3 个内联标签 (客户列表 / 挖掘新客 / 智能跟进)
- **业务工作台** `pages/workspace-biz/workspace-biz.vue`**NEW** 4 个内联标签 (翻译 / 产品库 / 报价单 / 营销素材)
- **个人中心** `pages/profile/profile.vue` — 更新导航 (通知中心 / 数据分析 / 团队 / 升级会员 / 联系客服)
- **旧页面保留** (customers, discovery, followup, translate, product, quotation, marketing 等仍可通过 deep link 访问)
- **pages.json** — 标签栏从 5 项变为 4 项,新增 workspace-customer 和 workspace-biz 页面
- **config.js** — PAGES 配置新增 WORKSPACE_CUSTOMER / WORKSPACE_BIZ
- **部署路径**: `trade.yuzhiran.com/app/` — nginx SPA fallback `try_files $uri $uri/ /app/index.html`
## AI Assistant (Frontend AI Chatbot) ## AI Assistant (Frontend AI Chatbot)
- **Components**: `user-frontend/src/components/AiAssistant.vue` + `admin-frontend/src/components/AiAssistant.vue` - **Components**: `user-frontend/src/components/AiAssistant.vue` + `admin-frontend/src/components/AiAssistant.vue`
+1 -1
View File
@@ -57,7 +57,7 @@ const configs = ref([])
const edits = reactive({}) const edits = reactive({})
const providers = ref([]) const providers = ref([])
const configLabels = { system_maintenance: '系统维护', feature_flags: '功能开关', translation_providers: '翻译服务', ai_model_config: 'AI模型', cache_ttl: '缓存时间', ai_routing: 'AI路由规则' } const configLabels = { system_maintenance: '系统维护', feature_flags: '功能开关', translation_providers: '翻译服务', ai_model_config: 'AI模型', cache_ttl: '缓存时间', ai_routing: 'AI路由规则' }
const fieldLabelsMap = { system_maintenance: { maintenance_mode: '维护模式', maintenance_message: '维护消息' }, feature_flags: { feature_wechat_login: '微信登录', feature_export: '数据导出' }, translation_providers: { primary: '首选服务', fallback: '备用服务' }, ai_model_config: { default_model: '默认模型', max_tokens: '最大Token' }, ai_routing: { translate: '翻译', reply: '回复建议', marketing: '营销文案', extract: '信息提取', quotation: '报价单', chat: 'AI助手' } } const fieldLabelsMap = { system_maintenance: { maintenance_mode: '维护模式', maintenance_message: '维护消息' }, feature_flags: { feature_wechat_login: '微信登录', feature_export: '数据导出' }, translation_providers: { primary: '首选服务', fallback: '备用服务' }, ai_model_config: { default_model: '默认模型', max_tokens: '最大Token' }, ai_routing: { translate: '翻译', reply: '回复建议', marketing: '营销文案', extract: '信息提取', quotation: '报价单', chat: 'AI助手', agent: '数字员工', followup: '智能跟进', outreach: '开发信', competitor: '竞品分析' } }
const taskFieldLabels = { primary: '主选', fallback: '备用' } const taskFieldLabels = { primary: '主选', fallback: '备用' }
function fieldLabel(key, k) { function fieldLabel(key, k) {
if (key === 'ai_routing') return configLabels[key] + ' > ' + (taskFieldLabels[k] || k) if (key === 'ai_routing') return configLabels[key] + ' > ' + (taskFieldLabels[k] || k)
+3 -1
View File
@@ -10,11 +10,13 @@ export const STORAGE_KEYS = {
export const PAGES = { export const PAGES = {
INDEX: '/pages/index/index', INDEX: '/pages/index/index',
WORKSPACE_CUSTOMER: '/pages/workspace-customer/workspace-customer',
WORKSPACE_BIZ: '/pages/workspace-biz/workspace-biz',
PROFILE: '/pages/profile/profile',
TRANSLATE: '/pages/translate/translate', TRANSLATE: '/pages/translate/translate',
CUSTOMERS: '/pages/customers/customers', CUSTOMERS: '/pages/customers/customers',
MARKETING: '/pages/marketing/marketing', MARKETING: '/pages/marketing/marketing',
QUOTATION: '/pages/quotation/quotation', QUOTATION: '/pages/quotation/quotation',
PROFILE: '/pages/profile/profile',
LOGIN: '/pages/login/login', LOGIN: '/pages/login/login',
PRODUCT: '/pages/product/product', PRODUCT: '/pages/product/product',
UPGRADE: '/pages/upgrade/upgrade', UPGRADE: '/pages/upgrade/upgrade',
+17 -7
View File
@@ -18,6 +18,20 @@
"navigationBarTitleText": "客户管理" "navigationBarTitleText": "客户管理"
} }
}, },
{
"path": "pages/workspace-customer/workspace-customer",
"style": {
"navigationBarTitleText": "客户工作台",
"navigationStyle": "custom"
}
},
{
"path": "pages/workspace-biz/workspace-biz",
"style": {
"navigationBarTitleText": "业务工作台",
"navigationStyle": "custom"
}
},
{ {
"path": "pages/marketing/marketing", "path": "pages/marketing/marketing",
"style": { "style": {
@@ -145,16 +159,12 @@
"text": "首页" "text": "首页"
}, },
{ {
"pagePath": "pages/customers/customers", "pagePath": "pages/workspace-customer/workspace-customer",
"text": "客户" "text": "客户"
}, },
{ {
"pagePath": "pages/marketing/marketing", "pagePath": "pages/workspace-biz/workspace-biz",
"text": "营销" "text": "业务"
},
{
"pagePath": "pages/quotation/quotation",
"text": "报价"
}, },
{ {
"pagePath": "pages/profile/profile", "pagePath": "pages/profile/profile",
+15 -7
View File
@@ -130,25 +130,33 @@
</view> </view>
<view class="more-section"> <view class="more-section">
<view class="section-title">功能矩阵</view> <view class="section-title">快捷入口</view>
<view class="more-grid"> <view class="more-grid">
<view class="more-item" @click="goToPage(PAGES.TRANSLATE)"> <view class="more-item" @click="goToPage(PAGES.WORKSPACE_BIZ)">
<text class="more-icon">🔤</text> <text class="more-icon">🔤</text>
<text class="more-text">翻译</text> <text class="more-text">翻译</text>
</view> </view>
<view class="more-item" @click="goToPage(PAGES.DISCOVERY)"> <view class="more-item" @click="goToPage(PAGES.WORKSPACE_CUSTOMER)">
<text class="more-icon">🔍</text> <text class="more-icon">🔍</text>
<text class="more-text">挖掘新</text> <text class="more-text"></text>
</view> </view>
<view class="more-item" @click="hasLogin ? goToPage(PAGES.PRODUCT) : goToLogin()"> <view class="more-item" @click="hasLogin ? goToPage(PAGES.WORKSPACE_BIZ) : goToLogin()">
<text class="more-icon">📦</text> <text class="more-icon">📦</text>
<text class="more-text">产品</text> <text class="more-text">产品</text>
</view> </view>
<view class="more-item" @click="hasLogin ? goToPage(PAGES.FOLLOWUP) : goToLogin()"> <view class="more-item" @click="hasLogin ? goToPage(PAGES.WORKSPACE_CUSTOMER) : goToLogin()">
<text class="more-icon">📋</text> <text class="more-icon">📋</text>
<text class="more-text">跟进</text> <text class="more-text">跟进</text>
<text class="notif-badge" v-if="hasLogin && followupStats.pending > 0">{{ followupStats.pending > 99 ? '99+' : followupStats.pending }}</text> <text class="notif-badge" v-if="hasLogin && followupStats.pending > 0">{{ followupStats.pending > 99 ? '99+' : followupStats.pending }}</text>
</view> </view>
<view class="more-item" @click="hasLogin ? goToPage(PAGES.WORKSPACE_BIZ) : goToLogin()">
<text class="more-icon">💰</text>
<text class="more-text">报价</text>
</view>
<view class="more-item" @click="hasLogin ? goToPage(PAGES.WORKSPACE_BIZ) : goToLogin()">
<text class="more-icon">📝</text>
<text class="more-text">营销</text>
</view>
<view class="more-item" @click="hasLogin ? goToPage(PAGES.NOTIFICATION) : goToLogin()"> <view class="more-item" @click="hasLogin ? goToPage(PAGES.NOTIFICATION) : goToLogin()">
<text class="more-icon">🔔</text> <text class="more-icon">🔔</text>
<text class="more-text">通知</text> <text class="more-text">通知</text>
+45 -3
View File
@@ -19,6 +19,26 @@
</view> </view>
</view> </view>
<view class="section">
<view class="section-title">工作台</view>
<view class="menu-item" @click="goNotification">
<text class="menu-icon">🔔</text>
<text class="menu-text">通知中心</text>
<text class="badge-count" v-if="unreadCount > 0">{{ unreadCount > 99 ? '99+' : unreadCount }}</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="goAnalytics">
<text class="menu-icon">📊</text>
<text class="menu-text">数据分析</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="goTeam">
<text class="menu-icon">👥</text>
<text class="menu-text">团队协作</text>
<text class="menu-arrow"></text>
</view>
</view>
<view class="section"> <view class="section">
<view class="section-title">账号设置</view> <view class="section-title">账号设置</view>
<view class="menu-item" v-if="user.tier !== 'guest'" @click="showProfileEdit = true"> <view class="menu-item" v-if="user.tier !== 'guest'" @click="showProfileEdit = true">
@@ -59,6 +79,16 @@
<text class="menu-text">意见反馈</text> <text class="menu-text">意见反馈</text>
<text class="menu-arrow"></text> <text class="menu-arrow"></text>
</view> </view>
<view class="menu-item" @click="goPage('/pages/upgrade/upgrade')">
<text class="menu-icon">💎</text>
<text class="menu-text">升级会员</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="showWechatModal = true">
<text class="menu-icon">💁</text>
<text class="menu-text">联系客服</text>
<text class="menu-arrow"></text>
</view>
</view> </view>
<view class="section"> <view class="section">
@@ -138,12 +168,13 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { onShow } from '@dcloudio/uni-app' import { onShow } from '@dcloudio/uni-app'
import { authApi, creditApi } from '@/utils/api.js' import { authApi, creditApi, notificationApi } from '@/utils/api.js'
import AiAssistant from '@/components/ai-assistant.vue' import AiAssistant from '@/components/ai-assistant.vue'
import { STORAGE_KEYS, PAGES, TIER_LABELS } from '@/config.js' import { STORAGE_KEYS, PAGES, TIER_LABELS } from '@/config.js'
const user = ref({}) const user = ref({})
const creditBalance = ref(0) const creditBalance = ref(0)
const unreadCount = ref(0)
const showProfileEdit = ref(false) const showProfileEdit = ref(false)
const showPassword = ref(false) const showPassword = ref(false)
const editForm = ref({ username: '', email: '' }) const editForm = ref({ username: '', email: '' })
@@ -217,6 +248,17 @@ const goFeedback = () => uni.navigateTo({ url: PAGES.FEEDBACK })
const goAgreement = (type) => uni.navigateTo({ url: `/pages/agreement/${type}` }) const goAgreement = (type) => uni.navigateTo({ url: `/pages/agreement/${type}` })
const goCertification = () => uni.navigateTo({ url: PAGES.CERTIFICATION }) const goCertification = () => uni.navigateTo({ url: PAGES.CERTIFICATION })
const goInvoice = () => uni.navigateTo({ url: PAGES.INVOICE }) const goInvoice = () => uni.navigateTo({ url: PAGES.INVOICE })
const goNotification = () => uni.navigateTo({ url: PAGES.NOTIFICATION })
const goAnalytics = () => uni.navigateTo({ url: PAGES.ANALYTICS })
const goTeam = () => uni.navigateTo({ url: PAGES.TEAM })
const goPage = (url) => uni.navigateTo({ url })
const loadUnread = async () => {
try {
const res = await notificationApi.unreadCount()
unreadCount.value = res.count || 0
} catch { unreadCount.value = 0 }
}
const logout = () => { const logout = () => {
uni.showModal({ uni.showModal({
@@ -232,8 +274,8 @@ const logout = () => {
}) })
} }
onMounted(loadUser) onMounted(() => { loadUser(); loadUnread() })
onShow(loadUser) onShow(() => { loadUser(); loadUnread() })
</script> </script>
<style scoped> <style scoped>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+335
View File
@@ -0,0 +1,335 @@
<template>
<div>
<div v-if="visible" class="commandk-overlay" @click.self="close" />
<div v-if="visible" class="commandk-palette" @keydown.esc="close">
<div class="commandk-input-wrap">
<el-icon class="commandk-search-icon"><Search /></el-icon>
<input
ref="inputRef"
v-model="query"
class="commandk-input"
:placeholder="$t('nav.home') === 'Home' ? 'Search actions or type a question...' : '搜索功能或输入问题...'"
@keydown.enter.prevent="handleEnter"
@keydown.down.prevent="moveSelection(1)"
@keydown.up.prevent="moveSelection(-1)"
/>
<span class="commandk-hint">K</span>
</div>
<div v-if="aiMode" class="commandk-ai-section">
<div class="commandk-ai-msgs" ref="aiMsgRef">
<div v-for="(msg, i) in aiMessages" :key="i" class="commandk-ai-msg" :class="msg.role">
<div class="commandk-ai-bubble">{{ msg.content }}</div>
</div>
<div v-if="aiLoading" class="commandk-ai-msg assistant">
<div class="commandk-ai-bubble commandk-ai-loading">
<el-icon class="is-loading"><Loading /></el-icon>
<span>思考中...</span>
</div>
</div>
</div>
<div class="commandk-ai-input-row">
<el-input
v-model="aiInput"
:placeholder="$t('nav.home') === 'Home' ? 'Ask AI anything...' : '向 AI 提问...'"
size="small"
@keyup.enter="sendAiMessage"
/>
<el-button type="primary" size="small" :disabled="!aiInput.trim() || aiLoading" @click="sendAiMessage">
<el-icon><Promotion /></el-icon>
</el-button>
</div>
</div>
<div v-else-if="filteredActions.length" class="commandk-results">
<div
v-for="(action, i) in filteredActions"
:key="action.id"
class="commandk-item"
:class="{ selected: i === selectedIndex }"
@click="executeAction(action)"
@mouseenter="selectedIndex = i"
>
<el-icon :size="16"><component :is="action.icon" /></el-icon>
<div class="commandk-item-content">
<div class="commandk-item-title">{{ action.label }}</div>
<div v-if="action.desc" class="commandk-item-desc">{{ action.desc }}</div>
</div>
</div>
</div>
<div v-else-if="query" class="commandk-empty">
<p>{{ $t('common.no_data') }}</p>
<el-button text type="primary" @click="switchToAi">{{ $t('nav.home') === 'Home' ? 'Ask AI directly' : '直接向 AI 提问' }}</el-button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Search, Promotion, Loading } from '@element-plus/icons-vue'
import { aiChat } from '@/api'
const router = useRouter()
const { t } = useI18n()
const visible = ref(false)
const query = ref('')
const selectedIndex = ref(0)
const inputRef = ref(null)
const aiMode = ref(false)
const aiInput = ref('')
const aiMessages = ref([])
const aiLoading = ref(false)
const aiMsgRef = ref(null)
const actions = computed(() => [
{ id: 'home', label: t('nav.home'), desc: t('nav.home') === 'Home' ? 'Back to dashboard' : '返回首页', icon: 'Odometer', route: '/workspace' },
{ id: 'customers', label: t('nav.customers'), desc: t('nav.home') === 'Home' ? 'Manage customers, discover leads, follow-ups' : '客户管理、发现客户、跟进提醒', icon: 'User', route: '/workspace/customers' },
{ id: 'biz', label: t('nav.biz'), desc: t('nav.home') === 'Home' ? 'Translate, quotations, products, marketing' : '翻译、报价、产品、营销', icon: 'Goods', route: '/workspace/biz' },
{ id: 'analytics', label: t('nav.analytics'), desc: t('nav.home') === 'Home' ? 'Data analysis & reports' : '数据分析和报表', icon: 'DataAnalysis', route: '/workspace/analytics' },
{ id: 'profile', label: t('nav.profile'), desc: t('nav.home') === 'Home' ? 'Personal settings & profile' : '个人中心和设置', icon: 'User', route: '/workspace/profile' },
{ id: 'team', label: t('nav.team'), desc: t('nav.home') === 'Home' ? 'Team collaboration' : '团队协作', icon: 'UserFilled', route: '/workspace/team' },
{ id: 'credits', label: t('nav.credits'), desc: t('nav.home') === 'Home' ? 'Purchase credits & subscriptions' : '购买次数和套餐', icon: 'Coin', route: '/workspace/profile/credits' },
{ id: 'notifications', label: t('nav.notifications'), desc: t('nav.home') === 'Home' ? 'View notifications' : '查看通知', icon: 'Bell', route: '/workspace/profile/notifications' },
{ id: 'translate', label: t('nav.translate'), desc: 'AI 智能翻译', icon: 'ChatLineSquare', route: '/workspace/biz' },
{ id: 'products', label: t('nav.products'), desc: '管理产品库', icon: 'Goods', route: '/workspace/biz' },
{ id: 'quotations', label: t('nav.quotations'), desc: '管理报价单', icon: 'DocumentCopy', route: '/workspace/biz' },
{ id: 'discovery', label: t('nav.discovery'), desc: '搜索潜在买家', icon: 'Search', route: '/workspace/customers' },
{ id: 'followup', label: t('nav.followup'), desc: '跟进提醒', icon: 'AlarmClock', route: '/workspace/customers' },
{ id: 'marketing', label: t('nav.marketing'), desc: '生成营销素材', icon: 'EditPen', route: '/workspace/biz' },
])
const filteredActions = computed(() => {
const q = query.value.toLowerCase().trim()
if (!q) return actions.value
return actions.value.filter(a =>
a.label.toLowerCase().includes(q) ||
(a.desc && a.desc.toLowerCase().includes(q))
)
})
watch(visible, (v) => {
if (v) {
query.value = ''
selectedIndex.value = 0
aiMode.value = false
aiMessages.value = []
aiInput.value = ''
nextTick(() => inputRef.value?.focus())
}
})
watch(query, () => { selectedIndex.value = 0 })
function moveSelection(delta) {
const len = filteredActions.value.length
if (!len) return
selectedIndex.value = (selectedIndex.value + delta + len) % len
}
function switchToAi() {
aiMode.value = true
nextTick(() => {
const el = document.querySelector('.commandk-ai-input-row input')
el?.focus()
})
}
function executeAction(action) {
close()
router.push(action.route)
}
async function sendAiMessage() {
const msg = aiInput.value.trim()
if (!msg || aiLoading.value) return
aiInput.value = ''
aiMessages.value.push({ role: 'user', content: msg })
aiLoading.value = true
try {
const hist = aiMessages.value.map(m => ({ role: m.role, content: m.content }))
const res = await aiChat(msg, hist.slice(0, -1))
aiMessages.value.push({ role: 'assistant', content: res.reply || '抱歉,我没有理解。' })
} catch (e) {
console.error('[CommandK] AI chat error:', e)
const detail = e?.detail || e?.message || ''
aiMessages.value.push({ role: 'assistant', content: detail ? `请求失败:${detail}` : '请求失败,请稍后重试。' })
} finally {
aiLoading.value = false
nextTick(() => {
if (aiMsgRef.value) aiMsgRef.value.scrollTop = aiMsgRef.value.scrollHeight
})
}
}
function handleEnter() {
if (aiMode.value) { sendAiMessage(); return }
const items = filteredActions.value
if (items[selectedIndex.value]) executeAction(items[selectedIndex.value])
}
function close() { visible.value = false }
function handleKeydown(e) {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
visible.value = !visible.value
}
if (e.key === 'Escape' && visible.value) {
if (aiMode.value) { aiMode.value = false; return }
close()
}
}
onMounted(() => document.addEventListener('keydown', handleKeydown))
onUnmounted(() => document.removeEventListener('keydown', handleKeydown))
</script>
<style scoped>
.commandk-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.35);
z-index: 9998;
}
.commandk-palette {
position: fixed;
top: 12vh;
left: 50%;
transform: translateX(-50%);
width: 560px;
max-width: calc(100vw - 32px);
max-height: 70vh;
background: #fff;
border-radius: 12px;
box-shadow: 0 16px 48px rgba(0,0,0,0.2);
z-index: 9999;
display: flex;
flex-direction: column;
overflow: hidden;
}
.commandk-input-wrap {
display: flex;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #e8e8e8;
gap: 10px;
}
.commandk-search-icon {
color: #999;
font-size: 18px;
flex-shrink: 0;
}
.commandk-input {
flex: 1;
border: none;
outline: none;
font-size: 15px;
background: transparent;
color: #333;
}
.commandk-hint {
font-size: 11px;
color: #bbb;
background: #f5f5f5;
padding: 2px 8px;
border-radius: 4px;
font-family: monospace;
flex-shrink: 0;
}
.commandk-results {
flex: 1;
overflow-y: auto;
padding: 6px 0;
}
.commandk-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
cursor: pointer;
color: #333;
transition: background 0.1s;
}
.commandk-item:hover,
.commandk-item.selected {
background: #f0f5ff;
color: #1890ff;
}
.commandk-item-content {
flex: 1;
min-width: 0;
}
.commandk-item-title {
font-size: 14px;
font-weight: 500;
}
.commandk-item-desc {
font-size: 12px;
color: #999;
margin-top: 2px;
}
.commandk-empty {
padding: 32px;
text-align: center;
color: #999;
font-size: 14px;
}
.commandk-ai-section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.commandk-ai-msgs {
flex: 1;
overflow-y: auto;
padding: 12px 16px;
}
.commandk-ai-msg {
margin-bottom: 10px;
}
.commandk-ai-msg.user {
text-align: right;
}
.commandk-ai-bubble {
display: inline-block;
padding: 8px 14px;
border-radius: 8px;
font-size: 13px;
line-height: 1.6;
max-width: 85%;
background: #f0f5ff;
color: #333;
text-align: left;
white-space: pre-wrap;
word-break: break-word;
}
.commandk-ai-msg.user .commandk-ai-bubble {
background: #667eea;
color: #fff;
}
.commandk-ai-loading {
display: inline-flex;
align-items: center;
gap: 6px;
background: #f5f5f5 !important;
color: #999 !important;
}
.commandk-ai-input-row {
display: flex;
gap: 8px;
padding: 10px 16px;
border-top: 1px solid #e8e8e8;
}
@media (max-width: 768px) {
.commandk-palette { top: 8vh; max-height: 80vh; width: calc(100vw - 16px); border-radius: 8px; }
.commandk-item { padding: 8px 12px; }
.commandk-item-desc { font-size: 11px; }
.commandk-ai-bubble { max-width: 90%; font-size: 12px; }
}
</style>
+101 -62
View File
@@ -5,9 +5,14 @@
<aside class="sidebar" :class="{ collapsed, 'mobile-show': showMobileMenu }"> <aside class="sidebar" :class="{ collapsed, 'mobile-show': showMobileMenu }">
<div class="sidebar-header"> <div class="sidebar-header">
<router-link to="/workspace" class="logo">{{ collapsed ? 'TM' : 'TradeMate' }}</router-link> <router-link to="/workspace" class="logo">{{ collapsed ? 'TM' : 'TradeMate' }}</router-link>
<el-button v-if="showMobileMenu" text style="color:#999;font-size:20px;margin-left:auto" @click="showMobileMenu = false"> <div class="sidebar-header-actions">
<el-icon><Close /></el-icon> <el-button text class="collapse-btn dt-only" @click="collapsed = !collapsed">
</el-button> <el-icon><Fold v-if="!collapsed" /><Expand v-else /></el-icon>
</el-button>
<el-button v-if="showMobileMenu" text class="collapse-btn" @click="showMobileMenu = false">
<el-icon><Close /></el-icon>
</el-button>
</div>
</div> </div>
<el-menu <el-menu
:default-active="route.path" :default-active="route.path"
@@ -16,24 +21,16 @@
:collapse-transition="false" :collapse-transition="false"
@select="showMobileMenu = false" @select="showMobileMenu = false"
> >
<el-menu-item index="/workspace"><el-icon><Odometer /></el-icon><span>{{ $t('nav.home') || '首页工作台' }}</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="/workspace/customers"><el-icon><User /></el-icon><span>{{ $t('nav.customers') }}</span></el-menu-item> <el-menu-item index="/workspace/customers"><el-icon><User /></el-icon><span>{{ $t('nav.customers') }}</span></el-menu-item>
<el-sub-menu index="products-quotations"> <el-menu-item index="/workspace/biz"><el-icon><Goods /></el-icon><span>{{ $t('nav.biz') }}</span></el-menu-item>
<template #title> <el-menu-item index="/workspace/analytics"><el-icon><DataAnalysis /></el-icon><span>{{ $t('nav.analytics') }}</span></el-menu-item>
<el-icon><Goods /></el-icon>
<span>{{ $t('nav.productsQuotations') || '报价产品' }}</span>
</template>
<el-menu-item index="/workspace/products"><el-icon><Goods /></el-icon><span>{{ $t('nav.products') }}</span></el-menu-item>
<el-menu-item index="/workspace/quotations"><el-icon><DocumentCopy /></el-icon><span>{{ $t('nav.quotations') }}</span></el-menu-item>
</el-sub-menu>
<el-menu-item index="/workspace/translate"><el-icon><ChatLineSquare /></el-icon><span>{{ $t('nav.translate') }}</span></el-menu-item>
<el-sub-menu index="more"> <el-sub-menu index="more">
<template #title> <template #title>
<el-icon><Menu /></el-icon> <el-icon><Menu /></el-icon>
<span>{{ $t('nav.more') || '更多' }}</span> <span>{{ $t('nav.more') || '更多' }}</span>
</template> </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/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-menu-item index="/workspace/team"><el-icon><UserFilled /></el-icon><span>{{ $t('nav.team') }}</span></el-menu-item>
</el-sub-menu> </el-sub-menu>
</el-menu> </el-menu>
@@ -62,7 +59,7 @@
<el-dropdown trigger="click"> <el-dropdown trigger="click">
<el-button text style="display:flex;align-items:center;gap:6px"> <el-button text style="display:flex;align-items:center;gap:6px">
<el-icon><User /></el-icon> <el-icon><User /></el-icon>
<span class="user-name">{{ auth.user?.username || '用户' }}</span> <span class="user-name">{{ displayName }}</span>
<el-icon><ArrowDown /></el-icon> <el-icon><ArrowDown /></el-icon>
</el-button> </el-button>
<template #dropdown> <template #dropdown>
@@ -80,41 +77,44 @@
<router-view /> <router-view />
</main> </main>
<footer class="footer">
<div class="footer-content">
<p>&copy; {{ new Date().getFullYear() }} TradeMate</p>
<div class="footer-links">
<a href="http://beian.miit.gov.cn/" target="_blank">{{ beianInfo.icp }}</a>
<a v-if="beianInfo.showGongan" :href="beianInfo.gonganLink" target="_blank" rel="noreferrer" class="gongan-link">
<img src="/images/beian/gongan-beian.png" alt="公安备案" class="gongan-icon" />
{{ beianInfo.gongan }}
</a>
</div>
</div>
</footer>
</div> </div>
<AiAssistant /> <CommandK />
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { getUnreadCount, getCreditBalance } from '@/api' import { getUnreadCount, getCreditBalance } from '@/api'
import AiAssistant from '@/components/AiAssistant.vue' import CommandK from '@/components/CommandK.vue'
import { switchLang } from '@/i18n' import { switchLang } from '@/i18n'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { locale } = useI18n() const { locale } = useI18n()
const auth = useAuthStore() const auth = useAuthStore()
const collapsed = ref(false) const collapsed = ref(window.innerWidth < 1024)
const showMobileMenu = ref(false) const showMobileMenu = ref(false)
const unread = ref(0) const unread = ref(0)
const creditBalance = ref(null) const creditBalance = ref(null)
function handleResize() {
const w = window.innerWidth
collapsed.value = w < 1024
if (w >= 768) showMobileMenu.value = false
}
onMounted(() => window.addEventListener('resize', handleResize))
onUnmounted(() => window.removeEventListener('resize', handleResize))
const displayName = computed(() => {
const u = auth.user
if (u && typeof u === 'object' && typeof u.username === 'string') return u.username
return '用户'
})
const currentLang = computed(() => locale.value === 'en' ? 'English' : '中文') const currentLang = computed(() => locale.value === 'en' ? 'English' : '中文')
function toggleLang() { function toggleLang() {
const next = locale.value === 'en' ? 'zh-CN' : 'en' const next = locale.value === 'en' ? 'zh-CN' : 'en'
@@ -136,17 +136,6 @@ onMounted(async () => {
loadCreditBalance() loadCreditBalance()
}) })
const beianInfo = computed(() => {
const hostname = window.location.hostname
if (hostname === 'yuzhiran.com' || hostname === 'www.yuzhiran.com') {
return { icp: '京ICP备2026007249号-1', gongan: '京公网安备11011502039545号', gonganLink: 'https://beian.mps.gov.cn/#/query/webSearch?code=11011502039545', showGongan: true }
}
if (hostname === 'yuzhiran.com.cn' || hostname === 'www.yuzhiran.com.cn') {
return { icp: '京ICP备2026007249号-2', gongan: '京公网安备11011502039622号', gonganLink: 'https://beian.mps.gov.cn/#/query/webSearch?code=11011502039622', showGongan: true }
}
return { icp: '京ICP备2026007249号-1', gongan: '京公网安备11011502039545号', gonganLink: 'https://beian.mps.gov.cn/#/query/webSearch?code=11011502039545', showGongan: true }
})
function handleLogout() { function handleLogout() {
auth.logout() auth.logout()
router.push('/') router.push('/')
@@ -154,40 +143,90 @@ function handleLogout() {
</script> </script>
<style scoped> <style scoped>
/* ===== Base Layout ===== */
.user-layout { display: flex; height: 100vh; overflow: hidden; } .user-layout { display: flex; height: 100vh; overflow: hidden; }
.sidebar-mask { display: none; } .sidebar-mask { display: none; }
.sidebar { width: 220px; background: #fff; border-right: 1px solid #e8e8e8; transition: width 0.25s; flex-shrink: 0; display: flex; flex-direction: column; }
.sidebar.collapsed { width: 64px; }
.sidebar-header { height: 60px; display: flex; align-items: center; padding: 0 16px; color: #1890ff; font-size: 18px; font-weight: 700; border-bottom: 1px solid #f0f0f0; flex-shrink: 0; }
.sidebar :deep(.el-menu) { border-right: none; flex: 1; }
.sidebar :deep(.el-menu-item) { margin: 2px 8px; border-radius: 8px; }
.sidebar :deep(.el-menu-item.is-active) { background: #e6f7ff; color: #1890ff !important; font-weight: 500; }
.main-area { flex: 1; display: flex; flex-direction: column; min-width: 0; } .main-area { flex: 1; display: flex; flex-direction: column; min-width: 0; }
.topbar { height: 60px; background: #fff; border-bottom: 1px solid #e8e8e8; display: flex; align-items: center; padding: 0 16px; flex-shrink: 0; gap: 12px; } .content { flex: 1; padding: 24px; overflow-y: auto; background: #f5f5f5; }
/* ===== Sidebar ===== */
.sidebar {
width: 220px; background: #fff; border-right: 1px solid #e8e8e8;
transition: width 0.25s; flex-shrink: 0; display: flex; flex-direction: column;
position: relative; z-index: 100;
}
.sidebar.collapsed { width: 64px; }
.sidebar-header {
height: 60px; display: flex; align-items: center; padding: 0 12px;
color: #1890ff; font-size: 18px; font-weight: 700; border-bottom: 1px solid #f0f0f0; flex-shrink: 0;
gap: 8px;
}
.sidebar-header .logo { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sidebar-header-actions { display: flex; flex-shrink: 0; }
.collapse-btn { font-size: 18px; color: #999; }
.dt-only { display: none; }
.sidebar :deep(.el-menu) { border-right: none; flex: 1; }
.sidebar :deep(.el-menu-item) { margin: 2px 8px; border-radius: 8px; white-space: nowrap; }
.sidebar :deep(.el-menu-item.is-active) { background: #e6f7ff; color: #1890ff !important; font-weight: 500; }
/* ===== Topbar ===== */
.topbar {
height: 60px; background: #fff; border-bottom: 1px solid #e8e8e8;
display: flex; align-items: center; padding: 0 16px; flex-shrink: 0; gap: 12px;
}
.menu-btn { display: none; font-size: 20px; } .menu-btn { display: none; font-size: 20px; }
.breadcrumb { flex: 1; min-width: 0; } .breadcrumb { flex: 1; min-width: 0; overflow: hidden; }
.breadcrumb :deep(.el-breadcrumb__inner) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 160px; display: inline-block; vertical-align: middle; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-shrink: 0; } .topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.notif-badge :deep(.el-badge__content) { top: 8px; right: 4px; } .notif-badge :deep(.el-badge__content) { top: 8px; right: 4px; }
.content { flex: 1; padding: 24px; overflow-y: auto; background: #f5f5f5; }
.footer { text-align: center; color: #999; font-size: 12px; border-top: 1px solid #e8e8e8; background: #fff; flex-shrink: 0; padding: 8px 24px; }
.footer-content { display: flex; justify-content: center; align-items: center; gap: 14px; flex-wrap: wrap; }
.footer-links { display: flex; gap: 14px; align-items: center; }
.footer-links a { color: #999; text-decoration: none; }
.footer-links a:hover { color: #1890ff; }
.gongan-link { display: inline-flex; align-items: center; gap: 4px; }
.gongan-icon { height: 14px; vertical-align: middle; }
.credit-btn { display: flex; align-items: center; gap: 4px; color: #e6a23c !important; font-weight: 600; } .credit-btn { display: flex; align-items: center; gap: 4px; color: #e6a23c !important; font-weight: 600; }
.credit-text { font-size: 13px; }
/* ===== Desktop: > 1024px ===== */
@media (min-width: 1025px) {
.dt-only { display: inline-flex; }
.sidebar-header { padding: 0 16px; }
.sidebar.collapsed .sidebar-header { justify-content: center; }
.sidebar.collapsed .sidebar-header .logo { font-size: 16px; text-align: center; }
}
/* ===== Tablet: 769-1024px ===== */
@media (min-width: 769px) and (max-width: 1024px) {
.sidebar { width: 64px; }
.sidebar-header { padding: 0; justify-content: center; }
.sidebar-header .logo { font-size: 16px; flex: none; }
.sidebar-header-actions .dt-only { display: none; }
.menu-btn { display: inline-flex; }
.content { padding: 20px; }
}
/* ===== Mobile: < 769px ===== */
@media (max-width: 768px) { @media (max-width: 768px) {
.sidebar { position: fixed; left: -220px; top: 0; bottom: 0; z-index: 1000; transition: left 0.3s; } .sidebar {
.sidebar.mobile-show { left: 0; } position: fixed; left: -220px; top: 0; bottom: 0; z-index: 1000;
transition: left 0.3s; width: 220px;
}
.sidebar.collapsed { width: 220px; } .sidebar.collapsed { width: 220px; }
.sidebar.mobile-show { left: 0; }
.sidebar.collapsed.mobile-show { left: 0; } .sidebar.collapsed.mobile-show { left: 0; }
.sidebar-mask { display: block; position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 999; opacity: 0; pointer-events: none; transition: opacity 0.3s; } .sidebar-header { padding: 0 16px; }
.sidebar-header .logo { font-size: 18px; flex: 1; }
.sidebar-header-actions .dt-only { display: none; }
.sidebar-mask {
display: block; position: fixed; inset: 0; background: rgba(0,0,0,0.3);
z-index: 999; opacity: 0; pointer-events: none; transition: opacity 0.3s;
}
.sidebar-mask:has(+ .sidebar.mobile-show) { opacity: 1; pointer-events: auto; } .sidebar-mask:has(+ .sidebar.mobile-show) { opacity: 1; pointer-events: auto; }
.menu-btn { display: inline-flex; } .menu-btn { display: inline-flex; }
.user-name { display: none; } .user-name { display: none; }
.content { padding: 16px; } .content { padding: 16px; }
.breadcrumb :deep(.el-breadcrumb__inner) { max-width: 80px; }
}
/* ===== Tiny: < 480px ===== */
@media (max-width: 480px) {
.topbar-right { gap: 4px; }
.credit-text { display: none; }
.content { padding: 12px; }
.breadcrumb :deep(.el-breadcrumb__inner) { max-width: 60px; }
} }
</style> </style>
+10 -9
View File
@@ -1,22 +1,23 @@
{ {
"nav": { "nav": {
"home": "Workspace", "home": "Home",
"more": "More", "more": "More",
"productsQuotations": "Products & Quotes",
"discovery": "Find Buyers",
"workspace": "Dashboard", "workspace": "Dashboard",
"customers": "Customers", "customers": "Customers",
"products": "Products", "biz": "Business",
"quotations": "Quotations",
"translate": "Translate",
"marketing": "Marketing",
"followup": "Follow-ups",
"analytics": "Analytics", "analytics": "Analytics",
"team": "Team", "team": "Team",
"notifications": "Notifications", "notifications": "Notifications",
"profile": "Profile", "profile": "Profile",
"credits": "Buy Credits", "credits": "Buy Credits",
"settings": "Settings" "settings": "Settings",
"customerList": "Customers",
"discovery": "Find Buyers",
"followup": "Follow-ups",
"translate": "Translate",
"products": "Products",
"quotations": "Quotations",
"marketing": "Marketing"
}, },
"topbar": { "topbar": {
"credits": "credits", "credits": "credits",
+11 -10
View File
@@ -1,22 +1,23 @@
{ {
"nav": { "nav": {
"home": "首页工作台", "home": "首页",
"more": "更多", "more": "更多",
"productsQuotations": "报价产品",
"discovery": "发现客户",
"workspace": "工作台", "workspace": "工作台",
"customers": "客户管理", "customers": "客户工作台",
"products": "产品库", "biz": "业务工作台",
"quotations": "报价单",
"translate": "智能翻译",
"marketing": "营销素材",
"followup": "跟进提醒",
"analytics": "数据分析", "analytics": "数据分析",
"team": "团队协作", "team": "团队协作",
"notifications": "通知中心", "notifications": "通知中心",
"profile": "个人中心", "profile": "个人中心",
"credits": "购买次数", "credits": "购买次数",
"settings": "设置" "settings": "设置",
"customerList": "客户列表",
"discovery": "发现客户",
"followup": "跟进提醒",
"translate": "智能翻译",
"products": "产品库",
"quotations": "报价单",
"marketing": "营销素材"
}, },
"topbar": { "topbar": {
"credits": "次", "credits": "次",
+13 -35
View File
@@ -8,11 +8,9 @@ const routes = [
component: () => import('@/layouts/UserLayout.vue'), component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
children: [ children: [
{ path: '', name: 'Home', component: () => import('@/views/NewHome.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: 'customers', name: 'Customers', component: () => import('@/views/WorkspaceCustomer.vue'), meta: { title: '客户工作台' } },
{ path: 'products', name: 'Products', component: () => import('@/views/Products.vue'), meta: { title: '产品库' } }, { path: 'biz', name: 'Biz', component: () => import('@/views/WorkspaceBiz.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: 'analytics', name: 'Analytics', component: () => import('@/views/Analytics.vue'), meta: { title: '数据分析' } },
{ path: 'team', name: 'Team', component: () => import('@/views/Team.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', name: 'Profile', component: () => import('@/views/Profile.vue'), meta: { title: '个人中心' } },
@@ -23,11 +21,11 @@ const routes = [
{ path: 'profile/feedback', name: 'Feedback', component: () => import('@/views/Feedback.vue'), meta: { title: '意见反馈' } }, { path: 'profile/feedback', name: 'Feedback', component: () => import('@/views/Feedback.vue'), meta: { title: '意见反馈' } },
] ]
}, },
// Redirect old top-level routes to new /workspace children // Consolidated redirects - old feature pages new workspace pages
{ path: '/customers', redirect: '/workspace/customers' }, { path: '/customers', redirect: '/workspace/customers' },
{ path: '/products', redirect: '/workspace/products' }, { path: '/products', redirect: '/workspace/biz' },
{ path: '/quotations', redirect: '/workspace/quotations' }, { path: '/quotations', redirect: '/workspace/biz' },
{ path: '/translate', redirect: '/workspace/translate' }, { path: '/translate', redirect: '/workspace/biz' },
{ path: '/analytics', redirect: '/workspace/analytics' }, { path: '/analytics', redirect: '/workspace/analytics' },
{ path: '/team', redirect: '/workspace/team' }, { path: '/team', redirect: '/workspace/team' },
{ path: '/profile', redirect: '/workspace/profile' }, { path: '/profile', redirect: '/workspace/profile' },
@@ -37,33 +35,13 @@ const routes = [
{ path: '/notifications', redirect: '/workspace/profile/notifications' }, { path: '/notifications', redirect: '/workspace/profile/notifications' },
{ path: '/feedback', redirect: '/workspace/profile/feedback' }, { path: '/feedback', redirect: '/workspace/profile/feedback' },
{ path: '/agent', redirect: '/workspace' }, { path: '/agent', redirect: '/workspace' },
{ path: '/discovery', redirect: '/workspace/customers' },
{ path: '/followup', redirect: '/workspace/customers' },
{ path: '/marketing', redirect: '/workspace/biz' },
{ path: '/workspace/home', redirect: '/workspace' }, { path: '/workspace/home', redirect: '/workspace' },
{ path: '/workspace/products', redirect: '/workspace/biz' },
// Hidden routes still accessible via direct URL but not in sidebar { path: '/workspace/quotations', redirect: '/workspace/biz' },
{ { path: '/workspace/translate', redirect: '/workspace/biz' },
path: '/discovery',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Discovery', component: () => import('@/views/Discovery.vue'), meta: { title: '发现客户' } },
]
},
{
path: '/marketing',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Marketing', component: () => import('@/views/Marketing.vue'), meta: { title: '营销素材' } },
]
},
{
path: '/followup',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Followup', component: () => import('@/views/Followup.vue'), meta: { title: '智能跟进' } },
]
},
{ path: '/:pathMatch(.*)*', redirect: '/' }, { path: '/:pathMatch(.*)*', redirect: '/' },
] ]
+138 -542
View File
@@ -1,61 +1,20 @@
<template> <template>
<div class="home-dashboard"> <div class="home-dashboard">
<!-- AI Input Bar (Wave 2 placeholder) --> <!-- Welcome + Credit -->
<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-section">
<div class="welcome-left"> <div class="welcome-left">
<h2>{{ $t('home.greeting') || '你好' }}{{ auth.user?.username || '用户' }}</h2> <h2>{{ $t('home.greeting') || '你好' }}{{ auth.user?.username || '用户' }}</h2>
<p class="welcome-desc">{{ $t('home.subtitle') || 'AI 数字员工为你自动挖掘、分析、触达、跟进' }}</p> <p class="welcome-desc">{{ $t('home.subtitle') || '按 ⌘K 打开 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>
<el-card shadow="never" class="credit-card">
<div class="credit-inner">
<span class="credit-amount">{{ creditBalance ?? '...' }}<small> {{ $t('topbar.credits') }}</small></span>
<el-button size="small" round @click="$router.push('/workspace/profile/credits')">{{ $t('home.buyCredits') || '购买' }} </el-button>
</div>
</el-card>
</div> </div>
<!-- Credit Balance --> <!-- Overview Stats -->
<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-row :gutter="16" class="stats-row">
<el-col :xs="12" :sm="6" v-for="item in stats" :key="item.label"> <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)"> <el-card shadow="hover" class="stat-card" @click="item.route && $router.push(item.route)">
@@ -65,23 +24,32 @@
</el-col> </el-col>
</el-row> </el-row>
<!-- Pipeline Stats (from Agent.vue) --> <!-- Quick Actions -->
<el-row :gutter="16" class="agent-stats"> <el-card shadow="never" class="quick-section">
<el-col :xs="12" :sm="3" v-for="s in pipelineStats" :key="s.label"> <template #header><span class="section-title">{{ $t('home.quickActions') || '快捷操作' }}</span></template>
<el-card shadow="never"> <div class="quick-grid">
<div class="stat-item"> <el-button size="large" class="quick-btn" @click="$router.push('/workspace/customers')">
<div class="stat-value" :style="{ color: s.color }">{{ s.value }}</div> <el-icon :size="24"><User /></el-icon>
<div class="stat-label">{{ s.label }}</div> <span>{{ $t('nav.customers') }}</span>
</div> </el-button>
</el-card> <el-button size="large" class="quick-btn" @click="$router.push('/workspace/biz')">
</el-col> <el-icon :size="24"><ChatLineSquare /></el-icon>
</el-row> <span>{{ $t('nav.translate') }}</span>
</el-button>
<el-button size="large" class="quick-btn" @click="$router.push('/workspace/biz')">
<el-icon :size="24"><DocumentCopy /></el-icon>
<span>{{ $t('nav.quotations') }}</span>
</el-button>
<el-button size="large" class="quick-btn" @click="$router.push('/workspace/biz')">
<el-icon :size="24"><EditPen /></el-icon>
<span>{{ $t('nav.marketing') }}</span>
</el-button>
</div>
</el-card>
<!-- Activity Feed (Wave 2 placeholder) --> <!-- Recent Activity -->
<el-card shadow="never" class="activity-section" v-if="recentActivities.length"> <el-card shadow="never" class="activity-section" v-if="recentActivities.length">
<template #header> <template #header><span class="section-title">{{ $t('home.recentActivity') || '最近活动' }}</span></template>
<span class="section-title">{{ $t('home.recentActivity') || '最近活动' }}</span>
</template>
<div class="activity-list"> <div class="activity-list">
<div v-for="act in recentActivities" :key="act.id" class="activity-item"> <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> <el-tag :type="act.type" size="small" class="activity-tag">{{ act.tag }}</el-tag>
@@ -91,366 +59,81 @@
</div> </div>
</el-card> </el-card>
<!-- Pipeline List --> <!-- Agent Pipelines Summary -->
<div class="agent-section"> <el-card shadow="never" class="agent-section" v-if="pipelines.length">
<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> <template #header>
<div class="detail-header"> <div class="section-header">
<div> <span class="section-title">{{ $t('agent.pipeline') || '数字员工任务' }}</span>
<strong>{{ selectedPipeline.product_name }}</strong> <el-button size="small" type="primary" link @click="$router.push('/workspace')">{{ $t('home.viewAll') || '查看全部' }}</el-button>
<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> </div>
</template> </template>
<div class="pipeline-mini-list">
<div v-for="p in pipelines.slice(0, 5)" :key="p.id" class="pipeline-mini-item" @click="selectPipeline(p)">
<div class="pipeline-mini-left">
<strong>{{ p.product_name }}</strong>
<span class="pipeline-mini-market">{{ p.target_market }}</span>
</div>
<el-tag :type="statusTag(p.status)" size="small">{{ statusLabel(p.status) }}</el-tag>
</div>
</div>
<div class="stage-steps"> <!-- Pipeline Detail -->
<div <div v-if="selectedPipeline" class="pipeline-detail-compact">
v-for="(st, stKey) in selectedPipeline.pipeline_data?.stages || {}" <el-divider />
:key="stKey" <div class="detail-header">
:class="['stage-step', st.status]" <strong>{{ selectedPipeline.product_name }}</strong>
> <el-button size="small" text @click="selectedPipeline = null">{{ $t('common.close') || '关闭' }}</el-button>
<div class="stage-icon"> </div>
<div class="stage-steps">
<div v-for="(st, stKey) in selectedPipeline.pipeline_data?.stages || {}" :key="stKey" :class="['stage-step', st.status]">
<el-icon v-if="st.status === 'completed'" color="#67c23a"><CircleCheck /></el-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-if="st.status === 'running'" class="is-loading" color="#409eff"><Loading /></el-icon>
<el-icon v-else color="#999"><CircleClose /></el-icon> <el-icon v-else color="#999"><CircleClose /></el-icon>
</div> <span>{{ stageLabel(stKey) }}</span>
<div class="stage-content">
<div class="stage-name">{{ stageLabel(stKey) }}</div>
<div class="stage-msg">{{ st.message || '' }}</div>
</div> </div>
</div> </div>
<div v-if="leads.length" class="leads-mini">
<span v-for="lead in leads.slice(0, 3)" :key="lead.name" class="lead-chip">
{{ lead.name }}
<el-tag v-if="lead.match_score >= 70" size="small" type="success">{{ lead.match_score }}%</el-tag>
</span>
</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> </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> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Plus, MagicStick, Search, Edit, ChatLineSquare, DocumentCopy, Loading, CircleCheck, CircleClose } from '@element-plus/icons-vue' import { User, ChatLineSquare, DocumentCopy, EditPen, CircleCheck, CircleClose, Loading } from '@element-plus/icons-vue'
import { import { getCreditBalance, getAnalyticsOverview, listAgentPipelines, getAgentPipeline } from '@/api'
startAgentPipeline,
listAgentPipelines,
getAgentPipeline,
createCustomer,
getCreditBalance,
getAnalyticsOverview,
} from '@/api'
const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const auth = useAuthStore() 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) const creditBalance = ref(null)
// Stats from analytics
const stats = ref([]) 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 pipelines = ref([])
const selectedPipeline = ref(null) const selectedPipeline = ref(null)
const selectedId = 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({ const leads = computed(() => selectedPipeline.value?.pipeline_data?.leads || [])
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 recentActivities = computed(() => {
const acts = [] const acts = []
const recent = [...pipelines.value].sort((a, b) => new Date(b.created_at) - new Date(a.created_at)).slice(0, 5) 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) { for (const p of recent) {
if (p.status === 'completed') { if (p.status === 'completed') {
acts.push({ acts.push({
id: `p-${p.id}`, id: `p-${p.id}`, type: 'success', tag: 'Agent',
type: 'success', text: `${p.product_name}${p.target_market}${p.pipeline_data?.summary?.total_leads || 0} 个线索`,
tag: 'Agent',
text: `${p.product_name}${p.target_market},发现 ${p.pipeline_data?.summary?.total_leads || 0} 个线索`,
time: formatTime(p.created_at), time: formatTime(p.created_at),
}) })
} else if (p.status === 'running') { } else if (p.status === 'running') {
acts.push({ acts.push({
id: `p-${p.id}`, id: `p-${p.id}`, type: 'warning', tag: 'Agent',
type: 'warning', text: `${p.product_name}${p.target_market}${p.progress || 0}%`,
tag: 'Agent',
text: `${p.product_name}${p.target_market},执行中 ${p.progress || 0}%`,
time: formatTime(p.created_at), time: formatTime(p.created_at),
}) })
} }
@@ -458,118 +141,34 @@ const recentActivities = computed(() => {
return acts return acts
}) })
// Methods
function statusTag(status) { function statusTag(status) {
return { running: 'warning', completed: 'success', failed: 'danger' }[status] || 'info' return { running: 'warning', completed: 'success', failed: 'danger' }[status] || 'info'
} }
function statusLabel(status) { function statusLabel(status) {
return { running: '进行中', completed: '已完成', failed: '失败', pending: '等待中' }[status] || status return { running: t('agent.running') || '进行中', completed: t('agent.done') || '已完成', failed: t('agent.failed') || '失败' }[status] || status
} }
function stageLabel(key) { function stageLabel(key) {
return { discover: '客户搜索', analyze: '匹配分析', outreach: '触达文案', complete: '任务完成' }[key] || 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) { function formatTime(ts) {
if (!ts) return '' if (!ts) return ''
const d = new Date(ts) const d = new Date(ts)
const pad = n => String(n).padStart(2, '0') const pad = n => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}` return `${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) { async function selectPipeline(p) {
selectedId.value = p.id selectedId.value = p.id
try { try {
const res = await getAgentPipeline(p.id) const res = await getAgentPipeline(p.id)
if (res.code === 0) { if (res.code === 0) selectedPipeline.value = res.data
selectedPipeline.value = res.data } catch { /* ignore */ }
}
} 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 () => { onMounted(async () => {
loadPipelines()
try { try {
const balance = await getCreditBalance() const balance = await getCreditBalance()
creditBalance.value = balance.balance creditBalance.value = balance.balance
@@ -579,101 +178,98 @@ onMounted(async () => {
const d = res?.data || res || {} const d = res?.data || res || {}
stats.value = [ stats.value = [
{ value: d.customers?.total || 0, label: t('home.totalCustomers') || '客户总数', color: '#1890ff', route: '/workspace/customers' }, { 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.translations?.today || 0, label: t('home.todayTranslate') || '今日翻译', color: '#52c41a', route: '/workspace/biz' },
{ value: d.quotations?.total || 0, label: t('home.totalQuotations') || '报价单数', color: '#faad14', route: '/workspace/quotations' }, { value: d.quotations?.total || 0, label: t('home.totalQuotations') || '报价单数', color: '#faad14', route: '/workspace/biz' },
{ value: pipelines.value.filter(p => p.status === 'running').length, label: t('home.runningTasks') || '执行中', color: '#e6a23c' }, { value: creditBalance.value ?? 0, label: t('home.credits') || '可用次数', color: '#e6a23c', route: '/workspace/profile/credits' },
] ]
} catch { /* ignore */ } } catch { /* ignore */ }
try {
const res = await listAgentPipelines({ page: 1, size: 10 })
if (res.code === 0) pipelines.value = res.data.items || []
} catch { /* ignore */ }
}) })
</script> </script>
<style scoped> <style scoped>
.home-dashboard { max-width: 1200px; margin: 0 auto; } .home-dashboard { max-width: 900px; margin: 0 auto; }
/* AI Input Bar */ .welcome-section {
.ai-input-section { margin-bottom: 24px; } display: flex; justify-content: space-between; align-items: center;
.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); } margin-bottom: 20px; gap: 16px; flex-wrap: wrap;
.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-left h2 { font-size: 22px; font-weight: 700; color: #333; margin: 0 0 4px; }
.welcome-desc { font-size: 13px; color: #999; margin: 0; } .welcome-desc { font-size: 13px; color: #999; margin: 0; }
.welcome-right { display: flex; gap: 12px; flex-shrink: 0; }
/* Credit */ .credit-card { background: linear-gradient(135deg, #667eea, #764ba2); color: #fff; flex-shrink: 0; }
.credit-card { margin-bottom: 16px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; } .credit-inner { display: flex; align-items: center; gap: 16px; padding: 4px 0; }
.credit-inner { display: flex; align-items: center; justify-content: space-between; padding: 4px 0; } .credit-amount { font-size: 24px; font-weight: 700; }
.credit-label { font-size: 13px; opacity: 0.9; } .credit-amount small { font-size: 13px; font-weight: 400; opacity: 0.8; }
.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; } .stats-row { margin-bottom: 20px; }
.stat-card { cursor: pointer; text-align: center; transition: all 0.25s; } .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-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-value { font-size: 28px; font-weight: 700; }
.stat-label { font-size: 12px; color: #999; margin-top: 4px; } .stat-label { font-size: 12px; color: #999; margin-top: 4px; }
.agent-stats { margin-bottom: 24px; } .quick-section { margin-bottom: 20px; }
.stat-item { text-align: center; padding: 8px 0; }
/* Activity */
.activity-section { margin-bottom: 24px; }
.section-title { font-weight: 600; font-size: 15px; } .section-title { font-weight: 600; font-size: 15px; }
.activity-list { display: flex; flex-direction: column; gap: 8px; } .quick-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.quick-btn {
display: flex; flex-direction: column; align-items: center; gap: 8px;
height: auto !important; padding: 20px 8px !important; border-radius: 8px;
color: #333; border-color: #e8e8e8;
}
.quick-btn:hover { color: #667eea; border-color: #667eea; }
.activity-section { margin-bottom: 20px; }
.activity-list { display: flex; flex-direction: column; gap: 6px; }
.activity-item { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid #f5f5f5; } .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-item:last-child { border-bottom: none; }
.activity-tag { flex-shrink: 0; } .activity-tag { flex-shrink: 0; }
.activity-text { flex: 1; font-size: 13px; color: #333; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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; } .activity-time { font-size: 11px; color: #999; flex-shrink: 0; }
/* Pipeline */
.agent-section { margin-bottom: 24px; } .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 { display: flex; justify-content: space-between; align-items: center; }
.section-header h3 { margin: 0; font-size: 16px; } .pipeline-mini-list { display: flex; flex-direction: column; gap: 6px; }
.pipeline-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; } .pipeline-mini-item {
.pipeline-card { cursor: pointer; transition: all 0.2s; } display: flex; justify-content: space-between; align-items: center;
.pipeline-card:hover { transform: translateY(-2px); } padding: 10px 12px; border-radius: 6px; cursor: pointer; transition: background 0.15s;
.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-mini-item:hover { background: #f5f7fa; }
.pipeline-card-body h4 { margin: 0; font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .pipeline-mini-left { display: flex; gap: 8px; align-items: center; font-size: 13px; }
.pipeline-market { color: #409eff; font-size: 12px; margin: 4px 0; } .pipeline-mini-left strong { max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pipeline-summary { color: #666; font-size: 12px; margin: 4px 0; } .pipeline-mini-market { color: #409eff; font-size: 12px; }
.pipeline-card-footer { display: flex; justify-content: space-between; font-size: 11px; color: #ccc; margin-top: 8px; } .pipeline-detail-compact { margin-top: 8px; }
.pipeline-time { color: #999; } .detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; font-size: 14px; }
.pagination-wrap { display: flex; justify-content: center; margin-top: 20px; } .stage-steps { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }
.stage-step {
.pipeline-detail { margin-top: 20px; } display: flex; align-items: center; gap: 6px; padding: 6px 12px;
.detail-header { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 8px; } border-radius: 6px; background: #f5f7fa; font-size: 12px;
.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.completed { background: #f0f9eb; }
.stage-step.running { background: #ecf5ff; } .stage-step.running { background: #ecf5ff; }
.stage-step.pending { opacity: 0.6; } .leads-mini { display: flex; gap: 8px; flex-wrap: wrap; }
.stage-icon { flex-shrink: 0; font-size: 20px; } .lead-chip { display: flex; align-items: center; gap: 4px; padding: 4px 10px; background: #f5f7fa; border-radius: 4px; font-size: 12px; }
.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) { @media (max-width: 768px) {
.pipeline-grid { grid-template-columns: 1fr; } .home-dashboard { padding: 0; }
.stage-steps { flex-direction: column; } .quick-grid { grid-template-columns: repeat(2, 1fr); gap: 8px; }
.welcome-section { flex-direction: column; align-items: flex-start; gap: 12px; } .quick-btn { padding: 14px 6px !important; }
.ai-input-bar { flex-direction: column; } .welcome-section { flex-direction: column; align-items: stretch; }
.ai-input-bar :deep(.el-input) { width: 100%; }
.welcome-left h2 { font-size: 20px; } .welcome-left h2 { font-size: 20px; }
.stat-value { font-size: 22px; }
.pipeline-mini-left { flex-direction: column; align-items: flex-start; gap: 2px; }
.pipeline-mini-left strong { max-width: 140px; }
.stage-steps { gap: 4px; }
.stage-step { padding: 4px 8px; font-size: 11px; }
.activity-text { max-width: 140px; }
.activity-item { flex-wrap: wrap; gap: 4px; }
}
@media (max-width: 480px) {
.quick-grid { grid-template-columns: repeat(2, 1fr); gap: 6px; }
.quick-btn { padding: 12px 4px !important; font-size: 12px; }
.credit-amount { font-size: 20px; }
.welcome-left h2 { font-size: 18px; }
} }
</style> </style>
+74
View File
@@ -0,0 +1,74 @@
<template>
<div class="wb">
<div class="wb-header">
<h2>{{ $t('nav.biz') }}</h2>
<el-button-group class="wb-tabs">
<el-button
v-for="tab in tabs" :key="tab.key"
:type="activeTab === tab.key ? 'primary' : 'default'"
size="small"
@click="activeTab = tab.key"
>
<el-icon :size="14"><component :is="tab.icon" /></el-icon>
{{ tab.label }}
</el-button>
</el-button-group>
</div>
<div v-show="activeTab === 'translate'" class="wb-panel">
<Translate ref="translateRef" />
</div>
<div v-show="activeTab === 'products'" class="wb-panel">
<Products ref="productsRef" />
</div>
<div v-show="activeTab === 'quotations'" class="wb-panel">
<Quotations ref="quotationsRef" />
</div>
<div v-show="activeTab === 'marketing'" class="wb-panel">
<Marketing ref="marketingRef" />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Translate from '@/views/Translate.vue'
import Products from '@/views/Products.vue'
import Quotations from '@/views/Quotations.vue'
import Marketing from '@/views/Marketing.vue'
const { t } = useI18n()
const activeTab = ref('translate')
const tabs = [
{ key: 'translate', label: t('nav.translate'), icon: 'ChatLineSquare' },
{ key: 'products', label: t('nav.products'), icon: 'Goods' },
{ key: 'quotations', label: t('nav.quotations'), icon: 'DocumentCopy' },
{ key: 'marketing', label: t('nav.marketing'), icon: 'EditPen' },
]
</script>
<style scoped>
.wb { height: 100%; display: flex; flex-direction: column; }
.wb-header {
display: flex; align-items: center; gap: 12px;
margin-bottom: 16px; flex-shrink: 0; flex-wrap: wrap;
}
.wb-header h2 { margin: 0; font-size: 18px; font-weight: 600; color: #303133; white-space: nowrap; }
.wb-panel { flex: 1; min-height: 0; }
.wb-tabs { flex-wrap: wrap; }
@media (max-width: 768px) {
.wb-header { flex-direction: column; align-items: stretch; gap: 8px; }
.wb-header h2 { font-size: 16px; }
.wb-tabs { display: flex; flex-wrap: nowrap; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; gap: 4px; }
.wb-tabs::-webkit-scrollbar { display: none; }
.wb-tabs .el-button { flex-shrink: 0; }
.wb-panel { margin: 0 -12px; }
}
</style>
@@ -0,0 +1,72 @@
<template>
<div class="wc">
<div class="wc-header">
<h2>{{ $t('nav.customers') }}</h2>
<el-button-group class="wc-tabs">
<el-button
v-for="tab in tabs" :key="tab.key"
:type="activeTab === tab.key ? 'primary' : 'default'"
size="small"
@click="activeTab = tab.key"
>
<el-icon :size="14"><component :is="tab.icon" /></el-icon>
{{ tab.label }}
</el-button>
</el-button-group>
</div>
<!-- Tab: Customer List -->
<div v-show="activeTab === 'list'" class="wc-panel">
<Customers ref="customersRef" />
</div>
<!-- Tab: Discovery -->
<div v-show="activeTab === 'discovery'" class="wc-panel">
<Discovery ref="discoveryRef" />
</div>
<!-- Tab: Followups -->
<div v-show="activeTab === 'followup'" class="wc-panel">
<Followup ref="followupRef" />
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Customers from '@/views/Customers.vue'
import Discovery from '@/views/Discovery.vue'
import Followup from '@/views/Followup.vue'
const { t } = useI18n()
const activeTab = ref('list')
const tabs = [
{ key: 'list', label: t('nav.customerList'), icon: 'User' },
{ key: 'discovery', label: t('nav.discovery'), icon: 'Search' },
{ key: 'followup', label: t('nav.followup'), icon: 'AlarmClock' },
]
</script>
<style scoped>
.wc { height: 100%; display: flex; flex-direction: column; }
.wc-header {
display: flex; align-items: center; gap: 12px;
margin-bottom: 16px; flex-shrink: 0; flex-wrap: wrap;
}
.wc-header h2 { margin: 0; font-size: 18px; font-weight: 600; color: #303133; white-space: nowrap; }
.wc-panel { flex: 1; min-height: 0; }
/* Tabs: scrollable on narrow screens, wrap on tablets */
.wc-tabs { flex-wrap: wrap; }
@media (max-width: 768px) {
.wc-header { flex-direction: column; align-items: stretch; gap: 8px; }
.wc-header h2 { font-size: 16px; }
.wc-tabs { display: flex; flex-wrap: nowrap; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; gap: 4px; }
.wc-tabs::-webkit-scrollbar { display: none; }
.wc-tabs .el-button { flex-shrink: 0; }
.wc-panel { margin: 0 -12px; /* span full bleed on mobile */ }
}
</style>