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
+10 -2
View File
@@ -37,9 +37,14 @@ logs/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Uni-app # Node
node_modules/
uni-app/dist/ uni-app/dist/
uni-app/node_modules/ admin-frontend/dist/
user-frontend/dist/
# Python test cache
.pytest_cache/
# Docker # Docker
docker-compose.override.yml docker-compose.override.yml
@@ -47,3 +52,6 @@ docker-compose.override.yml
# Misc # Misc
*.bak *.bak
*.tmp *.tmp
# Generated by MCP search server
backend/app/services/_bing_search.js
+17 -7
View File
@@ -15,16 +15,24 @@
# Backend (from project root — .env is there) # Backend (from project root — .env is there)
cd backend && source venv/bin/activate && uvicorn app.main:app --reload --port 8000 cd backend && source venv/bin/activate && uvicorn app.main:app --reload --port 8000
# Frontend # Frontend — uni-app (mobile)
cd uni-app && npm run dev:h5 cd uni-app && npm run dev:h5
# Admin frontend (PC management)
cd admin-frontend && npm run dev # port 5173, base: /admin/
# User workspace (PC workbench)
cd user-frontend && npm run dev # port 5174, base: /workspace/
# Tests (backend — needs PostgreSQL running with foreign_trade_test DB) # Tests (backend — needs PostgreSQL running with foreign_trade_test DB)
cd backend && venv/bin/pytest # all cd backend && venv/bin/pytest # all
venv/bin/pytest tests/test_auth_api.py # single file venv/bin/pytest tests/test_auth_api.py # single file
venv/bin/pytest tests/ -k "test_login" # keyword filter venv/bin/pytest tests/ -k "test_login" # keyword filter
# Frontend build (produces uni-app/dist/build/h5/) # Builds
cd uni-app && npm run build:h5 cd uni-app && npm run build:h5 # uni-app (mobile H5)
cd admin-frontend && npm run build # admin => /www/wwwroot/trade.yuzhiran.com/admin/
cd user-frontend && npm run build # workspace => /www/wwwroot/trade.yuzhiran.com/workspace/
# Alembic migrations # Alembic migrations
cd backend && alembic upgrade head cd backend && alembic upgrade head
@@ -34,10 +42,12 @@ alembic revision --autogenerate -m "desc"
## Deployment ## Deployment
- **Landing page** at `trade.yuzhiran.com/` — static marketing HTML - **Landing page** at `trade.yuzhiran.com/` — static marketing HTML
- **SPA** at `trade.yuzhiran.com/app/` — uni-app build - **SPA** at `trade.yuzhiran.com/app/` — uni-app build (mobile)
- **Nginx**: `location /app/ { try_files $uri $uri/ /app/index.html; }` for SPA fallback - **Admin** at `trade.yuzhiran.com/admin/` — Vue 3 + Element Plus (standalone)
- **vite config**: `base: '/app/'` so all asset paths are `/app/assets/...` - **Workspace** at `trade.yuzhiran.com/workspace/` — Vue 3 + Element Plus (standalone)
- **API**: proxied via nginx `location /api/` to `127.0.0.1:8000` - **Nginx**: SPA fallbacks for `/app/`, `/admin/`, `/workspace/`
- **vite config**: each project has its own `base` path and dev port
- **API**: proxied via nginx `location /api/` to `127.0.0.1:8002`
## Critical Quirks ## Critical Quirks
+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-admin",
"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>
+58
View File
@@ -0,0 +1,58 @@
import axios from 'axios'
const http = axios.create({ baseURL: '/api/v1', timeout: 30000 })
http.interceptors.request.use(config => {
const token = localStorage.getItem('admin_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
http.interceptors.response.use(
res => res.data,
err => {
if (err.response?.status === 401) {
localStorage.removeItem('admin_token')
localStorage.removeItem('admin_user')
const currentPath = window.location.pathname.replace('/admin', '') || '/'
window.location.href = '/admin/login?redirect=' + encodeURIComponent(currentPath)
}
return Promise.reject(err.response?.data || err)
}
)
export function login(data) { return http.post('/auth/login', data) }
export function getDashboard() { return http.get('/admin/dashboard') }
export function searchUsers(query) { return http.post('/admin/users/search', { query }) }
export function listUsers(page = 1, size = 20) { return http.get('/admin/users', { params: { page, size } }) }
export function getUserDetail(id) { return http.get(`/admin/users/${id}`) }
export function updateUser(id, data) { return http.put(`/admin/users/${id}`, data) }
export function getUsageStats() { return http.get('/admin/stats/usage') }
export function listLogs(params) { return http.get('/admin/logs', { params }) }
export function listConfig() { return http.get('/admin/config') }
export function updateConfig(key, value) { return http.put(`/admin/config/${key}`, { value }) }
export function listQuotas() { return http.get('/admin/quotas') }
export function updateQuota(version, data) { return http.put(`/admin/quotas/${version}`, data) }
export function resetQuota(version) { return http.post(`/admin/quotas/${version}/reset`) }
export function listCertifications(page = 1, size = 50, status = '') {
return http.get('/admin/certifications', { params: { page, size, status: status || undefined } })
}
export function reviewCertification(id, action, reason = '') {
return http.post(`/admin/certifications/${id}/review`, { action, reason })
}
export function listInvoices(page = 1, size = 50, status = '') {
return http.get('/admin/invoices', { params: { page, size, status: status || undefined } })
}
export function processInvoice(id, action) {
return http.post(`/admin/invoices/${id}/process`, { action })
}
export default http
+115
View File
@@ -0,0 +1,115 @@
<template>
<el-container class="layout-container">
<el-aside :width="collapsed ? '64px' : '220px'" class="sidebar">
<div class="sidebar-header">
<span v-show="!collapsed" class="logo-text">TradeMate</span>
<span v-show="collapsed" class="logo-text logo-sm">TM</span>
</div>
<el-menu
:default-active="route.path"
:collapse="collapsed"
router
>
<el-menu-item index="/dashboard">
<el-icon><Odometer /></el-icon>
<span>概览</span>
</el-menu-item>
<el-menu-item index="/users">
<el-icon><User /></el-icon>
<span>用户</span>
</el-menu-item>
<el-menu-item index="/stats">
<el-icon><DataAnalysis /></el-icon>
<span>统计</span>
</el-menu-item>
<el-menu-item index="/logs">
<el-icon><Document /></el-icon>
<span>日志</span>
</el-menu-item>
<el-menu-item index="/config">
<el-icon><Setting /></el-icon>
<span>配置</span>
</el-menu-item>
<el-menu-item index="/quota">
<el-icon><Coin /></el-icon>
<span>翻译配额</span>
</el-menu-item>
<el-menu-item index="/certifications">
<el-icon><Stamp /></el-icon>
<span>认证审核</span>
</el-menu-item>
<el-menu-item index="/invoices">
<el-icon><List /></el-icon>
<span>发票管理</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<div class="header-left">
<el-icon class="collapse-btn" @click="collapsed = !collapsed">
<Fold v-if="!collapsed" />
<Expand v-else />
</el-icon>
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>{{ route.meta?.title }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-right">
<el-dropdown trigger="click">
<span class="user-info">
<el-avatar :size="32" icon="UserFilled" style="background:#1890ff" />
<span class="user-name">{{ auth.user?.username || '管理员' }}</span>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-item @click="auth.logout(); router.push('/login')">
<el-icon><SwitchButton /></el-icon>退出登录
</el-dropdown-item>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="main-content">
<router-view />
</el-main>
<el-footer class="footer">
<span>TradeMate 外贸小助手 &copy; {{ new Date().getFullYear() }}</span>
</el-footer>
</el-container>
</el-container>
</template>
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const collapsed = ref(false)
</script>
<style scoped>
.layout-container { height: 100vh; }
.sidebar { background: #fff; border-right: 1px solid #e8e8e8; transition: width 0.3s; overflow: hidden; }
.sidebar-header { height: 60px; display: flex; align-items: center; justify-content: center; border-bottom: 1px solid #f0f0f0; }
.logo-text { color: #1890ff; font-size: 18px; font-weight: 700; white-space: nowrap; }
.logo-sm { font-size: 16px; }
.sidebar :deep(.el-menu) { border-right: none; }
.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; }
.header { display: flex; align-items: center; justify-content: space-between; background: #fff; border-bottom: 1px solid #e8e8e8; padding: 0 20px; height: 60px; }
.header-left { display: flex; align-items: center; gap: 16px; }
.collapse-btn { font-size: 20px; cursor: pointer; color: #666; }
.header-right { display: flex; align-items: center; }
.user-info { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.user-name { font-size: 14px; color: #333; }
.main-content { background: #f5f5f5; padding: 20px; overflow-y: auto; }
.footer { display: flex; align-items: center; justify-content: center; height: 48px; background: #fff; border-top: 1px solid #e8e8e8; color: #999; font-size: 12px; }
</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')
+85
View File
@@ -0,0 +1,85 @@
import { createRouter, createWebHistory } from 'vue-router'
import AdminLayout from '@/layouts/AdminLayout.vue'
const routes = [
{ path: '/login', redirect: '/' },
{ path: '/', name: 'Landing', component: () => import('@/views/Landing.vue') },
{
path: '/dashboard',
component: AdminLayout,
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Dashboard', component: () => import('@/views/Dashboard.vue'), meta: { title: '概览' } },
]
},
{
path: '/users',
component: AdminLayout,
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Users', component: () => import('@/views/Users.vue'), meta: { title: '用户' } },
]
},
{
path: '/stats',
component: AdminLayout,
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Stats', component: () => import('@/views/Stats.vue'), meta: { title: '统计' } },
]
},
{
path: '/logs',
component: AdminLayout,
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Logs', component: () => import('@/views/Logs.vue'), meta: { title: '日志' } },
]
},
{
path: '/config',
component: AdminLayout,
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Config', component: () => import('@/views/Config.vue'), meta: { title: '配置' } },
]
},
{
path: '/quota',
component: AdminLayout,
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Quota', component: () => import('@/views/Quota.vue'), meta: { title: '翻译配额' } },
]
},
{
path: '/certifications',
component: AdminLayout,
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Certifications', component: () => import('@/views/Certifications.vue'), meta: { title: '认证审核' } },
]
},
{
path: '/invoices',
component: AdminLayout,
meta: { requiresAuth: true },
children: [
{ path: '', name: 'Invoices', component: () => import('@/views/Invoices.vue'), meta: { title: '发票管理' } },
]
},
]
const router = createRouter({ history: createWebHistory('/admin/'), routes })
router.beforeEach((to, from, next) => {
if (to.meta?.requiresAuth) {
const token = localStorage.getItem('admin_token')
if (!token) next({ name: 'Login', query: { redirect: to.fullPath } })
else next()
} else {
next()
}
})
export default router
+28
View File
@@ -0,0 +1,28 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { login as loginApi } from '@/api'
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('admin_token') || '')
const user = ref(JSON.parse(localStorage.getItem('admin_user') || 'null'))
const isLoggedIn = computed(() => !!token.value)
async function login(credentials) {
const res = await loginApi(credentials)
token.value = res.access_token
user.value = res.user || {}
localStorage.setItem('admin_token', res.access_token)
localStorage.setItem('admin_user', JSON.stringify(res.user || {}))
return res
}
function logout() {
token.value = ''
user.value = null
localStorage.removeItem('admin_token')
localStorage.removeItem('admin_user')
}
return { token, user, isLoggedIn, login, logout }
})
+117
View File
@@ -0,0 +1,117 @@
<template>
<div>
<el-card shadow="never">
<template #header>
<div class="header-bar">
<span>实名认证审核</span>
<div class="filter-group">
<el-radio-group v-model="statusFilter" @change="page=1;load()">
<el-radio-button value="">全部</el-radio-button>
<el-radio-button value="pending">待审核</el-radio-button>
<el-radio-button value="approved">已通过</el-radio-button>
<el-radio-button value="rejected">已驳回</el-radio-button>
</el-radio-group>
</div>
</div>
</template>
<el-table :data="list" v-loading="loading" stripe style="width:100%">
<el-table-column label="类型" width="100">
<template #default="{ row }">{{ row.cert_type === 'individual' ? '个人认证' : '企业认证' }}</template>
</el-table-column>
<el-table-column label="姓名" width="120">
<template #default="{ row }">{{ row.personal_name || '-' }}</template>
</el-table-column>
<el-table-column label="企业名称">
<template #default="{ row }">{{ row.company_name || '-' }}</template>
</el-table-column>
<el-table-column label="税号" width="140">
<template #default="{ row }">{{ row.tax_id || '-' }}</template>
</el-table-column>
<el-table-column label="用户" width="100">
<template #default="{ row }">{{ row.user_id?.substring(0,8) }}...</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="{ pending: 'warning', approved: 'success', rejected: 'danger' }[row.status]" size="small">
{{ { pending: '待审核', approved: '已通过', rejected: '已驳回' }[row.status] }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="申请时间" width="160" :formatter="fmtDate" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<template v-if="row.status === 'pending'">
<el-button type="success" size="small" @click="review(row.id, 'approve')">通过</el-button>
<el-button type="danger" size="small" @click="openReject(row.id)">驳回</el-button>
</template>
<span v-else-if="row.reject_reason" style="font-size:12px;color:#f56c6c;">原因{{ row.reject_reason }}</span>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > pageSize">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev,pager,next" @current-change="load" background />
</div>
</el-card>
<el-dialog v-model="rejectDialog" title="驳回原因" width="400px">
<el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请输入驳回原因" />
<template #footer>
<el-button @click="rejectDialog = false">取消</el-button>
<el-button type="primary" @click="confirmReject">确认驳回</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { listCertifications, reviewCertification } from '@/api'
import dayjs from 'dayjs'
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = 20
const statusFilter = ref('pending')
const rejectDialog = ref(false)
const rejectId = ref('')
const rejectReason = ref('')
function fmtDate(_, __, val) { return val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-' }
async function load() {
loading.value = true
try {
const r = await listCertifications(page.value, pageSize, statusFilter.value)
list.value = r.items || []
total.value = r.total || 0
} catch (e) { console.error(e) }
finally { loading.value = false }
}
async function review(id, action, reason = '') {
try {
await reviewCertification(id, action, reason)
ElMessage.success(action === 'approve' ? '已通过' : '已驳回')
load()
} catch (e) { ElMessage.error(e?.detail || '操作失败') }
}
function openReject(id) { rejectId.value = id; rejectReason.value = ''; rejectDialog.value = true }
async function confirmReject() {
if (!rejectReason.value) { ElMessage.warning('请输入驳回原因'); return }
rejectDialog.value = false
await review(rejectId.value, 'reject', rejectReason.value)
}
onMounted(load)
</script>
<style scoped>
.header-bar { display: flex; justify-content: space-between; align-items: center; }
.filter-group { }
.pagination-wrap { margin-top: 20px; display: flex; justify-content: center; }
</style>
+70
View File
@@ -0,0 +1,70 @@
<template>
<div>
<el-card v-for="cfg in configs" :key="cfg.key" shadow="never" class="cfg-card">
<template #header>
<div class="cfg-header">
<span>{{ configLabels[cfg.key] || cfg.key }}</span>
<el-tag size="small" v-if="cfg.description">{{ cfg.description }}</el-tag>
</div>
</template>
<div v-if="typeof cfg.value === 'object' && !Array.isArray(cfg.value)">
<div class="cfg-field" v-for="(v, k) in cfg.value" :key="k">
<span class="cfg-label">{{ fieldLabel(cfg.key, k) }}</span>
<el-input v-if="typeof v === 'string'" v-model="edits[cfg.key][k]" size="small" style="width:300px" />
<el-input-number v-else-if="typeof v === 'number'" v-model="edits[cfg.key][k]" size="small" />
<el-switch v-else-if="typeof v === 'boolean'" v-model="edits[cfg.key][k]" />
<el-input v-else v-model="edits[cfg.key][k]" type="textarea" :rows="2" size="small" style="width:400px" />
</div>
</div>
<div v-else-if="typeof cfg.value === 'boolean'">
<el-switch v-model="edits[cfg.key]" />
</div>
<div v-else>
<el-input v-model="edits[cfg.key]" type="textarea" :rows="3" style="max-width:600px" />
</div>
<div class="cfg-actions">
<el-button type="success" size="small" @click="save(cfg.key)">保存</el-button>
</div>
</el-card>
<el-empty v-if="!configs.length" description="暂无配置" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { listConfig, updateConfig } from '@/api'
const configs = ref([])
const edits = reactive({})
const configLabels = { system_maintenance: '系统维护', feature_flags: '功能开关', translation_providers: '翻译服务', ai_model_config: 'AI模型', cache_ttl: '缓存时间' }
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' } }
function fieldLabel(key, k) { return fieldLabelsMap[key]?.[k] || k }
async function load() {
try {
const r = await listConfig()
configs.value = r.items || r || []
for (const cfg of configs.value) {
edits[cfg.key] = JSON.parse(JSON.stringify(cfg.value))
}
} catch (e) { console.error(e) }
}
async function save(key) {
try {
await updateConfig(key, edits[key])
ElMessage.success('已保存')
} catch (e) { ElMessage.error(e?.detail || '保存失败') }
}
onMounted(load)
</script>
<style scoped>
.cfg-card { margin-bottom: 16px; }
.cfg-header { display: flex; align-items: center; gap: 12px; font-weight: 600; }
.cfg-field { display: flex; align-items: center; gap: 12px; padding: 8px 0; }
.cfg-label { width: 160px; font-size: 13px; color: #666; flex-shrink: 0; }
.cfg-actions { margin-top: 12px; }
</style>
+77
View File
@@ -0,0 +1,77 @@
<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" @click="handleStatClick(item)">
<div class="stat-value">{{ item.value }}</div>
<div class="stat-label">{{ item.label }}</div>
</el-card>
</el-col>
</el-row>
<el-card class="section-card" shadow="never">
<template #header><span>最近注册用户</span></template>
<el-table :data="recentUsers" v-loading="loading" stripe style="width:100%">
<el-table-column prop="username" label="用户名" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="tier" label="套餐" width="100">
<template #default="{ row }">
<el-tag :type="tierType(row.tier)" size="small">{{ row.tier }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="role" label="角色" width="100">
<template #default="{ row }">
<el-tag :type="row.role === 'admin' ? 'danger' : 'info'" size="small">{{ row.role }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="注册时间" width="180" :formatter="fmtDate" />
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="showUser(row.id)">详情</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && !recentUsers.length" description="暂无数据" />
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getDashboard } from '@/api'
import dayjs from 'dayjs'
const router = useRouter()
const loading = ref(false)
const stats = ref([])
const recentUsers = ref([])
function tierType(t) { return { free: 'warning', pro: 'primary', enterprise: 'success' }[t] || 'info' }
function fmtDate(_, __, val) { return val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-' }
function handleStatClick(item) { if (item.route) router.push(item.route) }
function showUser(id) { router.push({ path: '/users', query: { detail: id } }) }
onMounted(async () => {
loading.value = true
try {
const res = await getDashboard()
const d = res.data || res
stats.value = [
{ value: d.users?.total || 0, label: '用户总数', route: '/users' },
{ value: d.teams?.total || 0, label: '团队数' },
{ value: d.customers?.total || 0, label: '客户总数' },
{ value: d.usage?.today || 0, label: '今日请求', route: '/stats' },
]
recentUsers.value = d.recent_users || []
} catch (e) { console.error(e) }
finally { loading.value = false }
})
</script>
<style scoped>
.stat-card { cursor: pointer; text-align: center; margin-bottom: 20px; }
.stat-value { font-size: 32px; font-weight: 700; color: #409eff; }
.stat-label { font-size: 14px; color: #999; margin-top: 4px; }
.section-card { margin-top: 10px; }
</style>
+112
View File
@@ -0,0 +1,112 @@
<template>
<div>
<el-card shadow="never">
<template #header>
<div class="header-bar">
<span>发票管理</span>
<div class="filter-group">
<el-radio-group v-model="statusFilter" @change="page=1;load()">
<el-radio-button value="">全部</el-radio-button>
<el-radio-button value="pending">待开票</el-radio-button>
<el-radio-button value="issued">已开票</el-radio-button>
<el-radio-button value="rejected">已驳回</el-radio-button>
</el-radio-group>
</div>
</div>
</template>
<el-table :data="list" v-loading="loading" stripe style="width:100%">
<el-table-column label="发票类型" width="100">
<template #default="{ row }">{{ row.invoice_type === 'individual' ? '个人' : '企业' }}</template>
</el-table-column>
<el-table-column prop="title" label="发票抬头" width="200" />
<el-table-column prop="tax_id" label="税号" width="140" />
<el-table-column label="金额" width="120">
<template #default="{ row }">¥{{ (row.amount || 0).toFixed(2) }}</template>
</el-table-column>
<el-table-column label="用户" width="100">
<template #default="{ row }">{{ row.user_id?.substring(0,8) }}...</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="{ pending: 'warning', issued: 'success', rejected: 'danger' }[row.status]" size="small">
{{ { pending: '待开票', issued: '已开票', rejected: '已驳回' }[row.status] }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="申请时间" width="160" :formatter="fmtDate" />
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<template v-if="row.status === 'pending'">
<el-button type="success" size="small" @click="process(row.id, 'issue')">已开票</el-button>
<el-button type="danger" size="small" @click="openReject(row.id)">驳回</el-button>
</template>
<span v-else-if="row.reject_reason" style="font-size:12px;color:#f56c6c;">原因{{ row.reject_reason }}</span>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > pageSize">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev,pager,next" @current-change="load" background />
</div>
</el-card>
<el-dialog v-model="rejectDialog" title="驳回原因" width="400px">
<el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请输入驳回原因" />
<template #footer>
<el-button @click="rejectDialog = false">取消</el-button>
<el-button type="primary" @click="confirmReject">确认驳回</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { listInvoices, processInvoice } from '@/api'
import dayjs from 'dayjs'
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = 20
const statusFilter = ref('pending')
const rejectDialog = ref(false)
const rejectId = ref('')
const rejectReason = ref('')
function fmtDate(_, __, val) { return val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-' }
async function load() {
loading.value = true
try {
const r = await listInvoices(page.value, pageSize, statusFilter.value)
list.value = r.items || []
total.value = r.total || 0
} catch (e) { console.error(e) }
finally { loading.value = false }
}
async function process(id, action) {
try {
await processInvoice(id, action)
ElMessage.success(action === 'issue' ? '已开票' : '已驳回')
load()
} catch (e) { ElMessage.error(e?.detail || '操作失败') }
}
function openReject(id) { rejectId.value = id; rejectReason.value = ''; rejectDialog.value = true }
async function confirmReject() {
if (!rejectReason.value) { ElMessage.warning('请输入驳回原因'); return }
rejectDialog.value = false
await process(rejectId.value, 'reject')
}
onMounted(load)
</script>
<style scoped>
.header-bar { display: flex; justify-content: space-between; align-items: center; }
.pagination-wrap { margin-top: 20px; display: flex; justify-content: center; }
</style>
+146
View File
@@ -0,0 +1,146 @@
<template>
<div class="landing-page">
<header class="landing-header">
<div class="header-inner">
<span class="logo">Trade<span>Mate</span></span>
<span class="subtitle">管理后台</span>
<div class="header-right">
<el-button v-if="isLoggedIn" @click="goDashboard">进入后台</el-button>
</div>
</div>
</header>
<section class="hero">
<div class="hero-inner">
<div class="hero-left">
<h1>TradeMate 管理后台</h1>
<p class="hero-desc">一站式管理你的外贸业务用户数据配置认证与发票</p>
</div>
<div class="hero-right">
<div v-if="!isLoggedIn" class="login-card">
<h3>管理员登录</h3>
<el-form :model="form" size="large" @keyup.enter="submit">
<el-form-item>
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" />
</el-form-item>
<el-form-item>
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock" show-password />
</el-form-item>
<p v-if="error" class="login-error">{{ error }}</p>
<el-button type="primary" :loading="loading" style="width:100%" @click="submit">登录</el-button>
</el-form>
</div>
<div v-else class="login-card logged-in">
<el-icon :size="48" color="#67c23a"><CircleCheckFilled /></el-icon>
<h3>已登录</h3>
<el-button type="primary" @click="goDashboard">进入后台</el-button>
</div>
</div>
</div>
</section>
<section class="features">
<div class="feature-grid">
<div v-for="f in features" :key="f.title" class="feature-card" @click="handleClick(f)">
<div class="feature-icon" :style="{ background: f.color + '18' }">
<el-icon :size="28" :color="f.color"><component :is="f.icon" /></el-icon>
</div>
<h3>{{ f.title }}</h3>
<p>{{ f.desc }}</p>
</div>
</div>
</section>
<footer class="landing-footer">
<span>TradeMate 外贸小助手 &copy; {{ new Date().getFullYear() }}</span>
</footer>
</div>
</template>
<script setup>
import { ref, reactive, computed } from 'vue'
import { useRouter } from 'vue-router'
import { login as loginApi } from '@/api'
import { ElMessage } from 'element-plus'
const router = useRouter()
const isLoggedIn = computed(() => !!localStorage.getItem('admin_token'))
const loading = ref(false)
const error = ref('')
const form = reactive({ username: '', password: '' })
const features = [
{ title: '概览', desc: '用户总数、团队数、今日请求等核心数据一目了然', icon: 'Odometer', color: '#409eff', route: '/dashboard' },
{ title: '用户管理', desc: '搜索、查看、编辑用户信息,管理套餐和权限', icon: 'User', color: '#67c23a', route: '/users' },
{ title: '使用统计', desc: '功能调用统计、日活跃用户、近7日趋势分析', icon: 'DataAnalysis', color: '#e6a23c', route: '/stats' },
{ title: '操作日志', desc: '查看系统操作记录,按动作、用户、日期筛选', icon: 'Document', color: '#909399', route: '/logs' },
{ title: '系统配置', desc: '管理功能开关、翻译服务、AI模型等系统设置', icon: 'Setting', color: '#f56c6c', route: '/config' },
{ title: '翻译配额', desc: '管理各版本翻译 API 的月配额和使用量', icon: 'Coin', color: '#409eff', route: '/quota' },
{ title: '认证审核', desc: '审核用户的实名认证申请(个人/企业)', icon: 'Stamp', color: '#67c23a', route: '/certifications' },
{ title: '发票管理', desc: '处理用户的开票申请,支持个人和企业发票', icon: 'List', color: '#e6a23c', route: '/invoices' },
]
async function submit() {
if (!form.username || !form.password) { error.value = '请输入用户名和密码'; return }
loading.value = true
error.value = ''
try {
const res = await loginApi(form)
localStorage.setItem('admin_token', res.access_token)
localStorage.setItem('admin_user', JSON.stringify(res.user || {}))
ElMessage.success('登录成功')
} catch (e) {
error.value = e?.detail || '登录失败'
} finally {
loading.value = false
}
}
function handleClick(f) {
if (!isLoggedIn.value) {
error.value = '请先登录'
} else {
router.push(f.route)
}
}
function goDashboard() { router.push('/dashboard') }
</script>
<style scoped>
.landing-page { min-height: 100vh; display: flex; flex-direction: column; background: #f5f5f5; }
.landing-header { background: #fff; border-bottom: 1px solid #eee; padding: 0 40px; height: 60px; display: flex; align-items: center; position: sticky; top: 0; z-index: 100; }
.header-inner { width: 100%; max-width: 1200px; margin: 0 auto; display: flex; align-items: center; gap: 16px; }
.logo { font-size: 20px; font-weight: 700; color: #1890ff; }
.logo span { color: #333; }
.subtitle { font-size: 13px; color: #999; flex: 1; }
.header-right { flex-shrink: 0; }
.hero { background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%); color: #fff; }
.hero-inner { max-width: 1200px; margin: 0 auto; padding: 60px 20px; display: flex; gap: 48px; align-items: center; }
.hero-left { flex: 1; }
.hero-left h1 { font-size: 36px; margin-bottom: 16px; }
.hero-desc { font-size: 16px; opacity: 0.85; line-height: 1.6; }
.hero-right { flex-shrink: 0; width: 360px; }
.login-card { background: #fff; border-radius: 12px; padding: 32px; box-shadow: 0 8px 40px rgba(0,0,0,0.15); }
.login-card h3 { text-align: center; margin-bottom: 24px; font-size: 18px; color: #333; }
.login-card.logged-in { text-align: center; }
.login-card.logged-in h3 { margin: 16px 0; }
.login-error { color: #f56c6c; text-align: center; font-size: 13px; margin: -8px 0 12px; }
.features { max-width: 1200px; margin: -30px auto 40px; padding: 0 20px; }
.feature-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
.feature-card { background: #fff; border-radius: 12px; padding: 28px 24px; cursor: pointer; transition: all 0.25s; box-shadow: 0 2px 12px rgba(0,0,0,0.06); }
.feature-card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0,0,0,0.1); }
.feature-icon { width: 52px; height: 52px; border-radius: 12px; display: flex; align-items: center; justify-content: center; margin-bottom: 16px; }
.feature-card h3 { font-size: 16px; margin-bottom: 8px; color: #333; }
.feature-card p { font-size: 13px; color: #999; line-height: 1.5; }
.landing-footer { text-align: center; padding: 24px; color: #999; font-size: 12px; margin-top: auto; border-top: 1px solid #e8e8e8; background: #fff; }
@media (max-width: 768px) {
.hero-inner { flex-direction: column; padding: 40px 20px; }
.hero-right { width: 100%; }
.feature-grid { grid-template-columns: 1fr; }
}
</style>
+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-back"><router-link to="/">返回首页</router-link></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)
const redirect = route.query.redirect || '/dashboard'
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-back { text-align: center; margin-top: 16px; font-size: 13px; }
.login-back a { color: #409eff; text-decoration: none; }
</style>
+83
View File
@@ -0,0 +1,83 @@
<template>
<div>
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="filter">
<el-form-item label="动作">
<el-input v-model="filter.action" placeholder="动作类型" clearable style="width:160px" />
</el-form-item>
<el-form-item label="用户ID">
<el-input v-model="filter.user_id" placeholder="用户ID" clearable style="width:200px" />
</el-form-item>
<el-form-item label="开始">
<el-date-picker v-model="filter.date_from" type="date" placeholder="开始日期" value-format="YYYY-MM-DD" style="width:140px" />
</el-form-item>
<el-form-item label="结束">
<el-date-picker v-model="filter.date_to" type="date" placeholder="结束日期" value-format="YYYY-MM-DD" style="width:140px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="page = 1; load()">筛选</el-button>
<el-button @click="reset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card shadow="never">
<template #header><span>操作日志 ( {{ total }} )</span></template>
<el-table :data="logs" v-loading="loading" stripe style="width:100%">
<el-table-column prop="action" label="动作" width="140" />
<el-table-column label="用户" width="120">
<template #default="{ row }">{{ row.user_id?.substring(0,8) }}...</template>
</el-table-column>
<el-table-column prop="ip_address" label="IP" width="140" />
<el-table-column prop="detail" label="详情">
<template #default="{ row }">
<span v-if="row.detail && Object.keys(row.detail).length">{{ JSON.stringify(row.detail) }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="时间" width="180" :formatter="fmtDate" />
</el-table>
<div class="pagination-wrap" v-if="total > pageSize">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev,pager,next" @current-change="load" background />
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { listLogs } from '@/api'
import dayjs from 'dayjs'
const loading = ref(false)
const logs = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = 20
const filter = reactive({ action: '', user_id: '', date_from: '', date_to: '' })
function fmtDate(_, __, val) { return val ? dayjs(val).format('YYYY-MM-DD HH:mm') : '-' }
function reset() { filter.action = ''; filter.user_id = ''; filter.date_from = ''; filter.date_to = ''; page.value = 1; load() }
async function load() {
loading.value = true
try {
const params = { page: page.value, size: pageSize }
if (filter.action) params.action = filter.action
if (filter.user_id) params.user_id = filter.user_id
if (filter.date_from) params.date_from = filter.date_from
if (filter.date_to) params.date_to = filter.date_to
const r = await listLogs(params)
logs.value = r.items || []
total.value = r.total || 0
} catch (e) { console.error(e) }
finally { loading.value = false }
}
onMounted(load)
</script>
<style scoped>
.filter-card { margin-bottom: 20px; }
.pagination-wrap { margin-top: 20px; display: flex; justify-content: center; }
</style>
+86
View File
@@ -0,0 +1,86 @@
<template>
<div>
<div v-for="q in quotas" :key="q.version" class="quota-card">
<el-card shadow="never">
<template #header>
<div class="quota-header">
<span class="quota-version">{{ q.version === 'ecommerce' ? '电商版' : '通用版' }}</span>
<el-tag size="small" v-if="q.description">{{ q.description }}</el-tag>
</div>
</template>
<div class="quota-stat">
<span class="quota-label">{{ q.current_month || '当前月' }}</span>
<el-progress :percentage="quotaPercent(q)" :stroke-width="20" />
<span class="quota-value">{{ q.used_chars }} / {{ q.monthly_limit }}</span>
</div>
<div class="quota-actions">
<el-form :inline="true" size="small">
<el-form-item label="月限额">
<el-input-number :model-value="q._edit_limit !== undefined ? q._edit_limit : q.monthly_limit" :min="1000" :step="10000" style="width:140px" @update:model-value="q._edit_limit=$event" />
</el-form-item>
<el-form-item label="启用">
<el-switch :model-value="q._edit_enabled !== undefined ? q._edit_enabled : q.enabled" @update:model-value="q._edit_enabled=$event" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="save(q)">保存</el-button>
<el-button @click="resetQuota(q.version)">重置用量</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
</div>
<el-empty v-if="!quotas.length" description="暂无配额数据" />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { listQuotas, updateQuota, resetQuota as resetApi } from '@/api'
const quotas = ref([])
function quotaPercent(q) {
const limit = q.monthly_limit || 1
return Math.min(100, Math.round((q.used_chars / limit) * 100))
}
async function load() {
try {
const r = await listQuotas()
quotas.value = (r.items || r || []).map(q => ({ ...q, _edit_limit: undefined, _edit_enabled: undefined }))
} catch (e) { console.error(e) }
}
async function save(q) {
try {
const data = {}
if (q._edit_limit !== undefined && q._edit_limit !== null) data.monthly_limit = q._edit_limit
if (q._edit_enabled !== undefined && q._edit_enabled !== null) data.enabled = q._edit_enabled
if (!Object.keys(data).length) { ElMessage.info('无改动'); return }
await updateQuota(q.version, data)
if (data.monthly_limit !== undefined) q.monthly_limit = data.monthly_limit
if (data.enabled !== undefined) q.enabled = data.enabled
q._edit_limit = undefined; q._edit_enabled = undefined
ElMessage.success('已保存')
} catch (e) { ElMessage.error(e?.detail || '保存失败') }
}
async function resetQuota(version) {
try { await resetApi(version); ElMessage.success('已重置'); load() }
catch (e) { ElMessage.error(e?.detail || '重置失败') }
}
onMounted(load)
</script>
<style scoped>
.quota-card { margin-bottom: 16px; }
.quota-header { display: flex; align-items: center; gap: 12px; }
.quota-version { font-size: 15px; font-weight: 600; }
.quota-stat { display: flex; align-items: center; gap: 16px; margin-bottom: 16px; }
.quota-label { width: 80px; font-size: 13px; color: #666; flex-shrink: 0; }
.el-progress { flex: 1; }
.quota-value { width: 180px; font-size: 13px; color: #333; text-align: right; flex-shrink: 0; }
.quota-actions { }
</style>
+83
View File
@@ -0,0 +1,83 @@
<template>
<div>
<el-row :gutter="20">
<el-col :span="8" v-for="item in topStats" :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">
<el-col :span="12">
<el-card shadow="never" class="section-card">
<template #header><span>今日各功能调用</span></template>
<div v-if="Object.keys(byAction).length" class="bar-chart">
<div class="bar-row" v-for="(count, action) in byAction" :key="action">
<span class="bar-label">{{ action }}</span>
<el-progress :percentage="barPercent(count)" :stroke-width="18" />
<span class="bar-count">{{ count }}</span>
</div>
</div>
<el-empty v-else description="暂无数据" />
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never" class="section-card">
<template #header><span>近7日趋势</span></template>
<div v-if="trend.length" class="bar-chart">
<div class="bar-row" v-for="d in trend" :key="d.date">
<span class="bar-label">{{ dayjs(d.date).format('MM-DD') }}</span>
<el-progress :percentage="trendPercent(d.count)" :stroke-width="18" color="#52c41a" />
<span class="bar-count">{{ d.count }}</span>
</div>
</div>
<el-empty v-else description="暂无数据" />
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { getUsageStats } from '@/api'
import dayjs from 'dayjs'
const topStats = ref([])
const byAction = ref({})
const trend = ref([])
let maxAction = 1, maxTrend = 1
function barPercent(v) { return Math.round((v / maxAction) * 100) }
function trendPercent(v) { return Math.round((v / maxTrend) * 100) }
onMounted(async () => {
try {
const r = await getUsageStats()
const d = r.data || r
topStats.value = [
{ value: d.today_total || 0, label: '今日请求' },
{ value: d.dau || 0, label: '日活跃用户' },
{ value: d.total_users || 0, label: '总用户' },
]
byAction.value = d.by_action || {}
maxAction = Math.max(...Object.values(byAction.value), 1)
trend.value = d.daily_trend || []
maxTrend = Math.max(...trend.value.map(d => d.count), 1)
} catch (e) { console.error(e) }
})
</script>
<style scoped>
.stat-card { text-align: center; margin-bottom: 20px; }
.stat-value { font-size: 32px; font-weight: 700; color: #409eff; }
.stat-label { font-size: 14px; color: #999; margin-top: 4px; }
.section-card { margin-bottom: 20px; }
.bar-chart { display: flex; flex-direction: column; gap: 12px; }
.bar-row { display: flex; align-items: center; gap: 12px; }
.bar-label { width: 80px; font-size: 13px; color: #666; text-align: right; flex-shrink: 0; }
.el-progress { flex: 1; }
.bar-count { width: 50px; font-size: 13px; color: #333; text-align: right; flex-shrink: 0; }
</style>
+120
View File
@@ -0,0 +1,120 @@
<template>
<div>
<el-card shadow="never" class="search-card">
<el-form :inline="true" :model="searchForm" @keyup.enter="doSearch">
<el-form-item label="搜索">
<el-input v-model="searchForm.query" placeholder="用户名/手机号" clearable style="width:260px" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="searching" @click="doSearch">搜索</el-button>
<el-button @click="searchForm.query = ''; searchResults = []">清空</el-button>
</el-form-item>
</el-form>
<el-table :data="searchResults" v-if="searchResults.length" stripe style="width:100%">
<el-table-column prop="username" label="用户名" />
<el-table-column prop="phone" label="手机号" />
<el-table-column prop="tier" label="套餐" width="100">
<template #default="{ row }"><el-tag :type="tierType(row.tier)" size="small">{{ row.tier }}</el-tag></template>
</el-table-column>
<el-table-column prop="role" label="角色" width="100">
<template #default="{ row }"><el-tag :type="row.role==='admin'?'danger':'info'" size="small">{{ row.role }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="280">
<template #default="{ row }">
<el-button size="small" :type="row.tier==='free'?'default':'warning'" @click="changeTier(row,'free')">免费</el-button>
<el-button size="small" type="primary" :disabled="row.tier==='pro'" @click="changeTier(row,'pro')">Pro</el-button>
<el-button size="small" type="success" :disabled="row.tier==='enterprise'" @click="changeTier(row,'enterprise')">企业</el-button>
<el-button size="small" :type="row.is_active?'danger':'success'" @click="toggleActive(row)">{{ row.is_active?'禁用':'启用' }}</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-card shadow="never" class="section-card">
<template #header><span>所有用户 ( {{ total }} )</span></template>
<el-table :data="users" v-loading="loading" stripe style="width:100%">
<el-table-column prop="username" label="用户名" />
<el-table-column prop="phone" label="手机号" width="140" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="tier" label="套餐" width="100">
<template #default="{ row }"><el-tag :type="tierType(row.tier)" size="small">{{ row.tier }}</el-tag></template>
</el-table-column>
<el-table-column prop="role" label="角色" width="100">
<template #default="{ row }"><el-tag :type="row.role==='admin'?'danger':'info'" size="small">{{ row.role }}</el-tag></template>
</el-table-column>
<el-table-column prop="is_active" label="状态" width="80">
<template #default="{ row }"><el-tag :type="row.is_active?'success':'danger'" size="small">{{ row.is_active?'正常':'禁用' }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="340">
<template #default="{ row }">
<el-button size="small" @click="changeTier(row,'free')" :disabled="row.tier==='free'">免费</el-button>
<el-button size="small" type="primary" @click="changeTier(row,'pro')" :disabled="row.tier==='pro'">Pro</el-button>
<el-button size="small" type="success" @click="changeTier(row,'enterprise')" :disabled="row.tier==='enterprise'">企业</el-button>
<el-button size="small" :type="row.is_active?'danger':'success'" @click="toggleActive(row)">{{ row.is_active?'禁用':'启用' }}</el-button>
<el-button size="small" type="warning" @click="toggleRole(row)">{{ row.role==='admin'?'撤销管理':'设为管理' }}</el-button>
</template>
</el-table-column>
</el-table>
<div class="pagination-wrap" v-if="total > pageSize">
<el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev,pager,next" @current-change="loadUsers" background />
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { listUsers, searchUsers, updateUser } from '@/api'
const loading = ref(false)
const searching = ref(false)
const users = ref([])
const searchResults = ref([])
const total = ref(0)
const page = ref(1)
const pageSize = 20
const searchForm = ref({ query: '' })
function tierType(t) { return { free: 'warning', pro: 'primary', enterprise: 'success' }[t] || 'info' }
async function loadUsers() {
loading.value = true
try { const r = await listUsers(page.value, pageSize); users.value = r.items || []; total.value = r.total || 0 }
catch (e) { console.error(e) }
finally { loading.value = false }
}
async function doSearch() {
const q = searchForm.value.query?.trim()
if (!q) { searchResults.value = []; return }
searching.value = true
try {
const r = await searchUsers(q)
searchResults.value = r.items || [r] || []
} catch (e) { console.error(e) }
finally { searching.value = false }
}
async function changeTier(row, tier) {
try { await updateUser(row.id, { tier }); row.tier = tier; ElMessage.success('已更新') }
catch (e) { ElMessage.error(e?.detail || '操作失败') }
}
async function toggleActive(row) {
try { await updateUser(row.id, { is_active: !row.is_active }); row.is_active = !row.is_active; ElMessage.success('已更新') }
catch (e) { ElMessage.error(e?.detail || '操作失败') }
}
async function toggleRole(row) {
const role = row.role === 'admin' ? 'user' : 'admin'
try { await updateUser(row.id, { role }); row.role = role; ElMessage.success('已更新') }
catch (e) { ElMessage.error(e?.detail || '操作失败') }
}
onMounted(loadUsers)
</script>
<style scoped>
.search-card { margin-bottom: 20px; }
.section-card { }
.pagination-wrap { margin-top: 20px; display: flex; justify-content: center; }
</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: '/admin/',
resolve: {
alias: { '@': resolve(__dirname, 'src') }
},
build: {
outDir: 'dist',
assetsDir: 'assets'
},
server: {
port: 5173,
proxy: {
'/api': { target: 'http://localhost:8002', changeOrigin: true }
}
}
})
@@ -0,0 +1,76 @@
"""add certification and invoice
Revision ID: ecab04cc0e1d
Revises: 93a81b22bd80
Create Date: 2026-05-22 09:20:37.807327
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = 'ecab04cc0e1d'
down_revision: Union[str, None] = '93a81b22bd80'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('certifications',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('cert_type', sa.Enum('individual', 'enterprise', name='certtype'), nullable=False),
sa.Column('personal_name', sa.String(length=100), nullable=True),
sa.Column('personal_id', sa.String(length=30), nullable=True),
sa.Column('company_name', sa.String(length=255), nullable=True),
sa.Column('tax_id', sa.String(length=30), nullable=True),
sa.Column('business_license_url', sa.String(length=500), nullable=True),
sa.Column('status', sa.Enum('pending', 'approved', 'rejected', name='certstatus'), nullable=True),
sa.Column('reject_reason', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_certifications_user_id'), 'certifications', ['user_id'], unique=False)
op.create_table('invoices',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('certification_id', postgresql.UUID(as_uuid=True), nullable=True),
sa.Column('invoice_type', sa.Enum('individual', 'enterprise', name='invoicetype'), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('tax_id', sa.String(length=30), nullable=True),
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('status', sa.Enum('pending', 'issued', 'rejected', name='invoicestatus'), nullable=True),
sa.Column('reject_reason', sa.Text(), nullable=True),
sa.Column('issued_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['certification_id'], ['certifications.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_invoices_user_id'), 'invoices', ['user_id'], unique=False)
op.drop_index('ix_preference_analyses_user_id', table_name='preference_analyses')
op.create_index(op.f('ix_preference_analyses_user_id'), 'preference_analyses', ['user_id'], unique=True)
op.drop_constraint('system_configs_key_key', 'system_configs', type_='unique')
op.drop_index('ix_system_configs_key', table_name='system_configs')
op.create_index(op.f('ix_system_configs_key'), 'system_configs', ['key'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_system_configs_key'), table_name='system_configs')
op.create_index('ix_system_configs_key', 'system_configs', ['key'], unique=False)
op.create_unique_constraint('system_configs_key_key', 'system_configs', ['key'])
op.drop_index(op.f('ix_preference_analyses_user_id'), table_name='preference_analyses')
op.create_index('ix_preference_analyses_user_id', 'preference_analyses', ['user_id'], unique=False)
op.drop_index(op.f('ix_invoices_user_id'), table_name='invoices')
op.drop_table('invoices')
op.drop_index(op.f('ix_certifications_user_id'), table_name='certifications')
op.drop_table('certifications')
# ### end Alembic commands ###
+1 -1
View File
@@ -13,7 +13,7 @@ class NvidiaProvider(OpenAIProvider):
api_key=api_key, api_key=api_key,
model=model, model=model,
base_url=base_url, base_url=base_url,
http_client=httpx.AsyncClient(timeout=httpx.Timeout(60.0)), http_client=httpx.AsyncClient(timeout=httpx.Timeout(20.0)),
) )
self._name = f"nvidia-{model}" self._name = f"nvidia-{model}"
+62
View File
@@ -6,6 +6,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.services.admin import AdminService from app.services.admin import AdminService
from app.services.translation_quota import TranslationQuotaService from app.services.translation_quota import TranslationQuotaService
from app.services.certification import CertificationService
from app.services.invoice import InvoiceService
from app.api.v1.deps import get_current_user from app.api.v1.deps import get_current_user
router = APIRouter() router = APIRouter()
@@ -212,3 +214,63 @@ async def reset_translation_quota(
if not result: if not result:
raise HTTPException(status_code=404, detail="Quota not found") raise HTTPException(status_code=404, detail="Quota not found")
return result return result
@router.get("/certifications")
async def admin_list_certifications(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
status: Optional[str] = Query(None),
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
service = CertificationService(db)
return await service.list_all(page, size, status)
@router.post("/certifications/{cert_id}/review")
async def admin_review_certification(
cert_id: str,
data: dict,
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
_validate_uuid(cert_id)
service = CertificationService(db)
action = data.get("action")
if action not in ("approve", "reject"):
raise HTTPException(status_code=400, detail="Action must be 'approve' or 'reject'")
result = await service.review(cert_id, action, data.get("reason"))
if not result:
raise HTTPException(status_code=404, detail="Certification not found")
return result
@router.get("/invoices")
async def admin_list_invoices(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
status: Optional[str] = Query(None),
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
service = InvoiceService(db)
return await service.list_all(page, size, status)
@router.post("/invoices/{invoice_id}/process")
async def admin_process_invoice(
invoice_id: str,
data: dict,
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
_validate_uuid(invoice_id)
service = InvoiceService(db)
action = data.get("action")
if action not in ("issue", "reject"):
raise HTTPException(status_code=400, detail="Action must be 'issue' or 'reject'")
result = await service.process(invoice_id, action, data.get("reason"))
if not result:
raise HTTPException(status_code=404, detail="Invoice not found")
return result
+41
View File
@@ -0,0 +1,41 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from typing import Optional
from app.database import get_db
from app.api.v1.deps import get_current_user_id
from app.services.certification import CertificationService
router = APIRouter()
class CertSubmitRequest(BaseModel):
cert_type: str
personal_name: Optional[str] = None
personal_id: Optional[str] = None
company_name: Optional[str] = None
tax_id: Optional[str] = None
business_license_url: Optional[str] = None
@router.post("/submit")
async def submit_certification(
data: CertSubmitRequest,
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
service = CertificationService(db)
result = await service.submit(user_id, data.model_dump())
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return {"success": True, "data": result}
@router.get("/status")
async def get_certification_status(
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
service = CertificationService(db)
cert = await service.get_user_cert(user_id)
return {"success": True, "data": cert}
+61
View File
@@ -0,0 +1,61 @@
from fastapi import APIRouter, HTTPException
from typing import Optional, Dict, Any
from pydantic import BaseModel
from app.services.discovery import DiscoveryService
router = APIRouter()
class SearchRequest(BaseModel):
product_description: str
target_market: str = "US"
class AnalyzeRequest(BaseModel):
company_url: str
product_description: str
class OutreachRequest(BaseModel):
company: Dict[str, Any]
product: Dict[str, Any]
@router.post("/search")
async def search_leads(req: SearchRequest):
if not req.product_description.strip():
raise HTTPException(status_code=400, detail="请填写产品描述")
svc = DiscoveryService()
try:
result = await svc.search(req.product_description, req.target_market)
return {"success": True, "data": result}
except Exception as e:
raise HTTPException(status_code=500, detail=f"搜索失败: {str(e)}")
@router.post("/analyze")
async def analyze_company(req: AnalyzeRequest):
if not req.company_url.strip():
raise HTTPException(status_code=400, detail="请填写公司网址")
if not req.product_description.strip():
raise HTTPException(status_code=400, detail="请填写产品描述")
svc = DiscoveryService()
try:
result = await svc.analyze(req.company_url, req.product_description)
return {"success": True, "data": result}
except Exception as e:
raise HTTPException(status_code=500, detail=f"分析失败: {str(e)}")
@router.post("/outreach")
async def generate_outreach(req: OutreachRequest):
if not req.company.get("name"):
raise HTTPException(status_code=400, detail="请填写公司名称")
if not req.product.get("name"):
raise HTTPException(status_code=400, detail="请填写产品名称")
svc = DiscoveryService()
try:
result = await svc.outreach(req.company, req.product)
return {"success": True, "data": result}
except Exception as e:
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
+39
View File
@@ -0,0 +1,39 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from typing import Optional
from app.database import get_db
from app.api.v1.deps import get_current_user_id
from app.services.invoice import InvoiceService
router = APIRouter()
class InvoiceApplyRequest(BaseModel):
invoice_type: str
title: str
tax_id: Optional[str] = None
amount: float
@router.post("/apply")
async def apply_invoice(
data: InvoiceApplyRequest,
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
service = InvoiceService(db)
result = await service.apply(user_id, data.model_dump())
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return {"success": True, "data": result}
@router.get("/list")
async def list_invoices(
user_id: str = Depends(get_current_user_id),
db: AsyncSession = Depends(get_db),
):
service = InvoiceService(db)
items = await service.list_user(user_id)
return {"success": True, "data": items}
+9 -5
View File
@@ -1,4 +1,4 @@
from pydantic import BaseSettings from pydantic_settings import BaseSettings
from typing import Optional from typing import Optional
from pathlib import Path from pathlib import Path
@@ -8,10 +8,11 @@ ENV_FILE = PROJECT_ROOT / ".env"
class Settings(BaseSettings): class Settings(BaseSettings):
class Config: model_config = {
env_file = str(ENV_FILE) "env_file": str(ENV_FILE),
env_file_encoding = "utf-8" "env_file_encoding": "utf-8",
extra = "ignore" "extra": "ignore",
}
APP_NAME: str = "TradeMate" APP_NAME: str = "TradeMate"
@@ -71,6 +72,9 @@ class Settings(BaseSettings):
EXCHANGE_RATE_API_KEY: Optional[str] = None EXCHANGE_RATE_API_KEY: Optional[str] = None
GOOGLE_API_KEY: Optional[str] = None
GOOGLE_CSE_ID: Optional[str] = None
UPLOAD_DIR: str = "./uploads" UPLOAD_DIR: str = "./uploads"
MAX_UPLOAD_SIZE: int = 10 * 1024 * 1024 MAX_UPLOAD_SIZE: int = 10 * 1024 * 1024
+4 -1
View File
@@ -54,7 +54,7 @@ async def health():
return {"status": "ok", "app": settings.APP_NAME, "version": "1.0.0"} return {"status": "ok", "app": settings.APP_NAME, "version": "1.0.0"}
from app.api.v1 import auth, marketing, translate, customer, quotation, whatsapp, product, exchange, push, admin, analytics, teams, onboarding, notification, feedback, payment, interaction, silent_pattern, training, followup, ai_assistant from app.api.v1 import auth, marketing, translate, customer, quotation, whatsapp, product, exchange, push, admin, analytics, teams, onboarding, notification, feedback, payment, interaction, silent_pattern, training, followup, ai_assistant, discovery, certification, invoice
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"]) app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(marketing.router, prefix="/api/v1/marketing", tags=["marketing"]) app.include_router(marketing.router, prefix="/api/v1/marketing", tags=["marketing"])
@@ -78,6 +78,9 @@ app.include_router(silent_pattern.router, prefix="/api/v1/silent-pattern", tags=
app.include_router(training.router, prefix="/api/v1/training", tags=["training"]) app.include_router(training.router, prefix="/api/v1/training", tags=["training"])
app.include_router(followup.router, prefix="/api/v1/followup", tags=["followup"]) app.include_router(followup.router, prefix="/api/v1/followup", tags=["followup"])
app.include_router(ai_assistant.router, prefix="/api/v1/ai", tags=["ai-assistant"]) app.include_router(ai_assistant.router, prefix="/api/v1/ai", tags=["ai-assistant"])
app.include_router(discovery.router, prefix="/api/v1/discovery", tags=["discovery"])
app.include_router(certification.router, prefix="/api/v1/certification", tags=["certification"])
app.include_router(invoice.router, prefix="/api/v1/invoices", tags=["invoices"])
if __name__ == "__main__": if __name__ == "__main__":
+4
View File
@@ -12,6 +12,8 @@ from .device import Device
from .followup import FollowupStrategy, FollowupLog from .followup import FollowupStrategy, FollowupLog
from .system_config import SystemConfig from .system_config import SystemConfig
from .translation_quota import TranslationQuota from .translation_quota import TranslationQuota
from .certification import Certification, CertType, CertStatus
from .invoice import Invoice, InvoiceType, InvoiceStatus
__all__ = [ __all__ = [
"User", "Product", "User", "Product",
@@ -28,4 +30,6 @@ __all__ = [
"FollowupStrategy", "FollowupLog", "FollowupStrategy", "FollowupLog",
"SystemConfig", "SystemConfig",
"TranslationQuota", "TranslationQuota",
"Certification", "CertType", "CertStatus",
"Invoice", "InvoiceType", "InvoiceStatus",
] ]
+41
View File
@@ -0,0 +1,41 @@
from sqlalchemy import Column, String, Boolean, Integer, DateTime, Text, ForeignKey, Enum as SAEnum
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from datetime import datetime
from app.database import Base
import uuid
import enum
class CertType(str, enum.Enum):
individual = "individual"
enterprise = "enterprise"
class CertStatus(str, enum.Enum):
pending = "pending"
approved = "approved"
rejected = "rejected"
class Certification(Base):
__tablename__ = "certifications"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
cert_type = Column(SAEnum(CertType), nullable=False)
personal_name = Column(String(100))
personal_id = Column(String(30))
company_name = Column(String(255))
tax_id = Column(String(30))
business_license_url = Column(String(500))
status = Column(SAEnum(CertStatus), default=CertStatus.pending)
reject_reason = Column(Text)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User")
+41
View File
@@ -0,0 +1,41 @@
from sqlalchemy import Column, String, Boolean, Integer, DateTime, Text, Float, ForeignKey, Enum as SAEnum
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from datetime import datetime
from app.database import Base
import uuid
import enum
class InvoiceType(str, enum.Enum):
individual = "individual"
enterprise = "enterprise"
class InvoiceStatus(str, enum.Enum):
pending = "pending"
issued = "issued"
rejected = "rejected"
class Invoice(Base):
__tablename__ = "invoices"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
certification_id = Column(UUID(as_uuid=True), ForeignKey("certifications.id"), nullable=True)
invoice_type = Column(SAEnum(InvoiceType), nullable=False)
title = Column(String(255), nullable=False)
tax_id = Column(String(30))
amount = Column(Float, nullable=False)
status = Column(SAEnum(InvoiceStatus), default=InvoiceStatus.pending)
reject_reason = Column(Text)
issued_at = Column(DateTime)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User")
certification = relationship("Certification")
+112
View File
@@ -0,0 +1,112 @@
from typing import Optional, Dict, Any
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.models.certification import Certification, CertType, CertStatus
from datetime import datetime
import uuid
class CertificationService:
def __init__(self, db: AsyncSession):
self.db = db
async def submit(self, user_id: str, data: Dict[str, Any]) -> Dict[str, Any]:
existing = await self._get_pending(user_id)
if existing:
return {"error": "已有审核中的认证申请,请勿重复提交"}
cert = Certification(
user_id=uuid.UUID(user_id),
cert_type=CertType(data["cert_type"]),
personal_name=data.get("personal_name"),
personal_id=data.get("personal_id"),
company_name=data.get("company_name"),
tax_id=data.get("tax_id"),
business_license_url=data.get("business_license_url"),
status=CertStatus.pending,
)
self.db.add(cert)
await self.db.flush()
return {"id": str(cert.id), "status": cert.status.value}
async def get_user_cert(self, user_id: str) -> Optional[Dict[str, Any]]:
result = await self.db.execute(
select(Certification)
.where(Certification.user_id == uuid.UUID(user_id))
.order_by(desc(Certification.created_at))
.limit(1)
)
cert = result.scalar_one_or_none()
if not cert:
return None
return {
"id": str(cert.id),
"cert_type": cert.cert_type.value,
"personal_name": cert.personal_name,
"personal_id": cert.personal_id,
"company_name": cert.company_name,
"tax_id": cert.tax_id,
"business_license_url": cert.business_license_url,
"status": cert.status.value,
"reject_reason": cert.reject_reason,
"created_at": cert.created_at.isoformat() if cert.created_at else None,
"updated_at": cert.updated_at.isoformat() if cert.updated_at else None,
}
async def list_all(self, page: int, size: int, status: Optional[str] = None) -> Dict[str, Any]:
query = select(Certification).order_by(desc(Certification.created_at))
if status:
query = query.where(Certification.status == CertStatus(status))
offset = (page - 1) * size
result = await self.db.execute(query.offset(offset).limit(size))
certs = result.scalars().all()
total_result = await self.db.execute(
select(Certification).where(Certification.status == CertStatus(status)) if status else select(Certification)
)
total = len(total_result.scalars().all())
return {
"items": [
{
"id": str(c.id),
"user_id": str(c.user_id),
"cert_type": c.cert_type.value,
"personal_name": c.personal_name,
"personal_id": c.personal_id,
"company_name": c.company_name,
"tax_id": c.tax_id,
"status": c.status.value,
"reject_reason": c.reject_reason,
"created_at": c.created_at.isoformat() if c.created_at else None,
}
for c in certs
],
"total": total,
"page": page,
"size": size,
}
async def review(self, cert_id: str, action: str, reason: Optional[str] = None) -> Optional[Dict[str, Any]]:
result = await self.db.execute(
select(Certification).where(Certification.id == uuid.UUID(cert_id))
)
cert = result.scalar_one_or_none()
if not cert:
return None
if action == "approve":
cert.status = CertStatus.approved
else:
cert.status = CertStatus.rejected
cert.reject_reason = reason
await self.db.flush()
return {"id": str(cert.id), "status": cert.status.value}
async def _get_pending(self, user_id: str) -> Optional[Certification]:
result = await self.db.execute(
select(Certification)
.where(
Certification.user_id == uuid.UUID(user_id),
Certification.status == CertStatus.pending,
)
.limit(1)
)
return result.scalar_one_or_none()
+272
View File
@@ -0,0 +1,272 @@
import asyncio
import json
import logging
from typing import Dict, Any, Optional
from app.ai.router import get_ai_router
from app.services.search_web import search_companies, fetch_page_text
from app.services.mcp_search_client import mcp_search
logger = logging.getLogger(__name__)
ANALYZE_MATCH_PROMPT = """你是外贸客户分析专家。分析目标公司的业务描述,判断其与用户产品的匹配度。
请以 JSON 格式返回(不要用 markdown 代码块标记):
{
"match_score": 0-100,
"match_reason": "为什么匹配/不匹配",
"company_summary": "这家公司的主要业务",
"product_fit": "产品匹配度说明",
"contact_info": {
"emails": ["找到的邮箱"],
"phones": ["找到的电话"],
"social": ["LinkedIn等社媒链接"]
}
}
只返回 JSON,不要其他内容。"""
class DiscoveryService:
def __init__(self):
ai_router = get_ai_router()
self.ai = ai_router
self._ai_available = len(ai_router.providers) > 0
async def search(self, product_description: str, target_market: str) -> Dict[str, Any]:
queries = self._build_queries(product_description, target_market)
all_results = await self._mcp_search_all(queries)
if all_results:
return {
"companies": all_results[:15],
"query": product_description,
"market": target_market,
"provider": "mcp_search",
}
all_results = await self._google_search_all(queries)
if all_results:
return {
"companies": all_results[:15],
"query": product_description,
"market": target_market,
"provider": "web_search",
}
logger.info("No real search results, using AI strategy")
return await self._ai_strategy(product_description, target_market)
async def analyze(self, company_url: str, product_description: str) -> Dict[str, Any]:
page_text = await fetch_page_text(company_url)
company_info = {"url": company_url}
if page_text:
company_info["page_text"] = page_text[:2500]
if not self._ai_available:
return self._template_analysis(company_url)
prompt = f"""用户的产品:{product_description}
目标公司信息:
URL: {company_url}
网页内容:{page_text[:2500] if page_text else "无法获取网页内容"}
请分析该公司的业务与用户产品的匹配度。"""
try:
result = await self.ai.chat(prompt, system_prompt=ANALYZE_MATCH_PROMPT)
content = result.get("reply", "")
parsed = self._extract_json(content)
if parsed:
parsed["url"] = company_url
parsed["provider"] = result.get("provider_used", "unknown")
return parsed
except (json.JSONDecodeError, Exception) as e:
logger.warning(f"Analysis AI parse failed: {e}")
return self._template_analysis(company_url)
async def outreach(self, company_info: Dict[str, Any], product_info: Dict[str, Any]) -> Dict[str, Any]:
if not self._ai_available:
return self._template_outreach(company_info, product_info)
prompt = f"""目标公司信息:
{json.dumps(company_info, ensure_ascii=False)}
我的产品信息:
{json.dumps(product_info, ensure_ascii=False)}
请生成个性化触达文案。"""
system = """你是外贸开发信专家。根据目标公司信息和你的产品,生成个性化触达文案。
请以 JSON 格式返回(不要用 markdown 代码块标记):
{
"subject": "邮件标题(如适用)",
"linkedin_message": "LinkedIn 私信文案(150字以内)",
"whatsapp_message": "WhatsApp 消息文案(100字以内)",
"email_body": "邮件正文(含开头问候、自我介绍、价值主张、行动号召、签名)",
"key_points": ["客户关注的3个要点"],
"tips": ["发送时的建议"]
}"""
try:
result = await self.ai.chat(prompt, system_prompt=system)
content = result.get("reply", "")
parsed = self._extract_json(content)
if parsed:
parsed["provider"] = result.get("provider_used", "unknown")
return parsed
except (json.JSONDecodeError, Exception) as e:
logger.warning(f"Outreach AI parse failed: {e}")
return self._template_outreach(company_info, product_info)
async def _mcp_search_all(self, queries: list) -> list:
seen_urls = set()
tasks = [asyncio.create_task(mcp_search(q, max_results=6)) for q in queries[:2]]
all_results = []
try:
for coro in asyncio.as_completed(tasks, timeout=8):
try:
results = await coro
for r in results:
url = r.get("url", "").rstrip("/")
if url and url not in seen_urls:
seen_urls.add(url)
all_results.append(r)
except (asyncio.TimeoutError, Exception) as e:
logger.debug(f"MCP search query failed: {e}")
except asyncio.TimeoutError:
logger.warning("MCP search overall timeout")
finally:
for t in tasks:
if not t.done():
t.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
if all_results:
return self._dedup_and_filter(all_results)[:15]
return []
def _dedup_and_filter(self, results: list) -> list:
seen = set()
filtered = []
for r in results:
url = r.get("url", "").rstrip("/")
title = r.get("title", "")
if not url or url in seen:
continue
seen.add(url)
s = url.split("/")[2] if "://" in url else url
hostname = s.split(":")[0].lower() if ":" in s else s.lower()
if any(tld in hostname for tld in [".cn", ".com.cn", ".edu", ".ac.", ".gov"]):
continue
if any(domain in hostname for domain in
["sciencedirect", "mdpi", "springer", "wiley", "acm.org",
"ieee.org", "researchgate", "nature.com", "oup.com",
"sagepub", "tandfonline", "ncbi", "semanticscholar",
"britannica", "dictionary", "cambridge", "iciba", "wikipedia"]):
continue
filtered.append(r)
return filtered
async def _google_search_all(self, queries: list) -> list:
all_results = []
seen_urls = set()
for q in queries[:3]:
results = await search_companies(q, max_results=8)
for r in results:
url = r["url"].rstrip("/")
if url not in seen_urls:
seen_urls.add(url)
all_results.append(r)
if len(all_results) >= 15:
break
return self._dedup_and_filter(all_results)[:15]
def _build_queries(self, product: str, market: str) -> list:
return [
f"{product} importer {market}",
f"{product} distributor {market}",
f"{product} wholesale buyer {market}",
f"{product} procurement {market}",
f"{product} company {market}",
f"buy {product} from {market}",
f"{product} supply chain {market}",
f"top {product} manufacturers {market}",
f"{product} import export {market}",
f"{product} trading company {market}",
]
def _extract_json(self, text: str) -> Optional[dict]:
text = text.strip()
for prefix in ["```json", "```", "```JSON"]:
if text.startswith(prefix):
text = text[len(prefix):]
for suffix in ["```"]:
if text.endswith(suffix):
text = text[:-len(suffix)]
text = text.strip()
try:
return json.loads(text)
except json.JSONDecodeError:
import re
brace = text.find("{")
end = text.rfind("}")
if brace >= 0 and end > brace:
try:
return json.loads(text[brace:end+1])
except json.JSONDecodeError:
pass
return None
async def _ai_strategy(self, product: str, market: str) -> Dict[str, Any]:
if not self._ai_available:
return self._template_strategy(product, market)
system = """你是外贸客户发现专家。根据用户的产品和目标市场,分析出潜在买家画像和获取策略。
请以 JSON 格式返回(不要用 markdown 代码块标记):
{
"buyer_personas": [{"type": "", "description": "", "channels": [], "search_queries": []}],
"strategy": "",
"tips": []
}"""
prompt = f"产品:{product}\n目标市场:{market}\n请分析潜在买家画像和获取策略。"
try:
result = await self.ai.chat(prompt, system_prompt=system)
content = result.get("reply", "")
parsed = self._extract_json(content)
if parsed:
parsed["provider"] = result.get("provider_used", "unknown")
return parsed
return self._template_strategy(product, market)
except Exception as e:
logger.warning(f"AI strategy failed: {e}")
return self._template_strategy(product, market)
def _template_strategy(self, product: str, market: str) -> Dict[str, Any]:
return {
"buyer_personas": [
{"type": "进口商/批发商", "description": f"从中国进口{product}并在{market}批发的贸易商", "channels": ["LinkedIn", "Google"], "search_queries": [f"{product} importer {market}"]},
{"type": "品牌商/OEM买家", "description": f"{market}销售自有品牌{product}的公司", "channels": ["LinkedIn", "行业展会"], "search_queries": [f"{product} manufacturer {market}"]},
],
"strategy": f"建议在 LinkedIn 和 Google 搜索 {market}{product} 相关公司",
"tips": ["使用多个搜索词", "找到公司后在 LinkedIn 找决策人"],
"provider": "template",
}
def _template_analysis(self, url: str) -> Dict[str, Any]:
return {
"match_score": 50,
"match_reason": "无法获取网页内容进行分析,建议手动查看",
"url": url,
"provider": "template",
}
def _template_outreach(self, company: Dict[str, Any], product: Dict[str, Any]) -> Dict[str, Any]:
company_name = company.get("name", "")
product_name = product.get("name", "")
return {
"subject": f"关于{product_name}的合作机会",
"linkedin_message": f"您好!了解到贵司{company_name}在经营相关业务,我们专业生产{product_name},品质稳定,价格有竞争力。如有兴趣,我可以发详细资料供参考。",
"whatsapp_message": f"Hello! We are a professional {product_name} manufacturer. Interested in exploring cooperation? Happy to share details.",
"email_body": f"Dear {company_name} team,\n\nWe are a professional {product_name} manufacturer with competitive pricing and consistent quality. Would you be open to a quick chat to explore potential cooperation?\n\nBest regards,\n[Your Name]",
"key_points": ["产品质量有保障", "价格有竞争力", "可定制"],
"tips": ["发送前先了解对方背景", "LinkedIn 消息要简短"],
"provider": "template",
}
+126
View File
@@ -0,0 +1,126 @@
from typing import Optional, Dict, Any, List
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from app.models.invoice import Invoice, InvoiceType, InvoiceStatus
from app.models.certification import Certification, CertStatus
from datetime import datetime
import uuid
class InvoiceService:
def __init__(self, db: AsyncSession):
self.db = db
async def apply(self, user_id: str, data: Dict[str, Any]) -> Dict[str, Any]:
invoice_type = InvoiceType(data["invoice_type"])
certification_id = None
cert = None
if invoice_type == InvoiceType.individual:
cert_result = await self.db.execute(
select(Certification)
.where(
Certification.user_id == uuid.UUID(user_id),
Certification.cert_type == "individual",
Certification.status == CertStatus.approved,
)
.limit(1)
)
cert = cert_result.scalar_one_or_none()
if not cert:
return {"error": "请先完成个人实名认证"}
certification_id = cert.id
else:
cert_result = await self.db.execute(
select(Certification)
.where(
Certification.user_id == uuid.UUID(user_id),
Certification.cert_type == "enterprise",
Certification.status == CertStatus.approved,
)
.limit(1)
)
cert = cert_result.scalar_one_or_none()
if not cert:
return {"error": "请先完成企业认证"}
certification_id = cert.id
invoice = Invoice(
user_id=uuid.UUID(user_id),
certification_id=certification_id,
invoice_type=invoice_type,
title=data["title"],
tax_id=data.get("tax_id"),
amount=data["amount"],
status=InvoiceStatus.pending,
)
self.db.add(invoice)
await self.db.flush()
return {"id": str(invoice.id), "status": invoice.status.value}
async def list_user(self, user_id: str) -> List[Dict[str, Any]]:
result = await self.db.execute(
select(Invoice)
.where(Invoice.user_id == uuid.UUID(user_id))
.order_by(desc(Invoice.created_at))
)
invoices = result.scalars().all()
return [
{
"id": str(inv.id),
"invoice_type": inv.invoice_type.value,
"title": inv.title,
"tax_id": inv.tax_id,
"amount": inv.amount,
"status": inv.status.value,
"reject_reason": inv.reject_reason,
"issued_at": inv.issued_at.isoformat() if inv.issued_at else None,
"created_at": inv.created_at.isoformat() if inv.created_at else None,
}
for inv in invoices
]
async def list_all(self, page: int, size: int, status: Optional[str] = None) -> Dict[str, Any]:
query = select(Invoice).order_by(desc(Invoice.created_at))
if status:
query = query.where(Invoice.status == InvoiceStatus(status))
offset = (page - 1) * size
result = await self.db.execute(query.offset(offset).limit(size))
invoices = result.scalars().all()
return {
"items": [
{
"id": str(inv.id),
"user_id": str(inv.user_id),
"invoice_type": inv.invoice_type.value,
"title": inv.title,
"tax_id": inv.tax_id,
"amount": inv.amount,
"status": inv.status.value,
"reject_reason": inv.reject_reason,
"issued_at": inv.issued_at.isoformat() if inv.issued_at else None,
"created_at": inv.created_at.isoformat() if inv.created_at else None,
}
for inv in invoices
],
"total": len(invoices),
"page": page,
"size": size,
}
async def process(self, invoice_id: str, action: str, reason: Optional[str] = None) -> Optional[Dict[str, Any]]:
result = await self.db.execute(
select(Invoice).where(Invoice.id == uuid.UUID(invoice_id))
)
inv = result.scalar_one_or_none()
if not inv:
return None
if action == "issue":
inv.status = InvoiceStatus.issued
inv.issued_at = datetime.utcnow()
else:
inv.status = InvoiceStatus.rejected
inv.reject_reason = reason
await self.db.flush()
return {"id": str(inv.id), "status": inv.status.value}
+101
View File
@@ -0,0 +1,101 @@
import asyncio
import json
import logging
import os
import sys
import warnings
from typing import Dict, Any, List, Optional
from mcp.client.stdio import stdio_client, StdioServerParameters
from mcp.client.session import ClientSession
logger = logging.getLogger(__name__)
SERVER_SCRIPT = os.path.join(os.path.dirname(__file__), "mcp_search_server.py")
VENV_PYTHON = sys.executable
class MCPClientManager:
_instance: Optional["MCPClientManager"] = None
_lock = asyncio.Lock()
def __init__(self):
self._session: Optional[ClientSession] = None
self._read = None
self._write = None
self._ctx = None
self._initialized = False
@classmethod
async def get_instance(cls) -> "MCPClientManager":
if cls._instance is None or not cls._instance._initialized:
async with cls._lock:
if cls._instance is None or not cls._instance._initialized:
cls._instance = cls()
try:
await asyncio.wait_for(cls._instance._start(), timeout=10)
except Exception as e:
logger.warning(f"MCP init failed: {e}")
cls._instance = None
raise
return cls._instance
async def _start(self):
params = StdioServerParameters(
command=VENV_PYTHON,
args=[SERVER_SCRIPT],
)
self._ctx = stdio_client(params)
self._read, self._write = await asyncio.wait_for(
self._ctx.__aenter__(), timeout=5
)
self._session = await asyncio.wait_for(
ClientSession(self._read, self._write).__aenter__(), timeout=5
)
await asyncio.wait_for(self._session.initialize(), timeout=5)
self._initialized = True
logger.info("MCP search client initialized")
async def search(self, query: str, max_results: int = 10) -> List[Dict[str, str]]:
if not self._initialized or self._session is None:
logger.warning("MCP client not initialized")
return []
try:
result = await asyncio.wait_for(
self._session.call_tool(
"web_search",
{"query": query, "max_results": max_results},
),
timeout=10,
)
if result.content and len(result.content) > 0:
text = result.content[0].text
data = json.loads(text)
return data.get("results", [])
return []
except (asyncio.TimeoutError, Exception) as e:
logger.warning(f"MCP search call failed: {e}")
return []
async def close(self):
self._initialized = False
MCPClientManager._instance = None
if self._session:
try:
await self._session.__aexit__(None, None, None)
except (BaseExceptionGroup, RuntimeError, Exception):
pass
if self._ctx:
try:
await self._ctx.__aexit__(None, None, None)
except (BaseExceptionGroup, RuntimeError, Exception):
pass
async def mcp_search(query: str, max_results: int = 10) -> List[Dict[str, str]]:
try:
mgr = await MCPClientManager.get_instance()
return await mgr.search(query, max_results)
except Exception as e:
logger.warning(f"MCP search failed: {e}")
return []
+105
View File
@@ -0,0 +1,105 @@
import asyncio
import json
import logging
import os
import subprocess
from typing import List, Dict
from mcp.server.fastmcp import FastMCP
logger = logging.getLogger(__name__)
PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
NODE_BIN = "/usr/bin/node"
BING_SCRIPT = r"""
const p = require('puppeteer');
(async () => {
const b = await p.launch({headless:true,args:['--no-sandbox','--disable-setuid-sandbox','--disable-blink-features=AutomationControlled']});
const page = await b.newPage();
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
await page.setExtraHTTPHeaders({'Accept-Language':'en-US,en;q=0.9'});
await page.evaluateOnNewDocument(() => { Object.defineProperty(navigator, 'webdriver', {get:()=>undefined}); });
const q = process.argv[process.argv.length - 2];
const max = parseInt(process.argv[process.argv.length - 1] || '10', 10);
const sk = ['bing.com','google.com','facebook.com','twitter.com','instagram.com','youtube.com','reddit.com','amazon.com','wikipedia.org','baidu.com','linkedin.com','pinterest.com','ebay.com','walmart.com','w3.org','whatsapp.com','wechat.com','qq.com','taobao.com','tmall.com','alibaba.com','alipay.com','dict','dictionary','translate','zhihu.com','baike.baidu.com','sogou.com','163.com','sohu.com','sina.com','iciba.com','cambridge','britannica','sciencedirect','mdpi.com','springer','wiley.com','acm.org','ieee.org','researchgate','semanticscholar','ncbi.nlm.nih','nature.com','oup.com','sagepub','tandfonline'];
try {
await page.goto('https://cn.bing.com/search?q=' + encodeURIComponent(q) + '&setlang=en-US&cc=US', {waitUntil:'domcontentloaded',timeout:10000});
await page.waitForSelector('.b_algo', {timeout:5000}).catch(()=>{});
const results = await page.evaluate((m, sk) => {
const reCJK = /[\u4e00-\u9fff\u3400-\u4dbf]/;
const found = []; const seen = new Set();
document.querySelectorAll('li.b_algo').forEach(li => {
const a = li.querySelector('h2 a'); if (!a) return;
let url = (a.href || '').replace(/\/$/,'');
if (!url.startsWith('http') || seen.has(url)) return;
seen.add(url);
if (sk.some(d => url.includes(d))) return;
const hostname = url.replace(/^https?:\/\//,'').split('/')[0];
if (hostname.endsWith('.cn') || hostname.endsWith('.com.cn') || hostname.endsWith('.edu') || hostname.endsWith('.ac')) return;
const title = (a.textContent||'').trim().substring(0,100);
if (reCJK.test(title)) return;
const s = li.querySelector('.b_caption p, .b_lineclamp2');
found.push({title, url, snippet:s?s.textContent.trim().substring(0,200):''});
});
return found.slice(0,m);
}, max, sk);
console.log(JSON.stringify(results));
} catch(e) { console.log('[]'); }
await b.close();
})();
"""
BING_SCRIPT_FILE = os.path.join(os.path.dirname(__file__), "_bing_search.js")
NODE_MODULES = os.path.join(PROJECT_ROOT, "node_modules")
async def search_bing(query: str, max_results: int = 10) -> List[Dict[str, str]]:
try:
with open(BING_SCRIPT_FILE, "w") as f:
f.write(BING_SCRIPT)
env = os.environ.copy()
env["NODE_PATH"] = NODE_MODULES
result = subprocess.run(
[NODE_BIN, BING_SCRIPT_FILE, query, str(max_results)],
capture_output=True,
text=True,
timeout=15,
cwd=PROJECT_ROOT,
env=env,
)
if result.returncode != 0:
logger.warning(f"Bing search failed: {result.stderr[:300]}")
return []
for line in result.stdout.strip().split("\n"):
line = line.strip()
if line.startswith("["):
return json.loads(line)
return []
except subprocess.TimeoutExpired:
logger.warning("Bing search timed out")
return []
except (json.JSONDecodeError, Exception) as e:
logger.warning(f"Bing search error: {e}")
return []
mcp = FastMCP("trade-search", log_level="WARNING")
@mcp.tool(
name="web_search",
description="Search the web for companies, buyers, or business information. Returns title, URL, and snippet for each result. Useful for finding potential customers, researching companies, or gathering market intelligence.",
)
async def web_search(query: str, max_results: int = 10) -> str:
results = await search_bing(query, max_results)
if not results:
return json.dumps({"results": [], "error": None})
return json.dumps({"results": results, "error": None})
def main():
asyncio.run(mcp.run_stdio_async())
if __name__ == "__main__":
main()
+73
View File
@@ -0,0 +1,73 @@
from typing import List, Dict, Optional
import httpx
import json
import logging
from app.config import settings
logger = logging.getLogger(__name__)
GOOGLE_CSE_URL = "https://www.googleapis.com/customsearch/v1"
IGNORE_DOMAINS = [
"google.com", "facebook.com", "twitter.com", "instagram.com",
"youtube.com", "reddit.com", "amazon.com", "ebay.com",
"wikipedia.org", "linkedin.com", "pinterest.com", "baidu.com",
"bing.com", "duckduckgo.com",
]
async def search_companies(query: str, max_results: int = 10) -> List[Dict[str, str]]:
api_key = settings.GOOGLE_API_KEY or ""
cse_id = settings.GOOGLE_CSE_ID or ""
if api_key and cse_id:
return await _google_cse(query, max_results, api_key, cse_id)
logger.info("Google CSE not configured, using template results")
return []
async def _google_cse(query: str, max_results: int, api_key: str, cse_id: str) -> List[Dict[str, str]]:
try:
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(GOOGLE_CSE_URL, params={
"key": api_key,
"cx": cse_id,
"q": query,
"num": min(max_results, 10),
"lr": "lang_en",
})
if resp.status_code != 200:
logger.warning(f"Google CSE returned {resp.status_code}")
return []
data = resp.json()
results = []
for item in data.get("items", []):
url = item.get("link", "")
if not url or any(d in url for d in IGNORE_DOMAINS):
continue
results.append({
"title": item.get("title", url)[:100],
"url": url.rstrip("/"),
"snippet": item.get("snippet", "")[:200],
})
return results[:max_results]
except Exception as e:
logger.warning(f"Google CSE failed: {e}")
return []
async def fetch_page_text(url: str) -> Optional[str]:
try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
resp = await client.get(url, headers={"User-Agent": "Mozilla/5.0"})
if resp.status_code == 200:
from bs4 import BeautifulSoup
soup = BeautifulSoup(resp.text, "html.parser")
for tag in soup(["script", "style", "nav", "footer", "header"]):
tag.decompose()
text = soup.get_text(separator=" ", strip=True)
import re
text = re.sub(r"\s+", " ", text)[:3000]
return text if len(text) > 100 else None
except Exception as e:
logger.debug(f"fetch {url} failed: {e}")
return None
+7 -4
View File
@@ -1,14 +1,15 @@
fastapi==0.100.0 fastapi==0.136.1
uvicorn==0.23.2 uvicorn==0.47.0
sqlalchemy==1.4.48 sqlalchemy==1.4.48
asyncpg==0.27.0 asyncpg==0.27.0
pydantic==1.10.12 pydantic==2.13.4
pydantic-settings==2.14.1
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4 passlib[bcrypt]==1.7.4
python-multipart==0.0.6 python-multipart==0.0.6
redis==4.5.5 redis==4.5.5
celery==5.2.7 celery==5.2.7
httpx==0.23.3 httpx>=0.23.3,<0.28
openai==1.12.0 openai==1.12.0
anthropic==0.8.1 anthropic==0.8.1
jinja2==3.1.2 jinja2==3.1.2
@@ -20,3 +21,5 @@ pytest-cov==4.1.0
weasyprint==60.2 weasyprint==60.2
openpyxl==3.1.2 openpyxl==3.1.2
edge-tts>=6.0.0 edge-tts>=6.0.0
mcp==1.27.1
starlette==1.0.0
+757
View File
@@ -0,0 +1,757 @@
{
"name": "trade-assistant",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"puppeteer": "^25.0.4"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@puppeteer/browsers": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/@puppeteer/browsers/-/browsers-3.0.3.tgz",
"integrity": "sha512-v3YaiGpzUTgOZkHBFR0iZg58Vto25SqBQxfLUXDiofJccwVl6Mlr7BdLCS1NZgxikdeIHf936cxYWL9IZp3tow==",
"license": "Apache-2.0",
"dependencies": {
"debug": "^4.4.3",
"progress": "^2.0.3",
"semver": "^7.7.4",
"tar-fs": "^3.1.1",
"yargs": "^17.7.2"
},
"bin": {
"browsers": "lib/main-cli.js"
},
"engines": {
"node": ">=22.12.0"
},
"peerDependencies": {
"proxy-agent": ">=8.0.1"
},
"peerDependenciesMeta": {
"proxy-agent": {
"optional": true
}
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/b4a": {
"version": "1.8.1",
"resolved": "https://registry.npmmirror.com/b4a/-/b4a-1.8.1.tgz",
"integrity": "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==",
"license": "Apache-2.0",
"peerDependencies": {
"react-native-b4a": "*"
},
"peerDependenciesMeta": {
"react-native-b4a": {
"optional": true
}
}
},
"node_modules/bare-events": {
"version": "2.8.3",
"resolved": "https://registry.npmmirror.com/bare-events/-/bare-events-2.8.3.tgz",
"integrity": "sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw==",
"license": "Apache-2.0",
"peerDependencies": {
"bare-abort-controller": "*"
},
"peerDependenciesMeta": {
"bare-abort-controller": {
"optional": true
}
}
},
"node_modules/bare-fs": {
"version": "4.7.1",
"resolved": "https://registry.npmmirror.com/bare-fs/-/bare-fs-4.7.1.tgz",
"integrity": "sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==",
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.5.4",
"bare-path": "^3.0.0",
"bare-stream": "^2.6.4",
"bare-url": "^2.2.2",
"fast-fifo": "^1.3.2"
},
"engines": {
"bare": ">=1.16.0"
},
"peerDependencies": {
"bare-buffer": "*"
},
"peerDependenciesMeta": {
"bare-buffer": {
"optional": true
}
}
},
"node_modules/bare-os": {
"version": "3.9.1",
"resolved": "https://registry.npmmirror.com/bare-os/-/bare-os-3.9.1.tgz",
"integrity": "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==",
"license": "Apache-2.0",
"engines": {
"bare": ">=1.14.0"
}
},
"node_modules/bare-path": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/bare-path/-/bare-path-3.0.0.tgz",
"integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
"license": "Apache-2.0",
"dependencies": {
"bare-os": "^3.0.1"
}
},
"node_modules/bare-stream": {
"version": "2.13.1",
"resolved": "https://registry.npmmirror.com/bare-stream/-/bare-stream-2.13.1.tgz",
"integrity": "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==",
"license": "Apache-2.0",
"dependencies": {
"streamx": "^2.25.0",
"teex": "^1.0.1"
},
"peerDependencies": {
"bare-abort-controller": "*",
"bare-buffer": "*",
"bare-events": "*"
},
"peerDependenciesMeta": {
"bare-abort-controller": {
"optional": true
},
"bare-buffer": {
"optional": true
},
"bare-events": {
"optional": true
}
}
},
"node_modules/bare-url": {
"version": "2.4.3",
"resolved": "https://registry.npmmirror.com/bare-url/-/bare-url-2.4.3.tgz",
"integrity": "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==",
"license": "Apache-2.0",
"dependencies": {
"bare-path": "^3.0.0"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/callsites/-/callsites-3.1.0.tgz",
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/chromium-bidi": {
"version": "16.0.1",
"resolved": "https://registry.npmmirror.com/chromium-bidi/-/chromium-bidi-16.0.1.tgz",
"integrity": "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==",
"license": "Apache-2.0",
"dependencies": {
"mitt": "^3.0.1",
"zod": "^3.24.1"
},
"engines": {
"node": ">=20.19.0 <22.0.0 || >=22.12.0"
},
"peerDependencies": {
"devtools-protocol": "*"
}
},
"node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/cosmiconfig": {
"version": "9.0.1",
"resolved": "https://registry.npmmirror.com/cosmiconfig/-/cosmiconfig-9.0.1.tgz",
"integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==",
"license": "MIT",
"dependencies": {
"env-paths": "^2.2.1",
"import-fresh": "^3.3.0",
"js-yaml": "^4.1.0",
"parse-json": "^5.2.0"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/d-fischer"
},
"peerDependencies": {
"typescript": ">=4.9.5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/devtools-protocol": {
"version": "0.0.1608973",
"resolved": "https://registry.npmmirror.com/devtools-protocol/-/devtools-protocol-0.0.1608973.tgz",
"integrity": "sha512-Tpm17fxYzt+J7VrGdc1k8YdRqS3YV7se/M6KeemEqvUbq/n7At1rWVuXMxQgpWkdwSdIEKYbU//Bve+Shm4YNQ==",
"license": "BSD-3-Clause"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/end-of-stream": {
"version": "1.4.5",
"resolved": "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz",
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/env-paths": {
"version": "2.2.1",
"resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz",
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/error-ex": {
"version": "1.3.4",
"resolved": "https://registry.npmmirror.com/error-ex/-/error-ex-1.3.4.tgz",
"integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.2.1"
}
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/events-universal": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/events-universal/-/events-universal-1.0.1.tgz",
"integrity": "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==",
"license": "Apache-2.0",
"dependencies": {
"bare-events": "^2.7.0"
}
},
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmmirror.com/fast-fifo/-/fast-fifo-1.3.2.tgz",
"integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
"license": "MIT"
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmmirror.com/import-fresh/-/import-fresh-3.3.1.tgz",
"integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
"license": "MIT",
"dependencies": {
"parent-module": "^1.0.0",
"resolve-from": "^4.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmmirror.com/is-arrayish/-/is-arrayish-0.2.1.tgz",
"integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==",
"license": "MIT"
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmmirror.com/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/json-parse-even-better-errors": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
"license": "MIT"
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"license": "MIT"
},
"node_modules/mitt": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz",
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz",
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
"license": "MIT",
"dependencies": {
"callsites": "^3.0.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/parse-json": {
"version": "5.2.0",
"resolved": "https://registry.npmmirror.com/parse-json/-/parse-json-5.2.0.tgz",
"integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
"license": "MIT",
"dependencies": {
"@babel/code-frame": "^7.0.0",
"error-ex": "^1.3.1",
"json-parse-even-better-errors": "^2.3.0",
"lines-and-columns": "^1.1.6"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz",
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/puppeteer": {
"version": "25.0.4",
"resolved": "https://registry.npmmirror.com/puppeteer/-/puppeteer-25.0.4.tgz",
"integrity": "sha512-QFdBAuNOqL0I+AdARTlRR1KcgPk0fo0dU127e1ZQFVxb9QPcpBDIiQp/dMgdbyLXHpF2GRjC/OezDmjKcLCKYw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "3.0.3",
"chromium-bidi": "16.0.1",
"cosmiconfig": "^9.0.0",
"devtools-protocol": "0.0.1608973",
"puppeteer-core": "25.0.4",
"typed-query-selector": "^2.12.2"
},
"bin": {
"puppeteer": "lib/puppeteer/node/cli.js"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/puppeteer-core": {
"version": "25.0.4",
"resolved": "https://registry.npmmirror.com/puppeteer-core/-/puppeteer-core-25.0.4.tgz",
"integrity": "sha512-K1LQKDP6w1rIr1jUyN9obH16TO/DCy86k3q+FBd2prGY+TStxhFySxmaZZuRF+0D3BJXjwCYFke7tMHCH4olTA==",
"license": "Apache-2.0",
"dependencies": {
"@puppeteer/browsers": "3.0.3",
"chromium-bidi": "16.0.1",
"debug": "^4.4.3",
"devtools-protocol": "0.0.1608973",
"typed-query-selector": "^2.12.2",
"webdriver-bidi-protocol": "0.4.1",
"ws": "^8.20.0"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-4.0.0.tgz",
"integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/streamx": {
"version": "2.25.0",
"resolved": "https://registry.npmmirror.com/streamx/-/streamx-2.25.0.tgz",
"integrity": "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==",
"license": "MIT",
"dependencies": {
"events-universal": "^1.0.0",
"fast-fifo": "^1.3.2",
"text-decoder": "^1.1.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/tar-fs": {
"version": "3.1.2",
"resolved": "https://registry.npmmirror.com/tar-fs/-/tar-fs-3.1.2.tgz",
"integrity": "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==",
"license": "MIT",
"dependencies": {
"pump": "^3.0.0",
"tar-stream": "^3.1.5"
},
"optionalDependencies": {
"bare-fs": "^4.0.1",
"bare-path": "^3.0.0"
}
},
"node_modules/tar-stream": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/tar-stream/-/tar-stream-3.2.0.tgz",
"integrity": "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==",
"license": "MIT",
"dependencies": {
"b4a": "^1.6.4",
"bare-fs": "^4.5.5",
"fast-fifo": "^1.2.0",
"streamx": "^2.15.0"
}
},
"node_modules/teex": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/teex/-/teex-1.0.1.tgz",
"integrity": "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==",
"license": "MIT",
"dependencies": {
"streamx": "^2.12.5"
}
},
"node_modules/text-decoder": {
"version": "1.2.7",
"resolved": "https://registry.npmmirror.com/text-decoder/-/text-decoder-1.2.7.tgz",
"integrity": "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==",
"license": "Apache-2.0",
"dependencies": {
"b4a": "^1.6.4"
}
},
"node_modules/typed-query-selector": {
"version": "2.12.2",
"resolved": "https://registry.npmmirror.com/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
"integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
"license": "MIT"
},
"node_modules/webdriver-bidi-protocol": {
"version": "0.4.1",
"resolved": "https://registry.npmmirror.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.1.tgz",
"integrity": "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw==",
"license": "Apache-2.0"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/ws": {
"version": "8.20.1",
"resolved": "https://registry.npmmirror.com/ws/-/ws-8.20.1.tgz",
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmmirror.com/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmmirror.com/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"dependencies": {
"puppeteer": "^25.0.4"
}
}
+5 -199
View File
@@ -1,207 +1,13 @@
<template> <template>
<div class="app-wrapper"> <router-view />
<div class="app-nav" id="appNav">
<div class="app-nav-brand">TradeMate</div>
<div
v-for="(item, index) in navList"
:key="index"
class="app-nav-item"
:class="{ active: currentIndex === index }"
@click="switchTab(index)"
>
<span class="app-nav-icon">{{ item.icon }}</span>
<span class="app-nav-text">{{ item.text }}</span>
</div>
</div>
<router-view />
</div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted, onUnmounted } from 'vue'
const currentIndex = ref(0)
const navList = [
{ pagePath: '/pages/index/index', text: '首页', icon: '🏠' },
{ pagePath: '/pages/customers/customers', text: '客户', icon: '👥' },
{ pagePath: '/pages/marketing/marketing', text: '营销', icon: '📢' },
{ pagePath: '/pages/quotation/quotation', text: '报价', icon: '📄' },
{ pagePath: '/pages/profile/profile', text: '我的', icon: '👤' },
]
const updateCurrentIndex = () => {
const hash = window.location.hash || ''
for (let i = 0; i < navList.length; i++) {
if (hash.includes(navList[i].pagePath)) {
currentIndex.value = i
return
}
}
currentIndex.value = 0
}
const switchTab = (index) => {
if (currentIndex.value === index) return
uni.switchTab({ url: navList[index].pagePath })
}
onMounted(() => {
updateCurrentIndex()
window.addEventListener('hashchange', updateCurrentIndex)
})
onUnmounted(() => {
window.removeEventListener('hashchange', updateCurrentIndex)
})
</script> </script>
<style> <style>
/* Global reset */ * { margin: 0; padding: 0; box-sizing: border-box; }
* { html, body, #app { height: 100%; width: 100%; }
margin: 0; uni-page { overflow-y: auto !important; }
padding: 0; uni-page-body { overflow-y: auto !important; min-height: 100% !important; }
box-sizing: border-box;
}
html, body, #app {
height: 100%;
width: 100%;
}
/* Let uni-app pages scroll */
uni-page {
overflow-y: auto !important;
}
uni-page-body {
overflow-y: auto !important;
min-height: 100% !important;
}
/* ===== Nav: hidden on mobile (uni-app default tab bar is used) ===== */
.app-nav {
display: none;
}
/* ===== Desktop responsive (≥1024px) ===== */
@media (min-width: 1024px) {
/* Sidebar nav */
.app-nav {
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
width: 220px;
height: 100vh;
background: #fff;
box-shadow: 4px 0 16px rgba(0,0,0,0.08);
z-index: 999999;
border-right: 1px solid #e0e0e0;
padding-top: 24px;
}
.app-nav-brand {
font-size: 20px;
font-weight: 700;
color: #1890ff;
padding: 20px 24px 32px;
text-align: center;
}
.app-nav-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 14px 24px;
margin: 2px 12px;
border-radius: 12px;
gap: 14px;
cursor: pointer;
transition: background 0.15s;
}
.app-nav-item:hover {
background: #f0f7ff;
}
.app-nav-item.active {
background: #e6f0ff;
}
.app-nav-icon {
font-size: 22px;
line-height: 1;
font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
}
.app-nav-text {
font-size: 15px;
color: #555;
}
.app-nav-item.active .app-nav-text {
color: #1890ff;
font-weight: 600;
}
/* Hide the built-in uni-app tab bar on desktop */
uni-tabbar {
display: none !important;
}
/* Sidebar — shift page content right */
uni-page-body {
margin-left: 220px !important;
font-size: 16px !important;
}
/* Remove the tab-bar-height bottom padding that uni-app adds for tab pages */
.uni-app--showtabbar uni-page-wrapper:after {
display: none !important;
}
/* Constrain + center page content */
uni-page-body > view {
max-width: 1200px !important;
margin-left: auto !important;
margin-right: auto !important;
padding: 40px 48px !important;
}
/* Cards more breathing room */
uni-page-body .card {
padding: 32px !important;
border-radius: 20px !important;
}
/* Buttons: reasonable desktop sizing */
uni-page-body button,
uni-page-body .btn-primary,
uni-page-body .btn-secondary,
uni-page-body .uni-btn,
uni-page-body [class*="btn-"] {
min-width: 120px;
padding: 12px 32px !important;
font-size: 15px !important;
}
/* Inputs */
uni-page-body input,
uni-page-body textarea,
uni-page-body .uni-input-input {
font-size: 15px !important;
padding: 10px 16px !important;
}
/* Fix floating AI assistant (clear sidebar) */
.ai-float-btn {
right: 40px !important;
bottom: 40px !important;
}
.ai-dialog {
right: 40px !important;
bottom: 100px !important;
}
}
</style> </style>
+3
View File
@@ -22,8 +22,11 @@ export const PAGES = {
FOLLOWUP: '/pages/followup/followup', FOLLOWUP: '/pages/followup/followup',
NOTIFICATION: '/pages/notification/notification', NOTIFICATION: '/pages/notification/notification',
ANALYTICS: '/pages/analytics/analytics', ANALYTICS: '/pages/analytics/analytics',
DISCOVERY: '/pages/discovery/discovery',
TEAM: '/pages/team/team', TEAM: '/pages/team/team',
ADMIN: '/pages/admin/admin', ADMIN: '/pages/admin/admin',
CERTIFICATION: '/pages/certification/certification',
INVOICE: '/pages/invoice/invoice',
AGREEMENT_PRIVACY: '/pages/agreement/privacy', AGREEMENT_PRIVACY: '/pages/agreement/privacy',
AGREEMENT_TERMS: '/pages/agreement/terms', AGREEMENT_TERMS: '/pages/agreement/terms',
} }
+19 -1
View File
@@ -46,7 +46,7 @@
{ {
"path": "pages/admin/admin", "path": "pages/admin/admin",
"style": { "style": {
"navigationBarTitleText": "管理后台" "navigationStyle": "custom"
} }
}, },
{ {
@@ -102,6 +102,24 @@
"style": { "style": {
"navigationBarTitleText": "个人中心" "navigationBarTitleText": "个人中心"
} }
},
{
"path": "pages/discovery/discovery",
"style": {
"navigationBarTitleText": "挖掘新客"
}
},
{
"path": "pages/certification/certification",
"style": {
"navigationBarTitleText": "实名认证"
}
},
{
"path": "pages/invoice/invoice",
"style": {
"navigationBarTitleText": "发票管理"
}
} }
], ],
"globalStyle": { "globalStyle": {
+250 -10
View File
@@ -1,13 +1,45 @@
<template> <template>
<view class="admin-container"> <view class="admin-container">
<scroll-view class="tabs" scroll-x> <view class="sidebar">
<view class="tab" :class="{ active: tab === 'overview' }" @click="tab = 'overview'">概览</view> <view class="sidebar-header">
<view class="tab" :class="{ active: tab === 'users' }" @click="tab = 'users'">用户</view> <text class="sidebar-title">管理后台</text>
<view class="tab" :class="{ active: tab === 'stats' }" @click="tab = 'stats'">统计</view> </view>
<view class="tab" :class="{ active: tab === 'logs' }" @click="tab = 'logs'">日志</view> <view class="tab" :class="{ active: tab === 'overview' }" @click="tab = 'overview'">
<view class="tab" :class="{ active: tab === 'config' }" @click="tab = 'config'">配置</view> <text class="tab-icon">📊</text><text>概览</text>
<view class="tab" :class="{ active: tab === 'quota' }" @click="tab = 'quota'">翻译配额</view> </view>
</scroll-view> <view class="tab" :class="{ active: tab === 'users' }" @click="tab = 'users'">
<text class="tab-icon">👥</text><text>用户</text>
</view>
<view class="tab" :class="{ active: tab === 'stats' }" @click="tab = 'stats'">
<text class="tab-icon">📈</text><text>统计</text>
</view>
<view class="tab" :class="{ active: tab === 'logs' }" @click="tab = 'logs'">
<text class="tab-icon">📋</text><text>日志</text>
</view>
<view class="tab" :class="{ active: tab === 'config' }" @click="tab = 'config'">
<text class="tab-icon"></text><text>配置</text>
</view>
<view class="tab" :class="{ active: tab === 'quota' }" @click="tab = 'quota'">
<text class="tab-icon">📄</text><text>翻译配额</text>
</view>
<view class="tab" :class="{ active: tab === 'cert' }" @click="tab = 'cert'; loadCerts()">
<text class="tab-icon">🪪</text><text>认证审核</text>
</view>
<view class="tab" :class="{ active: tab === 'invoice' }" @click="tab = 'invoice'; loadAdminInvoices()">
<text class="tab-icon">🧾</text><text>发票管理</text>
</view>
</view>
<view class="main-content">
<scroll-view class="tabs mobile-only" scroll-x>
<view class="tab" :class="{ active: tab === 'overview' }" @click="tab = 'overview'">概览</view>
<view class="tab" :class="{ active: tab === 'users' }" @click="tab = 'users'">用户</view>
<view class="tab" :class="{ active: tab === 'stats' }" @click="tab = 'stats'">统计</view>
<view class="tab" :class="{ active: tab === 'logs' }" @click="tab = 'logs'">日志</view>
<view class="tab" :class="{ active: tab === 'config' }" @click="tab = 'config'">配置</view>
<view class="tab" :class="{ active: tab === 'quota' }" @click="tab = 'quota'">翻译配额</view>
<view class="tab" :class="{ active: tab === 'cert' }" @click="tab = 'cert'; loadCerts()">认证审核</view>
<view class="tab" :class="{ active: tab === 'invoice' }" @click="tab = 'invoice'; loadAdminInvoices()">发票管理</view>
</scroll-view>
<!-- 概览 --> <!-- 概览 -->
<view v-if="tab === 'overview'"> <view v-if="tab === 'overview'">
@@ -266,6 +298,92 @@
</view> </view>
</view> </view>
<!-- 认证审核 -->
<view v-if="tab === 'cert'">
<view class="section">
<view class="section-header">
<text class="section-title">实名认证审核</text>
</view>
<view class="filter-bar">
<text class="filter-btn" :class="{ active: certFilter === '' }" @click="certFilter = ''; loadCerts()">全部</text>
<text class="filter-btn" :class="{ active: certFilter === 'pending' }" @click="certFilter = 'pending'; loadCerts()">待审核</text>
<text class="filter-btn" :class="{ active: certFilter === 'approved' }" @click="certFilter = 'approved'; loadCerts()">已通过</text>
<text class="filter-btn" :class="{ active: certFilter === 'rejected' }" @click="certFilter = 'rejected'; loadCerts()">已驳回</text>
</view>
<view class="cert-list" v-if="certs.length">
<view class="cert-card" v-for="c in certs" :key="c.id">
<view class="cert-header">
<text class="cert-type">{{ c.cert_type === 'individual' ? '个人认证' : '企业认证' }}</text>
<text class="cert-status" :class="c.status">{{ { pending: '待审核', approved: '已通过', rejected: '已驳回' }[c.status] }}</text>
</view>
<view class="cert-body">
<text class="cert-field" v-if="c.personal_name">姓名{{ c.personal_name }}</text>
<text class="cert-field" v-if="c.company_name">企业{{ c.company_name }}</text>
<text class="cert-field" v-if="c.tax_id">税号{{ c.tax_id }}</text>
<text class="cert-field">用户ID{{ c.user_id?.substring(0, 8) }}...</text>
<text class="cert-date">{{ c.created_at?.substring(0, 10) }}</text>
</view>
<view class="cert-actions" v-if="c.status === 'pending'">
<text class="action-btn approve" @click="reviewCert(c.id, 'approve')">通过</text>
<text class="action-btn reject" @click="showRejectCert = c.id">驳回</text>
</view>
<text v-if="c.reject_reason" class="cert-reason">驳回原因{{ c.reject_reason }}</text>
</view>
</view>
<text v-else class="empty-text">暂无认证申请</text>
</view>
</view>
<!-- 发票管理 -->
<view v-if="tab === 'invoice'">
<view class="section">
<view class="section-header">
<text class="section-title">开票管理</text>
</view>
<view class="filter-bar">
<text class="filter-btn" :class="{ active: invFilter === '' }" @click="invFilter = ''; loadAdminInvoices()">全部</text>
<text class="filter-btn" :class="{ active: invFilter === 'pending' }" @click="invFilter = 'pending'; loadAdminInvoices()">待开票</text>
<text class="filter-btn" :class="{ active: invFilter === 'issued' }" @click="invFilter = 'issued'; loadAdminInvoices()">已开票</text>
<text class="filter-btn" :class="{ active: invFilter === 'rejected' }" @click="invFilter = 'rejected'; loadAdminInvoices()">已驳回</text>
</view>
<view class="inv-list" v-if="adminInvoices.length">
<view class="inv-card" v-for="inv in adminInvoices" :key="inv.id">
<view class="inv-header">
<text class="inv-title">{{ inv.title }}</text>
<text class="inv-amount">¥{{ inv.amount }}</text>
</view>
<view class="inv-meta">
<text class="inv-type">{{ inv.invoice_type === 'individual' ? '个人' : '企业' }}发票</text>
<text class="inv-status" :class="inv.status">{{ { pending: '待开票', issued: '已开票', rejected: '已驳回' }[inv.status] }}</text>
</view>
<view class="inv-actions" v-if="inv.status === 'pending'">
<text class="action-btn approve" @click="processInv(inv.id, 'issue')">确认已开</text>
<text class="action-btn reject" @click="showRejectInv = inv.id">驳回</text>
</view>
<text v-if="inv.reject_reason" class="inv-reason">驳回原因{{ inv.reject_reason }}</text>
</view>
</view>
<text v-else class="empty-text">暂无开票申请</text>
</view>
</view>
<!-- 驳回弹窗 -->
<view class="modal-mask" v-if="showRejectCert || showRejectInv" @click="showRejectCert = null; showRejectInv = null">
<view class="modal-content" @click.stop>
<view class="modal-header">
<text class="modal-title">驳回原因</text>
<text class="modal-close" @click="showRejectCert = null; showRejectInv = null"></text>
</view>
<view class="modal-body">
<input class="reject-input" v-model="rejectReason" placeholder="输入驳回原因" />
<view class="modal-actions">
<text class="action-btn cancel" @click="showRejectCert = null; showRejectInv = null">取消</text>
<text class="action-btn reject" @click="confirmReject">确认驳回</text>
</view>
</view>
</view>
</view>
<!-- 用户详情弹窗 --> <!-- 用户详情弹窗 -->
<view class="modal-mask" v-if="userDetail" @click="userDetail = null"> <view class="modal-mask" v-if="userDetail" @click="userDetail = null">
<view class="modal-content" @click.stop> <view class="modal-content" @click.stop>
@@ -294,6 +412,7 @@
</view> </view>
</view> </view>
<AiAssistant /> <AiAssistant />
</view>
</view> </view>
</template> </template>
@@ -572,19 +691,110 @@ const resetQuota = async (version) => {
} }
} }
const certs = ref([])
const certFilter = ref('pending')
const showRejectCert = ref(null)
const showRejectInv = ref(null)
const rejectReason = ref('')
const adminInvoices = ref([])
const invFilter = ref('pending')
const loadCerts = async () => {
try {
const res = await adminApi.listCertifications(1, 50, certFilter.value || undefined)
certs.value = res.items || []
} catch (e) { certs.value = [] }
}
const reviewCert = async (id, action) => {
try {
await adminApi.reviewCertification(id, action)
uni.showToast({ title: action === 'approve' ? '已通过' : '已驳回', icon: 'success' })
loadCerts()
} catch (e) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
}
}
const confirmReject = async () => {
const id = showRejectCert.value || showRejectInv.value
const type = showRejectCert.value ? 'cert' : 'inv'
if (!rejectReason.value) { uni.showToast({ title: '请输入驳回原因', icon: 'none' }); return }
showRejectCert.value = null
showRejectInv.value = null
try {
if (type === 'cert') {
await adminApi.reviewCertification(id, 'reject', rejectReason.value)
} else {
await adminApi.processInvoice(id, 'reject', rejectReason.value)
}
uni.showToast({ title: '已驳回', icon: 'success' })
rejectReason.value = ''
loadCerts()
loadAdminInvoices()
} catch (e) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
}
}
const loadAdminInvoices = async () => {
try {
const res = await adminApi.listInvoices(1, 50, invFilter.value || undefined)
adminInvoices.value = res.items || []
} catch (e) { adminInvoices.value = [] }
}
const processInv = async (id, action) => {
try {
await adminApi.processInvoice(id, action)
uni.showToast({ title: action === 'issue' ? '已开票' : '已驳回', icon: 'success' })
loadAdminInvoices()
} catch (e) {
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
}
}
watch(tab, (val) => { watch(tab, (val) => {
if (val === 'stats') loadUsageStats() if (val === 'stats') loadUsageStats()
else if (val === 'logs') { logPage.value = 1; loadLogs() } else if (val === 'logs') { logPage.value = 1; loadLogs() }
else if (val === 'config') loadConfig() else if (val === 'config') loadConfig()
else if (val === 'quota') loadQuotas() else if (val === 'quota') loadQuotas()
else if (val === 'cert') loadCerts()
else if (val === 'invoice') loadAdminInvoices()
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.admin-container { min-height: 100vh; background: #f5f5f5; padding: 20rpx; } .admin-container { min-height: 100vh; background: #f5f5f5; display: flex; }
.tabs { width: 100%; background: #fff; border-radius: 16rpx; margin-bottom: 30rpx; white-space: nowrap; }
.tabs { width: 100%; background: #fff; border-radius: 16rpx; margin-bottom: 30rpx; white-space: nowrap; overflow-x: auto; }
.tab { display: inline-block; text-align: center; padding: 20rpx 28rpx; font-size: 26rpx; color: #666; font-weight: 500; } .tab { display: inline-block; text-align: center; padding: 20rpx 28rpx; font-size: 26rpx; color: #666; font-weight: 500; }
.tab.active { color: #667eea; border-bottom: 4rpx solid #667eea; } .tab.active { color: #667eea; border-bottom: 4rpx solid #667eea; }
.pc-only { display: none; }
.mobile-only { display: block; }
@media (min-width: 768px) {
.admin-container { padding: 0; }
.pc-only { display: block; }
.mobile-only { display: none; }
.sidebar { display: flex; flex-direction: column; width: 220px; min-height: 100vh; background: #001529; position: fixed; top: 0; left: 0; z-index: 9999; }
.sidebar-header { display: flex; align-items: center; padding: 16px 20px; background: #002140; border-bottom: 1px solid #003a5c; }
.sidebar-title { color: #fff; font-size: 16px; font-weight: 600; }
.sidebar .tab { display: flex; align-items: center; gap: 10px; padding: 14px 24px; font-size: 14px; color: rgba(255,255,255,0.65); cursor: pointer; transition: all 0.2s; }
.sidebar .tab:hover { color: #fff; background: #002140; }
.sidebar .tab.active { color: #fff; background: #1890ff; }
.tab-icon { font-size: 16px; }
.main-content { margin-left: 220px; flex: 1; padding: 24px; min-height: 100vh; width: calc(100% - 220px); }
}
@media (max-width: 767px) {
.admin-container { padding: 20rpx; flex-direction: column; }
.sidebar { display: none; }
.main-content { flex: 1; width: 100%; }
.tabs { width: 100%; background: #fff; border-radius: 16rpx; margin-bottom: 30rpx; white-space: nowrap; overflow-x: auto; }
}
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20rpx; margin-bottom: 30rpx; } .stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20rpx; margin-bottom: 30rpx; }
.stat-card { background: #fff; border-radius: 16rpx; padding: 30rpx; text-align: center; } .stat-card { background: #fff; border-radius: 16rpx; padding: 30rpx; text-align: center; }
.stat-value { font-size: 48rpx; font-weight: bold; color: #667eea; display: block; } .stat-value { font-size: 48rpx; font-weight: bold; color: #667eea; display: block; }
@@ -679,4 +889,34 @@ watch(tab, (val) => {
.quota-input { width: 140rpx; height: 56rpx; background: #fff; border: 1rpx solid #e0e0e0; border-radius: 8rpx; padding: 0 12rpx; font-size: 24rpx; text-align: center; } .quota-input { width: 140rpx; height: 56rpx; background: #fff; border: 1rpx solid #e0e0e0; border-radius: 8rpx; padding: 0 12rpx; font-size: 24rpx; text-align: center; }
.quota-reset-btn { font-size: 22rpx; padding: 6rpx 16rpx; background: #fff7e6; color: #fa8c16; border-radius: 6rpx; } .quota-reset-btn { font-size: 22rpx; padding: 6rpx 16rpx; background: #fff7e6; color: #fa8c16; border-radius: 6rpx; }
.quota-save-btn { font-size: 22rpx; padding: 6rpx 16rpx; background: #52c41a; color: #fff; border-radius: 6rpx; } .quota-save-btn { font-size: 22rpx; padding: 6rpx 16rpx; background: #52c41a; color: #fff; border-radius: 6rpx; }
.filter-bar { display: flex; gap: 12rpx; margin-bottom: 16rpx; flex-wrap: wrap; }
.filter-btn { font-size: 22rpx; padding: 6rpx 16rpx; background: #f5f5f5; color: #666; border-radius: 6rpx; }
.filter-btn.active { background: #667eea; color: #fff; }
.cert-list, .inv-list { display: flex; flex-direction: column; gap: 12rpx; }
.cert-card, .inv-card { padding: 16rpx; background: #f9f9f9; border-radius: 10rpx; }
.cert-header, .inv-header { display: flex; justify-content: space-between; margin-bottom: 8rpx; }
.cert-type { font-size: 24rpx; color: #333; font-weight: 500; }
.cert-status { font-size: 20rpx; padding: 2rpx 10rpx; border-radius: 4rpx; }
.cert-status.pending { background: #fff7e6; color: #d46b08; }
.cert-status.approved { background: #f6ffed; color: #389e0d; }
.cert-status.rejected { background: #fff2f0; color: #cf1322; }
.cert-body { }
.cert-field { font-size: 22rpx; color: #666; display: block; margin: 4rpx 0; }
.cert-date { font-size: 20rpx; color: #999; display: block; margin-top: 4rpx; }
.cert-actions { display: flex; gap: 12rpx; margin-top: 8rpx; }
.action-btn.approve { background: #f6ffed; color: #52c41a; }
.action-btn.reject { background: #fff2f0; color: #f5222d; }
.action-btn.cancel { background: #f5f5f5; color: #666; }
.cert-reason, .inv-reason { font-size: 20rpx; color: #cf1322; display: block; margin-top: 4rpx; }
.inv-title { font-size: 24rpx; color: #333; font-weight: 500; }
.inv-amount { font-size: 24rpx; color: #f5222d; font-weight: 600; }
.inv-meta { display: flex; justify-content: space-between; margin: 6rpx 0; }
.inv-type { font-size: 20rpx; color: #666; }
.inv-status { font-size: 20rpx; padding: 2rpx 10rpx; border-radius: 4rpx; }
.inv-status.pending { background: #fff7e6; color: #d46b08; }
.inv-status.issued { background: #f6ffed; color: #389e0d; }
.inv-status.rejected { background: #fff2f0; color: #cf1322; }
.inv-actions { display: flex; gap: 12rpx; margin-top: 8rpx; }
.reject-input { width: 100%; height: 80rpx; border: 1rpx solid #d9d9d9; border-radius: 8rpx; padding: 0 16rpx; font-size: 26rpx; box-sizing: border-box; }
.modal-actions { display: flex; gap: 16rpx; margin-top: 20rpx; justify-content: flex-end; }
</style> </style>
@@ -0,0 +1,172 @@
<template>
<view class="page">
<view class="header-card">
<text class="title">实名认证</text>
<text class="subtitle">认证后可申请发票</text>
</view>
<view v-if="loading.status" class="loading-wrap">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="cert" class="section">
<view class="status-bar" :class="cert.status">
<text class="status-icon">{{ statusIcon }}</text>
<text class="status-text">{{ statusText }}</text>
<text v-if="cert.reject_reason" class="reject-reason">原因{{ cert.reject_reason }}</text>
</view>
<view class="info-card">
<view class="info-row">
<text class="info-label">认证类型</text>
<text class="info-value">{{ cert.cert_type === 'individual' ? '个人认证' : '企业认证' }}</text>
</view>
<view class="info-row" v-if="cert.personal_name">
<text class="info-label">姓名</text>
<text class="info-value">{{ cert.personal_name }}</text>
</view>
<view class="info-row" v-if="cert.company_name">
<text class="info-label">企业名称</text>
<text class="info-value">{{ cert.company_name }}</text>
</view>
</view>
<view v-if="cert.status === 'rejected'" class="action-bar">
<button class="primary-btn" @click="resetForm">重新提交</button>
</view>
</view>
<view v-else class="section">
<view class="input-group">
<text class="input-label">认证类型</text>
<view class="type-selector">
<text class="type-option" :class="{ active: form.cert_type === 'individual' }" @click="form.cert_type = 'individual'">个人认证</text>
<text class="type-option" :class="{ active: form.cert_type === 'enterprise' }" @click="form.cert_type = 'enterprise'">企业认证</text>
</view>
</view>
<view class="input-group">
<text class="input-label">姓名</text>
<input class="input-field" v-model="form.personal_name" placeholder="输入真实姓名" />
</view>
<view class="input-group">
<text class="input-label">身份证号</text>
<input class="input-field" v-model="form.personal_id" placeholder="输入身份证号码" />
</view>
<view v-if="form.cert_type === 'enterprise'" class="enterprise-fields">
<view class="input-group">
<text class="input-label">企业名称</text>
<input class="input-field" v-model="form.company_name" placeholder="输入营业执照上的企业名称" />
</view>
<view class="input-group">
<text class="input-label">统一社会信用代码</text>
<input class="input-field" v-model="form.tax_id" placeholder="输入税号" />
</view>
</view>
<view class="notice">
<text class="notice-text">提交后预计 1-3 个工作日审核完成</text>
</view>
<button class="primary-btn" :disabled="loading.submit" @click="doSubmit">
<text v-if="loading.submit">提交中...</text>
<text v-else>提交认证</text>
</button>
</view>
</view>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { certificationApi } from '@/utils/api.js'
const loading = reactive({ status: false, submit: false })
const cert = ref(null)
const form = reactive({
cert_type: 'individual',
personal_name: '',
personal_id: '',
company_name: '',
tax_id: '',
})
const statusIcon = computed(() => ({ pending: '⏳', approved: '✅', rejected: '❌' })[cert.value?.status] || '')
const statusText = computed(() => ({ pending: '审核中', approved: '已认证', rejected: '认证未通过' })[cert.value?.status] || '')
async function loadStatus() {
loading.status = true
try {
const res = await certificationApi.status()
if (res.success && res.data) {
cert.value = res.data
form.personal_name = res.data.personal_name || ''
form.company_name = res.data.company_name || ''
}
} catch (e) {
// not certified yet
}
loading.status = false
}
function resetForm() {
cert.value = null
form.cert_type = 'individual'
form.personal_name = ''
form.personal_id = ''
form.company_name = ''
form.tax_id = ''
}
async function doSubmit() {
if (!form.personal_name) { uni.showToast({ title: '请填写姓名', icon: 'none' }); return }
if (!form.personal_id) { uni.showToast({ title: '请填写身份证号', icon: 'none' }); return }
if (form.cert_type === 'enterprise' && !form.company_name) {
uni.showToast({ title: '请填写企业名称', icon: 'none' }); return
}
loading.submit = true
try {
const res = await certificationApi.submit(form)
if (res.success) {
uni.showToast({ title: '提交成功', icon: 'success' })
loadStatus()
}
} catch (e) {
uni.showToast({ title: e.message || '提交失败', icon: 'none' })
}
loading.submit = false
}
onMounted(loadStatus)
</script>
<style>
page { background: #f5f5f5; }
.page { padding: 16px; }
.header-card { background: linear-gradient(135deg, #667eea, #764ba2); border-radius: 12px; padding: 20px; margin-bottom: 16px; }
.header-card .title { font-size: 20px; font-weight: 600; color: #fff; display: block; }
.header-card .subtitle { font-size: 13px; color: rgba(255,255,255,.8); display: block; margin-top: 4px; }
.section { background: #fff; border-radius: 12px; padding: 16px; margin-bottom: 16px; }
.loading-wrap { text-align: center; padding: 40px; }
.loading-text { color: #999; }
.status-bar { text-align: center; padding: 16px; border-radius: 8px; margin-bottom: 16px; }
.status-bar.pending { background: #fff7e6; }
.status-bar.approved { background: #f6ffed; }
.status-bar.rejected { background: #fff2f0; }
.status-icon { font-size: 32px; display: block; }
.status-text { font-size: 16px; font-weight: 500; display: block; margin-top: 4px; }
.reject-reason { font-size: 13px; color: #ff4d4f; display: block; margin-top: 8px; }
.info-card { }
.info-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid #f0f0f0; }
.info-label { color: #666; font-size: 14px; }
.info-value { color: #333; font-size: 14px; font-weight: 500; }
.action-bar { margin-top: 16px; }
.input-group { margin-bottom: 16px; }
.input-label { font-size: 14px; color: #333; display: block; margin-bottom: 6px; font-weight: 500; }
.input-field { width: 100%; height: 44px; border: 1px solid #d9d9d9; border-radius: 8px; padding: 0 12px; font-size: 14px; box-sizing: border-box; }
.type-selector { display: flex; gap: 12px; }
.type-option { flex: 1; text-align: center; padding: 10px; border: 2px solid #d9d9d9; border-radius: 8px; font-size: 14px; }
.type-option.active { border-color: #667eea; color: #667eea; font-weight: 500; }
.notice { background: #fffbe6; border: 1px solid #ffe58f; border-radius: 6px; padding: 10px; margin-bottom: 16px; }
.notice-text { font-size: 13px; color: #ad8b00; }
.primary-btn { width: 100%; height: 44px; background: #667eea; color: #fff; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; border: none; }
.primary-btn:disabled { opacity: .6; }
</style>
+357
View File
@@ -0,0 +1,357 @@
<template>
<view class="discovery-container">
<view class="header-card">
<text class="title">挖掘新客</text>
<text class="subtitle">AI 帮你找到潜在客户并生成开发信</text>
</view>
<view class="tab-bar">
<view class="tab-item" :class="{ active: activeTab === 'search' }" @click="activeTab = 'search'">
<text class="tab-text">客户挖掘</text>
</view>
<view class="tab-item" :class="{ active: activeTab === 'outreach' }" @click="activeTab = 'outreach'">
<text class="tab-text">开发信生成</text>
</view>
</view>
<view v-if="activeTab === 'search'" class="section">
<view class="input-group">
<text class="input-label">你的产品/服务</text>
<textarea class="input-area" v-model="searchForm.product" placeholder="例如:太阳能电池板 200W 单晶硅" />
</view>
<view class="input-group">
<text class="input-label">目标市场</text>
<input class="input-field" v-model="searchForm.market" placeholder="例如:美国、欧洲、东南亚" />
</view>
<button class="primary-btn" :disabled="loading.search" @click="doSearch">
<text v-if="loading.search">搜索中...</text>
<text v-else>开始挖掘</text>
</button>
<view v-if="searchResult" class="result-area">
<view v-if="searchResult.companies && searchResult.companies.length > 0">
<view class="result-card" v-for="(company, i) in searchResult.companies" :key="'c' + i">
<text class="company-title">{{ company.title }}</text>
<text class="company-url" @click="openUrl(company.url)">{{ company.url }}</text>
<text class="company-snippet" v-if="company.snippet">{{ company.snippet }}</text>
<view class="company-actions" v-if="analysisLoading !== i">
<text class="action-btn-sm" @click="analyzeCompany(company, i)">🔍 分析</text>
<text class="action-btn-sm add" @click="showAddCustomer(company)"> 加入客户</text>
</view>
<view class="company-actions" v-else>
<text class="loading-text">分析中...</text>
</view>
<view v-if="company.analysis" class="analysis-result">
<view class="score-bar">
<text class="score-label">匹配度</text>
<view class="score-track">
<view class="score-fill" :style="{ width: (company.analysis.match_score || 0) + '%' }" />
</view>
<text class="score-val">{{ company.analysis.match_score || '?' }}/100</text>
</view>
<text class="analysis-text" v-if="company.analysis.match_reason">{{ company.analysis.match_reason }}</text>
<view class="contact-row" v-if="company.analysis.contact_info">
<text class="contact-item" v-for="(emails, key) in company.analysis.contact_info" :key="key">
{{ key }}: {{ Array.isArray(emails) ? emails.join(', ') : emails }}
</text>
</view>
</view>
</view>
</view>
<view v-else-if="searchResult.buyer_personas">
<view class="strategy-card">
<text class="section-label">由于搜索服务未配置以下是 AI 推荐的发现策略</text>
</view>
<view class="result-card" v-for="(persona, i) in searchResult.buyer_personas" :key="'p' + i">
<text class="result-title">{{ persona.type }}</text>
<text class="result-desc">{{ persona.description }}</text>
<view class="tag-row">
<text class="tag" v-for="(ch, ci) in persona.channels" :key="'c' + ci">{{ ch }}</text>
</view>
<view class="query-box" v-if="persona.search_queries">
<text class="query-label">搜索关键词</text>
<text class="query-text" v-for="(q, qi) in persona.search_queries" :key="'q' + qi">{{ q }}</text>
</view>
</view>
<view class="strategy-card" v-if="searchResult.strategy">
<text class="strategy-title">策略建议</text>
<text class="strategy-text">{{ searchResult.strategy }}</text>
</view>
<view class="tips-card" v-if="searchResult.tips">
<text class="tips-title">💡 实用建议</text>
<text class="tip-item" v-for="(tip, ti) in searchResult.tips" :key="'t' + ti">{{ ti + 1 }}. {{ tip }}</text>
</view>
</view>
</view>
</view>
<view v-if="activeTab === 'outreach'" class="section">
<view class="input-group">
<text class="input-label">目标公司名称</text>
<input class="input-field" v-model="outreachForm.company" placeholder="例如:ABC Trading Co." />
</view>
<view class="input-group">
<text class="input-label">公司简介可选</text>
<textarea class="input-area" v-model="outreachForm.companyDesc" placeholder="对方的业务范围、主营产品等" />
</view>
<view class="input-group">
<text class="input-label">你的产品名称</text>
<input class="input-field" v-model="outreachForm.product" placeholder="例如:太阳能电池板" />
</view>
<view class="input-group">
<text class="input-label">产品优势可选</text>
<textarea class="input-area" v-model="outreachForm.productAdv" placeholder="价格优势、品质认证、交期快等" />
</view>
<button class="primary-btn" :disabled="loading.outreach" @click="doOutreach">
<text v-if="loading.outreach">生成中...</text>
<text v-else>生成开发信</text>
</button>
<view v-if="outreachResult" class="result-area">
<view class="outreach-card" v-if="outreachResult.email_body">
<text class="outreach-label">📧 邮件正文</text>
<text class="outreach-content" style="white-space: pre-line">{{ outreachResult.email_body }}</text>
<button class="copy-btn" @click="copyText(outreachResult.email_body)">复制</button>
</view>
<view class="outreach-card" v-if="outreachResult.linkedin_message">
<text class="outreach-label">💼 LinkedIn 私信</text>
<text class="outreach-content">{{ outreachResult.linkedin_message }}</text>
<button class="copy-btn" @click="copyText(outreachResult.linkedin_message)">复制</button>
</view>
<view class="outreach-card" v-if="outreachResult.whatsapp_message">
<text class="outreach-label">📱 WhatsApp 消息</text>
<text class="outreach-content">{{ outreachResult.whatsapp_message }}</text>
<button class="copy-btn" @click="copyText(outreachResult.whatsapp_message)">复制</button>
</view>
<view class="tips-card" v-if="outreachResult.tips">
<text class="tips-title">💡 发送建议</text>
<text class="tip-item" v-for="(tip, ti) in outreachResult.tips" :key="'ot' + ti">{{ ti + 1 }}. {{ tip }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, reactive } from 'vue'
const activeTab = ref('search')
const searchForm = reactive({
product: '',
market: 'US',
})
const outreachForm = reactive({
company: '',
companyDesc: '',
product: '',
productAdv: '',
})
const loading = reactive({ search: false, outreach: false })
const analysisLoading = ref(-1)
const searchResult = ref(null)
const outreachResult = ref(null)
const apiBase = '/api/v1'
async function doSearch() {
if (!searchForm.product.trim()) {
uni.showToast({ title: '请填写产品描述', icon: 'none' })
return
}
loading.search = true
searchResult.value = null
try {
const res = await uni.request({
url: `${apiBase}/discovery/search`,
method: 'POST',
data: {
product_description: searchForm.product,
target_market: searchForm.market,
},
})
const body = res.data
if (body.success && body.data) {
searchResult.value = body.data
} else {
uni.showToast({ title: '搜索失败,请重试', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '网络错误', icon: 'none' })
} finally {
loading.search = false
}
}
async function analyzeCompany(company, index) {
analysisLoading.value = index
try {
const res = await uni.request({
url: `${apiBase}/discovery/analyze`,
method: 'POST',
data: {
company_url: company.url,
product_description: searchForm.product,
},
})
const body = res.data
if (body.success && body.data) {
company.analysis = body.data
}
} catch (e) {
uni.showToast({ title: '分析失败', icon: 'none' })
} finally {
analysisLoading.value = -1
}
}
function showAddCustomer(company) {
uni.showModal({
title: '加入客户列表',
content: `将「${company.title}」添加到客户管理?`,
success: async (res) => {
if (res.confirm) {
try {
const token = uni.getStorageSync('token')
await uni.request({
url: `${apiBase}/customers`,
method: 'POST',
header: { Authorization: `Bearer ${token}` },
data: {
name: company.title,
company: company.title,
website: company.url,
notes: company.snippet || '',
status: 'potential',
},
})
uni.showToast({ title: '已加入客户列表', icon: 'success' })
} catch (e) {
uni.showToast({ title: '添加失败,请手动添加', icon: 'none' })
}
}
},
})
}
function openUrl(url) {
uni.setClipboardData({
data: url,
success: () => uni.showToast({ title: '网址已复制', icon: 'success' }),
})
}
async function doOutreach() {
if (!outreachForm.company.trim()) {
uni.showToast({ title: '请填写目标公司名称', icon: 'none' })
return
}
if (!outreachForm.product.trim()) {
uni.showToast({ title: '请填写你的产品名称', icon: 'none' })
return
}
loading.outreach = true
outreachResult.value = null
try {
const res = await uni.request({
url: `${apiBase}/discovery/outreach`,
method: 'POST',
data: {
company: {
name: outreachForm.company,
description: outreachForm.companyDesc,
},
product: {
name: outreachForm.product,
advantages: outreachForm.productAdv,
},
},
})
const body = res.data
if (body.success && body.data) {
outreachResult.value = body.data
} else {
uni.showToast({ title: '生成失败,请重试', icon: 'none' })
}
} catch (e) {
uni.showToast({ title: '网络错误', icon: 'none' })
} finally {
loading.outreach = false
}
}
function copyText(text) {
uni.setClipboardData({
data: text,
success: () => uni.showToast({ title: '已复制', icon: 'success' }),
})
}
</script>
<style>
.discovery-container { padding: 30rpx; }
.header-card { margin-bottom: 30rpx; }
.header-card .title { font-size: 36rpx; font-weight: 700; color: #333; }
.header-card .subtitle { font-size: 24rpx; color: #999; margin-top: 8rpx; display: block; }
.tab-bar { display: flex; background: #f5f5f5; border-radius: 12rpx; padding: 4rpx; margin-bottom: 30rpx; }
.tab-item { flex: 1; text-align: center; padding: 16rpx 0; border-radius: 10rpx; }
.tab-item.active { background: #fff; box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.06); }
.tab-text { font-size: 26rpx; color: #666; }
.tab-item.active .tab-text { color: #1890ff; font-weight: 600; }
.section { margin-bottom: 30rpx; }
.input-group { margin-bottom: 24rpx; }
.input-label { font-size: 26rpx; color: #333; font-weight: 500; margin-bottom: 10rpx; display: block; }
.input-area { width: 100%; min-height: 120rpx; background: #fff; border: 2rpx solid #e8e8e8; border-radius: 12rpx; padding: 20rpx; font-size: 26rpx; color: #333; box-sizing: border-box; }
.input-field { width: 100%; height: 72rpx; background: #fff; border: 2rpx solid #e8e8e8; border-radius: 12rpx; padding: 0 20rpx; font-size: 26rpx; color: #333; box-sizing: border-box; }
.primary-btn { width: 100%; height: 80rpx; background: #1890ff; color: #fff; font-size: 28rpx; font-weight: 600; border-radius: 12rpx; display: flex; align-items: center; justify-content: center; border: none; margin-top: 10rpx; }
.primary-btn[disabled] { opacity: 0.6; }
.result-area { margin-top: 30rpx; }
.company-title { font-size: 28rpx; font-weight: 600; color: #333; display: block; margin-bottom: 6rpx; }
.company-url { font-size: 22rpx; color: #1890ff; display: block; margin-bottom: 8rpx; }
.company-snippet { font-size: 24rpx; color: #666; line-height: 1.5; display: block; margin-bottom: 14rpx; }
.company-actions { display: flex; gap: 14rpx; margin-bottom: 12rpx; }
.action-btn-sm { font-size: 24rpx; color: #1890ff; background: #eaf4ff; padding: 8rpx 20rpx; border-radius: 8rpx; }
.action-btn-sm.add { color: #52c41a; background: #f0fff0; }
.loading-text { font-size: 24rpx; color: #999; }
.analysis-result { background: #f9f9f9; border-radius: 10rpx; padding: 18rpx; margin-top: 8rpx; }
.score-bar { display: flex; align-items: center; gap: 10rpx; margin-bottom: 10rpx; }
.score-label { font-size: 22rpx; color: #666; flex-shrink: 0; }
.score-track { flex: 1; height: 12rpx; background: #eee; border-radius: 6rpx; overflow: hidden; }
.score-fill { height: 100%; background: #1890ff; border-radius: 6rpx; transition: width 0.3s; }
.score-val { font-size: 22rpx; color: #1890ff; font-weight: 600; flex-shrink: 0; }
.analysis-text { font-size: 22rpx; color: #666; display: block; margin-bottom: 8rpx; }
.contact-row { display: flex; flex-wrap: wrap; gap: 6rpx; }
.contact-item { font-size: 20rpx; color: #999; background: #fff; padding: 4rpx 10rpx; border-radius: 4rpx; }
.section-label { font-size: 24rpx; color: #fa8c16; background: #fff7e6; padding: 12rpx 18rpx; border-radius: 8rpx; display: block; margin-bottom: 16rpx; }
.result-card { background: #fff; border-radius: 16rpx; padding: 28rpx; margin-bottom: 20rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04); }
.result-title { font-size: 28rpx; font-weight: 600; color: #1890ff; margin-bottom: 8rpx; display: block; }
.result-desc { font-size: 24rpx; color: #666; line-height: 1.6; display: block; margin-bottom: 14rpx; }
.tag-row { display: flex; flex-wrap: wrap; gap: 10rpx; margin-bottom: 12rpx; }
.tag { font-size: 22rpx; color: #1890ff; background: #eaf4ff; padding: 6rpx 16rpx; border-radius: 6rpx; }
.query-box { background: #f9f9f9; border-radius: 8rpx; padding: 14rpx; }
.query-label { font-size: 22rpx; color: #999; display: block; margin-bottom: 6rpx; }
.query-text { font-size: 22rpx; color: #333; background: #fff; padding: 4rpx 12rpx; border-radius: 4rpx; margin: 4rpx 6rpx 4rpx 0; display: inline-block; border: 1rpx solid #e8e8e8; }
.strategy-card, .tips-card, .outreach-card { background: #fff; border-radius: 16rpx; padding: 28rpx; margin-bottom: 20rpx; box-shadow: 0 2rpx 12rpx rgba(0,0,0,0.04); }
.strategy-title, .tips-title { font-size: 26rpx; font-weight: 600; color: #333; display: block; margin-bottom: 12rpx; }
.strategy-text { font-size: 24rpx; color: #666; line-height: 1.6; display: block; }
.tip-item { font-size: 24rpx; color: #666; line-height: 1.8; display: block; }
.outreach-label { font-size: 26rpx; font-weight: 600; color: #333; display: block; margin-bottom: 12rpx; }
.outreach-content { font-size: 24rpx; color: #444; line-height: 1.7; display: block; margin-bottom: 14rpx; }
.copy-btn { font-size: 22rpx; color: #1890ff; background: #eaf4ff; border: none; border-radius: 6rpx; padding: 8rpx 24rpx; }
</style>
+38 -16
View File
@@ -136,6 +136,10 @@
<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)">
<text class="more-icon">🔍</text>
<text class="more-text">挖掘新客</text>
</view>
<view class="more-item" @click="hasLogin ? goToPage(PAGES.PRODUCT) : goToLogin()"> <view class="more-item" @click="hasLogin ? goToPage(PAGES.PRODUCT) : goToLogin()">
<text class="more-icon">📦</text> <text class="more-icon">📦</text>
<text class="more-text">产品库</text> <text class="more-text">产品库</text>
@@ -166,10 +170,6 @@
<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" v-if="isAdmin" @click="goToPage(PAGES.ADMIN)">
<text class="more-icon"></text>
<text class="more-text">管理</text>
</view>
<view class="more-item" @click="showWechatModal = true"> <view class="more-item" @click="showWechatModal = true">
<text class="more-icon">💁</text> <text class="more-icon">💁</text>
<text class="more-text">联系客服</text> <text class="more-text">联系客服</text>
@@ -277,7 +277,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, onUnmounted } from 'vue' import { ref, onUnmounted } from 'vue'
import { onShow } from '@dcloudio/uni-app' import { onShow } from '@dcloudio/uni-app'
import { authApi, customerApi, analyticsApi, onboardingApi, notificationApi, followupApi, translateApi, BASE_URL } from '@/utils/api.js' import { authApi, customerApi, analyticsApi, onboardingApi, notificationApi, followupApi, translateApi, BASE_URL } from '@/utils/api.js'
import AiAssistant from '@/components/ai-assistant.vue' import AiAssistant from '@/components/ai-assistant.vue'
@@ -292,14 +292,12 @@ const announcements = [
] ]
let announcementTimer = null let announcementTimer = null
const hasLogin = computed(() => { const hasLogin = ref(false)
const token = uni.getStorageSync('token') function checkLogin() {
const isGuest = uni.getStorageSync('isGuest') const token = uni.getStorageSync(STORAGE_KEYS.TOKEN)
return !!token && !isGuest const isGuest = uni.getStorageSync(STORAGE_KEYS.IS_GUEST)
}) hasLogin.value = !!token && !isGuest
const isAdmin = computed(() => { }
return hasLogin.value && userInfo.value?.role === 'admin'
})
const userInfo = ref(null) const userInfo = ref(null)
const stats = ref({ const stats = ref({
customers: 0, customers: 0,
@@ -331,9 +329,8 @@ onShow(() => {
currentAnnouncement.value = (currentAnnouncement.value + 1) % announcements.length currentAnnouncement.value = (currentAnnouncement.value + 1) % announcements.length
}, 4000) }, 4000)
const token = uni.getStorageSync('token') checkLogin()
const isGuest = uni.getStorageSync('isGuest') if (hasLogin.value) {
if (token && !isGuest) {
uni.setStorageSync(STORAGE_KEYS.IS_GUEST, false) uni.setStorageSync(STORAGE_KEYS.IS_GUEST, false)
loadData() loadData()
checkOnboarding() checkOnboarding()
@@ -449,6 +446,24 @@ const goToLogin = () => {
}) })
} }
function handleLogout() {
uni.showModal({
title: '退出登录',
content: '确定退出当前账号?',
success: (res) => {
if (res.confirm) {
uni.removeStorageSync(STORAGE_KEYS.TOKEN)
uni.removeStorageSync(STORAGE_KEYS.REFRESH_TOKEN)
uni.removeStorageSync(STORAGE_KEYS.USER_INFO)
uni.removeStorageSync(STORAGE_KEYS.HAS_LOGIN)
uni.removeStorageSync(STORAGE_KEYS.IS_GUEST)
hasLogin.value = false
uni.switchTab({ url: PAGES.INDEX })
}
},
})
}
const doQuickTranslate = async () => { const doQuickTranslate = async () => {
if (!quickTranslateText.value.trim()) return if (!quickTranslateText.value.trim()) return
try { try {
@@ -1237,4 +1252,11 @@ const playTryResult = () => {
color: #bbb; color: #bbb;
display: block; display: block;
} }
/* On desktop, hide the feature matrix section (items are in the sidebar) */
@media (min-width: 1024px) {
.more-section {
display: none !important;
}
}
</style> </style>
+165
View File
@@ -0,0 +1,165 @@
<template>
<view class="page">
<view class="header-card">
<text class="title">发票管理</text>
<text class="subtitle">申请开票与开票记录</text>
</view>
<view class="section">
<view class="tab-bar">
<text class="tab-item" :class="{ active: tab === 'apply' }" @click="tab = 'apply'">申请开票</text>
<text class="tab-item" :class="{ active: tab === 'history' }" @click="tab = 'history'; loadInvoices()">开票记录</text>
</view>
</view>
<view v-if="tab === 'apply'" class="section">
<view class="input-group">
<text class="input-label">发票类型</text>
<view class="type-selector">
<text class="type-option" :class="{ active: form.invoice_type === 'individual' }" @click="form.invoice_type = 'individual'">个人发票</text>
<text class="type-option" :class="{ active: form.invoice_type === 'enterprise' }" @click="form.invoice_type = 'enterprise'">企业发票</text>
</view>
</view>
<view class="input-group">
<text class="input-label">发票抬头</text>
<input class="input-field" v-model="form.title" :placeholder="form.invoice_type === 'individual' ? '输入您的姓名' : '输入企业名称'" />
</view>
<view class="input-group" v-if="form.invoice_type === 'enterprise'">
<text class="input-label">税号</text>
<input class="input-field" v-model="form.tax_id" placeholder="输入统一社会信用代码" />
</view>
<view class="input-group">
<text class="input-label">开票金额</text>
<input class="input-field" v-model="form.amount" type="number" placeholder="输入开票金额" />
</view>
<view class="notice">
<text class="notice-text">个人发票需先完成个人实名认证企业发票需先完成企业认证</text>
</view>
<button class="primary-btn" :disabled="loading.apply" @click="doApply">
<text v-if="loading.apply">提交中...</text>
<text v-else>提交开票申请</text>
</button>
</view>
<view v-else class="section">
<view v-if="loading.history" class="loading-wrap">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="invoices.length === 0" class="empty-wrap">
<text class="empty-text">暂无开票记录</text>
</view>
<view v-else class="invoice-list">
<view class="invoice-item" v-for="inv in invoices" :key="inv.id">
<view class="inv-header">
<text class="inv-type">{{ inv.invoice_type === 'individual' ? '个人' : '企业' }}发票</text>
<text class="inv-status" :class="inv.status">{{ statusLabel(inv.status) }}</text>
</view>
<view class="inv-body">
<text class="inv-title">{{ inv.title }}</text>
<text class="inv-amount">¥{{ inv.amount }}</text>
</view>
<text v-if="inv.reject_reason" class="inv-reject">驳回原因{{ inv.reject_reason }}</text>
<text class="inv-date">{{ inv.created_at ? inv.created_at.substring(0, 10) : '' }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { invoiceApi } from '@/utils/api.js'
const tab = ref('apply')
const loading = reactive({ apply: false, history: false })
const invoices = ref([])
const form = reactive({
invoice_type: 'individual',
title: '',
tax_id: '',
amount: '',
})
function statusLabel(s) {
return { pending: '待开票', issued: '已开票', rejected: '已驳回' }[s] || s
}
async function doApply() {
if (!form.title) { uni.showToast({ title: '请填写发票抬头', icon: 'none' }); return }
if (!form.amount || parseFloat(form.amount) <= 0) { uni.showToast({ title: '请填写有效金额', icon: 'none' }); return }
if (form.invoice_type === 'enterprise' && !form.tax_id) { uni.showToast({ title: '请填写税号', icon: 'none' }); return }
loading.apply = true
try {
const res = await invoiceApi.apply({
invoice_type: form.invoice_type,
title: form.title,
tax_id: form.tax_id || undefined,
amount: parseFloat(form.amount),
})
if (res.success) {
uni.showToast({ title: '申请成功', icon: 'success' })
form.title = ''; form.tax_id = ''; form.amount = ''
tab.value = 'history'
loadInvoices()
}
} catch (e) {
uni.showToast({ title: e.message || '提交失败', icon: 'none' })
}
loading.apply = false
}
async function loadInvoices() {
loading.history = true
try {
const res = await invoiceApi.list()
if (res.success) invoices.value = res.data || []
} catch (e) { invoices.value = [] }
loading.history = false
}
onMounted(() => {})
</script>
<style>
page { background: #f5f5f5; }
.page { padding: 16px; }
.header-card { background: linear-gradient(135deg, #52c41a, #1890ff); border-radius: 12px; padding: 20px; margin-bottom: 16px; }
.header-card .title { font-size: 20px; font-weight: 600; color: #fff; display: block; }
.header-card .subtitle { font-size: 13px; color: rgba(255,255,255,.8); display: block; margin-top: 4px; }
.section { background: #fff; border-radius: 12px; padding: 16px; margin-bottom: 16px; }
.tab-bar { display: flex; gap: 0; }
.tab-item { flex: 1; text-align: center; padding: 10px; font-size: 15px; color: #666; border-bottom: 2px solid transparent; }
.tab-item.active { color: #1890ff; border-bottom-color: #1890ff; font-weight: 500; }
.input-group { margin-bottom: 16px; }
.input-label { font-size: 14px; color: #333; display: block; margin-bottom: 6px; font-weight: 500; }
.input-field { width: 100%; height: 44px; border: 1px solid #d9d9d9; border-radius: 8px; padding: 0 12px; font-size: 14px; box-sizing: border-box; }
.type-selector { display: flex; gap: 12px; }
.type-option { flex: 1; text-align: center; padding: 10px; border: 2px solid #d9d9d9; border-radius: 8px; font-size: 14px; }
.type-option.active { border-color: #1890ff; color: #1890ff; font-weight: 500; }
.notice { background: #fffbe6; border: 1px solid #ffe58f; border-radius: 6px; padding: 10px; margin-bottom: 16px; }
.notice-text { font-size: 13px; color: #ad8b00; }
.primary-btn { width: 100%; height: 44px; background: #1890ff; color: #fff; border-radius: 8px; display: flex; align-items: center; justify-content: center; font-size: 16px; border: none; }
.primary-btn:disabled { opacity: .6; }
.loading-wrap { text-align: center; padding: 40px; }
.loading-text { color: #999; }
.empty-wrap { text-align: center; padding: 40px; }
.empty-text { color: #999; }
.invoice-list { }
.invoice-item { padding: 12px 0; border-bottom: 1px solid #f0f0f0; }
.inv-header { display: flex; justify-content: space-between; margin-bottom: 6px; }
.inv-type { font-size: 13px; color: #666; }
.inv-status { font-size: 12px; padding: 2px 8px; border-radius: 4px; }
.inv-status.pending { background: #fff7e6; color: #d46b08; }
.inv-status.issued { background: #f6ffed; color: #389e0d; }
.inv-status.rejected { background: #fff2f0; color: #cf1322; }
.inv-body { display: flex; justify-content: space-between; }
.inv-title { font-size: 15px; color: #333; font-weight: 500; flex: 1; }
.inv-amount { font-size: 16px; color: #f5222d; font-weight: 600; }
.inv-reject { font-size: 12px; color: #cf1322; display: block; margin-top: 4px; }
.inv-date { font-size: 12px; color: #999; display: block; margin-top: 4px; }
</style>
+16
View File
@@ -28,6 +28,20 @@
</view> </view>
</view> </view>
<view class="section" v-if="user.tier !== 'guest'">
<view class="section-title">认证与发票</view>
<view class="menu-item" @click="goCertification">
<text class="menu-icon">🪪</text>
<text class="menu-text">实名认证</text>
<text class="menu-arrow"></text>
</view>
<view class="menu-item" @click="goInvoice">
<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" @click="goFeedback"> <view class="menu-item" @click="goFeedback">
@@ -174,6 +188,8 @@ const goLogin = () => uni.navigateTo({ url: PAGES.LOGIN })
const goUpgrade = () => uni.navigateTo({ url: PAGES.UPGRADE }) const goUpgrade = () => uni.navigateTo({ url: PAGES.UPGRADE })
const goFeedback = () => uni.navigateTo({ url: PAGES.FEEDBACK }) 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 goInvoice = () => uni.navigateTo({ url: PAGES.INVOICE })
const logout = () => { const logout = () => {
uni.showModal({ uni.showModal({
+24
View File
@@ -183,6 +183,20 @@ export const adminApi = {
request(`/admin/translation-quotas/${encodeURIComponent(version)}`, 'PUT', data), request(`/admin/translation-quotas/${encodeURIComponent(version)}`, 'PUT', data),
resetTranslationQuota: (version) => resetTranslationQuota: (version) =>
request(`/admin/translation-quotas/${encodeURIComponent(version)}/reset`, 'POST'), request(`/admin/translation-quotas/${encodeURIComponent(version)}/reset`, 'POST'),
listCertifications: (page = 1, size = 20, status) => {
let url = `/admin/certifications?page=${page}&size=${size}`
if (status) url += `&status=${status}`
return request(url)
},
reviewCertification: (id, action, reason) =>
request(`/admin/certifications/${id}/review`, 'POST', { action, reason }),
listInvoices: (page = 1, size = 20, status) => {
let url = `/admin/invoices?page=${page}&size=${size}`
if (status) url += `&status=${status}`
return request(url)
},
processInvoice: (id, action, reason) =>
request(`/admin/invoices/${id}/process`, 'POST', { action, reason }),
} }
export const aiChatApi = { export const aiChatApi = {
@@ -303,6 +317,16 @@ export const whatsappApi = {
request('/whatsapp/send', 'POST', { to, text, template_name: templateName, template_params: templateParams, media_url: mediaUrl, media_type: mediaType }), request('/whatsapp/send', 'POST', { to, text, template_name: templateName, template_params: templateParams, media_url: mediaUrl, media_type: mediaType }),
} }
export const certificationApi = {
submit: (data) => request('/certification/submit', 'POST', data),
status: () => request('/certification/status'),
}
export const invoiceApi = {
apply: (data) => request('/invoices/apply', 'POST', data),
list: () => request('/invoices/list'),
}
export const customerApi = { export const customerApi = {
list: (page = 1, size = 20, status) => { list: (page = 1, size = 20, status) => {
let params = `page=${page}&size=${size}` let params = `page=${page}&size=${size}`
+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 }
}
}
})