Add admin-frontend and user-frontend standalone projects, certification/invoice/discovery features, fix auth header and theme consistency
This commit is contained in:
+10
-2
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
Generated
+1961
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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 外贸小助手 © {{ 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>
|
||||||
@@ -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')
|
||||||
@@ -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
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 外贸小助手 © {{ 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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 ###
|
||||||
@@ -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}"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -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)}")
|
||||||
@@ -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}
|
||||||
@@ -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
@@ -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__":
|
||||||
|
|||||||
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -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 []
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Generated
+757
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"puppeteer": "^25.0.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-199
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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}`
|
||||||
|
|||||||
@@ -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>
|
||||||
Generated
+1961
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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 外贸小助手 © {{ 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>
|
||||||
@@ -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')
|
||||||
@@ -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
|
||||||
@@ -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 }
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user