chore: post-deployment cleanup and docs update
- Make AI routing rules DB-driven (read from system_configs, removed from config.py) - Add translation quota tracking to LLM translation (OpenAIProvider) - Add Alibaba MT ECS RAM role support (STS token, no AccessKey needed) - Fix admin sidebar link for AI模型配置 page - Fix Quota.vue API path (quotas → translation-quotas) - Fix login auto-redirect to dashboard - Add provider dropdown selects to AI routing config UI - Clean up stale ai_provider_* system_configs records - Remove OpencodeGo, Spark providers (code + DB) - Update deploy config: nginx port 8000, systemd cwd
This commit is contained in:
@@ -1,20 +1,31 @@
|
|||||||
# TradeMate (外贸小助手) — Agent Guide
|
# TradeMate (外贸小助手) — Agent Guide
|
||||||
|
|
||||||
|
## AI Assistant (Frontend AI Chatbot)
|
||||||
|
|
||||||
|
- **Components**: `user-frontend/src/components/AiAssistant.vue` + `admin-frontend/src/components/AiAssistant.vue`
|
||||||
|
- **Backend**: `backend/app/api/v1/ai_assistant.py` — `POST /api/v1/ai/chat`, `GET /api/v1/ai/quick-questions`
|
||||||
|
- **Action types** (configurable via `ACTION_INSTRUCTIONS`): create_customer, create_product, create_quotation, scan_followups, generate_marketing, discovery_search, navigate, search_users, update_user, update_config, review_certification, process_invoice
|
||||||
|
- **Frontend action dispatch**: `AiAssistant.vue` switch/case calls corresponding API from `@/api`
|
||||||
|
- **Layout integration**: `<AiAssistant />` in `UserLayout.vue` + `AdminLayout.vue`, floating button bottom-right
|
||||||
|
- **Quick questions**: configurable via `ai_assistant_quick_questions` `SystemConfig` key
|
||||||
|
- **System prompt**: configurable via `ai_assistant_prompt` `SystemConfig` key
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **Backend**: `backend/` — FastAPI + SQLAlchemy 1.4 async + asyncpg, single `app.main:app`
|
- **Backend**: `backend/` — FastAPI + SQLAlchemy 1.4 async + asyncpg, single `app.main:app`
|
||||||
- **Frontends**: `uni-app/` (mobile H5/mini-program), `admin-frontend/` (PC admin), `user-frontend/` (PC workspace)
|
- **Frontends**: `uni-app/` (mobile H5/mini-program), `admin-frontend/` (PC admin), `user-frontend/` (PC workspace)
|
||||||
- **Config**: `backend/app/config.py` reads from `/.env` (project root) via pydantic BaseSettings
|
- **Config**: `backend/app/config.py` reads from `/.env` (project root) via pydantic BaseSettings
|
||||||
- **Auth**: JWT (python-jose). Default dep `get_current_user_id` in `backend/app/api/v1/deps.py`
|
- **Auth**: JWT (python-jose). Default dep `get_current_user_id` in `backend/app/api/v1/deps.py`
|
||||||
- **AI Router**: `backend/app/ai/router.py` — singleton `AIRouter`, DB-driven providers. Primary = sensenova, fallbacks = alibaba-mt / opencode_go / nvidia / spark
|
- **AI Router**: `backend/app/ai/router.py` — singleton `AIRouter`, DB-driven providers + DB-driven routing rules. Routing reads `ai_routing` from `system_configs` table.
|
||||||
- **Database**: PostgreSQL via `asyncpg`, pool_size=20
|
- **Database**: PostgreSQL via `asyncpg`, pool_size=20
|
||||||
|
|
||||||
## AI Providers
|
## AI Providers
|
||||||
|
|
||||||
- **Active**: Sensenova (商汤), OpencodeGo, NVIDIA, 讯飞 Spark, 阿里机器翻译 (alibaba-mt)
|
- **Active**: Sensenova (商汤), NVIDIA, 阿里机器翻译 (alibaba-mt) — 5 providers in DB
|
||||||
- **Removed (dead code)**: Claude (`claude.py`), DeepL (`deepl.py`), Local (`local.py`) — git rm'd, not yet committed
|
- **Removed**: Claude, DeepL, Local, OpencodeGo, 讯飞 Spark — all git rm'd
|
||||||
- **DB-driven**: `AIProvider` model + `admin_ai.py` API — manage providers at runtime. `router.seed_from_env()` loads from `.env` on startup
|
- **DB-driven**: `AIProvider` model + `admin_ai.py` API — manage providers at runtime. `router.seed_from_env()` loads from `.env` on startup
|
||||||
- **Provider type mapping** in `router.py._build_provider()`: sensenova, opencode_go, nvidia, spark, alibaba-mt
|
- **ECS RAM role**: 阿里翻译使用 ECS 实例 RAM 角色 `trademate-translate` 获取 STS 临时凭证
|
||||||
|
- **Provider type mapping** in `router.py._build_provider()`: sensenova, nvidia, alibaba-mt
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
@@ -32,7 +43,10 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Backend (from project root — .env is there)
|
# Backend (from project root — .env is there)
|
||||||
|
# Dev: matches Vite proxy (both frontends proxy /api → localhost:8000)
|
||||||
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
|
||||||
|
# Production: nginx proxies /api/ → localhost:8002
|
||||||
|
cd backend && source venv/bin/activate && uvicorn app.main:app --port 8002
|
||||||
|
|
||||||
# Mobile H5
|
# Mobile H5
|
||||||
cd uni-app && npm run dev:h5
|
cd uni-app && npm run dev:h5
|
||||||
@@ -66,7 +80,7 @@ alembic revision --autogenerate -m "desc"
|
|||||||
- **Workspace**: `trade.yuzhiran.com/workspace/` — Vue 3 + Element Plus (standalone)
|
- **Workspace**: `trade.yuzhiran.com/workspace/` — Vue 3 + Element Plus (standalone)
|
||||||
- **Nginx**: SPA fallbacks for `/app/`, `/admin/`, `/workspace/`
|
- **Nginx**: SPA fallbacks for `/app/`, `/admin/`, `/workspace/`
|
||||||
- **vite config**: each project has its own `base` path and dev port
|
- **vite config**: each project has its own `base` path and dev port
|
||||||
- **API**: proxied via nginx `location /api/` to `127.0.0.1:8002`
|
- **API**: proxied via nginx `location /api/` to `127.0.0.1:8000`
|
||||||
|
|
||||||
## Critical Quirks
|
## Critical Quirks
|
||||||
|
|
||||||
@@ -80,6 +94,14 @@ alembic revision --autogenerate -m "desc"
|
|||||||
- **AI Router reload**: After modifying AI providers via admin API, call `POST /api/v1/admin/ai/reload` to refresh in-memory providers.
|
- **AI Router reload**: After modifying AI providers via admin API, call `POST /api/v1/admin/ai/reload` to refresh in-memory providers.
|
||||||
- **Payment**: Uses unified `pay-api` gateway (`UnifiedPayService`). NOT direct WeChat/Alipay integration. Credentials: `PAY_API_KEY`/`PAY_API_SECRET` from `.env`. HMAC-SHA256 auth. Webhook at `POST /api/v1/payment/webhook` skips CSRF.
|
- **Payment**: Uses unified `pay-api` gateway (`UnifiedPayService`). NOT direct WeChat/Alipay integration. Credentials: `PAY_API_KEY`/`PAY_API_SECRET` from `.env`. HMAC-SHA256 auth. Webhook at `POST /api/v1/payment/webhook` skips CSRF.
|
||||||
- **Payment gateway config**: `PAY_API_KEY`, `PAY_API_SECRET`, `PAY_API_BASE_URL`, `PAY_WEBHOOK_URL` in `config.py`. `pay_type` param: `"alipay"` (returns `pay_url`) or `"wechat"` (returns `code_url`). `UnifiedPayService` normalizes legacy `native`/`jsapi`/`pc` to `wechat`/`alipay`.
|
- **Payment gateway config**: `PAY_API_KEY`, `PAY_API_SECRET`, `PAY_API_BASE_URL`, `PAY_WEBHOOK_URL` in `config.py`. `pay_type` param: `"alipay"` (returns `pay_url`) or `"wechat"` (returns `code_url`). `UnifiedPayService` normalizes legacy `native`/`jsapi`/`pc` to `wechat`/`alipay`.
|
||||||
|
- **Manual auth on some endpoints**: `keywords` and `competitor-analysis` endpoints use `authorization: str = Header(None)` instead of `Depends(get_current_user_id)`.
|
||||||
|
- **MarketingService fallback**: When no AI providers initialized, returns template content instead of crashing.
|
||||||
|
- **Onboarding service**: calls `mkt.generate(product_info={"name": ..., ...})`, not keyword args. Check `onboarding.py` for the exact dict shape.
|
||||||
|
- **CustomerHealthService**: `get_health_overview` endpoint must use `CustomerHealthService(db)` not `CustomerService(db)`.
|
||||||
|
- **CSRF**: Sensitive endpoints (auth/payment/profile) require `X-CSRF-Token` header. Token available via `csrf_token` cookie / `X-CSRF-Token` response header.
|
||||||
|
- **AI Router reload**: After modifying AI providers via admin API, call `POST /api/v1/admin/ai/reload` to refresh in-memory providers.
|
||||||
|
- **Payment**: Uses unified `pay-api` gateway (`UnifiedPayService`). NOT direct WeChat/Alipay integration. Credentials: `PAY_API_KEY`/`PAY_API_SECRET` from `.env`. HMAC-SHA256 auth. Webhook at `POST /api/v1/payment/webhook` skips CSRF.
|
||||||
|
- **Payment gateway config**: `PAY_API_KEY`, `PAY_API_SECRET`, `PAY_API_BASE_URL`, `PAY_WEBHOOK_URL` in `config.py`. `pay_type` param: `"alipay"` (returns `pay_url`) or `"wechat"` (returns `code_url`). `UnifiedPayService` normalizes legacy `native`/`jsapi`/`pc` to `wechat`/`alipay`.
|
||||||
|
|
||||||
## Project Conventions
|
## Project Conventions
|
||||||
|
|
||||||
|
|||||||
+102
-22
@@ -1,7 +1,7 @@
|
|||||||
# TradeMate (外贸小助手) - 项目进度文档
|
# TradeMate (外贸小助手) - 项目进度文档
|
||||||
|
|
||||||
**更新时间**: 2026-05-29 12:00
|
**更新时间**: 2026-06-02 12:00
|
||||||
**状态**: ✅ 生产分支活跃开发 — 安全加固完成 + 4 前端项目并行 + 客户挖掘+AI 模型管理
|
**状态**: ✅ 生产环境运行中 — AI 路由 DB 驱动 + 翻译配额全链路 + ECS RAM 角色认证
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,9 +11,11 @@
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| 后端 API | http://localhost:8000 | ✅ 运行中 |
|
| 后端 API | http://localhost:8000 | ✅ 运行中 |
|
||||||
| API 文档 | http://localhost:8000/docs | ✅ 可用 |
|
| API 文档 | http://localhost:8000/docs | ✅ 可用 |
|
||||||
| 前端 H5 (uni-app) | http://localhost:5173 | ✅ 运行中 |
|
| 主站 — 营销落地页 | https://trade.yuzhiran.com/ | ✅ 已部署 |
|
||||||
| 管理后台 (admin-frontend) | http://localhost:5173 | ✅ 运行中 |
|
| 主站 — 移动端 H5 | https://trade.yuzhiran.com/app/ | ✅ 已部署 |
|
||||||
| 用户工作台 (user-frontend) | http://localhost:5174 | ✅ 运行中 |
|
| 主站 — 管理后台 | https://trade.yuzhiran.com/admin/ | ✅ 已部署 |
|
||||||
|
| 主站 — 用户工作台 | https://trade.yuzhiran.com/workspace/ | ✅ 已部署 |
|
||||||
|
| 镜像站 | https://www.yuzhiran.com.cn/ | ✅ 同步部署 |
|
||||||
| Redis | localhost:6379 | ✅ 运行中 |
|
| Redis | localhost:6379 | ✅ 运行中 |
|
||||||
| PostgreSQL | localhost:5432 | ✅ 运行中 |
|
| PostgreSQL | localhost:5432 | ✅ 运行中 |
|
||||||
|
|
||||||
@@ -40,21 +42,30 @@
|
|||||||
|------|------|------|
|
|------|------|------|
|
||||||
| **CORS 白名单** | `app/core/middleware.py` | 限制特定前端域名、方法和请求头 |
|
| **CORS 白名单** | `app/core/middleware.py` | 限制特定前端域名、方法和请求头 |
|
||||||
| **速率限制** | `app/core/middleware.py` | 登录 5次/分、注册 3次/时、改密 3次/5分、支付 20次/分、Admin 30次/分 |
|
| **速率限制** | `app/core/middleware.py` | 登录 5次/分、注册 3次/时、改密 3次/5分、支付 20次/分、Admin 30次/分 |
|
||||||
| **CSRF 防护** | `app/core/csrf.py` | 双提交 Cookie 模式中间件;auth/payment/profile 等敏感端点必检;webhook 跳过 |
|
| **CSRF 防护** | `app/core/csrf.py` | 双提交 Cookie 模式中间件;payment/profile 等敏感端点必检;login/register/webhook 跳过 |
|
||||||
| **敏感日志清理** | 全代码审计 | 移除 Token/Key 等敏感信息打印 (T-002) |
|
| **敏感日志清理** | 全代码审计 | 移除 Token/Key 等敏感信息打印 (T-002) |
|
||||||
|
|
||||||
### 3. AI 提供商重构 ✅
|
### 3. AI 提供商重构 ✅
|
||||||
|
|
||||||
| 变更 | 说明 |
|
| 变更 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **移除** | Claude (`claude.py`)、DeepL (`deepl.py`)、本地模型 (`local.py`) |
|
| **移除** | Claude (`claude.py`)、DeepL (`deepl.py`)、本地模型 (`local.py`)、OpencodeGo (`opencode_go.py`)、讯飞 Spark (`spark.py`) |
|
||||||
| **新增** | 阿里机器翻译 (`alibaba.py`)、NVIDIA (`nvidia.py`) |
|
| **当前提供商** | Sensenova (商汤) + NVIDIA + 阿里机器翻译 (ECS RAM 角色) |
|
||||||
| **主提供商** | Sensenova (商汤, `sensenova.py`) |
|
| **DB 驱动配置** | `AIProvider` 模型 + `admin_ai.py` API → 管理后台在线增删改 AI 模型 |
|
||||||
| **Fallback** | OpencodeGo → NVIDIA → 讯飞 Spark |
|
| **路由规则 DB 化** | 从 `config.py` 硬编码改为 `system_configs` 表读取 (key: `ai_routing`),保存后自动重载 |
|
||||||
| **DB 驱动配置** | 新增 `AIProvider` 模型 + `admin_ai.py` API → 管理后台在线增删改 AI 模型 |
|
| **ECS RAM 角色** | 阿里翻译无需 AccessKey,优先使用 ECS 实例 RAM 角色获取 STS 临时凭证 |
|
||||||
| **路由规则** | `router.py` 支持 `reload_from_db()` 从数据库实时加载配置 |
|
|
||||||
| **Env 自动种子** | 启动时从 `.env` 读取 API Key 自动写入 DB |
|
| **Env 自动种子** | 启动时从 `.env` 读取 API Key 自动写入 DB |
|
||||||
|
|
||||||
|
### 3.1 当前 AI 提供商
|
||||||
|
|
||||||
|
| 名称 | 类型 | 模型 | 用途 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| Sensenova (商汤) | sensenova | deepseek-v4-flash | 翻译/回复/营销/提取/聊天 |
|
||||||
|
| NVIDIA | nvidia | stepfun-ai/step-3.7-flash | 翻译/回复/营销/提取/聊天 |
|
||||||
|
| Sensenova (商汤) lite | sensenova | sensenova-6.7-flash-lite | 轻量备用 |
|
||||||
|
| NVIDIA qwen | nvidia | qwen/qwen3.5-397b-a17b | 备用 |
|
||||||
|
| 阿里翻译 | alibaba-mt | alibaba-mt | 专用翻译 (ecommerce/general) |
|
||||||
|
|
||||||
### 4. 客户挖掘 (Discovery) ✅
|
### 4. 客户挖掘 (Discovery) ✅
|
||||||
|
|
||||||
| 功能 | 文件 | 说明 |
|
| 功能 | 文件 | 说明 |
|
||||||
@@ -73,6 +84,26 @@
|
|||||||
| 年费定价 | `payment.py` — 新增 yearly 套餐选项 |
|
| 年费定价 | `payment.py` — 新增 yearly 套餐选项 |
|
||||||
| 搜索 API 管理 | `admin_search.py` — 管理后台配置搜索提供商 |
|
| 搜索 API 管理 | `admin_search.py` — 管理后台配置搜索提供商 |
|
||||||
|
|
||||||
|
### 5.1 支付系统 ✅
|
||||||
|
|
||||||
|
| 组件 | 文件 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| **统一网关** | `services/unified_pay.py` | 对接 宇之然 pay-api,HMAC-SHA256 签名 |
|
||||||
|
| **网关抽象** | `services/payment_gateway.py` | `PaymentGateway` 抽象基类 |
|
||||||
|
| **支付服务** | `services/payment.py` | 多套餐创建/查询/退款/回调 |
|
||||||
|
| **交易记录** | `models/payment_transaction.py` | 订单号/金额/状态/退款流水 |
|
||||||
|
| **支付 API** | `api/v1/payment.py` | 下单/查询/退款/Webhook 统一入口 |
|
||||||
|
| **管理接口** | `api/v1/admin.py` | 交易列表/统计/后台退款 |
|
||||||
|
| **数据迁移** | `alembic/versions/add_payment_transactions_table.py` | `payment_transactions` 表 |
|
||||||
|
|
||||||
|
**支付流程**:
|
||||||
|
1. 前端 POST `/api/v1/payment/create-order` (`plan` + `pay_type`: `alipay` / `wechat`)
|
||||||
|
2. 后端调用 unified pay-api 创建订单,返回 `pay_url`(支付宝) 或 `code_url`(微信扫码)
|
||||||
|
3. 用户完成支付后,pay-api 回调 `POST /api/v1/payment/webhook`
|
||||||
|
4. 后端更新订单状态 + 激活用户套餐
|
||||||
|
|
||||||
|
**凭证**: `PAY_API_KEY` / `PAY_API_SECRET` 从 `.env` 读取(外贸助手密钥),HMAC-SHA256 认证
|
||||||
|
|
||||||
### 6. PC 桌面端布局 ✅
|
### 6. PC 桌面端布局 ✅
|
||||||
|
|
||||||
| 功能 | 说明 |
|
| 功能 | 说明 |
|
||||||
@@ -99,6 +130,8 @@
|
|||||||
| 11 | `app/api/v1/auth.py` + `deps.py` | API 500 根因 — 游客 UUID 格式问题 | ✅ 已修复 |
|
| 11 | `app/api/v1/auth.py` + `deps.py` | API 500 根因 — 游客 UUID 格式问题 | ✅ 已修复 |
|
||||||
| 12 | `backend/.env` + `app/main.py` | CORS 配置不当 | ✅ 已修复 |
|
| 12 | `backend/.env` + `app/main.py` | CORS 配置不当 | ✅ 已修复 |
|
||||||
| 13 | `uni-app/src/utils/api.js` | 前端直连后端端口 → 跨域 | ✅ 已修复 |
|
| 13 | `uni-app/src/utils/api.js` | 前端直连后端端口 → 跨域 | ✅ 已修复 |
|
||||||
|
| 14 | `app/api/v1/auth.py` | 登录/注册 CSRF 鸡生蛋 — 匿名用户无 cookie 导致 403 | ✅ 已修复 |
|
||||||
|
| 15 | 4 个前端登录/注册页面 | 后端错误英文直接展示给用户 | ✅ 已修复 |
|
||||||
|
|
||||||
### 8. 游客模式 (Guest Mode) ✅
|
### 8. 游客模式 (Guest Mode) ✅
|
||||||
|
|
||||||
@@ -119,7 +152,26 @@
|
|||||||
| AI 模型配置 | 在线增删改 AI 提供商、重载配置、启停控制 |
|
| AI 模型配置 | 在线增删改 AI 提供商、重载配置、启停控制 |
|
||||||
| 搜索配置 | 搜索提供商管理 |
|
| 搜索配置 | 搜索提供商管理 |
|
||||||
|
|
||||||
### 10. 其他增强
|
### 10. 翻译配额全链路 ✅
|
||||||
|
|
||||||
|
| 组件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `TranslationQuota` 模型 | 按月配额管理,支持 ecommerce/general/llm 三个版本 |
|
||||||
|
| `TranslationQuotaService` | 自动按月重置、配额检查/扣减/查询/管理 |
|
||||||
|
| `AlibabaMTProvider.translate()` | 调用前检查配额,调用后扣减字符 |
|
||||||
|
| `OpenAIProvider.translate()` | LLM 翻译也走配额检查 (`llm` 版本) |
|
||||||
|
| 后台 `Quota.vue` | 配额管理页 (月限额/启用/重置),修复了 API 路径 bug |
|
||||||
|
|
||||||
|
### 11. 管理后台增强
|
||||||
|
|
||||||
|
| 功能 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| AI 路由规则 | 配置页新增"AI路由规则"卡片,下拉选择已启用供应商 |
|
||||||
|
| 翻译配额页 | 修复路径 bug (`quotas` → `translation-quotas`),新增 `llm` 配额 |
|
||||||
|
| AI 模型配置 | 修复侧边栏链接路径 |
|
||||||
|
| 登录跳转 | 登录后自动跳转仪表盘 |
|
||||||
|
|
||||||
|
### 12. 其他增强
|
||||||
|
|
||||||
| 功能 | 说明 |
|
| 功能 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -176,13 +228,14 @@
|
|||||||
|
|
||||||
### 4.2 AI 提供商
|
### 4.2 AI 提供商
|
||||||
|
|
||||||
| 提供商 | 类型 | 用途 |
|
| 提供商 | 类型 | 模型 | 用途 |
|
||||||
|--------|------|------|
|
|--------|------|------|------|
|
||||||
| Sensenova (商汤) | 主提供商 | 翻译/回复/营销/提取 |
|
| Sensenova (商汤) | 主提供商 | deepseek-v4-flash | 翻译/回复/营销/提取/聊天 |
|
||||||
| OpencodeGo | Fallback | 翻译/回复/营销/提取 |
|
| NVIDIA | Fallback | stepfun-ai/step-3.7-flash | 翻译/回复/营销/提取/聊天 |
|
||||||
| NVIDIA | Fallback | 通用对话 |
|
| Sensenova lite | 轻量备用 | sensenova-6.7-flash-lite | 备用 |
|
||||||
| 讯飞 Spark | Fallback | 通用对话 |
|
| NVIDIA qwen | 备用 | qwen/qwen3.5-397b-a17b | 备用 |
|
||||||
| 阿里机器翻译 | 专用 | 翻译 (translate) |
|
| 阿里机器翻译 | 专用翻译 | alibaba-mt | 翻译 (ecommerce/general) |
|
||||||
|
| **已删除** | — | — | OpencodeGo / 讯飞 Spark / Claude / DeepL / 本地模型 |
|
||||||
|
|
||||||
### 4.3 前端
|
### 4.3 前端
|
||||||
|
|
||||||
@@ -273,13 +326,39 @@ cd backend && alembic upgrade head
|
|||||||
|
|
||||||
## 八、部署架构
|
## 八、部署架构
|
||||||
|
|
||||||
|
### 8.1 域名
|
||||||
|
|
||||||
|
| 域名 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `trade.yuzhiran.com` | 主站 — 营销落地页 + Web 应用(当前生产域名) |
|
||||||
|
| `www.yuzhiran.com.cn` | 之前用作镜像站 AI 教育,外贸项目不再使用 |
|
||||||
|
|
||||||
|
### 8.2 目录结构
|
||||||
|
|
||||||
```
|
```
|
||||||
trade.yuzhiran.com/
|
trade.yuzhiran.com/
|
||||||
├── / → 静态营销落地页
|
├── / → 静态营销落地页
|
||||||
├── /app/ → uni-app 构建 (移动端 H5)
|
├── /app/ → uni-app 构建 (移动端 H5,当前未部署)
|
||||||
├── /admin/ → admin-frontend 构建 (管理后台)
|
├── /admin/ → admin-frontend 构建 (管理后台)
|
||||||
├── /workspace/ → user-frontend 构建 (用户工作台)
|
├── /workspace/ → user-frontend 构建 (用户工作台)
|
||||||
└── /api/ → Nginx proxy → 127.0.0.1:8002
|
└── /api/ → Nginx proxy → 127.0.0.1:8000 (systemd 管理)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 部署流程
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 前端构建 & 部署
|
||||||
|
cd user-frontend && npm run build && cp -r dist/* /www/wwwroot/trade.yuzhiran.com/workspace/
|
||||||
|
cd admin-frontend && npm run build && cp -r dist/* /www/wwwroot/trade.yuzhiran.com/admin/
|
||||||
|
|
||||||
|
# 后端重启 (systemd)
|
||||||
|
sudo systemctl restart ftrade-backend.service
|
||||||
|
|
||||||
|
# 查看启动日志
|
||||||
|
sudo journalctl -u ftrade-backend.service -n 20
|
||||||
|
|
||||||
|
# 本地开发启动
|
||||||
|
cd backend && source venv/bin/activate && uvicorn app.main:app --reload --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -288,6 +367,7 @@ trade.yuzhiran.com/
|
|||||||
|
|
||||||
| 日期 | 变更内容 |
|
| 日期 | 变更内容 |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
|
| 2026-06-02 | 生产环境部署 + AI 路由 DB 驱动 + 翻译配额扩展至 LLM + ECS RAM 角色认证 + 删除 OpencodeGo/Spark |
|
||||||
| 2026-05-29 | 安全加固 (T-005): 限流/CSRF/CORS + AI 提供商 DB 管理 + 客户挖掘联系人提取 |
|
| 2026-05-29 | 安全加固 (T-005): 限流/CSRF/CORS + AI 提供商 DB 管理 + 客户挖掘联系人提取 |
|
||||||
| 2026-05-28 | 加载反馈 + 搜索历史自动保存 + 超时修复 |
|
| 2026-05-28 | 加载反馈 + 搜索历史自动保存 + 超时修复 |
|
||||||
| 2026-05-26 | 落地页 + 推荐系统 + 用量配额 + 搜索 API 管理 + 年费定价 |
|
| 2026-05-26 | 落地页 + 推荐系统 + 用量配额 + 搜索 API 管理 + 年费定价 |
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ export function listLogs(params) { return http.get('/admin/logs', { params }) }
|
|||||||
export function listConfig() { return http.get('/admin/config') }
|
export function listConfig() { return http.get('/admin/config') }
|
||||||
export function updateConfig(key, value) { return http.put(`/admin/config/${key}`, { value }) }
|
export function updateConfig(key, value) { return http.put(`/admin/config/${key}`, { value }) }
|
||||||
|
|
||||||
export function listQuotas() { return http.get('/admin/quotas') }
|
export function listQuotas() { return http.get('/admin/translation-quotas') }
|
||||||
export function updateQuota(version, data) { return http.put(`/admin/quotas/${version}`, data) }
|
export function updateQuota(version, data) { return http.put(`/admin/translation-quotas/${version}`, data) }
|
||||||
export function resetQuota(version) { return http.post(`/admin/quotas/${version}/reset`) }
|
export function resetQuota(version) { return http.post(`/admin/translation-quotas/${version}/reset`) }
|
||||||
|
|
||||||
export function listCertifications(page = 1, size = 50, status = '') {
|
export function listCertifications(page = 1, size = 50, status = '') {
|
||||||
return http.get('/admin/certifications', { params: { page, size, status: status || undefined } })
|
return http.get('/admin/certifications', { params: { page, size, status: status || undefined } })
|
||||||
@@ -55,4 +55,7 @@ export function processInvoice(id, action) {
|
|||||||
return http.post(`/admin/invoices/${id}/process`, { action })
|
return http.post(`/admin/invoices/${id}/process`, { action })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function aiChat(message, history = []) { return http.post('/ai/chat', { message, history }) }
|
||||||
|
export function aiQuickQuestions() { return http.get('/ai/quick-questions') }
|
||||||
|
|
||||||
export default http
|
export default http
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="ai-float-btn" @click="visible = true">
|
||||||
|
<span class="ai-float-icon">AI</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
title="TradeMate AI 助手"
|
||||||
|
width="480px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
class="ai-dialog"
|
||||||
|
top="5vh"
|
||||||
|
@opened="onOpened"
|
||||||
|
>
|
||||||
|
<div class="ai-messages" ref="msgContainer">
|
||||||
|
<div v-for="(msg, i) in messages" :key="i" class="ai-msg-row" :class="msg.role">
|
||||||
|
<div class="ai-avatar" v-if="msg.role === 'assistant'">
|
||||||
|
<el-icon :size="18" color="#667eea"><Cpu /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="ai-msg-body">
|
||||||
|
<div class="ai-msg-bubble">
|
||||||
|
<div class="ai-msg-text">{{ msg.content }}</div>
|
||||||
|
|
||||||
|
<div v-if="msg.actions && msg.actions.length" class="ai-action-card">
|
||||||
|
<div v-for="(action, ai) in msg.actions" :key="ai" class="ai-action-item">
|
||||||
|
<div class="ai-action-title">{{ action.label }}</div>
|
||||||
|
<div v-if="action.type === 'navigate'" class="ai-navigate-hint">
|
||||||
|
<el-tag type="info">跳转至 {{ action.fields?.path }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<div v-for="(val, key) in action.fields" :key="key" class="ai-field-row" v-if="action.type !== 'navigate'">
|
||||||
|
<span class="ai-field-label">{{ fieldLabel(action.type, key) }}</span>
|
||||||
|
<el-input
|
||||||
|
v-model="action.fields[key]"
|
||||||
|
:placeholder="fieldPlaceholder(action.type, key)"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ai-action-btns">
|
||||||
|
<el-button size="small" @click="cancelAction(i, ai)">取消</el-button>
|
||||||
|
<el-button size="small" type="primary" @click="confirmAction(i, ai)">确认执行</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="ai-loading">
|
||||||
|
<el-icon class="is-loading" :size="18"><Loading /></el-icon>
|
||||||
|
<span>思考中...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showSuggestions" class="ai-suggestions">
|
||||||
|
<div
|
||||||
|
v-for="(s, i) in suggestions"
|
||||||
|
:key="i"
|
||||||
|
class="ai-suggestion"
|
||||||
|
@click="sendQuick(s)"
|
||||||
|
>
|
||||||
|
{{ s }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="ai-input-bar">
|
||||||
|
<el-input
|
||||||
|
v-model="inputText"
|
||||||
|
placeholder="输入你的问题..."
|
||||||
|
:disabled="loading"
|
||||||
|
size="default"
|
||||||
|
@keyup.enter="send"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" :disabled="!inputText.trim() || loading" @click="send">发送</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, nextTick } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Cpu, Loading } from '@element-plus/icons-vue'
|
||||||
|
import { aiChat, aiQuickQuestions, searchUsers, updateUser, updateConfig, reviewCertification, processInvoice } from '@/api'
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const inputText = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const suggestions = ref([])
|
||||||
|
const msgContainer = ref(null)
|
||||||
|
const messages = ref([
|
||||||
|
{ role: 'assistant', content: '你好!我是管理后台 AI 助手,可以帮你管理用户、配置、认证审核、发票等。你可以说:"查找用户"、"审核认证"等。' },
|
||||||
|
])
|
||||||
|
const showSuggestions = ref(false)
|
||||||
|
|
||||||
|
const fieldLabel = (type, key) => {
|
||||||
|
const labels = {
|
||||||
|
name: '名称', phone: '电话', email: '邮箱', company: '公司',
|
||||||
|
country: '国家', status: '状态', notes: '备注',
|
||||||
|
key: '配置键', value: '配置值',
|
||||||
|
action: '操作', reason: '原因',
|
||||||
|
username: '用户名', role: '角色',
|
||||||
|
version: '版本', quota: '额度',
|
||||||
|
path: '目标页面',
|
||||||
|
}
|
||||||
|
return labels[key] || key
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldPlaceholder = (type, key) => {
|
||||||
|
const placeholders = {
|
||||||
|
reason: '审核备注(可选)',
|
||||||
|
action: 'approved / rejected',
|
||||||
|
role: 'admin / user',
|
||||||
|
value: '配置值',
|
||||||
|
quota: '如 100',
|
||||||
|
path: '如 /users, /config, /certifications',
|
||||||
|
}
|
||||||
|
return placeholders[key] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelAction = (msgIdx, actionIdx) => {
|
||||||
|
const msg = messages.value[msgIdx]
|
||||||
|
if (msg.actions) msg.actions.splice(actionIdx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmAction = async (msgIdx, actionIdx) => {
|
||||||
|
const action = messages.value[msgIdx].actions[actionIdx]
|
||||||
|
const { type, fields } = action
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
switch (type) {
|
||||||
|
case 'navigate':
|
||||||
|
if (fields.path) {
|
||||||
|
const router = (await import('@/router')).default
|
||||||
|
router.push(fields.path)
|
||||||
|
ElMessage.success(`正在跳转到 ${fields.path}`)
|
||||||
|
messages.value[msgIdx].actions = []
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case 'search_users':
|
||||||
|
if (!fields.query) { ElMessage.warning('搜索关键词不能为空'); return }
|
||||||
|
await searchUsers(fields.query)
|
||||||
|
break
|
||||||
|
case 'update_user':
|
||||||
|
if (!fields.user_id && !fields.username) { ElMessage.warning('用户标识不能为空'); return }
|
||||||
|
await updateUser(fields.user_id || fields.username, fields)
|
||||||
|
break
|
||||||
|
case 'update_config':
|
||||||
|
if (!fields.key) { ElMessage.warning('配置键不能为空'); return }
|
||||||
|
await updateConfig(fields.key, fields.value)
|
||||||
|
break
|
||||||
|
case 'review_certification':
|
||||||
|
if (!fields.id) { ElMessage.warning('认证ID不能为空'); return }
|
||||||
|
await reviewCertification(fields.id, fields.action || 'approved', fields.reason)
|
||||||
|
break
|
||||||
|
case 'process_invoice':
|
||||||
|
if (!fields.id) { ElMessage.warning('发票ID不能为空'); return }
|
||||||
|
await processInvoice(fields.id, fields.action || 'approve')
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
ElMessage.warning(`未知操作类型: ${type}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.success('操作成功')
|
||||||
|
messages.value[msgIdx].actions = []
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message || e.detail || '操作失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendQuick = (text) => {
|
||||||
|
inputText.value = text
|
||||||
|
send()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchSuggestions = async () => {
|
||||||
|
try {
|
||||||
|
const res = await aiQuickQuestions()
|
||||||
|
if (Array.isArray(res)) suggestions.value = res
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onOpened = () => {
|
||||||
|
if (suggestions.value.length === 0) fetchSuggestions()
|
||||||
|
showSuggestions.value = messages.value.length <= 1
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
const msg = inputText.value.trim()
|
||||||
|
if (!msg || loading.value) return
|
||||||
|
inputText.value = ''
|
||||||
|
showSuggestions.value = false
|
||||||
|
messages.value.push({ role: 'user', content: msg })
|
||||||
|
loading.value = true
|
||||||
|
scrollToBottom()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hist = messages.value.map(m => ({ role: m.role, content: m.content }))
|
||||||
|
const res = await aiChat(msg, hist.slice(0, -1))
|
||||||
|
const newMsg = { role: 'assistant', content: res.reply || '抱歉,我没有理解,请重新描述一下你的问题。' }
|
||||||
|
if (res.actions && res.actions.length) {
|
||||||
|
newMsg.actions = res.actions
|
||||||
|
}
|
||||||
|
messages.value.push(newMsg)
|
||||||
|
} catch {
|
||||||
|
messages.value.push({ role: 'assistant', content: '请求失败,请稍后重试。' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToBottom = async () => {
|
||||||
|
await nextTick()
|
||||||
|
const el = msgContainer.value
|
||||||
|
if (el) el.scrollTop = el.scrollHeight
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ai-float-btn {
|
||||||
|
position: fixed;
|
||||||
|
right: 30px;
|
||||||
|
bottom: 30px;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
|
||||||
|
z-index: 9999;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.ai-float-btn:hover {
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
.ai-float-icon {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.ai-dialog {
|
||||||
|
--el-dialog-content-padding: 0;
|
||||||
|
}
|
||||||
|
.ai-dialog :deep(.el-dialog__body) {
|
||||||
|
padding: 0;
|
||||||
|
height: 480px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.ai-messages {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.ai-msg-row {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.ai-msg-row.user {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
.ai-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f0edff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ai-msg-body {
|
||||||
|
max-width: 80%;
|
||||||
|
}
|
||||||
|
.ai-msg-bubble {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
.ai-msg-row.user .ai-msg-bubble {
|
||||||
|
background: #667eea;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.ai-msg-text {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.ai-msg-row.user .ai-msg-text {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.ai-action-card {
|
||||||
|
margin-top: 12px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
.ai-action-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.ai-field-row {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.ai-field-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
.ai-action-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.ai-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.ai-suggestions {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.ai-suggestion {
|
||||||
|
background: #f0edff;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #667eea;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.ai-suggestion:hover {
|
||||||
|
background: #e0dbff;
|
||||||
|
}
|
||||||
|
.ai-input-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
<el-icon><Search /></el-icon>
|
<el-icon><Search /></el-icon>
|
||||||
<span>搜索配置</span>
|
<span>搜索配置</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item index="/system/ai-providers">
|
<el-menu-item index="/system/search-config/ai-providers">
|
||||||
<el-icon><Cpu /></el-icon>
|
<el-icon><Cpu /></el-icon>
|
||||||
<span>AI 模型配置</span>
|
<span>AI 模型配置</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
@@ -128,6 +128,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</el-footer>
|
</el-footer>
|
||||||
</el-container>
|
</el-container>
|
||||||
|
<AiAssistant />
|
||||||
</el-container>
|
</el-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -135,6 +136,7 @@
|
|||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import AiAssistant from '@/components/AiAssistant.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
import AdminLayout from '@/layouts/AdminLayout.vue'
|
import AdminLayout from '@/layouts/AdminLayout.vue'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/login', redirect: '/' },
|
{ path: '/login', redirect: to => ({ path: '/', query: to.query }) },
|
||||||
{ path: '/', name: 'Landing', component: () => import('@/views/Landing.vue') },
|
{ path: '/', name: 'Landing', component: () => import('@/views/Landing.vue') },
|
||||||
{
|
{
|
||||||
path: '/dashboard',
|
path: '/dashboard',
|
||||||
|
|||||||
@@ -7,7 +7,24 @@
|
|||||||
<el-tag size="small" v-if="cfg.description">{{ cfg.description }}</el-tag>
|
<el-tag size="small" v-if="cfg.description">{{ cfg.description }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-if="typeof cfg.value === 'object' && !Array.isArray(cfg.value)">
|
<div v-if="cfg.key === 'ai_routing'">
|
||||||
|
<div v-for="(taskVal, taskKey) in cfg.value" :key="taskKey" class="cfg-nested-group">
|
||||||
|
<div class="cfg-group-title">{{ fieldLabelsMap.ai_routing?.[taskKey] || taskKey }}</div>
|
||||||
|
<div class="cfg-field">
|
||||||
|
<span class="cfg-label">主选</span>
|
||||||
|
<el-select v-model="edits[cfg.key][taskKey].primary" size="small" style="width:300px" filterable>
|
||||||
|
<el-option v-for="p in providers" :key="p.provider_type" :value="p.provider_type" :label="p.name + ' — ' + p.model_name" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
<div class="cfg-field">
|
||||||
|
<span class="cfg-label">备用</span>
|
||||||
|
<el-select v-model="edits[cfg.key][taskKey].fallback" size="small" style="width:400px" multiple filterable collapse-tags>
|
||||||
|
<el-option v-for="p in providers" :key="p.provider_type" :value="p.provider_type" :label="p.name + ' — ' + p.model_name" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="typeof cfg.value === 'object' && !Array.isArray(cfg.value)">
|
||||||
<div class="cfg-field" v-for="(v, k) in cfg.value" :key="k">
|
<div class="cfg-field" v-for="(v, k) in cfg.value" :key="k">
|
||||||
<span class="cfg-label">{{ fieldLabel(cfg.key, k) }}</span>
|
<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 v-if="typeof v === 'string'" v-model="edits[cfg.key][k]" size="small" style="width:300px" />
|
||||||
@@ -34,12 +51,33 @@
|
|||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { listConfig, updateConfig } from '@/api'
|
import { listConfig, updateConfig } from '@/api'
|
||||||
|
import http from '@/api'
|
||||||
|
|
||||||
const configs = ref([])
|
const configs = ref([])
|
||||||
const edits = reactive({})
|
const edits = reactive({})
|
||||||
const configLabels = { system_maintenance: '系统维护', feature_flags: '功能开关', translation_providers: '翻译服务', ai_model_config: 'AI模型', cache_ttl: '缓存时间' }
|
const providers = ref([])
|
||||||
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' } }
|
const configLabels = { system_maintenance: '系统维护', feature_flags: '功能开关', translation_providers: '翻译服务', ai_model_config: 'AI模型', cache_ttl: '缓存时间', ai_routing: 'AI路由规则' }
|
||||||
function fieldLabel(key, k) { return fieldLabelsMap[key]?.[k] || k }
|
const fieldLabelsMap = { system_maintenance: { maintenance_mode: '维护模式', maintenance_message: '维护消息' }, feature_flags: { feature_wechat_login: '微信登录', feature_export: '数据导出' }, translation_providers: { primary: '首选服务', fallback: '备用服务' }, ai_model_config: { default_model: '默认模型', max_tokens: '最大Token' }, ai_routing: { translate: '翻译', reply: '回复建议', marketing: '营销文案', extract: '信息提取', quotation: '报价单', chat: 'AI助手' } }
|
||||||
|
const taskFieldLabels = { primary: '主选', fallback: '备用' }
|
||||||
|
function fieldLabel(key, k) {
|
||||||
|
if (key === 'ai_routing') return configLabels[key] + ' > ' + (taskFieldLabels[k] || k)
|
||||||
|
return fieldLabelsMap[key]?.[k] || k
|
||||||
|
}
|
||||||
|
function taskFieldLabel(cfgKey, taskKey, subKey) {
|
||||||
|
if (cfgKey === 'ai_routing') {
|
||||||
|
const taskLabel = fieldLabelsMap.ai_routing?.[taskKey] || taskKey
|
||||||
|
const subLabel = taskFieldLabels[subKey] || subKey
|
||||||
|
return taskLabel + ' > ' + subLabel
|
||||||
|
}
|
||||||
|
return subKey || taskKey
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProviders() {
|
||||||
|
try {
|
||||||
|
const res = await http.get('/admin/ai-providers')
|
||||||
|
providers.value = (res.items || []).filter(p => p.enabled)
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
@@ -48,13 +86,14 @@ async function load() {
|
|||||||
for (const cfg of configs.value) {
|
for (const cfg of configs.value) {
|
||||||
edits[cfg.key] = JSON.parse(JSON.stringify(cfg.value))
|
edits[cfg.key] = JSON.parse(JSON.stringify(cfg.value))
|
||||||
}
|
}
|
||||||
|
await loadProviders()
|
||||||
} catch (e) { console.error(e) }
|
} catch (e) { console.error(e) }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save(key) {
|
async function save(key) {
|
||||||
try {
|
try {
|
||||||
await updateConfig(key, edits[key])
|
await updateConfig(key, edits[key])
|
||||||
ElMessage.success('已保存')
|
ElMessage.success('已保存,AI 路由器已重载')
|
||||||
} catch (e) { ElMessage.error(e?.detail || '保存失败') }
|
} catch (e) { ElMessage.error(e?.detail || '保存失败') }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,5 +105,7 @@ onMounted(load)
|
|||||||
.cfg-header { display: flex; align-items: center; gap: 12px; font-weight: 600; }
|
.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-field { display: flex; align-items: center; gap: 12px; padding: 8px 0; }
|
||||||
.cfg-label { width: 160px; font-size: 13px; color: #666; flex-shrink: 0; }
|
.cfg-label { width: 160px; font-size: 13px; color: #666; flex-shrink: 0; }
|
||||||
|
.cfg-nested-group { margin-bottom: 12px; padding: 8px 12px; background: #f9fafb; border-radius: 6px; }
|
||||||
|
.cfg-group-title { font-weight: 600; font-size: 13px; color: #333; margin-bottom: 4px; padding-bottom: 4px; border-bottom: 1px solid #e5e7eb; }
|
||||||
.cfg-actions { margin-top: 12px; }
|
.cfg-actions { margin-top: 12px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ async function submit() {
|
|||||||
const res = await loginApi(form)
|
const res = await loginApi(form)
|
||||||
localStorage.setItem('admin_token', res.access_token)
|
localStorage.setItem('admin_token', res.access_token)
|
||||||
localStorage.setItem('admin_user', JSON.stringify(res.user || {}))
|
localStorage.setItem('admin_user', JSON.stringify(res.user || {}))
|
||||||
ElMessage.success('登录成功')
|
router.push('/dashboard')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const map = { 'Invalid credentials': '用户名或密码错误' }
|
const map = { 'Invalid credentials': '用户名或密码错误' }
|
||||||
error.value = map[e?.detail] || e?.detail || '登录失败'
|
error.value = map[e?.detail] || e?.detail || '登录失败'
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<el-card shadow="never">
|
<el-card shadow="never">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="quota-header">
|
<div class="quota-header">
|
||||||
<span class="quota-version">{{ q.version === 'ecommerce' ? '电商版' : '通用版' }}</span>
|
<span class="quota-version">{{ { ecommerce: '电商版', general: '通用版', llm: 'AI模型翻译' }[q.version] || q.version }}</span>
|
||||||
<el-tag size="small" v-if="q.description">{{ q.description }}</el-tag>
|
<el-tag size="small" v-if="q.description">{{ q.description }}</el-tag>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': { target: 'http://localhost:8002', changeOrigin: true }
|
'/api': { target: 'http://localhost:8000', changeOrigin: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import sqlalchemy as sa
|
|||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
revision = "add_payment_transactions"
|
revision = "add_payment_transactions"
|
||||||
down_revision = "add_ai_providers_table"
|
down_revision = "add_ai_providers"
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
from .openai import OpenAIProvider
|
from .openai import OpenAIProvider
|
||||||
from .spark import SparkProvider
|
|
||||||
from .sensenova import SensenovaProvider
|
from .sensenova import SensenovaProvider
|
||||||
from .opencode_go import OpencodeGoProvider
|
|
||||||
from .nvidia import NvidiaProvider
|
from .nvidia import NvidiaProvider
|
||||||
from .alibaba import AlibabaMTProvider
|
from .alibaba import AlibabaMTProvider
|
||||||
|
|
||||||
__all__ = ["OpenAIProvider", "SparkProvider", "SensenovaProvider", "OpencodeGoProvider", "NvidiaProvider", "AlibabaMTProvider"]
|
__all__ = ["OpenAIProvider", "SensenovaProvider", "NvidiaProvider", "AlibabaMTProvider"]
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
from aliyunsdkcore.client import AcsClient
|
from aliyunsdkcore.client import AcsClient
|
||||||
|
from aliyunsdkcore.auth.credentials import StsTokenCredential
|
||||||
from aliyunsdkalimt.request.v20181012 import TranslateGeneralRequest, TranslateECommerceRequest
|
from aliyunsdkalimt.request.v20181012 import TranslateGeneralRequest, TranslateECommerceRequest
|
||||||
from app.services.translation_quota import TranslationQuotaService
|
from app.services.translation_quota import TranslationQuotaService
|
||||||
from app.database import AsyncSessionLocal
|
from app.database import AsyncSessionLocal
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -16,11 +18,55 @@ ALIBABA_LANG_MAP = {
|
|||||||
"id": "id", "ms": "ms", "tl": "tl", "hi": "hi",
|
"id": "id", "ms": "ms", "tl": "tl", "hi": "hi",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ECS_METADATA_URL = "http://100.100.100.200/latest/meta-data/ram/security-credentials/"
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_ecs_ram_credentials():
|
||||||
|
try:
|
||||||
|
import urllib.request
|
||||||
|
req = urllib.request.Request(ECS_METADATA_URL, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=2) as resp:
|
||||||
|
role_name = resp.read().decode().strip()
|
||||||
|
if not role_name:
|
||||||
|
logger.warning("ECS metadata returned empty role name")
|
||||||
|
return None
|
||||||
|
url = f"{ECS_METADATA_URL}{role_name}"
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
with urllib.request.urlopen(req, timeout=2) as resp:
|
||||||
|
data = json.loads(resp.read().decode())
|
||||||
|
if data.get("Code") == "Success":
|
||||||
|
logger.info(f"Fetched STS token for role {role_name}, expires {data.get('Expiration')}")
|
||||||
|
return (data["AccessKeyId"], data["AccessKeySecret"], data["SecurityToken"])
|
||||||
|
else:
|
||||||
|
logger.warning(f"ECS metadata returned non-success: {data.get('Code')}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"ECS metadata fetch failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_acs_client(access_key_id: str = "", access_key_secret: str = "",
|
||||||
|
region_id: str = "cn-hangzhou") -> AcsClient:
|
||||||
|
creds = _fetch_ecs_ram_credentials()
|
||||||
|
if creds:
|
||||||
|
ak, sk, token = creds
|
||||||
|
sts_cred = StsTokenCredential(ak, sk, token)
|
||||||
|
client = AcsClient(credential=sts_cred, region_id=region_id)
|
||||||
|
logger.info("Alibaba MT using ECS RAM role (STS token)")
|
||||||
|
return client
|
||||||
|
|
||||||
|
ak = access_key_id or os.getenv("ALIBABA_ACCESS_KEY_ID", "")
|
||||||
|
sk = access_key_secret or os.getenv("ALIBABA_ACCESS_KEY_SECRET", "")
|
||||||
|
if ak and sk:
|
||||||
|
logger.info("Alibaba MT using AccessKey credentials")
|
||||||
|
return AcsClient(ak, sk, region_id)
|
||||||
|
|
||||||
|
raise ValueError("No Alibaba Cloud credentials found (neither ECS RAM role nor AccessKey)")
|
||||||
|
|
||||||
|
|
||||||
class AlibabaMTProvider:
|
class AlibabaMTProvider:
|
||||||
def __init__(self, access_key_id: str, access_key_secret: str,
|
def __init__(self, access_key_id: str = "", access_key_secret: str = "",
|
||||||
region_id: str = "cn-hangzhou"):
|
region_id: str = "cn-hangzhou"):
|
||||||
self.client = AcsClient(access_key_id, access_key_secret, region_id)
|
self.client = _build_acs_client(access_key_id, access_key_secret, region_id)
|
||||||
self._name = "alibaba-mt"
|
self._name = "alibaba-mt"
|
||||||
|
|
||||||
async def translate(self, text: str, source_lang: Optional[str],
|
async def translate(self, text: str, source_lang: Optional[str],
|
||||||
|
|||||||
@@ -51,6 +51,20 @@ class OpenAIProvider(AIProvider):
|
|||||||
self._cheap_model = "gpt-4o-mini" if model == "gpt-4o" else model
|
self._cheap_model = "gpt-4o-mini" if model == "gpt-4o" else model
|
||||||
|
|
||||||
async def translate(self, text: str, source_lang: Optional[str], target_lang: str, context: Optional[str] = None) -> Dict[str, Any]:
|
async def translate(self, text: str, source_lang: Optional[str], target_lang: str, context: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
from app.services.translation_quota import TranslationQuotaService
|
||||||
|
from app.database import AsyncSessionLocal
|
||||||
|
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
quota_svc = TranslationQuotaService(db)
|
||||||
|
if not await quota_svc.check_quota("llm"):
|
||||||
|
raise Exception("LLM translation quota exhausted or disabled")
|
||||||
|
result = await self._do_translate(text, source_lang, target_lang, context)
|
||||||
|
if result and result.get("translated_text"):
|
||||||
|
await quota_svc.consume("llm", len(text))
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def _do_translate(self, text: str, source_lang: Optional[str], target_lang: str, context: Optional[str] = None) -> Dict[str, Any]:
|
||||||
system = SYSTEM_PROMPTS["translate"]
|
system = SYSTEM_PROMPTS["translate"]
|
||||||
if context:
|
if context:
|
||||||
system += f"\nContext: this is about {context}"
|
system += f"\nContext: this is about {context}"
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
from app.ai.providers.openai import OpenAIProvider
|
|
||||||
|
|
||||||
|
|
||||||
class OpencodeGoProvider(OpenAIProvider):
|
|
||||||
def __init__(self, api_key: str, model: str = "deepseek-v4-flash", base_url: str = "https://opencode.ai/zen/go/v1"):
|
|
||||||
super().__init__(api_key=api_key, model=model, base_url=base_url)
|
|
||||||
self._name = f"opencode-go-{model}"
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
from typing import Dict, Any, Optional
|
|
||||||
import json
|
|
||||||
from app.ai.base import AIProvider
|
|
||||||
|
|
||||||
|
|
||||||
SYSTEM_PROMPTS = {
|
|
||||||
"translate": "You are a professional translator specialized in foreign trade. "
|
|
||||||
"Translate business terms accurately. Return ONLY the translated text.",
|
|
||||||
"reply": "You are an experienced foreign trade sales expert. Write professional, "
|
|
||||||
"clear business replies. Return ONLY the reply text.",
|
|
||||||
"marketing": "You are a creative copywriter for international trade. "
|
|
||||||
"Return ONLY the marketing copy, no explanations.",
|
|
||||||
"extract": "Extract structured data from text. Return ONLY valid JSON.",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class SparkProvider(AIProvider):
|
|
||||||
def __init__(self, api_key: str, model: str = "astron-code-latest", base_url: str = None):
|
|
||||||
from app.config import settings
|
|
||||||
try:
|
|
||||||
from openai import AsyncOpenAI
|
|
||||||
except ImportError:
|
|
||||||
raise ImportError("openai>=1.0 is required for SparkProvider")
|
|
||||||
self.client = AsyncOpenAI(
|
|
||||||
api_key=api_key,
|
|
||||||
base_url=base_url or settings.IFLYTEK_API_BASE,
|
|
||||||
)
|
|
||||||
self.model = model
|
|
||||||
self._name = f"spark-{model}"
|
|
||||||
|
|
||||||
async def translate(self, text: str, source_lang: Optional[str], target_lang: str, context: Optional[str] = None) -> Dict[str, Any]:
|
|
||||||
system = SYSTEM_PROMPTS["translate"]
|
|
||||||
if context:
|
|
||||||
system += f"\nContext: {context}"
|
|
||||||
prompt = f"Translate {f'from {source_lang} ' if source_lang and source_lang != 'auto' else ''}to {target_lang}:\n\n{text}"
|
|
||||||
content = await self._call(system, prompt)
|
|
||||||
return {"translated_text": content, "provider": self.name}
|
|
||||||
|
|
||||||
async def reply(self, inquiry: str, context: Optional[Dict[str, Any]] = None, tone: str = "professional", preference_context: Optional[str] = None) -> Dict[str, Any]:
|
|
||||||
system = SYSTEM_PROMPTS["reply"] + f"\nTone: {tone}"
|
|
||||||
if preference_context:
|
|
||||||
system += f"\nUser preference: {preference_context}"
|
|
||||||
ctx = ""
|
|
||||||
if context:
|
|
||||||
ctx = "\n".join(f"{k}: {v}" for k, v in context.items() if v)
|
|
||||||
prompt = f"{ctx}\nCustomer inquiry:\n{inquiry}\n\nWrite a reply:"
|
|
||||||
content = await self._call(system, prompt)
|
|
||||||
return {"reply": content, "provider": self.name}
|
|
||||||
|
|
||||||
async def generate_marketing(self, product_info: Dict[str, Any], target: str, style: str = "professional", language: str = "en", preference_context: Optional[str] = None) -> Dict[str, Any]:
|
|
||||||
system = SYSTEM_PROMPTS["marketing"] + f"\nStyle: {style}\nAudience: {target}\nLanguage: {language}"
|
|
||||||
if preference_context:
|
|
||||||
system += f"\nUser preference: {preference_context}"
|
|
||||||
info = json.dumps(product_info, ensure_ascii=False)
|
|
||||||
prompt = f"Product:\n{info}\n\nGenerate marketing copy:"
|
|
||||||
content = await self._call(system, prompt, max_tokens=1500)
|
|
||||||
return {"content": content, "provider": self.name}
|
|
||||||
|
|
||||||
async def extract_info(self, text: str, schema: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
system = SYSTEM_PROMPTS["extract"]
|
|
||||||
prompt = f"Schema:\n{json.dumps(schema, indent=2)}\n\nText:\n{text}\n\nJSON:"
|
|
||||||
content = await self._call(system, prompt, response_format={"type": "json_object"})
|
|
||||||
try:
|
|
||||||
data = json.loads(content)
|
|
||||||
return {"data": data, "confidence": 0.9, "provider": self.name}
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return {"data": {}, "confidence": 0.0, "provider": self.name}
|
|
||||||
|
|
||||||
async def _call(self, system: str, prompt: str, max_tokens: int = 1000, response_format: Optional[Dict] = None) -> str:
|
|
||||||
kwargs = {
|
|
||||||
"model": self.model,
|
|
||||||
"messages": [
|
|
||||||
{"role": "system", "content": system},
|
|
||||||
{"role": "user", "content": prompt},
|
|
||||||
],
|
|
||||||
"max_tokens": max_tokens,
|
|
||||||
"temperature": 0.7,
|
|
||||||
}
|
|
||||||
if response_format:
|
|
||||||
kwargs["response_format"] = response_format
|
|
||||||
resp = await self.client.chat.completions.create(**kwargs)
|
|
||||||
return resp.choices[0].message.content
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self) -> str:
|
|
||||||
return self._name
|
|
||||||
|
|
||||||
@property
|
|
||||||
def cost_per_1k_tokens(self) -> float:
|
|
||||||
return 0.0
|
|
||||||
+56
-27
@@ -1,17 +1,26 @@
|
|||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
from app.ai.base import AIProvider
|
from app.ai.base import AIProvider
|
||||||
from app.ai.providers import SparkProvider, SensenovaProvider, OpencodeGoProvider, NvidiaProvider, AlibabaMTProvider
|
from app.ai.providers import SensenovaProvider, NvidiaProvider, AlibabaMTProvider
|
||||||
from app.config import settings
|
|
||||||
from app.ai.trade_corpus import TradeCorpus
|
from app.ai.trade_corpus import TradeCorpus
|
||||||
|
from app.config import settings
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_ROUTING: Dict[str, dict] = {
|
||||||
|
"translate": {"primary": "sensenova", "fallback": ["alibaba-mt", "nvidia"]},
|
||||||
|
"reply": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||||
|
"marketing": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||||
|
"extract": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||||
|
"quotation": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||||
|
"chat": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class AIRouter:
|
class AIRouter:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.providers: Dict[str, AIProvider] = {}
|
self.providers: Dict[str, AIProvider] = {}
|
||||||
self.routing_rules = settings.AI_ROUTING
|
self.routing_rules = dict(DEFAULT_ROUTING)
|
||||||
self.corpus = TradeCorpus()
|
self.corpus = TradeCorpus()
|
||||||
|
|
||||||
async def reload_from_db(self, db_session) -> int:
|
async def reload_from_db(self, db_session) -> int:
|
||||||
@@ -38,8 +47,47 @@ class AIRouter:
|
|||||||
else:
|
else:
|
||||||
logger.warning("No enabled AI providers found in DB")
|
logger.warning("No enabled AI providers found in DB")
|
||||||
|
|
||||||
|
await self._load_routing_rules(db_session)
|
||||||
return len(rows)
|
return len(rows)
|
||||||
|
|
||||||
|
async def _load_routing_rules(self, db_session):
|
||||||
|
from app.models.system_config import SystemConfig
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
# Try consolidated key first
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(SystemConfig).where(SystemConfig.key == "ai_routing")
|
||||||
|
)
|
||||||
|
cfg = result.scalar_one_or_none()
|
||||||
|
if cfg and isinstance(cfg.value, dict):
|
||||||
|
self.routing_rules = {**DEFAULT_ROUTING, **cfg.value}
|
||||||
|
logger.info("Loaded routing rules from system_configs (ai_routing)")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Fallback: load individual per-task keys
|
||||||
|
task_keys = {
|
||||||
|
"translate": "ai_provider_translate",
|
||||||
|
"reply": "ai_provider_reply",
|
||||||
|
"marketing": "ai_provider_marketing",
|
||||||
|
"extract": "ai_provider_extract",
|
||||||
|
"quotation": "ai_provider_quotation",
|
||||||
|
}
|
||||||
|
loaded = {}
|
||||||
|
for task, key in task_keys.items():
|
||||||
|
result = await db_session.execute(
|
||||||
|
select(SystemConfig).where(SystemConfig.key == key)
|
||||||
|
)
|
||||||
|
cfg = result.scalar_one_or_none()
|
||||||
|
if cfg and isinstance(cfg.value, dict):
|
||||||
|
loaded[task] = cfg.value
|
||||||
|
|
||||||
|
if loaded:
|
||||||
|
self.routing_rules = {**DEFAULT_ROUTING, **loaded}
|
||||||
|
logger.info(f"Loaded routing rules from system_configs (individual keys): {list(loaded.keys())}")
|
||||||
|
else:
|
||||||
|
self.routing_rules = dict(DEFAULT_ROUTING)
|
||||||
|
logger.info("No routing rules in system_configs, using defaults")
|
||||||
|
|
||||||
async def seed_from_env(self, db_session) -> int:
|
async def seed_from_env(self, db_session) -> int:
|
||||||
from app.models.ai_provider import AIProvider
|
from app.models.ai_provider import AIProvider
|
||||||
|
|
||||||
@@ -53,33 +101,18 @@ class AIRouter:
|
|||||||
base_url=settings.SENSENOVA_BASE_URL,
|
base_url=settings.SENSENOVA_BASE_URL,
|
||||||
model_name=settings.SENSENOVA_MODEL, priority=0, enabled=True,
|
model_name=settings.SENSENOVA_MODEL, priority=0, enabled=True,
|
||||||
))
|
))
|
||||||
if settings.OPENCODE_GO_API_KEY:
|
|
||||||
seeds.append(AIProvider(
|
|
||||||
name="OpencodeGo", provider_type="opencode_go",
|
|
||||||
api_key=settings.OPENCODE_GO_API_KEY,
|
|
||||||
base_url=settings.OPENCODE_GO_BASE_URL,
|
|
||||||
model_name=settings.OPENCODE_GO_MODEL, priority=1, enabled=True,
|
|
||||||
))
|
|
||||||
if settings.NVIDIA_API_KEY:
|
if settings.NVIDIA_API_KEY:
|
||||||
seeds.append(AIProvider(
|
seeds.append(AIProvider(
|
||||||
name="NVIDIA", provider_type="nvidia",
|
name="NVIDIA", provider_type="nvidia",
|
||||||
api_key=settings.NVIDIA_API_KEY,
|
api_key=settings.NVIDIA_API_KEY,
|
||||||
base_url=settings.NVIDIA_BASE_URL,
|
base_url=settings.NVIDIA_BASE_URL,
|
||||||
model_name=settings.NVIDIA_MODEL, priority=2, enabled=True,
|
model_name=settings.NVIDIA_MODEL, priority=1, enabled=True,
|
||||||
))
|
))
|
||||||
if settings.IFLYTEK_API_KEY:
|
|
||||||
seeds.append(AIProvider(
|
|
||||||
name="讯飞 Spark", provider_type="spark",
|
|
||||||
api_key=settings.IFLYTEK_API_KEY,
|
|
||||||
base_url=settings.IFLYTEK_API_BASE,
|
|
||||||
model_name=settings.IFLYTEK_MODEL, priority=3, enabled=True,
|
|
||||||
))
|
|
||||||
if settings.ALIBABA_ACCESS_KEY_ID and settings.ALIBABA_ACCESS_KEY_SECRET:
|
|
||||||
seeds.append(AIProvider(
|
seeds.append(AIProvider(
|
||||||
name="阿里翻译", provider_type="alibaba-mt",
|
name="阿里翻译", provider_type="alibaba-mt",
|
||||||
api_key=settings.ALIBABA_ACCESS_KEY_ID,
|
api_key=settings.ALIBABA_ACCESS_KEY_ID or "",
|
||||||
api_secret=settings.ALIBABA_ACCESS_KEY_SECRET,
|
api_secret=settings.ALIBABA_ACCESS_KEY_SECRET or "",
|
||||||
model_name="alibaba-mt", priority=4, enabled=True,
|
model_name="alibaba-mt", priority=3, enabled=True,
|
||||||
))
|
))
|
||||||
|
|
||||||
for p in seeds:
|
for p in seeds:
|
||||||
@@ -99,12 +132,8 @@ class AIRouter:
|
|||||||
t = p.provider_type
|
t = p.provider_type
|
||||||
if t == "sensenova":
|
if t == "sensenova":
|
||||||
return SensenovaProvider(api_key=p.api_key, model=p.model_name, base_url=p.base_url)
|
return SensenovaProvider(api_key=p.api_key, model=p.model_name, base_url=p.base_url)
|
||||||
elif t == "opencode_go":
|
|
||||||
return OpencodeGoProvider(api_key=p.api_key, model=p.model_name, base_url=p.base_url)
|
|
||||||
elif t == "nvidia":
|
elif t == "nvidia":
|
||||||
return NvidiaProvider(api_key=p.api_key, model=p.model_name, base_url=p.base_url)
|
return NvidiaProvider(api_key=p.api_key, model=p.model_name, base_url=p.base_url)
|
||||||
elif t == "spark":
|
|
||||||
return SparkProvider(api_key=p.api_key, model=p.model_name, base_url=p.base_url)
|
|
||||||
elif t == "alibaba-mt":
|
elif t == "alibaba-mt":
|
||||||
return AlibabaMTProvider(access_key_id=p.api_key, access_key_secret=p.api_secret or "")
|
return AlibabaMTProvider(access_key_id=p.api_key, access_key_secret=p.api_secret or "")
|
||||||
else:
|
else:
|
||||||
@@ -117,7 +146,7 @@ class AIRouter:
|
|||||||
def get_providers_for_task(self, task_type: str) -> List[AIProvider]:
|
def get_providers_for_task(self, task_type: str) -> List[AIProvider]:
|
||||||
rules = self.routing_rules.get(
|
rules = self.routing_rules.get(
|
||||||
task_type,
|
task_type,
|
||||||
{"primary": "sensenova", "fallback": ["opencode_go"]},
|
{"primary": "sensenova", "fallback": ["nvidia"]},
|
||||||
)
|
)
|
||||||
ordered = []
|
ordered = []
|
||||||
seen = set()
|
seen = set()
|
||||||
|
|||||||
@@ -42,9 +42,7 @@ class AIProviderUpdate(BaseModel):
|
|||||||
|
|
||||||
PROVIDER_TYPE_LABELS = {
|
PROVIDER_TYPE_LABELS = {
|
||||||
"sensenova": "Sensenova (商汤)",
|
"sensenova": "Sensenova (商汤)",
|
||||||
"opencode_go": "OpencodeGo",
|
|
||||||
"nvidia": "NVIDIA",
|
"nvidia": "NVIDIA",
|
||||||
"spark": "讯飞 Spark",
|
|
||||||
"alibaba-mt": "阿里翻译",
|
"alibaba-mt": "阿里翻译",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ router = APIRouter()
|
|||||||
|
|
||||||
|
|
||||||
ACTION_INSTRUCTIONS = """
|
ACTION_INSTRUCTIONS = """
|
||||||
当用户想要执行操作时(如添加客户、创建产品、生成报价单等),请执行以下步骤:
|
当用户想要执行操作时(如添加客户、创建产品、生成报价单、发送跟进、营销生成等),请执行以下步骤:
|
||||||
1. 从用户消息中提取所有必要的信息
|
1. 从用户消息中提取所有必要的信息
|
||||||
2. 在回复末尾附上 JSON 格式的动作块,格式如下:
|
2. 在回复末尾附上 JSON 格式的动作块,格式如下:
|
||||||
|
|
||||||
@@ -28,9 +28,20 @@ ACTION_INSTRUCTIONS = """
|
|||||||
[{"type": "create_customer", "label": "添加客户", "fields": {"name": "...", "phone": "...", "email": "...", "company": "...", "country": "...", "notes": "..."}}]
|
[{"type": "create_customer", "label": "添加客户", "fields": {"name": "...", "phone": "...", "email": "...", "company": "...", "country": "...", "notes": "..."}}]
|
||||||
```
|
```
|
||||||
|
|
||||||
支持的 action type:
|
支持的 action type 及字段说明:
|
||||||
- create_customer:添加客户,fields 支持 name(必填), phone, email, company, country, notes
|
- create_customer:添加客户,fields 支持 name(必填), phone, email, company, country, website, notes
|
||||||
- create_product:添加产品(开发中)
|
- create_product:添加产品,fields 支持 name(必填), name_en, description, description_en, category, price, price_unit(默认USD), moq, keywords(逗号分隔)
|
||||||
|
- create_quotation:生成报价单,fields 支持 customer_name(必填), product_info(必填), quantity(必填), price, terms
|
||||||
|
- scan_followups:扫描待跟进客户,fields 不需要(空对象)
|
||||||
|
- send_followup:发送跟进消息,fields 支持 customer_name(必填), message(必填)
|
||||||
|
- generate_marketing:生成营销素材,fields 支持 product_name(必填), target_market, tone(如professional/casual), language
|
||||||
|
- discovery_search:搜索潜在客户,fields 支持 keywords(必填), country, industry
|
||||||
|
- navigate:跳转到指定页面,fields 支持 path(必填, 如 /customers /products /quotations /marketing /discovery /followup /translate /team /analytics)
|
||||||
|
- search_users:搜索用户,fields 支持 query(必填)
|
||||||
|
- update_user:修改用户信息,fields 支持 user_id(必填), username, phone, email, role, status
|
||||||
|
- update_config:更新系统配置,fields 支持 key(必填), value(必填)
|
||||||
|
- review_certification:审核认证,fields 支持 id(必填), action(approved/rejected), reason
|
||||||
|
- process_invoice:处理发票,fields 支持 id(必填), action(approve/reject)
|
||||||
|
|
||||||
如果用户没有提供足够信息,请先询问缺少的字段,不要生成 action。
|
如果用户没有提供足够信息,请先询问缺少的字段,不要生成 action。
|
||||||
如果用户明确表示要执行操作但缺少信息,生成 action 但标注缺失的字段。
|
如果用户明确表示要执行操作但缺少信息,生成 action 但标注缺失的字段。
|
||||||
|
|||||||
@@ -32,13 +32,7 @@ class Settings(BaseSettings):
|
|||||||
SENSENOVA_BASE_URL: str = "https://token.sensenova.cn/v1"
|
SENSENOVA_BASE_URL: str = "https://token.sensenova.cn/v1"
|
||||||
SENSENOVA_MODEL: str = "deepseek-v4-flash"
|
SENSENOVA_MODEL: str = "deepseek-v4-flash"
|
||||||
|
|
||||||
IFLYTEK_API_KEY: Optional[str] = None
|
|
||||||
IFLYTEK_API_BASE: str = "https://maas-api.cn-huabei-1.xf-yun.com/v2"
|
|
||||||
IFLYTEK_MODEL: str = "astron-code-latest"
|
|
||||||
|
|
||||||
OPENCODE_GO_API_KEY: Optional[str] = None
|
|
||||||
OPENCODE_GO_BASE_URL: str = "https://opencode.ai/zen/go/v1"
|
|
||||||
OPENCODE_GO_MODEL: str = "minimax-m2.7"
|
|
||||||
|
|
||||||
NVIDIA_API_KEY: Optional[str] = None
|
NVIDIA_API_KEY: Optional[str] = None
|
||||||
NVIDIA_BASE_URL: str = "https://integrate.api.nvidia.com/v1"
|
NVIDIA_BASE_URL: str = "https://integrate.api.nvidia.com/v1"
|
||||||
@@ -74,15 +68,6 @@ class Settings(BaseSettings):
|
|||||||
SENTRY_DSN: Optional[str] = None
|
SENTRY_DSN: Optional[str] = None
|
||||||
DEBUG: bool = True
|
DEBUG: bool = True
|
||||||
|
|
||||||
AI_ROUTING: dict = {
|
|
||||||
"translate": {"primary": "sensenova", "fallback": ["alibaba-mt", "opencode_go"]},
|
|
||||||
"reply": {"primary": "sensenova", "fallback": ["opencode_go"]},
|
|
||||||
"marketing": {"primary": "sensenova", "fallback": ["opencode_go"]},
|
|
||||||
"extract": {"primary": "sensenova", "fallback": ["opencode_go"]},
|
|
||||||
"quotation": {"primary": "sensenova", "fallback": ["opencode_go"]},
|
|
||||||
"chat": {"primary": "sensenova", "fallback": ["opencode_go", "nvidia"]},
|
|
||||||
}
|
|
||||||
|
|
||||||
FREE_DAILY_TRANSLATE_CHARS: int = 5000
|
FREE_DAILY_TRANSLATE_CHARS: int = 5000
|
||||||
FREE_DAILY_REPLIES: int = 20
|
FREE_DAILY_REPLIES: int = 20
|
||||||
FREE_DAILY_MARKETING: int = 5
|
FREE_DAILY_MARKETING: int = 5
|
||||||
|
|||||||
@@ -288,11 +288,14 @@ class AdminService:
|
|||||||
|
|
||||||
async def _seed_default_configs(self):
|
async def _seed_default_configs(self):
|
||||||
defaults = [
|
defaults = [
|
||||||
SystemConfig(key="ai_provider_translate", value={"primary": "sensenova", "fallback": ["alibaba-mt", "opencode_go"]}, description="翻译任务 AI 模型选择"),
|
SystemConfig(key="ai_routing", value={
|
||||||
SystemConfig(key="ai_provider_reply", value={"primary": "sensenova", "fallback": ["opencode_go"]}, description="回复建议 AI 模型选择"),
|
"translate": {"primary": "sensenova", "fallback": ["alibaba-mt", "nvidia"]},
|
||||||
SystemConfig(key="ai_provider_marketing", value={"primary": "sensenova", "fallback": ["opencode_go"]}, description="营销文案 AI 模型选择"),
|
"reply": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||||
SystemConfig(key="ai_provider_extract", value={"primary": "sensenova", "fallback": ["opencode_go"]}, description="信息提取 AI 模型选择"),
|
"marketing": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||||
SystemConfig(key="ai_provider_quotation", value={"primary": "sensenova", "fallback": ["opencode_go"]}, description="报价单 AI 模型选择"),
|
"extract": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||||
|
"quotation": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||||
|
"chat": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||||
|
}, description="AI 路由规则:各任务的主选/备用供应商"),
|
||||||
SystemConfig(key="feature_guest_mode", value={"enabled": True}, description="游客模式开关"),
|
SystemConfig(key="feature_guest_mode", value={"enabled": True}, description="游客模式开关"),
|
||||||
SystemConfig(key="feature_wechat_login", value={"enabled": False}, description="微信登录开关"),
|
SystemConfig(key="feature_wechat_login", value={"enabled": False}, description="微信登录开关"),
|
||||||
SystemConfig(key="feature_registration", value={"enabled": True}, description="新用户注册开关"),
|
SystemConfig(key="feature_registration", value={"enabled": True}, description="新用户注册开关"),
|
||||||
@@ -305,6 +308,19 @@ class AdminService:
|
|||||||
self.db.add(cfg)
|
self.db.add(cfg)
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
|
|
||||||
|
async def _migrate_routing_configs(self):
|
||||||
|
from sqlalchemy import delete
|
||||||
|
# Remove stale individual routing keys (replaced by consolidated ai_routing)
|
||||||
|
stale_prefixes = ["ai_provider_translate", "ai_provider_reply", "ai_provider_marketing",
|
||||||
|
"ai_provider_extract", "ai_provider_quotation"]
|
||||||
|
for key in stale_prefixes:
|
||||||
|
await self.db.execute(
|
||||||
|
delete(SystemConfig).where(SystemConfig.key == key)
|
||||||
|
)
|
||||||
|
if stale_prefixes:
|
||||||
|
await self.db.flush()
|
||||||
|
logger.info("Cleaned up stale ai_provider_* routing configs")
|
||||||
|
|
||||||
async def list_config(self) -> List[Dict[str, Any]]:
|
async def list_config(self) -> List[Dict[str, Any]]:
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(func.count(SystemConfig.id))
|
select(func.count(SystemConfig.id))
|
||||||
@@ -312,6 +328,28 @@ class AdminService:
|
|||||||
if result.scalar() == 0:
|
if result.scalar() == 0:
|
||||||
await self._seed_default_configs()
|
await self._seed_default_configs()
|
||||||
|
|
||||||
|
await self._migrate_routing_configs()
|
||||||
|
|
||||||
|
# Ensure consolidated ai_routing exists
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(SystemConfig).where(SystemConfig.key == "ai_routing")
|
||||||
|
)
|
||||||
|
if not result.scalar_one_or_none():
|
||||||
|
self.db.add(SystemConfig(
|
||||||
|
key="ai_routing",
|
||||||
|
value={
|
||||||
|
"translate": {"primary": "sensenova", "fallback": ["alibaba-mt", "nvidia"]},
|
||||||
|
"reply": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||||
|
"marketing": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||||
|
"extract": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||||
|
"quotation": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||||
|
"chat": {"primary": "sensenova", "fallback": ["nvidia"]},
|
||||||
|
},
|
||||||
|
description="AI 路由规则:各任务的主选/备用供应商",
|
||||||
|
))
|
||||||
|
await self.db.flush()
|
||||||
|
logger.info("Seeded ai_routing config")
|
||||||
|
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(SystemConfig).order_by(SystemConfig.key)
|
select(SystemConfig).order_by(SystemConfig.key)
|
||||||
)
|
)
|
||||||
@@ -336,6 +374,12 @@ class AdminService:
|
|||||||
config.value = value
|
config.value = value
|
||||||
config.updated_at = datetime.utcnow()
|
config.updated_at = datetime.utcnow()
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
|
|
||||||
|
if key == "ai_routing":
|
||||||
|
from app.ai.router import get_ai_router
|
||||||
|
await get_ai_router().reload_from_db(self.db)
|
||||||
|
logger.info("AI router reloaded after ai_routing config update")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"key": config.key,
|
"key": config.key,
|
||||||
"value": config.value,
|
"value": config.value,
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ class TranslationQuotaService:
|
|||||||
def __init__(self, db: AsyncSession):
|
def __init__(self, db: AsyncSession):
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
|
def _default_desc(self, version: str) -> str:
|
||||||
|
labels = {"ecommerce": "阿里云翻译电商版", "general": "阿里云翻译通用版", "llm": "AI模型翻译"}
|
||||||
|
return labels.get(version, f"阿里云翻译{version}版")
|
||||||
|
|
||||||
async def _get_or_create(self, version: str) -> TranslationQuota:
|
async def _get_or_create(self, version: str) -> TranslationQuota:
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
select(TranslationQuota).where(TranslationQuota.version == version)
|
select(TranslationQuota).where(TranslationQuota.version == version)
|
||||||
@@ -25,7 +29,7 @@ class TranslationQuotaService:
|
|||||||
used_chars=0,
|
used_chars=0,
|
||||||
current_month=now.strftime("%Y-%m"),
|
current_month=now.strftime("%Y-%m"),
|
||||||
enabled=True,
|
enabled=True,
|
||||||
description=f"阿里云翻译{version}版",
|
description=self._default_desc(version),
|
||||||
)
|
)
|
||||||
self.db.add(quota)
|
self.db.add(quota)
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
@@ -57,8 +61,7 @@ class TranslationQuotaService:
|
|||||||
return remaining
|
return remaining
|
||||||
|
|
||||||
async def get_all_quotas(self) -> list:
|
async def get_all_quotas(self) -> list:
|
||||||
default_versions = ["ecommerce", "general"]
|
for v in ("ecommerce", "general", "llm"):
|
||||||
for v in default_versions:
|
|
||||||
await self._get_or_create(v)
|
await self._get_or_create(v)
|
||||||
|
|
||||||
result = await self.db.execute(select(TranslationQuota).order_by(TranslationQuota.version))
|
result = await self.db.execute(select(TranslationQuota).order_by(TranslationQuota.version))
|
||||||
|
|||||||
@@ -14,11 +14,12 @@ class TestConfig:
|
|||||||
assert settings.REFRESH_TOKEN_EXPIRE_DAYS == 30
|
assert settings.REFRESH_TOKEN_EXPIRE_DAYS == 30
|
||||||
|
|
||||||
def test_ai_routing_config(self):
|
def test_ai_routing_config(self):
|
||||||
assert "translate" in settings.AI_ROUTING
|
from app.ai.router import DEFAULT_ROUTING
|
||||||
assert "reply" in settings.AI_ROUTING
|
assert "translate" in DEFAULT_ROUTING
|
||||||
assert "marketing" in settings.AI_ROUTING
|
assert "reply" in DEFAULT_ROUTING
|
||||||
assert "extract" in settings.AI_ROUTING
|
assert "marketing" in DEFAULT_ROUTING
|
||||||
assert "primary" in settings.AI_ROUTING["translate"]
|
assert "extract" in DEFAULT_ROUTING
|
||||||
|
assert "primary" in DEFAULT_ROUTING["translate"]
|
||||||
|
|
||||||
def test_free_tier_limits(self):
|
def test_free_tier_limits(self):
|
||||||
assert settings.FREE_DAILY_TRANSLATE_CHARS == 5000
|
assert settings.FREE_DAILY_TRANSLATE_CHARS == 5000
|
||||||
|
|||||||
@@ -116,4 +116,7 @@ export function sendWhatsApp(data) { return http.post('/whatsapp/send', data) }
|
|||||||
|
|
||||||
export function getUsageStats() { return http.get('/usage/stats') }
|
export function getUsageStats() { return http.get('/usage/stats') }
|
||||||
|
|
||||||
|
export function aiChat(message, history = []) { return http.post('/ai/chat', { message, history }) }
|
||||||
|
export function aiQuickQuestions() { return http.get('/ai/quick-questions') }
|
||||||
|
|
||||||
export default http
|
export default http
|
||||||
|
|||||||
@@ -0,0 +1,382 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="ai-float-btn" @click="visible = true">
|
||||||
|
<span class="ai-float-icon">AI</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="visible"
|
||||||
|
title="TradeMate AI 助手"
|
||||||
|
width="480px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
class="ai-dialog"
|
||||||
|
top="5vh"
|
||||||
|
@opened="onOpened"
|
||||||
|
>
|
||||||
|
<div class="ai-messages" ref="msgContainer">
|
||||||
|
<div v-for="(msg, i) in messages" :key="i" class="ai-msg-row" :class="msg.role">
|
||||||
|
<div class="ai-avatar" v-if="msg.role === 'assistant'">
|
||||||
|
<el-icon :size="18" color="#667eea"><Cpu /></el-icon>
|
||||||
|
</div>
|
||||||
|
<div class="ai-msg-body">
|
||||||
|
<div class="ai-msg-bubble">
|
||||||
|
<div class="ai-msg-text">{{ msg.content }}</div>
|
||||||
|
|
||||||
|
<div v-if="msg.actions && msg.actions.length" class="ai-action-card">
|
||||||
|
<div v-for="(action, ai) in msg.actions" :key="ai" class="ai-action-item">
|
||||||
|
<div class="ai-action-title">{{ action.label }}</div>
|
||||||
|
<div v-for="(val, key) in action.fields" :key="key" class="ai-field-row">
|
||||||
|
<span class="ai-field-label">{{ fieldLabel(action.type, key) }}</span>
|
||||||
|
<el-input
|
||||||
|
v-model="action.fields[key]"
|
||||||
|
:placeholder="fieldPlaceholder(action.type, key)"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="ai-action-btns">
|
||||||
|
<el-button size="small" @click="cancelAction(i, ai)">取消</el-button>
|
||||||
|
<el-button size="small" type="primary" @click="confirmAction(i, ai)">确认执行</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="ai-loading">
|
||||||
|
<el-icon class="is-loading" :size="18"><Loading /></el-icon>
|
||||||
|
<span>思考中...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showSuggestions" class="ai-suggestions">
|
||||||
|
<div
|
||||||
|
v-for="(s, i) in suggestions"
|
||||||
|
:key="i"
|
||||||
|
class="ai-suggestion"
|
||||||
|
@click="sendQuick(s)"
|
||||||
|
>
|
||||||
|
{{ s }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="ai-input-bar">
|
||||||
|
<el-input
|
||||||
|
v-model="inputText"
|
||||||
|
placeholder="输入你的问题..."
|
||||||
|
:disabled="loading"
|
||||||
|
size="default"
|
||||||
|
@keyup.enter="send"
|
||||||
|
/>
|
||||||
|
<el-button type="primary" :disabled="!inputText.trim() || loading" @click="send">发送</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, nextTick } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { Cpu, Loading } from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
aiChat, aiQuickQuestions,
|
||||||
|
createCustomer, createProduct, createQuotation,
|
||||||
|
scanFollowups, generateMarketing, discoverySearch,
|
||||||
|
} from '@/api'
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const inputText = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const suggestions = ref([])
|
||||||
|
const msgContainer = ref(null)
|
||||||
|
const messages = ref([
|
||||||
|
{ role: 'assistant', content: '你好!我是 TradeMate AI 助手,可以帮你操作外贸工具的各项功能。你可以说:"帮我添加一个客户"、"创建一个产品"、"生成报价单"等。' },
|
||||||
|
])
|
||||||
|
const showSuggestions = ref(false)
|
||||||
|
|
||||||
|
const fieldLabel = (type, key) => {
|
||||||
|
const labels = {
|
||||||
|
name: '名称', phone: '电话', email: '邮箱', company: '公司',
|
||||||
|
country: '国家', website: '网站', notes: '备注',
|
||||||
|
name_en: '英文名称', description: '描述', description_en: '英文描述',
|
||||||
|
category: '分类', price: '价格', price_unit: '货币', moq: 'MOQ',
|
||||||
|
keywords: '关键词',
|
||||||
|
customer_name: '客户名称', product_info: '产品信息', quantity: '数量',
|
||||||
|
terms: '交易条款',
|
||||||
|
message: '消息内容',
|
||||||
|
product_name: '产品名称', target_market: '目标市场', tone: '语气',
|
||||||
|
language: '语言',
|
||||||
|
industry: '行业',
|
||||||
|
}
|
||||||
|
return labels[key] || key
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldPlaceholder = (type, key) => {
|
||||||
|
const placeholders = {
|
||||||
|
phone: '如 +86-13800138000',
|
||||||
|
email: '如 contact@example.com',
|
||||||
|
price: '如 12.50',
|
||||||
|
price_unit: '默认 USD',
|
||||||
|
keywords: '逗号分隔',
|
||||||
|
quantity: '如 1000 pcs',
|
||||||
|
target_market: '如 美国、欧洲',
|
||||||
|
}
|
||||||
|
return placeholders[key] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelAction = (msgIdx, actionIdx) => {
|
||||||
|
const msg = messages.value[msgIdx]
|
||||||
|
if (msg.actions) msg.actions.splice(actionIdx, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmAction = async (msgIdx, actionIdx) => {
|
||||||
|
const action = messages.value[msgIdx].actions[actionIdx]
|
||||||
|
const { type, fields } = action
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
switch (type) {
|
||||||
|
case 'create_customer':
|
||||||
|
if (!fields.name) { ElMessage.warning('客户名称不能为空'); return }
|
||||||
|
await createCustomer(fields)
|
||||||
|
break
|
||||||
|
case 'create_product':
|
||||||
|
if (!fields.name) { ElMessage.warning('产品名称不能为空'); return }
|
||||||
|
if (fields.keywords && typeof fields.keywords === 'string') {
|
||||||
|
fields.keywords = fields.keywords.split(/[,,]/).map(s => s.trim()).filter(Boolean)
|
||||||
|
}
|
||||||
|
await createProduct(fields)
|
||||||
|
break
|
||||||
|
case 'create_quotation':
|
||||||
|
if (!fields.customer_name) { ElMessage.warning('客户名称不能为空'); return }
|
||||||
|
if (!fields.product_info) { ElMessage.warning('产品信息不能为空'); return }
|
||||||
|
if (!fields.quantity) { ElMessage.warning('数量不能为空'); return }
|
||||||
|
await createQuotation({
|
||||||
|
customer_name: fields.customer_name,
|
||||||
|
items: [{ description: fields.product_info, quantity: fields.quantity, price: fields.price || '0' }],
|
||||||
|
terms: fields.terms || '',
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'scan_followups':
|
||||||
|
await scanFollowups()
|
||||||
|
break
|
||||||
|
case 'generate_marketing':
|
||||||
|
if (!fields.product_name) { ElMessage.warning('产品名称不能为空'); return }
|
||||||
|
await generateMarketing({
|
||||||
|
product_info: { name: fields.product_name },
|
||||||
|
target_market: fields.target_market,
|
||||||
|
tone: fields.tone,
|
||||||
|
language: fields.language,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
case 'discovery_search':
|
||||||
|
if (!fields.keywords) { ElMessage.warning('关键词不能为空'); return }
|
||||||
|
await discoverySearch({
|
||||||
|
keywords: fields.keywords.split(/[,,]/).map(s => s.trim()).filter(Boolean),
|
||||||
|
country: fields.country,
|
||||||
|
industry: fields.industry,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
ElMessage.warning(`未知操作类型: ${type}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.success('操作成功')
|
||||||
|
messages.value[msgIdx].actions = []
|
||||||
|
} catch (e) {
|
||||||
|
ElMessage.error(e.message || e.detail || '操作失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendQuick = (text) => {
|
||||||
|
inputText.value = text
|
||||||
|
send()
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchSuggestions = async () => {
|
||||||
|
try {
|
||||||
|
const res = await aiQuickQuestions()
|
||||||
|
if (Array.isArray(res)) suggestions.value = res
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onOpened = () => {
|
||||||
|
if (suggestions.value.length === 0) fetchSuggestions()
|
||||||
|
showSuggestions.value = messages.value.length <= 1
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
const msg = inputText.value.trim()
|
||||||
|
if (!msg || loading.value) return
|
||||||
|
inputText.value = ''
|
||||||
|
showSuggestions.value = false
|
||||||
|
messages.value.push({ role: 'user', content: msg })
|
||||||
|
loading.value = true
|
||||||
|
scrollToBottom()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const hist = messages.value.map(m => ({ role: m.role, content: m.content }))
|
||||||
|
const res = await aiChat(msg, hist.slice(0, -1))
|
||||||
|
const newMsg = { role: 'assistant', content: res.reply || '抱歉,我没有理解,请重新描述一下你的问题。' }
|
||||||
|
if (res.actions && res.actions.length) {
|
||||||
|
newMsg.actions = res.actions
|
||||||
|
}
|
||||||
|
messages.value.push(newMsg)
|
||||||
|
} catch {
|
||||||
|
messages.value.push({ role: 'assistant', content: '请求失败,请稍后重试。' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
scrollToBottom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToBottom = async () => {
|
||||||
|
await nextTick()
|
||||||
|
const el = msgContainer.value
|
||||||
|
if (el) el.scrollTop = el.scrollHeight
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ai-float-btn {
|
||||||
|
position: fixed;
|
||||||
|
right: 30px;
|
||||||
|
bottom: 30px;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 20px rgba(102, 126, 234, 0.4);
|
||||||
|
z-index: 9999;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.ai-float-btn:hover {
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
.ai-float-icon {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.ai-dialog {
|
||||||
|
--el-dialog-content-padding: 0;
|
||||||
|
}
|
||||||
|
.ai-dialog :deep(.el-dialog__body) {
|
||||||
|
padding: 0;
|
||||||
|
height: 480px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.ai-messages {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.ai-msg-row {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.ai-msg-row.user {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
.ai-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f0edff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.ai-msg-body {
|
||||||
|
max-width: 80%;
|
||||||
|
}
|
||||||
|
.ai-msg-bubble {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||||
|
}
|
||||||
|
.ai-msg-row.user .ai-msg-bubble {
|
||||||
|
background: #667eea;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.ai-msg-text {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.ai-msg-row.user .ai-msg-text {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.ai-action-card {
|
||||||
|
margin-top: 12px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
.ai-action-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.ai-field-row {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.ai-field-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
.ai-action-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.ai-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #999;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.ai-suggestions {
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
.ai-suggestion {
|
||||||
|
background: #f0edff;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #667eea;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.ai-suggestion:hover {
|
||||||
|
background: #e0dbff;
|
||||||
|
}
|
||||||
|
.ai-input-bar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -102,6 +102,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
<AiAssistant />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -110,6 +111,7 @@ import { ref, computed, onMounted } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { getUnreadCount } from '@/api'
|
import { getUnreadCount } from '@/api'
|
||||||
|
import AiAssistant from '@/components/AiAssistant.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@@ -193,7 +193,8 @@ async function register() {
|
|||||||
regForm.password = ''
|
regForm.password = ''
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const map = { 'Phone already registered': '该手机号已被注册', 'Invalid credentials': '手机号或密码错误' }
|
const map = { 'Phone already registered': '该手机号已被注册', 'Invalid credentials': '手机号或密码错误' }
|
||||||
regError.value = map[e?.detail] || e?.detail || '注册失败'
|
const detail = typeof e === 'string' ? e : e?.detail
|
||||||
|
regError.value = map[detail] || detail || '注册失败,请检查网络连接'
|
||||||
} finally {
|
} finally {
|
||||||
regLoading.value = false
|
regLoading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 5174,
|
port: 5174,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': { target: 'http://localhost:8002', changeOrigin: true }
|
'/api': { target: 'http://localhost:8000', changeOrigin: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user