Add admin-frontend and user-frontend standalone projects, certification/invoice/discovery features, fix auth header and theme consistency

This commit is contained in:
TradeMate Dev
2026-05-22 18:35:30 +08:00
parent 18c6cf5406
commit 52dba37f22
79 changed files with 10333 additions and 248 deletions
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TradeMate 工作台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+1961
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "trademate-user",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.5.13",
"vue-router": "^4.5.0",
"element-plus": "^2.9.1",
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9",
"pinia": "^2.3.0",
"dayjs": "^1.11.13"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"vite": "^6.0.7"
}
}
+22
View File
@@ -0,0 +1,22 @@
<template>
<router-view />
</template>
<style>
:root {
--el-color-primary: #1890ff;
--el-color-primary-light-3: #40a9ff;
--el-color-primary-light-5: #69c0ff;
--el-color-primary-light-7: #91d5ff;
--el-color-primary-light-8: #bae7ff;
--el-color-primary-light-9: #e6f7ff;
--el-color-primary-dark-2: #096dd9;
--el-color-success: #52c41a;
--el-color-warning: #faad14;
--el-color-danger: #ff4d4f;
--el-color-info: #909399;
}
.el-button--primary { --el-button-bg-color: #1890ff; --el-button-border-color: #1890ff; --el-button-hover-bg-color: #40a9ff; --el-button-hover-border-color: #40a9ff; --el-button-active-bg-color: #096dd9; --el-button-active-border-color: #096dd9; }
.el-tag--primary { --el-tag-bg-color: #e6f7ff; --el-tag-border-color: #91d5ff; --el-tag-text-color: #1890ff; }
a { color: #1890ff; }
</style>
+113
View File
@@ -0,0 +1,113 @@
import axios from 'axios'
const TOKEN_KEY = 'token'
const http = axios.create({ baseURL: '/api/v1', timeout: 30000 })
http.interceptors.request.use(config => {
const token = localStorage.getItem(TOKEN_KEY)
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
http.interceptors.response.use(
res => res.data,
err => {
if (err.response?.status === 401) {
localStorage.removeItem(TOKEN_KEY)
const path = window.location.pathname.replace('/workspace', '') || '/'
window.location.href = '/workspace/login?redirect=' + encodeURIComponent(path)
}
return Promise.reject(err.response?.data || err)
}
)
export function login(data) { return http.post('/auth/login', data) }
export function register(data) { return http.post('/auth/register', data) }
export function getUserInfo() { return http.get('/auth/me') }
export function updateProfile(data) { return http.put('/auth/me', data) }
export function changePassword(data) { return http.put('/auth/password', data) }
export function translate(data) { return http.post('/translate', data) }
export function translateReply(data) { return http.post('/translate/reply', data) }
export function extractInfo(data) { return http.post('/translate/extract', data) }
export function translateFeedback(data) { return http.post('/translate/feedback', data) }
export function sendSuggestion(data) { return http.post('/interaction/select', data) }
export function listCustomers(params) { return http.get('/customers', { params }) }
export function getCustomer(id) { return http.get(`/customers/${id}`) }
export function createCustomer(data) { return http.post('/customers', data) }
export function updateCustomer(id, data) { return http.patch(`/customers/${id}`, data) }
export function deleteCustomer(id) { return http.delete(`/customers/${id}`) }
export function getSilentCustomers(days = 3) { return http.get('/customers/silent', { params: { days } }) }
export function getCustomerConversation(id) { return http.get(`/customers/${id}/conversation`) }
export function exportCustomersCsv() { return http.get('/customers/export/csv') }
export function exportCustomersXlsx() { return http.get('/customers/export/xlsx') }
export function importCustomers(data) { return http.post('/customers/import', data) }
export function getHealthOverview() { return http.get('/customers/health-overview') }
export function getHealthScores() { return http.get('/customers/health-scores') }
export function getCustomerHealth(id) { return http.get(`/customers/${id}/health`) }
export function listProducts(params) { return http.get('/products', { params }) }
export function createProduct(data) { return http.post('/products', data) }
export function updateProduct(id, data) { return http.patch(`/products/${id}`, data) }
export function deleteProduct(id) { return http.delete(`/products/${id}`) }
export function exportProductsCsv() { return http.get('/products/export/csv') }
export function exportProductsXlsx() { return http.get('/products/export/xlsx') }
export function importProducts(data) { return http.post('/products/import', data) }
export function listQuotations(params) { return http.get('/quotations', { params }) }
export function getQuotation(id) { return http.get(`/quotations/${id}`) }
export function createQuotation(data) { return http.post('/quotations', data) }
export function updateQuotationStatus(id, status) { return http.patch(`/quotations/${id}/status`, { status }) }
export function exportQuotationPdf(id) { return http.get(`/quotations/${id}/pdf`) }
export function exportQuotationsCsv() { return http.get('/quotations/export/csv') }
export function exportQuotationsXlsx() { return http.get('/quotations/export/xlsx') }
export function generateQuoteFromInquiry(data) { return http.post('/quotations/generate-from-inquiry', data) }
export function importQuotations(data) { return http.post('/quotations/import', data) }
export function generateMarketing(data) { return http.post('/marketing/generate', data) }
export function getKeywords(data) { return http.post('/marketing/keywords', data) }
export function competitorAnalysis(data) { return http.post('/marketing/competitor-analysis', data) }
export function discoverySearch(data) { return http.post('/discovery/search', data) }
export function discoveryAnalyze(data) { return http.post('/discovery/analyze', data) }
export function discoveryOutreach(data) { return http.post('/discovery/outreach', data) }
export function getFollowupStats() { return http.get('/followup/stats') }
export function getFollowupPending() { return http.get('/followup/pending') }
export function getFollowupLogs(params) { return http.get('/followup/logs', { params }) }
export function markFollowupSent(id) { return http.post(`/followup/${id}/send`) }
export function editFollowup(id, data) { return http.post(`/followup/${id}/edit`, data) }
export function scanFollowups() { return http.post('/followup/scan') }
export function getAnalyticsOverview() { return http.get('/analytics/overview') }
export function listTeams() { return http.get('/teams') }
export function createTeam(data) { return http.post('/teams', data) }
export function inviteTeamMember(id, userId) { return http.post(`/teams/${id}/invite`, { user_id: userId }) }
export function deleteTeamMember(id, memberId) { return http.delete(`/teams/${id}/members/${memberId}`) }
export function leaveTeam(id) { return http.post(`/teams/${id}/leave`) }
export function updateTeamMemberRole(id, memberId, role) { return http.patch(`/teams/${id}/members/${memberId}/role`, { role }) }
export function listNotifications(params) { return http.get('/notifications', { params }) }
export function getUnreadCount() { return http.get('/notifications/unread-count') }
export function markNotificationRead(id) { return http.patch(`/notifications/${id}/read`) }
export function markAllRead() { return http.post('/notifications/read-all') }
export function getPlans() { return http.get('/payment/plans') }
export function getSubscription() { return http.get('/payment/subscription') }
export function createOrder(planId) { return http.post('/payment/create-order', { plan_id: planId }) }
export function submitCertification(data) { return http.post('/certification/submit', data) }
export function getCertificationStatus() { return http.get('/certification/status') }
export function applyInvoice(data) { return http.post('/invoices/apply', data) }
export function listInvoices(params) { return http.get('/invoices/list', { params }) }
export function submitFeedback(data) { return http.post('/feedback', data) }
export function sendWhatsApp(data) { return http.post('/whatsapp/send', data) }
export default http
+109
View File
@@ -0,0 +1,109 @@
<template>
<div class="user-layout">
<aside class="sidebar" :class="{ collapsed }">
<div class="sidebar-header">
<span class="logo">{{ collapsed ? 'TM' : 'TradeMate' }}</span>
</div>
<el-menu
:default-active="route.path"
:collapse="collapsed"
router
:collapse-transition="false"
>
<el-menu-item index="/"><el-icon><Odometer /></el-icon><span>工作台</span></el-menu-item>
<el-menu-item index="/translate"><el-icon><ChatLineSquare /></el-icon><span>智能翻译</span></el-menu-item>
<el-menu-item index="/customers"><el-icon><User /></el-icon><span>客户管理</span></el-menu-item>
<el-menu-item index="/products"><el-icon><Goods /></el-icon><span>产品库</span></el-menu-item>
<el-menu-item index="/quotations"><el-icon><DocumentCopy /></el-icon><span>报价单</span></el-menu-item>
<el-menu-item index="/marketing"><el-icon><Promotion /></el-icon><span>营销素材</span></el-menu-item>
<el-menu-item index="/discovery"><el-icon><Search /></el-icon><span>挖掘新客</span></el-menu-item>
<el-menu-item index="/followup"><el-icon><Message /></el-icon><span>智能跟进</span></el-menu-item>
<el-menu-item index="/analytics"><el-icon><DataAnalysis /></el-icon><span>数据分析</span></el-menu-item>
<el-menu-item index="/team"><el-icon><UserFilled /></el-icon><span>团队协作</span></el-menu-item>
</el-menu>
</aside>
<div class="main-area">
<header class="topbar">
<el-button text @click="collapsed = !collapsed" style="font-size:18px;margin-right:12px">
<el-icon><Fold v-if="!collapsed" /><Expand v-else /></el-icon>
</el-button>
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="'/'">工作台</el-breadcrumb-item>
<el-breadcrumb-item v-if="route.meta?.title" :to="route.path">{{ route.meta.title }}</el-breadcrumb-item>
</el-breadcrumb>
<div class="topbar-right">
<el-badge :value="unread" :hidden="!unread" class="notif-badge">
<el-button text style="font-size:18px" @click="$router.push('/notifications')">
<el-icon><Bell /></el-icon>
</el-button>
</el-badge>
<el-dropdown trigger="click">
<el-button text style="display:flex;align-items:center;gap:6px">
<el-icon><User /></el-icon>
<span>{{ auth.user?.username || '用户' }}</span>
<el-icon><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="$router.push('/profile')">个人中心</el-dropdown-item>
<el-dropdown-item @click="$router.push('/notifications')">通知中心</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</header>
<main class="content">
<router-view />
</main>
<footer class="footer">
<span>TradeMate 外贸小助手 &copy; {{ new Date().getFullYear() }}</span>
</footer>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { getUnreadCount } from '@/api'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const collapsed = ref(false)
const unread = ref(0)
onMounted(async () => {
await auth.fetchUser()
try {
const res = await getUnreadCount()
unread.value = res.count || res || 0
} catch { /* ignore */ }
})
function handleLogout() {
auth.logout()
router.push('/login')
}
</script>
<style scoped>
.user-layout { display: flex; height: 100vh; overflow: hidden; }
.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; justify-content: center; 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; }
.topbar { height: 60px; background: #fff; border-bottom: 1px solid #e8e8e8; display: flex; align-items: center; padding: 0 24px; flex-shrink: 0; }
.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 8px; }
.notif-badge :deep(.el-badge__content) { top: 8px; right: 4px; }
.content { flex: 1; padding: 24px; overflow-y: auto; background: #f5f5f5; }
.footer { text-align: center; padding: 12px; color: #999; font-size: 12px; border-top: 1px solid #e8e8e8; background: #fff; flex-shrink: 0; }
</style>
+15
View File
@@ -0,0 +1,15 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
for (const [k, v] of Object.entries(ElementPlusIconsVue)) app.component(k, v)
app.mount('#app')
+43
View File
@@ -0,0 +1,43 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{ path: '/login', name: 'Login', component: () => import('@/views/Login.vue') },
{
path: '/',
component: () => import('@/layouts/UserLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Workspace', component: () => import('@/views/Workspace.vue'), meta: { title: '工作台' } },
{ path: 'translate', name: 'Translate', component: () => import('@/views/Translate.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: 'marketing', name: 'Marketing', component: () => import('@/views/Marketing.vue'), meta: { title: '营销素材' } },
{ path: 'discovery', name: 'Discovery', component: () => import('@/views/Discovery.vue'), meta: { title: '挖掘新客' } },
{ path: 'followup', name: 'Followup', component: () => import('@/views/Followup.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: 'notifications', name: 'Notifications', component: () => import('@/views/Notifications.vue'), meta: { title: '通知中心' } },
{ path: 'profile', name: 'Profile', component: () => import('@/views/Profile.vue'), meta: { title: '个人中心' } },
{ path: 'upgrade', name: 'Upgrade', component: () => import('@/views/Upgrade.vue'), meta: { title: '升级会员' } },
{ path: 'certification', name: 'Certification', component: () => import('@/views/Certification.vue'), meta: { title: '实名认证' } },
{ path: 'invoice', name: 'Invoice', component: () => import('@/views/Invoice.vue'), meta: { title: '发票管理' } },
{ path: 'feedback', name: 'Feedback', component: () => import('@/views/Feedback.vue'), meta: { title: '意见反馈' } },
]
},
{ path: '/:pathMatch(.*)*', redirect: '/' },
]
const router = createRouter({ history: createWebHistory('/workspace/'), routes })
router.beforeEach((to, from, next) => {
if (to.meta?.requiresAuth) {
const token = localStorage.getItem('token')
if (!token) next({ name: 'Login', query: { redirect: to.fullPath } })
else next()
} else {
next()
}
})
export default router
+40
View File
@@ -0,0 +1,40 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { login as loginApi, getUserInfo } from '@/api'
const TOKEN_KEY = 'token'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem(TOKEN_KEY) || '')
const user = ref(JSON.parse(localStorage.getItem('user') || 'null'))
const isLoggedIn = computed(() => !!token.value)
async function login(credentials) {
const res = await loginApi(credentials)
token.value = res.access_token
localStorage.setItem(TOKEN_KEY, res.access_token)
if (res.user) {
user.value = res.user
localStorage.setItem('user', JSON.stringify(res.user))
}
return res
}
async function fetchUser() {
try {
const res = await getUserInfo()
user.value = res.data || res
localStorage.setItem('user', JSON.stringify(user.value))
} catch { /* 401 will trigger interceptor redirect */ }
}
function logout() {
token.value = ''
user.value = null
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem('user')
}
return { token, user, isLoggedIn, login, fetchUser, logout }
})
+77
View File
@@ -0,0 +1,77 @@
<template>
<div>
<el-row :gutter="20">
<el-col :span="6" v-for="item in cards" :key="item.label">
<el-card shadow="hover" style="text-align:center;margin-bottom:20px">
<div class="card-value">{{ item.value }}</div>
<div class="card-label">{{ item.label }}</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-card shadow="never">
<template #header><span>客户状态分布</span></template>
<div style="padding:20px;text-align:center">
<div v-for="s in statusData" :key="s.label" style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
<span style="width:80px;text-align:right">{{ s.label }}</span>
<el-progress :percentage="s.pct" :color="s.color" :stroke-width="18" />
<span style="width:40px">{{ s.count }}</span>
</div>
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never">
<template #header><span>国家分布 Top 10</span></template>
<div style="padding:20px">
<div v-for="c in countryData" :key="c.country" style="display:flex;align-items:center;gap:12px;margin-bottom:8px">
<span style="width:100px">{{ c.country }}</span>
<el-progress :percentage="c.pct" :stroke-width="16" />
<span style="width:30px">{{ c.count }}</span>
</div>
<el-empty v-if="!countryData.length" description="暂无数据" :image-size="50" />
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getAnalyticsOverview } from '@/api'
const cards = ref([])
const statusData = ref([])
const countryData = ref([])
onMounted(async () => {
try {
const res = await getAnalyticsOverview()
const d = res.data || res
cards.value = [
{ value: d.customers?.total || 0, label: '客户总数' },
{ value: d.translations?.today || 0, label: '今日翻译' },
{ value: d.quotations?.total || 0, label: '报价单数' },
{ value: d.messages?.today || 0, label: '今日消息' },
]
const customers = d.customers || {}
const statusCounts = customers.status_counts || customers.by_status || {}
const total = Object.values(statusCounts).reduce((a, b) => a + b, 0) || 1
const statusLabels = { lead: '潜在客户', negotiating: '洽谈中', customer: '已成交', lost: '流失' }
const statusColors = { lead: '#909399', negotiating: '#e6a23c', customer: '#67c23a', lost: '#f56c6c' }
statusData.value = Object.entries(statusCounts).map(([k, v]) => ({ label: statusLabels[k] || k, count: v, pct: Math.round(v / total * 100), color: statusColors[k] || '#409eff' }))
const countries = customers.by_country || d.countries || []
const countryTotal = countries.reduce((a, b) => a + (b.count || b.value || 0), 0) || 1
countryData.value = (countries.slice ? countries.slice(0, 10) : []).map(c => ({ country: c.country || c.name, count: c.count || c.value || 0, pct: Math.round((c.count || c.value || 0) / countryTotal * 100) }))
} catch { /* ignore */ }
})
</script>
<style scoped>
.card-value { font-size: 28px; font-weight: 700; color: #409eff; }
.card-label { font-size: 13px; color: #999; margin-top: 4px; }
</style>
+57
View File
@@ -0,0 +1,57 @@
<template>
<div>
<el-card shadow="never">
<template #header><span>实名认证</span></template>
<el-alert v-if="status === 'approved'" title="认证已通过" type="success" show-icon :closable="false" style="margin-bottom:16px" />
<el-alert v-else-if="status === 'rejected'" :title="'认证被拒绝:' + (reason || '')" type="error" show-icon :closable="false" style="margin-bottom:16px" />
<el-alert v-else-if="status === 'pending'" title="认证审核中" type="warning" show-icon :closable="false" style="margin-bottom:16px" />
<el-form :model="form" label-width="100" size="large" v-if="status !== 'approved'">
<el-form-item label="认证类型">
<el-radio-group v-model="form.type">
<el-radio value="individual">个人实名</el-radio>
<el-radio value="enterprise">企业认证</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="姓名"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="身份证号" v-if="form.type === 'individual'"><el-input v-model="form.id_number" /></el-form-item>
<el-form-item label="公司名称" v-if="form.type === 'enterprise'"><el-input v-model="form.company_name" /></el-form-item>
<el-form-item label="税号" v-if="form.type === 'enterprise'"><el-input v-model="form.tax_id" /></el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="submit">提交认证</el-button>
</el-form-item>
</el-form>
<el-empty v-if="!loading && status === null" description="点击底部按钮获取认证状态" :image-size="60" />
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { submitCertification, getCertificationStatus } from '@/api'
import { ElMessage } from 'element-plus'
const loading = ref(false)
const status = ref(null)
const reason = ref('')
const form = reactive({ type: 'individual', name: '', id_number: '', company_name: '', tax_id: '' })
onMounted(async () => {
try {
const res = await getCertificationStatus()
const d = res.data || res
status.value = d.status
reason.value = d.reason || ''
} catch { /* not certified yet */ }
})
async function submit() {
loading.value = true
try {
await submitCertification(form)
ElMessage.success('提交成功,等待审核')
status.value = 'pending'
} catch (e) { ElMessage.error(e?.detail || '提交失败') }
finally { loading.value = false }
}
</script>
+165
View File
@@ -0,0 +1,165 @@
<template>
<div>
<el-card shadow="never">
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap">
<el-input v-model="searchQuery" placeholder="搜索客户名称/公司..." style="width:260px" clearable @clear="load" @keyup.enter="load" />
<el-select v-model="statusFilter" placeholder="状态" clearable style="width:140px" @change="load">
<el-option label="潜在客户" value="lead" />
<el-option label="洽谈中" value="negotiating" />
<el-option label="已成交" value="customer" />
<el-option label="流失" value="lost" />
</el-select>
<el-button type="primary" @click="showCreate = true">新增客户</el-button>
</div>
<el-table :data="list" v-loading="loading" stripe style="width:100%">
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column prop="company" label="公司" min-width="160" />
<el-table-column prop="country" label="国家" width="120" />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="health_grade" label="健康度" width="90">
<template #default="{ row }">
<el-tag :type="healthType(row.health_grade)" size="small">{{ row.health_grade || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="viewDetail(row)">详情</el-button>
<el-button type="primary" link size="small" @click="editRow(row)">编辑</el-button>
<el-popconfirm title="确定删除?" @confirm="deleteRow(row.id)">
<template #reference>
<el-button type="danger" link size="small">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && !list.length" description="暂无客户" />
<div style="margin-top:16px;text-align:right">
<el-pagination
v-model:current-page="page"
:page-size="size"
:total="total"
layout="prev,pager,next"
@current-change="load"
/>
</div>
</el-card>
<el-dialog v-model="showCreate" :title="editing ? '编辑客户' : '新增客户'" width="500">
<el-form :model="form" label-width="80">
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="公司"><el-input v-model="form.company" /></el-form-item>
<el-form-item label="国家"><el-input v-model="form.country" /></el-form-item>
<el-form-item label="手机"><el-input v-model="form.phone" /></el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status" style="width:100%">
<el-option label="潜在客户" value="lead" />
<el-option label="洽谈中" value="negotiating" />
<el-option label="已成交" value="customer" />
<el-option label="流失" value="lost" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreate = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="showDetail" title="客户详情" width="600">
<template v-if="detail">
<el-descriptions :column="2" border>
<el-descriptions-item label="名称">{{ detail.name }}</el-descriptions-item>
<el-descriptions-item label="公司">{{ detail.company }}</el-descriptions-item>
<el-descriptions-item label="国家">{{ detail.country }}</el-descriptions-item>
<el-descriptions-item label="手机">{{ detail.phone }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ statusLabel(detail.status) }}</el-descriptions-item>
<el-descriptions-item label="健康度">{{ detail.health_grade || '-' }}</el-descriptions-item>
</el-descriptions>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue'
import { listCustomers, createCustomer, updateCustomer, deleteCustomer, getCustomer } from '@/api'
import { ElMessage } from 'element-plus'
const loading = ref(false)
const list = ref([])
const page = ref(1)
const size = ref(20)
const total = ref(0)
const searchQuery = ref('')
const statusFilter = ref('')
const showCreate = ref(false)
const showDetail = ref(false)
const editing = ref(false)
const saving = ref(false)
const detail = ref(null)
const form = reactive({ name: '', company: '', country: '', phone: '', status: 'lead' })
function statusType(s) { return { lead: 'info', negotiating: 'warning', customer: 'success', lost: 'danger' }[s] || 'info' }
function statusLabel(s) { return { lead: '潜在客户', negotiating: '洽谈中', customer: '已成交', lost: '流失' }[s] || s }
function healthType(g) { return { A: 'success', B: 'warning', C: 'danger' }[g] || 'info' }
onMounted(load)
async function load() {
loading.value = true
try {
const params = { page: page.value, size: size.value }
if (searchQuery.value) params.query = searchQuery.value
if (statusFilter.value) params.status = statusFilter.value
const res = await listCustomers(params)
const d = res.data || res
list.value = d.items || d.rows || d.data || d || []
total.value = d.total || list.value.length
} catch { /* ignore */ }
finally { loading.value = false }
}
async function save() {
saving.value = true
try {
if (editing.value) {
await updateCustomer(editing.value, form)
ElMessage.success('已更新')
} else {
await createCustomer(form)
ElMessage.success('已创建')
}
showCreate.value = false
load()
} catch (e) { ElMessage.error(e?.detail || '操作失败') }
finally { saving.value = false }
}
function editRow(row) {
editing.value = row.id
Object.assign(form, { name: row.name, company: row.company || '', country: row.country || '', phone: row.phone || '', status: row.status || 'lead' })
showCreate.value = true
}
async function viewDetail(row) {
try {
const res = await getCustomer(row.id)
detail.value = res.data || res
showDetail.value = true
} catch { ElMessage.error('获取详情失败') }
}
async function deleteRow(id) {
try {
await deleteCustomer(id)
ElMessage.success('已删除')
load()
} catch { ElMessage.error('删除失败') }
}
</script>
+93
View File
@@ -0,0 +1,93 @@
<template>
<div>
<el-tabs v-model="tab">
<el-tab-pane label="客户挖掘" name="search">
<el-card shadow="never">
<el-form :model="form" label-width="100">
<el-form-item label="产品"><el-input v-model="form.product" placeholder="你的产品名称" /></el-form-item>
<el-form-item label="目标市场"><el-input v-model="form.market" placeholder="如:美国、德国" /></el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="search">挖掘</el-button>
</el-form-item>
</el-form>
<div v-if="results.length">
<el-card v-for="r in results" :key="r.id || r.name" shadow="hover" style="margin-top:12px">
<h4>{{ r.name }} <el-tag v-if="r.match_score" size="small" :type="scoreType(r.match_score)">{{ r.match_score }}%</el-tag></h4>
<p v-if="r.description" style="color:#666;font-size:13px">{{ r.description }}</p>
<p v-if="r.contact" style="font-size:12px;color:#999">联系方式{{ r.contact }}</p>
<div style="margin-top:8px">
<el-button size="small" type="primary" @click="addCustomer(r)">添加为客户</el-button>
</div>
</el-card>
</div>
<el-empty v-if="!loading && !results.length && searched" description="未找到匹配客户" :image-size="60" />
</el-card>
</el-tab-pane>
<el-tab-pane label="开发信生成" name="outreach">
<el-card shadow="never">
<el-form :model="outForm" label-width="100">
<el-form-item label="目标公司"><el-input v-model="outForm.company" placeholder="公司名称" /></el-form-item>
<el-form-item label="你的产品"><el-input v-model="outForm.product" placeholder="你的产品/服务" /></el-form-item>
<el-form-item label="渠道">
<el-select v-model="outForm.channel" style="width:160px">
<el-option label="开发信" value="email" />
<el-option label="LinkedIn" value="linkedin" />
<el-option label="WhatsApp" value="whatsapp" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="outLoading" @click="generateOutreach">生成</el-button>
</el-form-item>
</el-form>
<div v-if="outreachResult" style="padding:16px;background:#f5f5f5;border-radius:6px;margin-top:12px;white-space:pre-wrap">{{ outreachResult }}</div>
</el-card>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { discoverySearch, discoveryOutreach, createCustomer } from '@/api'
import { ElMessage } from 'element-plus'
const tab = ref('search')
const loading = ref(false)
const searched = ref(false)
const results = ref([])
const form = ref({ product: '', market: '' })
const outForm = ref({ company: '', product: '', channel: 'email' })
const outLoading = ref(false)
const outreachResult = ref('')
function scoreType(s) { if (s >= 80) return 'success'; if (s >= 60) return 'warning'; return 'info' }
async function search() {
if (!form.value.product) { ElMessage.warning('请输入产品'); return }
loading.value = true
searched.value = true
try {
const res = await discoverySearch(form.value)
const d = res.data || res
results.value = d.companies || d.items || d.results || d || []
} catch { ElMessage.error('挖掘失败') }
finally { loading.value = false }
}
async function addCustomer(r) {
try {
await createCustomer({ name: r.name, company: r.name, country: r.country || '', description: r.description || '' })
ElMessage.success('已添加为客户')
} catch (e) { ElMessage.error(e?.detail || '添加失败') }
}
async function generateOutreach() {
if (!outForm.value.company || !outForm.value.product) { ElMessage.warning('请填写完整'); return }
outLoading.value = true
try {
const res = await discoveryOutreach(outForm.value)
outreachResult.value = res.data?.content || res.content || res.text || ''
} catch { ElMessage.error('生成失败') }
finally { outLoading.value = false }
}
</script>
+61
View File
@@ -0,0 +1,61 @@
<template>
<div>
<el-card shadow="never">
<template #header><span>意见反馈</span></template>
<el-form :model="form" label-width="80" size="large">
<el-form-item label="类别">
<el-select v-model="form.type" style="width:200px">
<el-option label="功能建议" value="feature" />
<el-option label="问题反馈" value="bug" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
<el-form-item label="内容">
<el-input v-model="form.content" type="textarea" :rows="5" placeholder="请详细描述你的建议或问题..." />
</el-form-item>
<el-form-item label="联系方式">
<el-input v-model="form.contact" placeholder="手机号或邮箱(选填)" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="submit">提交</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never" style="margin-top:16px">
<template #header><span>常见问题</span></template>
<el-collapse>
<el-collapse-item title="如何使用智能翻译?" name="1">
<p style="font-size:13px;color:#666">在工作台点击"智能翻译"输入需要翻译的外贸文本选择目标语言即可还支持回复建议和信息提取功能</p>
</el-collapse-item>
<el-collapse-item title="如何管理客户?" name="2">
<p style="font-size:13px;color:#666">"客户管理"页面可以新增编辑删除客户支持按状态筛选和关键词搜索还可以查看客户的健康度分析和跟进记录</p>
</el-collapse-item>
<el-collapse-item title="如何开具发票?" name="3">
<p style="font-size:13px;color:#666">"个人中心 - 发票管理"页面提交开票申请填写抬头和金额管理员审核通过后即可获取发票</p>
</el-collapse-item>
</el-collapse>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { submitFeedback } from '@/api'
import { ElMessage } from 'element-plus'
const loading = ref(false)
const form = reactive({ type: 'feature', content: '', contact: '' })
async function submit() {
if (!form.content) { ElMessage.warning('请输入反馈内容'); return }
loading.value = true
try {
await submitFeedback(form)
ElMessage.success('感谢你的反馈!')
form.content = ''
form.contact = ''
} catch (e) { ElMessage.error(e?.detail || '提交失败') }
finally { loading.value = false }
}
</script>
+145
View File
@@ -0,0 +1,145 @@
<template>
<div>
<el-row :gutter="20">
<el-col :span="6" v-for="s in statItems" :key="s.label">
<el-card shadow="hover" style="text-align:center;margin-bottom:16px">
<div class="stat-num">{{ s.value }}</div>
<div class="stat-label">{{ s.label }}</div>
</el-card>
</el-col>
</el-row>
<el-tabs v-model="tab">
<el-tab-pane label="待跟进" name="pending">
<el-card shadow="never">
<el-table :data="pendingList" v-loading="loading" stripe style="width:100%">
<el-table-column prop="customer_name" label="客户" min-width="140" />
<el-table-column label="沉默天数" width="100">
<template #default="{ row }"><el-tag :type="row.silent_days > 7 ? 'danger' : 'warning'" size="small">{{ row.silent_days }}</el-tag></template>
</el-table-column>
<el-table-column label="跟进内容" min-width="260">
<template #default="{ row }">
<span style="font-size:13px;color:#666">{{ (row.content || row.suggested_content || '').slice(0, 80) }}{{ (row.content || row.suggested_content || '').length > 80 ? '...' : '' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" @click="sendFollowup(row)">发送</el-button>
<el-button size="small" @click="editFollowup(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && !pendingList.length" description="暂无待跟进" />
</el-card>
</el-tab-pane>
<el-tab-pane label="历史记录" name="history">
<el-card shadow="never">
<el-table :data="logList" v-loading="loadingLogs" stripe style="width:100%">
<el-table-column prop="customer_name" label="客户" min-width="140" />
<el-table-column prop="content" label="内容" min-width="260">
<template #default="{ row }">{{ (row.content || '').slice(0, 80) }}{{ row.content?.length > 80 ? '...' : '' }}</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'sent' ? 'success' : 'info'" size="small">{{ row.status === 'sent' ? '已发送' : '草稿' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="时间" width="170" />
</el-table>
</el-card>
</el-tab-pane>
</el-tabs>
<el-dialog v-model="showEdit" title="编辑跟进内容" width="500">
<el-input v-model="editContent" type="textarea" :rows="6" />
<template #footer>
<el-button @click="showEdit = false">取消</el-button>
<el-button type="primary" :loading="editSaving" @click="saveEdit">保存并发送</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getFollowupStats, getFollowupPending, getFollowupLogs, markFollowupSent, editFollowup as editFollowupApi, scanFollowups } from '@/api'
import { ElMessage } from 'element-plus'
const tab = ref('pending')
const loading = ref(false)
const loadingLogs = ref(false)
const pendingList = ref([])
const logList = ref([])
const statItems = ref([{ label: '待跟进', value: 0 }, { label: '已发送', value: 0 }, { label: '已回复', value: 0 }, { label: '完成率', value: '0%' }])
const showEdit = ref(false)
const editContent = ref('')
const editId = ref(null)
const editSaving = ref(false)
onMounted(() => { loadStats(); loadPending(); loadLogs() })
async function loadStats() {
try {
const res = await getFollowupStats()
const d = res.data || res
statItems.value = [
{ label: '待跟进', value: d.pending || d.total_pending || 0 },
{ label: '已发送', value: d.sent || d.total_sent || 0 },
{ label: '已回复', value: d.replied || d.total_replied || 0 },
{ label: '完成率', value: (d.completion_rate || d.rate || 0) + '%' },
]
} catch { /* ignore */ }
}
async function loadPending() {
loading.value = true
try {
const res = await getFollowupPending()
const d = res.data || res
pendingList.value = d.items || d || []
} catch { /* ignore */ }
finally { loading.value = false }
}
async function loadLogs() {
loadingLogs.value = true
try {
const res = await getFollowupLogs({ page: 1, size: 50 })
const d = res.data || res
logList.value = d.items || d.rows || d.data || d || []
} catch { /* ignore */ }
finally { loadingLogs.value = false }
}
async function sendFollowup(row) {
try {
await markFollowupSent(row.id)
ElMessage.success('已发送')
loadPending()
loadLogs()
} catch { ElMessage.error('发送失败') }
}
function editFollowup(row) {
editId.value = row.id
editContent.value = row.content || row.suggested_content || ''
showEdit.value = true
}
async function saveEdit() {
editSaving.value = true
try {
await editFollowupApi(editId.value, { content: editContent.value })
ElMessage.success('已保存并发送')
showEdit.value = false
loadPending()
loadLogs()
} catch { ElMessage.error('操作失败') }
finally { editSaving.value = false }
}
</script>
<style scoped>
.stat-num { font-size: 28px; font-weight: 700; color: #409eff; }
.stat-label { font-size: 13px; color: #999; margin-top: 4px; }
</style>
+86
View File
@@ -0,0 +1,86 @@
<template>
<div>
<el-tabs v-model="tab">
<el-tab-pane label="开票申请" name="apply">
<el-card shadow="never">
<el-form :model="form" label-width="100" size="large">
<el-form-item label="发票类型">
<el-radio-group v-model="form.type">
<el-radio value="individual">个人发票</el-radio>
<el-radio value="enterprise">企业发票</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="抬头"><el-input v-model="form.title" /></el-form-item>
<el-form-item label="税号" v-if="form.type === 'enterprise'"><el-input v-model="form.tax_id" /></el-form-item>
<el-form-item label="金额"><el-input-number v-model="form.amount" :min="1" style="width:200px" /></el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="apply">提交申请</el-button>
</el-form-item>
</el-form>
</el-card>
</el-tab-pane>
<el-tab-pane label="开票记录" name="history">
<el-card shadow="never">
<el-table :data="list" v-loading="loadingList" stripe style="width:100%">
<el-table-column label="类型" width="100">
<template #default="{ row }">{{ row.type === 'individual' ? '个人' : '企业' }}</template>
</el-table-column>
<el-table-column prop="title" label="抬头" min-width="140" />
<el-table-column label="金额" width="120">
<template #default="{ row }">{{ row.amount }} </template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="申请时间" width="170" />
<el-table-column v-if="rejectedReason" label="驳回原因" min-width="140">
<template #default="{ row }">{{ row.reason || '-' }}</template>
</el-table-column>
</el-table>
<el-empty v-if="!loadingList && !list.length" description="暂无开票记录" />
</el-card>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { applyInvoice, listInvoices } from '@/api'
import { ElMessage } from 'element-plus'
const tab = ref('apply')
const loading = ref(false)
const loadingList = ref(false)
const list = ref([])
const form = ref({ type: 'individual', title: '', tax_id: '', amount: 0 })
function statusType(s) { return { pending: 'warning', issued: 'success', rejected: 'danger' }[s] || 'info' }
function statusLabel(s) { return { pending: '处理中', issued: '已开票', rejected: '已驳回' }[s] || s }
onMounted(loadList)
async function apply() {
if (!form.value.title || !form.value.amount) { ElMessage.warning('请填写完整'); return }
loading.value = true
try {
await applyInvoice(form.value)
ElMessage.success('申请已提交')
form.value = { type: 'individual', title: '', tax_id: '', amount: 0 }
loadList()
} catch (e) { ElMessage.error(e?.detail || '提交失败') }
finally { loading.value = false }
}
async function loadList() {
loadingList.value = true
try {
const res = await listInvoices({ page: 1, size: 50 })
const d = res.data || res
list.value = d.items || d.rows || d.data || d || []
} catch { /* ignore */ }
finally { loadingList.value = false }
}
</script>
+60
View File
@@ -0,0 +1,60 @@
<template>
<div class="login-page">
<div class="login-card">
<h2 class="login-title">TradeMate 工作台</h2>
<el-form :model="form" :rules="rules" ref="formRef" size="large" @keyup.enter="submit">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" show-password />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" style="width:100%" @click="submit">登录</el-button>
</el-form-item>
</el-form>
<p v-if="error" class="login-error">{{ error }}</p>
<p class="login-hint">已在主站登录请先退出后重新登录</p>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const formRef = ref(null)
const loading = ref(false)
const error = ref('')
const form = reactive({ username: '', password: '' })
const rules = { username: [{ required: true, message: '请输入用户名' }], password: [{ required: true, message: '请输入密码' }] }
async function submit() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
error.value = ''
try {
await auth.login(form)
await auth.fetchUser()
const redirect = route.query.redirect || '/'
router.push(redirect)
} catch (e) {
error.value = e?.detail || '登录失败'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page { height: 100vh; display: flex; align-items: center; justify-content: center; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
.login-card { width: 400px; padding: 40px; background: #fff; border-radius: 12px; box-shadow: 0 8px 40px rgba(0,0,0,0.15); }
.login-title { text-align: center; margin-bottom: 30px; font-size: 22px; color: #333; }
.login-error { color: #f56c6c; text-align: center; font-size: 13px; margin-top: -10px; }
.login-hint { text-align: center; margin-top: 16px; font-size: 12px; color: #999; }
</style>
+84
View File
@@ -0,0 +1,84 @@
<template>
<div>
<el-card shadow="never">
<template #header><span>AI 营销素材生成</span></template>
<el-form :model="form" label-width="100">
<el-form-item label="产品名称"><el-input v-model="form.product_name" placeholder="你的产品名称" /></el-form-item>
<el-form-item label="产品描述"><el-input v-model="form.product_desc" type="textarea" :rows="3" placeholder="产品特点、优势等" /></el-form-item>
<el-form-item label="目标市场"><el-input v-model="form.target_market" placeholder="如:北美、欧洲、东南亚" /></el-form-item>
<el-form-item label="写作风格">
<el-select v-model="form.style" style="width:160px">
<el-option label="专业" value="professional" />
<el-option label="友好" value="friendly" />
<el-option label="说服型" value="persuasive" />
</el-select>
</el-form-item>
<el-form-item label="生成类型">
<el-select v-model="form.type" style="width:160px">
<el-option label="开发信" value="cold_email" />
<el-option label="WhatsApp话术" value="whatsapp" />
<el-option label="产品描述" value="product_desc" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="generate">生成</el-button>
<el-button @click="showKeywords = !showKeywords">关键词建议</el-button>
</el-form-item>
</el-form>
<div v-if="result" style="padding:16px;background:#f5f5f5;border-radius:6px;margin-top:12px">
<p style="white-space:pre-wrap">{{ result }}</p>
<div style="margin-top:8px;display:flex;gap:8px">
<el-button text type="primary" size="small" @click="copyText(result)">复制</el-button>
</div>
</div>
</el-card>
<el-card v-if="showKeywords" shadow="never" style="margin-top:16px">
<template #header><span>关键词建议</span></template>
<el-input v-model="kwInput" placeholder="输入产品关键词描述" style="margin-bottom:12px" />
<el-button type="primary" :loading="kwLoading" @click="fetchKeywords">生成</el-button>
<div v-if="keywords.length" style="margin-top:12px">
<el-tag v-for="k in keywords" :key="k" style="margin:4px">{{ k }}</el-tag>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { generateMarketing, getKeywords } from '@/api'
import { ElMessage } from 'element-plus'
const form = ref({ product_name: '', product_desc: '', target_market: '', style: 'professional', type: 'cold_email' })
const loading = ref(false)
const result = ref('')
const showKeywords = ref(false)
const kwInput = ref('')
const kwLoading = ref(false)
const keywords = ref([])
async function generate() {
if (!form.value.product_name) { ElMessage.warning('请输入产品名称'); return }
loading.value = true
try {
const res = await generateMarketing({ product_info: { name: form.value.product_name, description: form.value.product_desc }, target_market: form.value.target_market, style: form.value.style, type: form.value.type })
result.value = res.data?.content || res.content || res.data?.text || res.text || ''
} catch { ElMessage.error('生成失败') }
finally { loading.value = false }
}
async function fetchKeywords() {
if (!kwInput.value.trim()) return
kwLoading.value = true
try {
const res = await getKeywords({ text: kwInput.value })
keywords.value = res.data?.keywords || res.keywords || []
} catch { ElMessage.error('生成失败') }
finally { kwLoading.value = false }
}
function copyText(text) {
navigator.clipboard.writeText(text).then(() => ElMessage.success('已复制'))
}
</script>
+61
View File
@@ -0,0 +1,61 @@
<template>
<div>
<el-card shadow="never">
<template #header>
<div style="display:flex;justify-content:space-between;align-items:center">
<span>通知中心</span>
<el-button v-if="list.length" size="small" @click="markAll">全部已读</el-button>
</div>
</template>
<div v-for="n in list" :key="n.id" class="notif-item" :class="{ unread: !n.is_read }" @click="markRead(n)">
<div style="display:flex;justify-content:space-between">
<strong>{{ n.title }}</strong>
<el-tag v-if="!n.is_read" size="small" type="danger"></el-tag>
</div>
<p style="margin:4px 0;font-size:13px;color:#666">{{ n.content }}</p>
<span style="font-size:11px;color:#999">{{ n.created_at }}</span>
</div>
<el-empty v-if="!list.length" description="暂无通知" :image-size="60" />
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { listNotifications, markNotificationRead, markAllRead } from '@/api'
import { ElMessage } from 'element-plus'
const list = ref([])
onMounted(load)
async function load() {
try {
const res = await listNotifications({ page: 1, size: 50 })
const d = res.data || res
list.value = d.items || d.rows || d.data || d || []
} catch { /* ignore */ }
}
async function markRead(n) {
if (n.is_read) return
try {
await markNotificationRead(n.id)
n.is_read = true
} catch { /* ignore */ }
}
async function markAll() {
try {
await markAllRead()
list.value.forEach(n => n.is_read = true)
ElMessage.success('已全部标记为已读')
} catch { /* ignore */ }
}
</script>
<style scoped>
.notif-item { padding: 14px 16px; border-bottom: 1px solid #f0f0f0; cursor: pointer; transition: background 0.2s; }
.notif-item:hover { background: #fafafa; }
.notif-item.unread { background: #f0f5ff; }
</style>
+112
View File
@@ -0,0 +1,112 @@
<template>
<div>
<el-card shadow="never">
<div style="display:flex;gap:12px;margin-bottom:16px">
<el-input v-model="searchQuery" placeholder="搜索产品名称..." style="width:260px" clearable @clear="load" @keyup.enter="load" />
<el-button type="primary" @click="showForm = true">新增产品</el-button>
</div>
<el-table :data="list" v-loading="loading" stripe style="width:100%">
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column prop="name_en" label="英文名" min-width="160" />
<el-table-column prop="category" label="类别" width="120" />
<el-table-column prop="price" label="价格" width="100">
<template #default="{ row }">{{ row.price }} {{ row.currency || 'USD' }}</template>
</el-table-column>
<el-table-column prop="moq" label="MOQ" width="80" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="editRow(row)">编辑</el-button>
<el-popconfirm title="确定删除?" @confirm="deleteRow(row.id)">
<template #reference>
<el-button type="danger" link size="small">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && !list.length" description="暂无产品" />
</el-card>
<el-dialog v-model="showForm" :title="editing ? '编辑产品' : '新增产品'" width="520">
<el-form :model="form" label-width="80">
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="英文名"><el-input v-model="form.name_en" /></el-form-item>
<el-form-item label="类别"><el-input v-model="form.category" /></el-form-item>
<el-form-item label="描述"><el-input v-model="form.description" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="价格">
<el-input-number v-model="form.price" :min="0" style="width:200px" />
<el-select v-model="form.currency" style="width:100px;margin-left:8px">
<el-option label="USD" value="USD" />
<el-option label="CNY" value="CNY" />
<el-option label="EUR" value="EUR" />
</el-select>
</el-form-item>
<el-form-item label="MOQ"><el-input-number v-model="form.moq" :min="1" /></el-form-item>
<el-form-item label="关键词"><el-input v-model="form.keywords" placeholder="逗号分隔" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="showForm = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue'
import { listProducts, createProduct, updateProduct, deleteProduct } from '@/api'
import { ElMessage } from 'element-plus'
const loading = ref(false)
const list = ref([])
const searchQuery = ref('')
const showForm = ref(false)
const editing = ref(false)
const saving = ref(false)
const form = reactive({ name: '', name_en: '', category: '', description: '', price: 0, currency: 'USD', moq: 1, keywords: '' })
onMounted(load)
async function load() {
loading.value = true
try {
const params = {}
if (searchQuery.value) params.query = searchQuery.value
const res = await listProducts(params)
const d = res.data || res
list.value = d.items || d.rows || d.data || d || []
} catch { /* ignore */ }
finally { loading.value = false }
}
async function save() {
saving.value = true
try {
const payload = { ...form }
if (editing.value) {
await updateProduct(editing.value, payload)
ElMessage.success('已更新')
} else {
await createProduct(payload)
ElMessage.success('已创建')
}
showForm.value = false
load()
} catch (e) { ElMessage.error(e?.detail || '操作失败') }
finally { saving.value = false }
}
function editRow(row) {
editing.value = row.id
Object.assign(form, { name: row.name, name_en: row.name_en || '', category: row.category || '', description: row.description || '', price: row.price || 0, currency: row.currency || 'USD', moq: row.moq || 1, keywords: row.keywords || '' })
showForm.value = true
}
async function deleteRow(id) {
try {
await deleteProduct(id)
ElMessage.success('已删除')
load()
} catch { ElMessage.error('删除失败') }
}
</script>
+119
View File
@@ -0,0 +1,119 @@
<template>
<div>
<el-row :gutter="20">
<el-col :span="8">
<el-card shadow="never">
<div style="text-align:center;padding:20px 0">
<el-avatar :size="72" style="background:#409eff;font-size:28px">{{ (user?.username || 'U')[0].toUpperCase() }}</el-avatar>
<h3 style="margin:12px 0 4px">{{ user?.username || '用户' }}</h3>
<el-tag size="small" :type="tierType(user?.tier)">{{ user?.tier || 'free' }}</el-tag>
<el-tag v-if="user?.role === 'admin'" type="danger" size="small" style="margin-left:4px">管理员</el-tag>
</div>
<el-divider style="margin:8px 0" />
<div class="profile-menu">
<div class="menu-item" @click="$router.push('/upgrade')">
<el-icon><Crown /></el-icon><span>升级会员</span>
</div>
<div class="menu-item" @click="$router.push('/certification')">
<el-icon><Stamp /></el-icon><span>实名认证</span>
</div>
<div class="menu-item" @click="$router.push('/invoice')">
<el-icon><List /></el-icon><span>发票管理</span>
</div>
<div class="menu-item" @click="$router.push('/notifications')">
<el-icon><Bell /></el-icon><span>通知中心</span>
</div>
<div class="menu-item" @click="$router.push('/feedback')">
<el-icon><ChatDotSquare /></el-icon><span>意见反馈</span>
</div>
</div>
</el-card>
</el-col>
<el-col :span="16">
<el-card shadow="never">
<template #header><span>编辑资料</span></template>
<el-form :model="form" label-width="80" size="large">
<el-form-item label="用户名">
<el-input v-model="form.username" :disabled="true" />
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="form.email" placeholder="输入邮箱" />
</el-form-item>
<el-form-item label="手机">
<el-input v-model="form.phone" :disabled="true" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="saving" @click="saveProfile">保存</el-button>
<el-button @click="showPassword = true">修改密码</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
</el-row>
<el-dialog v-model="showPassword" title="修改密码" width="400">
<el-form :model="pwForm" label-width="80">
<el-form-item label="旧密码"><el-input v-model="pwForm.old_password" type="password" /></el-form-item>
<el-form-item label="新密码"><el-input v-model="pwForm.new_password" type="password" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="showPassword = false">取消</el-button>
<el-button type="primary" :loading="pwLoading" @click="changePw">确认</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { updateProfile, changePassword } from '@/api'
import { ElMessage } from 'element-plus'
const auth = useAuthStore()
const user = auth.user
const saving = ref(false)
const showPassword = ref(false)
const pwLoading = ref(false)
const form = reactive({ username: '', email: '', phone: '' })
const pwForm = reactive({ old_password: '', new_password: '' })
function tierType(t) { return { free: 'warning', pro: 'primary', enterprise: 'success' }[t] || 'info' }
onMounted(() => {
if (user.value) {
form.username = user.value.username || ''
form.email = user.value.email || ''
form.phone = user.value.phone || ''
}
})
async function saveProfile() {
saving.value = true
try {
await updateProfile({ email: form.email })
ElMessage.success('已保存')
auth.fetchUser()
} catch (e) { ElMessage.error(e?.detail || '保存失败') }
finally { saving.value = false }
}
async function changePw() {
if (!pwForm.old_password || !pwForm.new_password) { ElMessage.warning('请填写完整'); return }
pwLoading.value = true
try {
await changePassword(pwForm)
ElMessage.success('密码已修改')
showPassword.value = false
pwForm.old_password = ''
pwForm.new_password = ''
} catch (e) { ElMessage.error(e?.detail || '修改失败') }
finally { pwLoading.value = false }
}
</script>
<style scoped>
.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:hover { background: #f0f5ff; color: #409eff; }
</style>
+153
View File
@@ -0,0 +1,153 @@
<template>
<div>
<el-card shadow="never">
<div style="display:flex;gap:12px;margin-bottom:16px;flex-wrap:wrap">
<el-select v-model="statusFilter" placeholder="状态" clearable style="width:140px" @change="load">
<el-option label="草稿" value="draft" />
<el-option label="已发送" value="sent" />
<el-option label="已接受" value="accepted" />
</el-select>
<el-button type="primary" @click="showForm = true">新建报价</el-button>
<el-button @click="showInquiry = true">AI 智能报价</el-button>
</div>
<el-table :data="list" v-loading="loading" stripe style="width:100%">
<el-table-column prop="title" label="标题" min-width="160" />
<el-table-column label="客户" width="140">
<template #default="{ row }">{{ row.customer_name || row.customer?.name || '-' }}</template>
</el-table-column>
<el-table-column label="金额" width="120">
<template #default="{ row }">{{ row.total_amount }} {{ row.currency || 'USD' }}</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="170" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button v-if="row.status === 'draft'" type="primary" link size="small" @click="markSent(row)">标记已发</el-button>
<el-button type="primary" link size="small" @click="editRow(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && !list.length" description="暂无报价单" />
</el-card>
<el-dialog v-model="showForm" :title="editing ? '编辑报价' : '新建报价'" width="600">
<el-form :model="form" label-width="80">
<el-form-item label="标题"><el-input v-model="form.title" /></el-form-item>
<el-form-item label="客户">
<el-select v-model="form.customer_id" filterable style="width:100%">
<el-option v-for="c in customerOptions" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="金额"><el-input-number v-model="form.total_amount" :min="0" style="width:200px" /></el-form-item>
<el-form-item label="币种">
<el-select v-model="form.currency" style="width:120px">
<el-option label="USD" value="USD" />
<el-option label="CNY" value="CNY" />
<el-option label="EUR" value="EUR" />
</el-select>
</el-form-item>
<el-form-item label="备注"><el-input v-model="form.notes" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="showForm = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="showInquiry" title="AI 智能报价" width="500">
<el-input v-model="inquiryText" type="textarea" :rows="5" placeholder="输入客户询盘内容..." />
<div style="margin-top:12px">
<el-button type="primary" :loading="genLoading" @click="generateFromInquiry">生成报价</el-button>
</div>
<div v-if="genResult" style="margin-top:12px;padding:12px;background:#f5f5f5;border-radius:6px;white-space:pre-wrap">{{ genResult }}</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue'
import { listQuotations, createQuotation, updateQuotationStatus, generateQuoteFromInquiry, listCustomers } from '@/api'
import { ElMessage } from 'element-plus'
const loading = ref(false)
const list = ref([])
const statusFilter = ref('')
const showForm = ref(false)
const showInquiry = ref(false)
const editing = ref(false)
const saving = ref(false)
const customerOptions = ref([])
const inquiryText = ref('')
const genLoading = ref(false)
const genResult = ref('')
const form = reactive({ title: '', customer_id: null, total_amount: 0, currency: 'USD', notes: '' })
function statusType(s) { return { draft: 'info', sent: 'warning', accepted: 'success' }[s] || 'info' }
function statusLabel(s) { return { draft: '草稿', sent: '已发送', accepted: '已接受' }[s] || s }
onMounted(() => { load(); loadCustomers() })
async function load() {
loading.value = true
try {
const params = { page: 1, size: 50 }
if (statusFilter.value) params.status = statusFilter.value
const res = await listQuotations(params)
const d = res.data || res
list.value = d.items || d.rows || d.data || d || []
} catch { /* ignore */ }
finally { loading.value = false }
}
async function loadCustomers() {
try {
const res = await listCustomers({ page: 1, size: 200 })
const d = res.data || res
customerOptions.value = d.items || d.rows || d || []
} catch { /* ignore */ }
}
async function save() {
saving.value = true
try {
if (editing.value) {
await createQuotation(form)
ElMessage.success('已更新')
} else {
await createQuotation(form)
ElMessage.success('已创建')
}
showForm.value = false
load()
} catch (e) { ElMessage.error(e?.detail || '操作失败') }
finally { saving.value = false }
}
async function markSent(row) {
try {
await updateQuotationStatus(row.id, 'sent')
ElMessage.success('已标记为已发送')
load()
} catch { ElMessage.error('操作失败') }
}
async function generateFromInquiry() {
if (!inquiryText.value.trim()) return
genLoading.value = true
try {
const res = await generateQuoteFromInquiry({ text: inquiryText.value })
genResult.value = res.data?.quotation || res.quotation || JSON.stringify(res.data || res, null, 2)
} catch { ElMessage.error('生成失败') }
finally { genLoading.value = false }
}
function editRow(row) {
editing.value = row.id
Object.assign(form, { title: row.title, customer_id: row.customer_id || (row.customer?.id || null), total_amount: row.total_amount || 0, currency: row.currency || 'USD', notes: row.notes || '' })
showForm.value = true
}
</script>
+106
View File
@@ -0,0 +1,106 @@
<template>
<div>
<el-card shadow="never">
<div style="display:flex;gap:12px;margin-bottom:16px">
<el-button type="primary" @click="showCreate = true">创建团队</el-button>
</div>
<div v-for="t in teams" :key="t.id" class="team-card">
<div style="display:flex;justify-content:space-between;align-items:center">
<div>
<h4 style="margin:0">{{ t.name }}</h4>
<p v-if="t.description" style="color:#999;font-size:13px;margin:4px 0">{{ t.description }}</p>
<p style="font-size:12px;color:#666">成员 {{ t.members?.length || 0 }} </p>
</div>
<el-button v-if="t.role === 'owner' || t.role === 'admin'" size="small" @click="showInvite(t)">邀请成员</el-button>
</div>
<div v-if="t.members?.length" style="margin-top:12px;display:flex;gap:12px;flex-wrap:wrap">
<el-tag v-for="m in t.members" :key="m.id" :type="m.role === 'owner' ? 'danger' : m.role === 'admin' ? 'warning' : 'info'" size="small">
{{ m.username || m.user?.username }}({{ m.role }})
</el-tag>
</div>
</div>
<el-empty v-if="!teams.length" description="暂无团队" />
</el-card>
<el-dialog v-model="showCreate" title="创建团队" width="400">
<el-form :model="createForm" label-width="80">
<el-form-item label="名称"><el-input v-model="createForm.name" /></el-form-item>
<el-form-item label="描述"><el-input v-model="createForm.description" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="showCreate = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="createTeam">创建</el-button>
</template>
</el-dialog>
<el-dialog v-model="showInviteDialog" title="邀请成员" width="400">
<p>团队{{ inviteTeam?.name }}</p>
<el-input v-model="inviteUserId" placeholder="输入用户ID" style="margin-top:12px" />
<template #footer>
<el-button @click="showInviteDialog = false">取消</el-button>
<el-button type="primary" :loading="inviting" @click="doInvite">邀请</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue'
import { listTeams, createTeam as createTeamApi, inviteTeamMember } from '@/api'
import { ElMessage } from 'element-plus'
const teams = ref([])
const showCreate = ref(false)
const creating = ref(false)
const createForm = reactive({ name: '', description: '' })
const showInviteDialog = ref(false)
const inviteTeam = ref(null)
const inviteUserId = ref('')
const inviting = ref(false)
onMounted(load)
async function load() {
try {
const res = await listTeams()
const d = res.data || res
teams.value = d.items || d.rows || d.data || d || []
} catch { /* ignore */ }
}
async function createTeam() {
if (!createForm.name) { ElMessage.warning('请输入团队名称'); return }
creating.value = true
try {
await createTeamApi(createForm)
ElMessage.success('已创建')
showCreate.value = false
createForm.name = ''
createForm.description = ''
load()
} catch (e) { ElMessage.error(e?.detail || '创建失败') }
finally { creating.value = false }
}
function showInvite(t) {
inviteTeam.value = t
inviteUserId.value = ''
showInviteDialog.value = true
}
async function doInvite() {
if (!inviteUserId.value) { ElMessage.warning('请输入用户ID'); return }
inviting.value = true
try {
await inviteTeamMember(inviteTeam.value.id, inviteUserId.value)
ElMessage.success('已邀请')
showInviteDialog.value = false
load()
} catch (e) { ElMessage.error(e?.detail || '邀请失败') }
finally { inviting.value = false }
}
</script>
<style scoped>
.team-card { background: #fff; border: 1px solid #e8e8e8; border-radius: 8px; padding: 20px; margin-bottom: 12px; }
</style>
+103
View File
@@ -0,0 +1,103 @@
<template>
<div>
<el-card shadow="never">
<template #header><span>文本翻译</span></template>
<el-input v-model="form.text" type="textarea" :rows="5" placeholder="输入需要翻译的外贸文本..." />
<div style="margin:16px 0;display:flex;gap:12px;align-items:center">
<el-select v-model="form.target_lang" style="width:160px">
<el-option label="英语" value="en" />
<el-option label="中文" value="zh" />
<el-option label="西班牙语" value="es" />
<el-option label="日语" value="ja" />
</el-select>
<el-button type="primary" :loading="loading" @click="doTranslate">翻译</el-button>
<el-button @click="showReply = !showReply">回复建议</el-button>
<el-button @click="showExtract = !showExtract">信息提取</el-button>
</div>
<div v-if="result" style="padding:16px;background:#f5f5f5;border-radius:6px">
<p style="white-space:pre-wrap">{{ result }}</p>
<div style="margin-top:8px;display:flex;gap:8px">
<el-button text type="primary" size="small" @click="copyText(result)">复制</el-button>
</div>
</div>
</el-card>
<el-card v-if="showReply" shadow="never" style="margin-top:16px">
<template #header><span>回复建议</span></template>
<el-input v-model="replyInquiry" type="textarea" :rows="3" placeholder="输入客户询盘内容..." />
<div style="margin-top:12px;display:flex;gap:8px">
<el-button type="primary" :loading="replyLoading" @click="getReply">生成建议</el-button>
</div>
<div v-if="suggestions.length" style="margin-top:12px">
<el-card v-for="(s, i) in suggestions" :key="i" shadow="hover" style="margin-bottom:8px">
<template #header>
<span style="font-weight:500">{{ s.tone || s.style || '建议 ' + (i+1) }}</span>
</template>
<p style="white-space:pre-wrap">{{ s.content || s.text }}</p>
<el-button text type="primary" size="small" style="margin-top:8px" @click="copyText(s.content || s.text)">复制</el-button>
</el-card>
</div>
</el-card>
<el-card v-if="showExtract" shadow="never" style="margin-top:16px">
<template #header><span>信息提取</span></template>
<el-input v-model="extractText" type="textarea" :rows="3" placeholder="输入要提取信息的文本..." />
<div style="margin-top:12px">
<el-button type="primary" :loading="extractLoading" @click="doExtract">提取</el-button>
</div>
<pre v-if="extractResult" style="margin-top:12px;padding:12px;background:#f5f5f5;border-radius:6px;white-space:pre-wrap">{{ extractResult }}</pre>
</el-card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { translate, translateReply, extractInfo } from '@/api'
import { ElMessage } from 'element-plus'
const form = ref({ text: '', target_lang: 'en' })
const loading = ref(false)
const result = ref('')
const showReply = ref(false)
const showExtract = ref(false)
const replyInquiry = ref('')
const replyLoading = ref(false)
const suggestions = ref([])
const extractText = ref('')
const extractLoading = ref(false)
const extractResult = ref('')
async function doTranslate() {
if (!form.value.text.trim()) return
loading.value = true
try {
const res = await translate(form.value)
result.value = res.data?.translated_text || res.translated_text || ''
} catch { ElMessage.error('翻译失败') }
finally { loading.value = false }
}
async function getReply() {
if (!replyInquiry.value.trim()) return
replyLoading.value = true
try {
const res = await translateReply({ text: replyInquiry.value })
suggestions.value = res.data?.suggestions || res.suggestions || []
} catch { ElMessage.error('生成建议失败') }
finally { replyLoading.value = false }
}
async function doExtract() {
if (!extractText.value.trim()) return
extractLoading.value = true
try {
const res = await extractInfo({ text: extractText.value })
extractResult.value = JSON.stringify(res.data || res, null, 2)
} catch { ElMessage.error('提取失败') }
finally { extractLoading.value = false }
}
function copyText(text) {
navigator.clipboard.writeText(text).then(() => ElMessage.success('已复制'))
}
</script>
+64
View File
@@ -0,0 +1,64 @@
<template>
<div>
<el-row :gutter="20">
<el-col :span="8" v-for="p in plans" :key="p.id">
<el-card shadow="hover" :class="{ 'plan-highlight': p.id === currentPlan }">
<template #header>
<div style="text-align:center">
<h3 style="margin:0">{{ p.name }}</h3>
<p style="font-size:28px;font-weight:700;color:#409eff;margin:12px 0">
¥{{ p.price || 0 }}<span style="font-size:14px;font-weight:400;color:#999">/</span>
</p>
</div>
</template>
<div>
<p v-for="f in p.features || []" :key="f" style="font-size:13px;color:#666;margin:8px 0">
<el-icon color="#67c23a" style="margin-right:6px"><Check /></el-icon>{{ f }}
</p>
</div>
<div style="text-align:center;margin-top:16px">
<el-button v-if="p.id === currentPlan" type="default" disabled>当前套餐</el-button>
<el-button v-else type="primary" :loading="loadingId === p.id" @click="upgrade(p.id)">升级</el-button>
</div>
</el-card>
</el-col>
</el-row>
<el-empty v-if="!plans.length" description="暂无套餐信息" />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getPlans, getSubscription, createOrder } from '@/api'
import { ElMessage } from 'element-plus'
const plans = ref([])
const currentPlan = ref(null)
const loadingId = ref(null)
onMounted(async () => {
try {
const [plansRes, subRes] = await Promise.all([getPlans(), getSubscription().catch(() => null)])
const pd = plansRes.data || plansRes
plans.value = pd.plans || pd.items || pd || []
if (subRes) {
const sd = subRes.data || subRes
currentPlan.value = sd.plan_id || sd.plan
}
} catch { /* ignore */ }
})
async function upgrade(planId) {
loadingId.value = planId
try {
const res = await createOrder(planId)
ElMessage.success('订单已创建' + (res.pay_url ? ',正在跳转支付...' : ''))
if (res.pay_url) window.open(res.pay_url)
} catch (e) { ElMessage.error(e?.detail || '升级失败') }
finally { loadingId.value = null }
}
</script>
<style scoped>
.plan-highlight { border: 2px solid #409eff; transform: scale(1.02); }
</style>
+117
View File
@@ -0,0 +1,117 @@
<template>
<div>
<el-row :gutter="20">
<el-col :span="6" v-for="item in stats" :key="item.label">
<el-card shadow="hover" class="stat-card">
<div class="stat-value">{{ item.value }}</div>
<div class="stat-label">{{ item.label }}</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top:20px">
<el-col :span="12">
<el-card shadow="never">
<template #header><span>快速翻译</span></template>
<el-input v-model="quickText" type="textarea" :rows="3" placeholder="输入要翻译的文本..." />
<div style="margin-top:12px;display:flex;gap:8px">
<el-select v-model="quickLang" style="width:140px">
<el-option label="英语" value="en" />
<el-option label="中文" value="zh" />
<el-option label="西班牙语" value="es" />
<el-option label="日语" value="ja" />
</el-select>
<el-button type="primary" :loading="translating" @click="doQuickTranslate">翻译</el-button>
</div>
<p v-if="quickResult" style="margin-top:12px;padding:12px;background:#f5f5f5;border-radius:6px">{{ quickResult }}</p>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never">
<template #header><span>跟进提醒</span></template>
<div v-if="followups.length">
<div v-for="f in followups" :key="f.id" class="followup-item">
<span class="followup-name">{{ f.customer_name }}</span>
<span class="followup-days">{{ f.silent_days }}天未联系</span>
</div>
</div>
<el-empty v-else description="暂无跟进提醒" :image-size="60" />
</el-card>
</el-col>
</el-row>
<el-card shadow="never" style="margin-top:20px">
<template #header><span>功能入口</span></template>
<div class="feature-grid">
<div v-for="f in features" :key="f.title" class="feature-item" @click="$router.push(f.route)">
<el-icon :size="24" :color="f.color"><component :is="f.icon" /></el-icon>
<span>{{ f.title }}</span>
</div>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getAnalyticsOverview, translate, getFollowupPending } from '@/api'
const stats = ref([])
const quickText = ref('')
const quickLang = ref('en')
const quickResult = ref('')
const translating = ref(false)
const followups = ref([])
const features = [
{ title: '智能翻译', icon: 'ChatLineSquare', color: '#409eff', route: '/translate' },
{ title: '客户管理', icon: 'User', color: '#67c23a', route: '/customers' },
{ title: '产品库', icon: 'Goods', color: '#e6a23c', route: '/products' },
{ title: '报价单', icon: 'DocumentCopy', color: '#f56c6c', route: '/quotations' },
{ title: '营销素材', icon: 'Promotion', color: '#909399', route: '/marketing' },
{ title: '挖掘新客', icon: 'Search', color: '#409eff', route: '/discovery' },
{ title: '智能跟进', icon: 'Message', color: '#67c23a', route: '/followup' },
{ title: '数据分析', icon: 'DataAnalysis', color: '#e6a23c', route: '/analytics' },
{ title: '团队协作', icon: 'UserFilled', color: '#f56c6c', route: '/team' },
]
onMounted(async () => {
try {
const [overview, fup] = await Promise.all([
getAnalyticsOverview().catch(() => null),
getFollowupPending().catch(() => [])
])
const d = overview?.data || overview || {}
stats.value = [
{ value: d.customers?.total || d.total_customers || 0, label: '客户总数' },
{ value: d.translations?.today || d.today_translations || 0, label: '今日翻译' },
{ value: d.quotations?.total || d.total_quotations || 0, label: '报价单数' },
{ value: fup?.length || 0, label: '待跟进' },
]
followups.value = Array.isArray(fup) ? fup.slice(0, 5) : []
} catch { /* ignore */ }
})
async function doQuickTranslate() {
if (!quickText.value.trim()) return
translating.value = true
try {
const res = await translate({ text: quickText.value, target_lang: quickLang.value })
quickResult.value = res.data?.translated_text || res.translated_text || ''
} catch { quickResult.value = '翻译失败' }
finally { translating.value = false }
}
</script>
<style scoped>
.stat-card { cursor: default; text-align: center; }
.stat-value { font-size: 32px; font-weight: 700; color: #409eff; }
.stat-label { font-size: 14px; color: #999; margin-top: 4px; }
.followup-item { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f0f0f0; }
.followup-name { font-weight: 500; }
.followup-days { color: #f56c6c; font-size: 12px; }
.feature-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 16px; }
.feature-item { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 20px 12px; cursor: pointer; border-radius: 8px; transition: background 0.2s; }
.feature-item:hover { background: #f0f5ff; }
.feature-item span { font-size: 13px; color: #333; }
</style>
+21
View File
@@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
base: '/workspace/',
resolve: {
alias: { '@': resolve(__dirname, 'src') }
},
build: {
outDir: 'dist',
assetsDir: 'assets'
},
server: {
port: 5174,
proxy: {
'/api': { target: 'http://localhost:8002', changeOrigin: true }
}
}
})