3 Commits

Author SHA1 Message Date
wmq af49d22ca7 feat: release contextual omnichannel customer platform
Add Telegram, WhatsApp and email account management, realtime messaging, contextual multilingual AI, customer intelligence, knowledge workflows, enterprise controls, a unified design system and complete repository documentation.
2026-07-30 02:29:53 +08:00
wmq 9804341463 feat(history): add multi-account and WhatsApp foundation 2026-07-30 00:14:32 +08:00
wmq 7f09132fad feat(history): complete messaging core before multi-account gateways 2026-07-28 22:36:58 +08:00
107 changed files with 15154 additions and 1153 deletions
+11
View File
@@ -15,6 +15,12 @@ TELEGRAM_GATEWAY_SERVICE_EMAIL=replace-with-gateway-service-email
TELEGRAM_GATEWAY_SERVICE_PASSWORD=replace-with-random-service-password
TELEGRAM_GATEWAY_ACCOUNT_ID=replace-with-default-account-id
TELEGRAM_WEBHOOK_SECRET=replace-with-random-webhook-secret
# 普通 WhatsApp 账户通过“关联设备”接入。两个值均至少 32 位且必须彼此不同。
WHATSAPP_GATEWAY_TOKEN=replace-with-at-least-32-random-characters
WHATSAPP_WEBHOOK_SECRET=replace-with-another-32-random-secret
WHATSAPP_LOG_LEVEL=info
PUBLIC_WEBHOOK_BASE_URL=http://host.docker.internal:8088
APP_PUBLIC_URL=http://127.0.0.1:8088
@@ -26,6 +32,11 @@ SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM=
# 企业邮箱渠道(IMAP 收信 + SMTP 发信)
# 建议使用 32 字节以上独立随机值;未配置时回退到 LX_JWT_SECRET 派生密钥。
EMAIL_CREDENTIAL_KEY=
EMAIL_POLL_MS=30000
# 真实 Stripe Checkout
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
+22
View File
@@ -0,0 +1,22 @@
* text=auto
*.js text eol=lf
*.jsx text eol=lf
*.ts text eol=lf
*.tsx text eol=lf
*.json text eol=lf
*.md text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.css text eol=lf
*.html text eol=lf
*.svg text eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.webp binary
*.ico binary
*.pdf binary
*.docx binary
+49 -8
View File
@@ -1,13 +1,54 @@
node_modules
dist
dist-ssr
# Dependencies and build output
node_modules/
**/node_modules/
dist/
**/dist/
dist-ssr/
coverage/
.vite/
# Local configuration and secrets
.env
server/.env
.env.*
!.env.example
*.local
.env.local
.env.*.local
*.pem
*.key
id_rsa*
id_ed25519*
# Runtime data
.smoke-data/
**/.smoke-data/
data/
uploads/
storage/
*.db
*.sqlite
*.sqlite3
*.dump
*.sql
# Test and diagnostic output
playwright-report/
test-results/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.smoke-*.log
# Archives and local backups
*.zip
*.7z
*.tar
*.tar.gz
backups/
# Editors and OS metadata
.DS_Store
.idea
Thumbs.db
.idea/
.vscode/*
!.vscode/extensions.json
npm-debug.log*
.claude/
+45
View File
@@ -0,0 +1,45 @@
# 版本迭代记录
本项目遵循语义化版本。早期版本由经过脱敏的本地源码快照恢复,因此使用带日期的 `snapshot-*` 标签;自 `v1.0.0` 起按正式发布流程维护。
## [1.0.0] - 2026-07-30
### 新增
- Telegram、普通 WhatsApp 和企业邮箱统一消息中心。
- 多账号绑定、默认账号切换、渠道状态和账号级操作。
- WebSocket 实时消息、全站通知、已读上报和历史游标分页。
- 图片、文档、语音、贴纸、相册等媒体链路。
- 上下文感知的多语种双向翻译、逐句意图识别及独立开关。
- 每会话上下文记忆、自动压缩、最近消息优先和历史低优先级补齐。
- 情绪、风险、客户画像、雷达图、知识库、智能回复与翻译备注。
- WhatsApp 消息状态、撤回/编辑事件、输入状态、群组、联系人、代理、设备、失败重试、死信重放及备份接口。
- 团队、RBAC、API Key、审计、商机、任务、分析和支付基础能力。
- 全站蓝色品牌设计系统和 Telegram 风格消息界面。
### 修复
- 修复会话点击后错误置顶、删除群组后残留、消息列表空值崩溃。
- 修复语言切换、意图开关 JSON 写入、智能回复采纳与关闭。
- 修复头像、用户名、国家信息和渠道文案混用。
- 修复历史消息、实时会话列表、WhatsApp 媒体下载和状态同步问题。
## [snapshot-2026.07.30-pre-ui] - 2026-07-30
- 多账号与 WhatsApp 接入后的界面改造前快照。
- 已具备主要业务模块,保留后续上下文翻译与界面统一前的状态。
## [snapshot-2026.07.28-pre-multiaccount] - 2026-07-28
- Telegram 消息闭环和主要 SaaS 页面快照。
- 多账号、WhatsApp 网关与后续企业级能力尚未合入。
## [snapshot-2026.07.27-platform] - 2026-07-27
- React 前端、Node.js API、PostgreSQL 与 Telegram Gateway 集成版本。
- 建立客户、消息、AI、团队与设置等平台基础。
## [snapshot-2026.07.25-initial] - 2026-07-25
- 初始 Web 产品原型与页面结构。
- 作为历史参考保留,不建议部署。
+38
View File
@@ -0,0 +1,38 @@
# 协作与提交规范
## 分支
- `main`:始终保持可构建、可部署。
- `feature/<name>`:新功能。
- `fix/<name>`:缺陷修复。
- `docs/<name>`:仅文档。
禁止直接覆盖历史标签。合并前应同步 `main` 并完成与变更风险相匹配的测试。
## 提交信息
使用 Conventional Commits:
```text
feat(inbox): add message intent toggle
fix(whatsapp): reconcile delivery status
docs(deploy): document reverse proxy setup
```
常用类型:`feat`、`fix`、`refactor`、`test`、`docs`、`chore`、`perf`、`security`。
## 变更检查
1. 不提交密钥、账号登录态、数据库、客户媒体和本地日志。
2. 更新相关接口类型、错误文案和文档。
3. 前端至少运行 `npm run typecheck` 和 `npm run build`。
4. 后端改动运行 `server` 测试;WhatsApp 改动运行网关测试。
5. 用户路径变更补充或更新 Playwright E2E。
6. 数据结构变更必须可重复执行,并说明备份与回滚步骤。
## 发布
1. 更新 `CHANGELOG.md` 和版本号。
2. 完成全量测试和 Docker 健康检查。
3. 创建带说明的版本标签:`git tag -a vX.Y.Z -m "..."`。
4. 推送分支和标签,随后按部署文档发布。
+54 -28
View File
@@ -1,53 +1,79 @@
# 灵犀跨境
灵犀跨境是面向跨境业务团队的 Telegram 客户沟通与 AI 经营平台。当前版本只开放 Telegram,所有开放功能均访问真实 API、PostgreSQL、DeepSeek 和 Telegram Gateway,不包含前端 Mock 数据。
灵犀跨境是一套面向跨境业务团队的多渠道客户经营 SaaS。系统将 Telegram、普通 WhatsApp 账号和企业邮箱接入统一消息中心,并提供上下文翻译、逐句意图识别、情绪与风险分析、客户画像、商机、任务、知识库和经营分析能力。
## 服务组成
当前仓库包含 Web 前端、业务 API 和 WhatsApp Gateway;Telegram 通过独立部署的 Telegram Gateway 接入。开放功能均连接真实数据库和真实网关,不使用前端 Mock 作为生产数据源。
- React + TypeScript 前端,由 Nginx 提供静态资源和 API 反向代理。
- Node.js API,负责独立租户、用户认证、CRM、会话、AI 和网关适配。
- PostgreSQL,持久化租户隔离的业务数据。
- 已有 Telegram Gateway,负责真实 Telegram 账号与 TDLib 操作。
- DeepSeek Anthropic-compatible API,负责翻译、情感、意图、风险、摘要和回复建议。
## 核心能力
## Docker 启动
- 多租户注册、登录、RBAC、API Key、密码修改和会话撤销。
- 多 Telegram、WhatsApp、企业邮箱账号绑定、切换和状态管理。
- 实时会话、历史分页、已读、引用、媒体、消息状态和 WebSocket 推送。
- 自动识别目标语言的双向翻译,逐会话上下文压缩与历史后台补齐。
- 情感、意图、风险、摘要、智能回复和知识库辅助。
- 客户、标签、画像、商机、任务、团队、通知、审计和经营分析。
- WhatsApp 群组、联系人、代理、设备、重试、死信重放与备份接口。
1. 复制 `.env.example` 为 `.env`。
2. 生成数据库、JWT、Webhook 随机密钥。
3. 设置轮换后的 `DEEPSEEK_API_KEY`。
4. 设置 Telegram Gateway 的租户令牌、租户 ID 和账号 ID。
5. 启动:
## 快速启动
要求:Docker Engine 24+、Docker Compose v2,以及一个可访问的 Telegram Gateway。
```powershell
Copy-Item .env.example .env
# 编辑 .env,替换所有 replace-with-* 配置
docker compose --env-file .env up -d --build
docker compose ps
```
默认访问地址:`http://127.0.0.1:8088`。
健康检查:
默认入口为 `http://127.0.0.1:8088`。存活与就绪检查:
```powershell
Invoke-RestMethod http://127.0.0.1:8088/health
Invoke-RestMethod http://127.0.0.1:8088/api/dashboard/overview
docker compose exec api node -e "fetch('http://127.0.0.1:3100/health/ready').then(async r=>{console.log(await r.text());process.exit(r.ok?0:1)})"
```
业务 API 需要通过注册或登录取得 Bearer Token。浏览器永远不会收到 DeepSeek 或 Telegram Gateway 服务凭据。
完整部署步骤、生产加固和回滚方法见 [部署文档](docs/DEPLOYMENT.md)。
## 开发检查
## 本地开发与测试
```powershell
npm install
npm ci
npm run typecheck
npm run build
Set-Location server
npm install
node --check src/index.js
Push-Location server
npm ci
npm test
Pop-Location
Push-Location whatsapp-gateway
npm ci
npm test
Pop-Location
npm run test:e2e
```
## 安全说明
E2E 默认访问 `http://127.0.0.1:8088`,可通过 `E2E_BASE_URL` 修改。
- `.env` 已加入 `.gitignore`,严禁提交真实密钥。
- AI 回复只提供人工确认建议,不会自动向 Telegram 发送。
- 所有业务资源都通过 JWT 中的租户 ID 隔离。
- Webhook 必须配置独立随机密钥;生产部署应进一步使用 HTTPS、反向代理和 IP 白名单。
## 文档
- [系统架构](docs/ARCHITECTURE.md)
- [技术选型](docs/TECH-STACK.md)
- [配置说明](docs/CONFIGURATION.md)
- [部署与回滚](docs/DEPLOYMENT.md)
- [接口目录](docs/API.md)
- [测试策略](docs/TESTING.md)
- [运维手册](docs/OPERATIONS.md)
- [备份恢复](docs/BACKUP-RESTORE.md)
- [版本历史](CHANGELOG.md)
- [协作规范](CONTRIBUTING.md)
- [安全策略](SECURITY.md)
## 版本
`main` 保存当前版本。历史源码以 `snapshot-YYYY.MM.DD-*` 标签归档,当前稳定版本使用语义化标签。任何历史标签都可通过 `git checkout <tag>` 独立检出。
## 安全
禁止提交 `.env`、数据库导出、私钥、Telegram/WhatsApp 登录态或客户媒体。浏览器不接触 DeepSeek 与网关服务凭据。发现安全问题请按 [SECURITY.md](SECURITY.md) 中的私下通报流程处理。
+28
View File
@@ -0,0 +1,28 @@
# 安全策略
## 私下报告
安全漏洞、凭据泄漏或租户越权问题不要提交公开 Issue。请通过公司内部安全联系人或仓库维护者私下报告,并附影响范围、复现条件和建议缓解方式。
## 敏感数据
以下内容不得进入 Git:
- `.env` 与任何真实 API Key、JWT、Webhook、SMTP、数据库密码。
- SSH 私钥、证书私钥和个人访问令牌。
- Telegram TDLib、WhatsApp Baileys 登录态及关联设备凭据。
- PostgreSQL 导出、客户消息、联系人、媒体与生产日志。
若密钥曾出现在聊天、日志、压缩包或提交中,应立即轮换;仅从提交中删除并不能消除泄漏风险。
## 上线基线
- 所有公网入口启用 HTTPS、HSTS 和可信反向代理。
- 每个环境使用不同的强随机密钥;Webhook 密钥互不复用。
- PostgreSQL 与网关不直接暴露公网。
- 管理操作执行 RBAC、租户归属校验、审计和必要的二次确认。
- 定期验证备份可恢复,并为登录、Webhook、消息发送和 AI 请求设置限流与告警。
## 支持范围
安全修复优先发布到当前 `main` 和最新稳定版本。历史 `snapshot-*` 仅用于审计与回溯,不提供安全维护。
+1
View File
@@ -0,0 +1 @@
1.0.0
+14
View File
@@ -0,0 +1,14 @@
name: lingxi-email-protocol
services:
greenmail:
image: lingxi-greenmail-test:2.1.8-runtime
ports:
- "127.0.0.1:3025:3025"
- "127.0.0.1:3143:3143"
healthcheck:
test: ["CMD-SHELL", "nc -z 127.0.0.1 3025"]
interval: 3s
timeout: 2s
retries: 20
restart: unless-stopped
+24
View File
@@ -16,6 +16,21 @@ services:
retries: 10
restart: unless-stopped
whatsapp-gateway:
build:
context: ./whatsapp-gateway
environment:
PORT: 3200
DATA_DIR: /data
WHATSAPP_GATEWAY_TOKEN: ${WHATSAPP_GATEWAY_TOKEN:?WHATSAPP_GATEWAY_TOKEN is required}
LOG_LEVEL: ${WHATSAPP_LOG_LEVEL:-info}
volumes:
- lingxi_whatsapp_data:/data
dns:
- 1.1.1.1
- 8.8.8.8
restart: unless-stopped
api:
build:
context: ./server
@@ -33,6 +48,10 @@ services:
TELEGRAM_GATEWAY_SERVICE_PASSWORD: ${TELEGRAM_GATEWAY_SERVICE_PASSWORD:?TELEGRAM_GATEWAY_SERVICE_PASSWORD is required}
TELEGRAM_GATEWAY_ACCOUNT_ID: ${TELEGRAM_GATEWAY_ACCOUNT_ID:-}
TELEGRAM_WEBHOOK_SECRET: ${TELEGRAM_WEBHOOK_SECRET:?TELEGRAM_WEBHOOK_SECRET is required}
WHATSAPP_GATEWAY_URL: http://whatsapp-gateway:3200
WHATSAPP_GATEWAY_TOKEN: ${WHATSAPP_GATEWAY_TOKEN:?WHATSAPP_GATEWAY_TOKEN is required}
WHATSAPP_WEBHOOK_SECRET: ${WHATSAPP_WEBHOOK_SECRET:?WHATSAPP_WEBHOOK_SECRET is required}
WHATSAPP_WEBHOOK_BASE_URL: ${WHATSAPP_WEBHOOK_BASE_URL:-http://api:3100}
PUBLIC_WEBHOOK_BASE_URL: ${PUBLIC_WEBHOOK_BASE_URL:-http://host.docker.internal:8088}
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://127.0.0.1:8088}
SMTP_HOST: ${SMTP_HOST:-}
@@ -41,6 +60,8 @@ services:
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM: ${SMTP_FROM:-}
EMAIL_CREDENTIAL_KEY: ${EMAIL_CREDENTIAL_KEY:-}
EMAIL_POLL_MS: ${EMAIL_POLL_MS:-30000}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
STRIPE_PRICE_LITE: ${STRIPE_PRICE_LITE:-}
STRIPE_PRICE_STANDARD: ${STRIPE_PRICE_STANDARD:-}
@@ -54,6 +75,8 @@ services:
depends_on:
postgres:
condition: service_healthy
whatsapp-gateway:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3100/health/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 10s
@@ -75,3 +98,4 @@ services:
volumes:
lingxi_pgdata:
lingxi_whatsapp_data:
+4
View File
@@ -0,0 +1,4 @@
FROM lingxi-greenmail-test:2.1.8
ENTRYPOINT ["java"]
CMD ["-Dgreenmail.setup.test.smtp", "-Dgreenmail.setup.test.imap", "-Dgreenmail.hostname=0.0.0.0", "-Dgreenmail.users.login=email", "-Dgreenmail.users=sender:pwd@local.test,receiver:pwd@local.test", "-jar", "/tmp/greenmail.jar"]
+81
View File
@@ -0,0 +1,81 @@
# API 目录
API 统一位于 `/api`,内部渠道回调位于 `/internal/webhooks`。除注册、登录、健康检查和签名 Webhook 外,业务接口均要求 `Authorization: Bearer <token>`。
成功响应采用统一数据包装;错误响应包含可展示消息和 HTTP 状态。调用方必须处理 `401`、`403`、`404`、`409`、`429` 和 `5xx`。
## 认证与安全
- `POST /api/auth/register`、`POST /api/auth/login`
- `GET /api/auth/me`
- `POST /api/auth/ws-ticket`:生成短期 WebSocket 票据
- `POST /api/auth/change-password`
- `POST /api/auth/logout-all`
- `GET|POST|DELETE /api/api-keys`
## 消息与会话
- `GET /api/conversations`
- `GET /api/conversations/:id`
- `GET /api/conversations/:id/messages/page`:游标分页
- `POST /api/conversations/:id/messages`:发送文本/回复
- `POST /api/conversations/:id/media`
- `POST /api/conversations/:id/album`
- `POST /api/conversations/:id/read`
- `POST /api/conversations/:id/presence`
- `POST /api/conversations/:id/messages/action`
- `PATCH /api/conversations/:id/messages/:messageId`
- `GET /api/conversations/:id/messages/:messageId/attachment`
- `POST /api/conversations/:id/actions`:归档、静音、清空、删除等会话动作
## 渠道
- `GET /api/channels`
- Telegram:`/api/channels/telegram/start` 以及 `phone`、`code`、`password`、`state`
- WhatsApp:`/api/channels/whatsapp/start`、状态、联系人、代理、设备、资料、指标和备份
- 邮箱:`POST /api/channels/email/connect`
- 通用:设为默认、状态、日志、删除和渠道测试
- 可靠投递:Webhook 投递记录/重放、出站任务/重试
## WhatsApp 群组
- `GET /api/conversations/:id/whatsapp/group`
- `POST /api/conversations/:id/whatsapp/group/action`
动作由服务端白名单控制,包括成员、管理员、名称、描述和邀请链接管理。
## AI
- `POST /api/ai/translate`
- `POST /api/ai/analyze`
- `POST /api/ai/replies`
- `POST /api/ai/summarize`
- `POST /api/ai/text-action`
- `POST /api/ai/adoptions`
- `GET|PATCH /api/ai/translations`
- `GET /api/conversations/:id/ai-status`
- `POST /api/conversations/:id/messages/:messageId/analyze`
AI 接口是业务增强能力。模型超时或限流不应阻断原始消息收发。
## 客户经营与管理
- 客户:列表、详情、导入、导出、查重、合并和基础 CRUD。
- 商机、任务等资源:统一 CRUD 路由。
- 团队:成员、邀请、角色、操作日志和登录记录。
- 设置、通知、知识库、计费、仪表盘、分析与全局搜索。
## Webhook
- `POST /internal/webhooks/telegram/:tenantId/:channelId`
- `POST /internal/webhooks/whatsapp/:tenantId/:channelId`
生产调用必须携带对应签名。处理器同时校验 URL 中租户/渠道与数据库归属,事件应携带稳定 ID 以实现幂等。
## 健康检查
- `GET /health/live`:进程存活。
- `GET /health/ready`:依赖就绪。
- Nginx 暴露 `/health` 供容器健康检查。
接口的请求/响应类型以 `src/services/`、`src/types/` 和 `server/src/index.js` 为当前实现准则。正式对外开放 API 前,应从路由生成 OpenAPI 3 文档并进行契约测试。
+79
View File
@@ -0,0 +1,79 @@
# 系统架构
## 1. 架构目标
灵犀跨境以“租户隔离、渠道可扩展、消息实时、AI 异步化”为核心。浏览器只访问统一 Web/API 入口;Telegram、WhatsApp、邮箱和模型凭据只保存在服务端环境与加密存储中。
```text
Browser
│ HTTPS / WebSocket
▼
Nginx (web:80)
├── React SPA
└── /api, /internal, /health ──► Node.js API (:3100)
├── PostgreSQL 16
├── DeepSeek Anthropic API
├── Telegram Gateway / TDLib
├── WhatsApp Gateway / Baileys
├── IMAP + SMTP
└── Stripe
```
## 2. 仓库结构
| 路径 | 职责 |
| --- | --- |
| `src/` | React 页面、组件、状态、API 客户端、国际化与类型 |
| `server/src/index.js` | 认证、租户业务、消息、AI、渠道、Webhook、WebSocket 与数据初始化 |
| `whatsapp-gateway/` | 普通 WhatsApp 多账号连接、消息/媒体/群组/联系人操作 |
| `tests/e2e/` | Playwright 桌面与移动端验收 |
| `docker/` | Nginx 与邮件协议测试辅助镜像 |
| `docs/` | 架构、部署、接口、测试与运维文档 |
## 3. 核心数据流
### 入站消息
1. Telegram/WhatsApp 网关向带租户和渠道标识的内部 Webhook 投递事件;邮箱轮询器从 IMAP 拉取邮件。
2. API 校验签名、渠道归属与事件幂等标识。
3. 消息、会话、联系人和状态写入 PostgreSQL。
4. API 向当前租户的 WebSocket 客户端广播增量事件。
5. 文本消息进入 AI 高优先级队列;较早历史进入低优先级补齐队列。
6. 翻译、意图、情感、风险和会话记忆完成后再次广播更新。
### 出站消息
1. 前端提交渠道、会话、正文、媒体、回复目标和客户端幂等标识。
2. API 验证 RBAC、租户归属、渠道状态与限流。
3. 先落库发送任务,再调用对应网关或 SMTP。
4. 网关状态事件持续对账已发送、已送达、已读和失败状态。
5. 可恢复错误进入持久化重试;超过阈值进入死信等待人工重放。
### 上下文 AI
- 最近消息优先处理,以保证实时聊天速度。
- 每个会话维护独立上下文;超过长度阈值后压缩为结构化记忆。
- 历史消息按游标后台补齐,避免首次打开阻塞。
- 翻译目标可自动识别,也可由坐席指定;智能回复保持人工确认发送。
## 4. 隔离与权限
- JWT 含用户、租户和角色信息;业务查询必须带 `tenant_id`。
- 渠道、会话、消息、客户、任务与管理资源均在服务端校验租户归属。
- WebSocket 使用短期票据,不把长期 JWT 放入 URL。
- API Key 仅在创建时返回明文,持久化摘要并支持撤销。
- 邮箱密码使用 `EMAIL_CREDENTIAL_KEY` 加密;网关登录态位于独立持久卷。
## 5. 可靠性边界
- PostgreSQL 是平台业务数据的事实来源。
- Telegram/WhatsApp 平台状态由异步事件最终一致对账。
- WebSocket 只用于加速展示;断线后客户端必须通过 REST 游标补齐。
- Webhook 接收快速确认,耗时处理异步化;失败投递可查询和重放。
- AI 失败不能阻断原始消息收发,任务可独立重试。
## 6. 当前约束
- Telegram Gateway 为独立仓库和独立部署,本仓库只保存适配与配置。
- 普通 WhatsApp 接入依赖非官方 Web 多设备协议,生产使用前必须评估账号风控和平台条款。
- API 目前集中在单入口文件,业务增长后应按领域拆分路由、服务、仓储和迁移文件。
+60
View File
@@ -0,0 +1,60 @@
# 备份与恢复
## 备份范围
1. PostgreSQL:租户、用户、客户、消息、任务、审计和持久队列。
2. `lingxi_whatsapp_data`:WhatsApp 多设备登录态与网关缓存。
3. Telegram Gateway:由其独立部署执行 TDLib 数据和配置备份。
4. 环境密钥:存入公司 Secret 管理系统,不与数据备份打包。
5. 对象存储或上传目录:若生产启用,按独立生命周期备份。
## PostgreSQL
```bash
docker compose exec -T postgres pg_dump \
-U "${LX_DB_USER:-lingxi}" \
-d "${LX_DB_NAME:-lingxi_crossborder}" \
-Fc > "lingxi-postgres-$(date +%Y%m%d-%H%M%S).dump"
```
恢复必须在隔离环境先演练:
```bash
createdb lingxi_restore_test
pg_restore --clean --if-exists --no-owner \
--dbname lingxi_restore_test lingxi-postgres-YYYYMMDD-HHMMSS.dump
```
## WhatsApp 持久卷
备份前短暂停止网关写入,避免文件不一致:
```bash
docker compose stop whatsapp-gateway
docker run --rm \
-v lingxi-crossborder_lingxi_whatsapp_data:/source:ro \
-v "$PWD/backups:/backup" \
alpine tar -czf /backup/whatsapp-data.tar.gz -C /source .
docker compose start whatsapp-gateway
```
恢复前保留现有卷副本,在测试环境验证账号能重连;登录态过期时应走重新授权,不能把旧凭据强行用于其他账号。
## 策略
- PostgreSQL:每日全量,生产规模扩大后增加连续归档或托管 PITR。
- 网关登录态:每日加密备份,账号变更后额外备份。
- 至少保留 7 个日备、4 个周备、6 个月备,按法规和客户合同调整。
- 备份使用独立凭据、服务端加密和异地存储。
## 恢复演练
每月至少一次:
1. 在隔离网络创建全新环境。
2. 恢复数据库、网关数据和必要配置。
3. 验证租户数、关键表行数和最近消息时间。
4. 验证登录、会话读取、渠道重连、收发和媒体下载。
5. 记录 RPO、RTO、失败点和改进项。
仅“备份成功”不等于可恢复;没有完成验收的备份不得视为合格。
+63
View File
@@ -0,0 +1,63 @@
# 配置说明
以 `.env.example` 为唯一模板。开发、测试、生产分别保存自己的 `.env`,不得提交 Git。
## 基础配置
| 变量 | 必填 | 说明 |
| --- | --- | --- |
| `LX_DB_USER` | 否 | PostgreSQL 用户,默认 `lingxi` |
| `LX_DB_PASSWORD` | 是 | 数据库强密码 |
| `LX_DB_NAME` | 否 | 数据库名,默认 `lingxi_crossborder` |
| `LX_JWT_SECRET` | 是 | 至少 32 字节随机值 |
| `LX_WEB_PORT` | 否 | Web 暴露端口,默认 `8088` |
| `APP_PUBLIC_URL` | 是 | 用户访问的 HTTPS 根地址 |
| `PUBLIC_WEBHOOK_BASE_URL` | 是 | 外部网关可访问的 Webhook 根地址 |
## AI
| 变量 | 必填 | 说明 |
| --- | --- | --- |
| `DEEPSEEK_API_KEY` | 是 | 服务端模型密钥 |
| `DEEPSEEK_MODEL` | 是 | 当前默认 `deepseek-v4-flash` |
API 基础地址在 Compose 中设置为 `https://api.deepseek.com/anthropic`。切换模型前应验证 Anthropic 兼容接口、JSON 输出稳定性、延迟和成本。
## Telegram
| 变量 | 说明 |
| --- | --- |
| `TELEGRAM_GATEWAY_URL` | 独立 Telegram Gateway API 地址 |
| `TELEGRAM_GATEWAY_TOKEN` | 网关服务令牌 |
| `TELEGRAM_GATEWAY_TENANT_ID` | 网关租户 |
| `TELEGRAM_GATEWAY_SERVICE_EMAIL` / `PASSWORD` | 服务账号 |
| `TELEGRAM_GATEWAY_ACCOUNT_ID` | 可选默认账号 |
| `TELEGRAM_WEBHOOK_SECRET` | Telegram Webhook 独立签名密钥 |
## WhatsApp
| 变量 | 说明 |
| --- | --- |
| `WHATSAPP_GATEWAY_TOKEN` | API 与网关间认证,至少 32 位 |
| `WHATSAPP_WEBHOOK_SECRET` | Webhook 签名密钥,不能与 Token 相同 |
| `WHATSAPP_LOG_LEVEL` | `info`、`warn` 或 `error` |
WhatsApp 登录态保存在 `lingxi_whatsapp_data` 卷,不得复制到源码目录或提交 Git。
## 邮箱与支付
- 团队邀请:`SMTP_HOST`、`SMTP_PORT`、`SMTP_SECURE`、`SMTP_USER`、`SMTP_PASSWORD`、`SMTP_FROM`。
- 企业邮箱渠道使用用户提供的 IMAP/SMTP 信息;`EMAIL_CREDENTIAL_KEY` 用于加密凭据。
- Stripe:`STRIPE_SECRET_KEY`、`STRIPE_WEBHOOK_SECRET` 和各套餐 `STRIPE_PRICE_*`。
## 生成随机密钥
PowerShell 示例:
```powershell
$bytes = New-Object byte[] 48
[Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
[Convert]::ToBase64String($bytes)
```
每个变量单独生成,不复用。生产密钥应放入部署平台的 Secret 管理,不写入 Compose、镜像或 CI 日志。
+73
View File
@@ -0,0 +1,73 @@
# 部署与回滚
## 1. 前置条件
- Linux x86_64 主机,建议 4 核、8 GB 内存、50 GB 可扩展磁盘。
- Docker Engine 24+、Compose v2。
- 域名、HTTPS 证书和可访问的 Telegram Gateway。
- 出站可访问 Telegram、WhatsApp、DeepSeek、邮箱服务和 Stripe。
## 2. 首次部署
```bash
git clone ssh://git@git.ohkeji.cn:23/wmq/lingxi-web.git
cd lingxi-web
cp .env.example .env
# 编辑 .env 并替换全部占位值
docker compose --env-file .env build
docker compose --env-file .env up -d
docker compose ps
```
检查:
```bash
curl -fsS http://127.0.0.1:8088/health
docker compose exec api node -e "fetch('http://127.0.0.1:3100/health/ready').then(async r=>{console.log(await r.text());process.exit(r.ok?0:1)})"
docker compose logs --tail=100 api whatsapp-gateway web
```
## 3. 生产入口
在 Compose 外层部署 Caddy、Nginx 或云负载均衡:
- 只对公网开放 80/443。
- 将 HTTPS 请求转发到 `127.0.0.1:8088`。
- 保留 `Host`、`X-Forwarded-For`、`X-Forwarded-Proto`。
- WebSocket 路径启用 Upgrade/Connection 头并提高空闲超时。
- PostgreSQL、API、WhatsApp Gateway 不映射公网端口。
将 `APP_PUBLIC_URL` 和 `PUBLIC_WEBHOOK_BASE_URL` 改为正式 HTTPS 地址,随后重建 API。
## 4. 更新
```bash
git fetch --tags
git checkout main
git pull --ff-only
docker compose --env-file .env build
docker compose --env-file .env up -d
docker compose ps
```
更新前先执行数据库和 WhatsApp 登录态备份。更新后验证登录、渠道状态、收发、WebSocket、AI、媒体和健康检查。
## 5. 回滚
1. 记录当前版本:`git describe --tags --always`。
2. 恢复与目标版本兼容的数据库备份。
3. 检出稳定标签,例如 `git checkout v1.0.0`。
4. 重新构建并启动:`docker compose up -d --build`。
5. 执行健康和关键业务检查。
`snapshot-*` 是历史源码归档,不保证数据库向后兼容,不应直接作为生产回滚点。
## 6. 零停机演进
当前 Compose 是单实例,会有短暂重启窗口。需要零停机时应:
- 将 PostgreSQL 迁移到托管或高可用实例。
- 将 Web/API 部署为至少两个实例并使用外部负载均衡。
- WebSocket 使用共享事件总线或粘性会话。
- 将持久任务改用共享队列并确保幂等。
- 数据库变更采用“先兼容、再切换、后清理”的多阶段迁移。
+58
View File
@@ -0,0 +1,58 @@
# 运维手册
## 日常检查
```powershell
docker compose ps
docker compose logs --since=30m api
docker compose logs --since=30m whatsapp-gateway
Invoke-RestMethod http://127.0.0.1:8088/health
docker compose exec api node -e "fetch('http://127.0.0.1:3100/health/ready').then(async r=>{console.log(await r.text());process.exit(r.ok?0:1)})"
```
重点关注:API 错误率、Webhook 延迟与死信数、出站队列深度、WebSocket 在线数、AI 延迟/限流、数据库连接与磁盘、渠道断线数。
## 常见故障
### 页面“加载失败”
1. 检查 `web` 与 `api` 健康状态。
2. 检查浏览器请求的 HTTP 状态和 API 日志。
3. `401` 重新登录;`403` 检查角色;`5xx` 检查数据库和外部依赖。
4. 不要通过清库或删除持久卷解决普通错误。
### 渠道显示已连接但无消息
1. 验证渠道最近心跳、Webhook 状态和投递日志。
2. 检查 Webhook 地址是否能从网关容器访问。
3. 检查签名密钥是否一致、渠道 ID 是否属于当前租户。
4. 触发安全的状态对账;不要反复解绑账号。
### WebSocket 不实时
1. 确认反向代理允许 Upgrade。
2. 检查短期票据是否过期以及客户端是否自动重连。
3. 断线期间通过 REST 游标补齐,不依赖丢失的内存事件。
### AI 变慢或失败
1. 查看模型响应状态、超时和限流。
2. 确认高优先级实时队列未被历史补齐占满。
3. 暂停历史补齐而不是暂停消息接收。
4. 原始消息应照常展示,待模型恢复后重试增强任务。
## 变更与事件处理
- 配置、密钥、数据库和网关升级都先备份并记录操作人。
- 重大故障先止损:暂停高风险发送、保留日志、不要破坏现场。
- 恢复后补做根因、影响租户、时间线、数据一致性和预防措施记录。
## 告警建议
- 健康检查连续 3 次失败。
- 5 分钟 API 5xx 比例超过 2%。
- Webhook 最老未处理任务超过 60 秒或死信新增。
- WhatsApp/Telegram 渠道离线超过 2 分钟。
- PostgreSQL 磁盘超过 75%、连接超过 80%。
- AI P95 超过目标或 `429` 持续出现。
- 备份任务失败或超过 24 小时未成功。
+56
View File
@@ -0,0 +1,56 @@
# 技术选型
## Web
| 技术 | 版本范围 | 选择原因 |
| --- | --- | --- |
| React | 18.3 | 组件生态成熟,适合高交互消息工作台 |
| TypeScript | 5.7 | 为接口、消息事件和页面状态提供静态约束 |
| Vite | 5.4 | 本地启动和生产构建快速 |
| React Router | 7.x | 页面路由和错误边界 |
| TanStack Query | 5.x | 服务端状态缓存、刷新和失败重试 |
| Zustand | 4.x | 轻量跨组件客户端状态 |
| Tailwind CSS | 3.4 | 设计令牌与响应式界面 |
| Recharts | 2.15 | 仪表盘趋势和客户画像雷达图 |
| Zod + React Hook Form | 3.x / 7.x | 表单校验与可维护的输入状态 |
| Lucide React | 0.462 | 一致的线性图标系统 |
## API 与数据
| 技术 | 作用 |
| --- | --- |
| Node.js 20 | 前后端统一运行时,适合 I/O 密集的消息网关 |
| Express 5 | REST、Webhook 与中间件 |
| PostgreSQL 16 | 多租户业务数据、队列和审计的持久化 |
| `pg` | 直接、透明的 SQL 访问 |
| `ws` | 实时消息与状态广播 |
| JWT + bcryptjs | 身份令牌与密码哈希 |
| Helmet + CORS | 基础 HTTP 安全头和来源约束 |
| IMAPFlow + Nodemailer | 企业邮箱收取与发送 |
| Mailparser | MIME 邮件解析 |
| Mammoth + pdf-parse | DOCX/PDF 知识文件抽取 |
## 渠道与 AI
| 组件 | 作用 |
| --- | --- |
| Telegram Gateway + TDLib | 普通 Telegram 账号登录、同步和操作 |
| Baileys 7 | WhatsApp 多设备连接、消息和群组能力 |
| DeepSeek Anthropic-compatible API | 翻译、意图、情感、风险、摘要和回复建议 |
| Stripe | 套餐结算与 Webhook 对账 |
## 交付与质量
| 技术 | 作用 |
| --- | --- |
| Docker Compose | 本地、测试和单机生产编排 |
| Nginx | 静态站点、SPA fallback、API/WebSocket 反向代理 |
| Node Test Runner | API 与网关单元/协议测试 |
| Playwright | Chromium 桌面与移动端视觉/交互 E2E |
## 取舍
- 当前采用模块化单体 API,部署简单、事务边界清晰;代码量继续增长时应优先拆文件而不是立即拆微服务。
- PostgreSQL 同时承载业务和持久任务,减少早期基础设施;高吞吐阶段可引入 Redis 与专用队列。
- WhatsApp 普通账号接入覆盖面高,但稳定性和合规性弱于官方 Business Platform,应为企业客户预留官方适配器。
- AI 采用异步增强而非消息主链路依赖,模型故障时仍可正常沟通。
+45
View File
@@ -0,0 +1,45 @@
# 测试策略
## 测试层次
| 层次 | 命令 | 覆盖 |
| --- | --- | --- |
| 类型检查 | `npm run typecheck` | 前端 TypeScript |
| 生产构建 | `npm run build` | 类型、打包、资源引用 |
| API 单元/业务 | `npm test --prefix server` | 业务函数、权限与数据逻辑 |
| 邮件协议 | `npm run test:email-protocol --prefix server` | IMAP/SMTP 协议 |
| 邮件 API E2E | `npm run test:email-api --prefix server` | 真实测试邮件服务链路 |
| WhatsApp 网关 | `npm test --prefix whatsapp-gateway` | 路由、状态、消息与可靠性 |
| 浏览器 E2E | `npm run test:e2e` | 桌面/移动端主要用户路径 |
## 提交前最低要求
- 文档或纯样式:类型检查、构建、相关页面 E2E。
- 前端业务:类型检查、构建、完整 Playwright。
- API:语法检查、API 测试、相关 E2E。
- 网关:网关测试、容器健康、真实测试账号冒烟。
- 认证、租户、支付、备份:完整回归并人工复核安全边界。
## 真实链路验收
Mock 测试不能证明渠道真实可用。发布前使用无重要数据的测试账号逐项验证:
1. Telegram 手机号、验证码、二步密码登录。
2. WhatsApp QR/配对、断开、重连和多账号切换。
3. 邮箱 IMAP 首次同步、SMTP 发信和附件。
4. 双向文本、引用、图片、文档、语音、贴纸、相册。
5. 已发送、送达、已读、编辑、撤回、输入状态。
6. 历史分页、断网恢复、WebSocket 重连和增量补齐。
7. 多语言翻译、意图开关、会话记忆压缩和 AI 失败降级。
8. Webhook 失败重试、死信、人工重放和幂等。
9. RBAC 越权请求、租户交叉资源 ID 和登录限流。
## 测试数据
- 只使用专用测试租户和测试账号。
- 不把手机号、验证码、会话凭据、邮件密码和生产消息写入测试代码或报告。
- Playwright 失败产物可能包含页面数据;归档前脱敏,且不得提交 Git。
## 发布记录
每次版本标签应记录执行日期、提交 SHA、环境、测试命令、通过/跳过/失败数量和已知限制。跳过项必须说明原因,不能等同于通过。
+2
View File
@@ -4,6 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="灵犀跨境 - 让跨境沟通更简单。面向中小外贸企业的跨境AI经营服务平台。" />
<link rel="icon" type="image/svg+xml" href="/brand-mark.svg" />
<meta name="theme-color" content="#2563EB" />
<title>灵犀跨境 · 跨境AI经营服务平台</title>
</head>
<body>
+1 -1
View File
@@ -3,7 +3,7 @@
"private": true,
"version": "1.0.0",
"type": "module",
"description": "灵犀跨境 - Telegram 跨境 AI 客户经营平台",
"description": "灵犀跨境 - Telegram、WhatsApp 与企业邮箱 AI 客户经营平台",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
+3
View File
@@ -5,6 +5,9 @@ export default defineConfig({
timeout: 30_000,
expect: { timeout: 10_000 },
fullyParallel: false,
// E2E projects share one real tenant and gateway; serialize mutations to avoid
// desktop/mobile runs racing on conversation ordering and channel state.
workers: 1,
retries: 0,
reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
use: {
Binary file not shown.

After

Width:  |  Height:  |  Size: 673 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>灵犀跨境</title>
<image href="/brand-mark.png" width="512" height="512" preserveAspectRatio="xMidYMid meet"/>
</svg>

After

Width:  |  Height:  |  Size: 232 B

+4
View File
@@ -0,0 +1,4 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>灵犀跨境品牌符号</title>
<image href="/brand-mark.png" width="512" height="512" preserveAspectRatio="xMidYMid meet"/>
</svg>

After

Width:  |  Height:  |  Size: 244 B

+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320" viewBox="0 0 320 320">
<g fill="none" stroke="#6f96bd" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" opacity=".13">
<path d="M25 31h39a9 9 0 0 1 9 9v19a9 9 0 0 1-9 9H47l-11 9 2-9H25a9 9 0 0 1-9-9V40a9 9 0 0 1 9-9Z"/>
<path d="M29 45h31M29 54h20"/>
<path d="m221 26 36 15-36 15 7-12 16-3-16-3-7-12Z"/>
<circle cx="132" cy="91" r="18"/>
<path d="M114 91h36M132 73c6 6 9 12 9 18s-3 12-9 18c-6-6-9-12-9-18s3-12 9-18Z"/>
<path d="m278 105 3 8 8 3-8 3-3 8-3-8-8-3 8-3 3-8Z"/>
<rect x="25" y="174" width="46" height="32" rx="7"/>
<path d="m29 180 19 14 19-14"/>
<path d="M168 195a20 20 0 0 1 40 0v18M168 195v12a7 7 0 0 0 7 7h5v-19h-5a7 7 0 0 0-7 7M208 195v19h5a7 7 0 0 0 7-7v-5a7 7 0 0 0-7-7h-5Z"/>
<path d="M179 226h17a8 8 0 0 0 8-8"/>
<path d="M270 228h27a8 8 0 0 1 8 8v16a8 8 0 0 1-8 8h-8l-8 7 1-7h-12a8 8 0 0 1-8-8v-16a8 8 0 0 1 8-8Z"/>
<path d="M274 239h19M274 247h13"/>
<circle cx="93" cy="267" r="12"/>
<path d="M88 267h10M93 262v10"/>
<path d="m31 278 2 5 5 2-5 2-2 5-2-5-5-2 5-2 2-5Z"/>
<circle cx="252" cy="164" r="3"/>
<circle cx="102" cy="155" r="2"/>
<circle cx="292" cy="58" r="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+494
View File
@@ -12,7 +12,10 @@
"cors": "^2.8.5",
"express": "^5.1.0",
"helmet": "^8.1.0",
"imapflow": "^1.6.1",
"jsonwebtoken": "^9.0.2",
"libphonenumber-js": "^1.11.20",
"mailparser": "^3.9.14",
"mammoth": "^1.9.0",
"nodemailer": "^7.0.5",
"pdf-parse": "^1.1.1",
@@ -20,6 +23,28 @@
"ws": "^8.18.3"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmmirror.com/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@selderee/plugin-htmlparser2": {
"version": "0.12.0",
"resolved": "https://registry.npmmirror.com/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz",
"integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==",
"license": "MIT",
"dependencies": {
"domelementtype": "~2.3.0",
"domhandler": "~5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/KillyMXI"
},
"peerDependencies": {
"selderee": "~0.12.0"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
@@ -29,6 +54,17 @@
"node": ">=10.0.0"
}
},
"node_modules/@zone-eu/mailsplit": {
"version": "5.4.14",
"resolved": "https://registry.npmmirror.com/@zone-eu/mailsplit/-/mailsplit-5.4.14.tgz",
"integrity": "sha512-rz0FQOhN3Vq1XrSeSSa9+dPcaFbBxmQPjiZm6zS9oxdVHV7rOWIAYX3yP2YAUf0qBncY8CI+NogzPCmMVrMXcw==",
"license": "(MIT OR EUPL-1.1+)",
"dependencies": {
"libbase64": "1.3.0",
"libmime": "5.4.1",
"libqp": "2.1.1"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz",
@@ -51,6 +87,15 @@
"sprintf-js": "~1.0.2"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
@@ -244,6 +289,15 @@
}
}
},
"node_modules/deepmerge-ts": {
"version": "7.1.5",
"resolved": "https://registry.npmmirror.com/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
@@ -259,6 +313,61 @@
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
"license": "BSD-2-Clause"
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmmirror.com/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmmirror.com/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/duck": {
"version": "0.1.12",
"resolved": "https://registry.npmmirror.com/duck/-/duck-0.1.12.tgz",
@@ -306,6 +415,27 @@
"node": ">= 0.8"
}
},
"node_modules/encoding-japanese": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
"license": "MIT",
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -515,6 +645,15 @@
"node": ">= 0.4"
}
},
"node_modules/he": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz",
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
"license": "MIT",
"bin": {
"he": "bin/he"
}
},
"node_modules/helmet": {
"version": "8.3.0",
"resolved": "https://registry.npmmirror.com/helmet/-/helmet-8.3.0.tgz",
@@ -527,6 +666,56 @@
"url": "https://github.com/sponsors/EvanHahn"
}
},
"node_modules/html-to-text": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/html-to-text/-/html-to-text-10.0.0.tgz",
"integrity": "sha512-2OH59Gtprdczel+7Rxgpz9hGVJREaf8Lt1H4kZwWHpEn70VQKRuMNGsb2eDbwaTzrYzb0hheiOG1P7Dim0B4dQ==",
"license": "MIT",
"dependencies": {
"@selderee/plugin-htmlparser2": "~0.12.0",
"deepmerge-ts": "^7.1.5",
"dom-serializer": "^2.0.0",
"htmlparser2": "^10.1.0",
"selderee": "~0.12.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/sponsors/KillyMXI"
}
},
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.2.2",
"entities": "^7.0.1"
}
},
"node_modules/htmlparser2/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
@@ -563,6 +752,22 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/imapflow": {
"version": "1.6.1",
"resolved": "https://registry.npmmirror.com/imapflow/-/imapflow-1.6.1.tgz",
"integrity": "sha512-lBkh0ZcKeYHDS3sWfxEcD7j24frrrn4wtbe0UUtLqdLwMSvm9Mygx8j7ZtzWgnvqI7V1DkNfcsidJ/qasb7FBA==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.14",
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.3",
"libbase64": "1.3.0",
"libmime": "5.4.1",
"libqp": "2.1.1",
"pino": "10.3.1",
"socks": "2.8.9"
}
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz",
@@ -575,6 +780,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.3.1",
"resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.3.1.tgz",
"integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -651,6 +865,45 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/leac": {
"version": "0.7.0",
"resolved": "https://registry.npmmirror.com/leac/-/leac-0.7.0.tgz",
"integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/KillyMXI"
}
},
"node_modules/libbase64": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/libbase64/-/libbase64-1.3.0.tgz",
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
"license": "MIT"
},
"node_modules/libmime": {
"version": "5.4.1",
"resolved": "https://registry.npmmirror.com/libmime/-/libmime-5.4.1.tgz",
"integrity": "sha512-0wHGhsofo9IdQPenr3BBHXuxcwMq4atFUTsZ9Ogc1OvI5h4rUdDIrBQEN9JHjCXfDMrE59LUMJWsTD82wTYk8A==",
"license": "MIT",
"dependencies": {
"encoding-japanese": "2.2.0",
"iconv-lite": "0.7.3",
"libbase64": "1.3.0",
"libqp": "2.1.1"
}
},
"node_modules/libphonenumber-js": {
"version": "1.11.20",
"resolved": "https://registry.npmmirror.com/libphonenumber-js/-/libphonenumber-js-1.11.20.tgz",
"integrity": "sha512-/ipwAMvtSZRdiQBHqW1qxqeYiBMzncOQLVA+62MWYr7N4m7Q2jqpJ0WgT7zlOEOpyLRSqrMXidbJpC0J77AaKA==",
"license": "MIT"
},
"node_modules/libqp": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/libqp/-/libqp-2.1.1.tgz",
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
"license": "MIT"
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",
@@ -660,6 +913,25 @@
"immediate": "~3.0.5"
}
},
"node_modules/linkify-it": {
"version": "5.0.2",
"resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.2.tgz",
"integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/markdown-it"
}
],
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
}
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmmirror.com/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -713,6 +985,33 @@
"underscore": "^1.13.1"
}
},
"node_modules/mailparser": {
"version": "3.9.14",
"resolved": "https://registry.npmmirror.com/mailparser/-/mailparser-3.9.14.tgz",
"integrity": "sha512-3QD6TRXcyXtq2NCuyA2AEjqmallQkyxYmZI9GMCIvQDCaB9Uc034WUI1x8RUBYFnk5+p7h14JEz4O/lrxQmttw==",
"license": "MIT",
"dependencies": {
"@zone-eu/mailsplit": "5.4.14",
"encoding-japanese": "2.2.0",
"he": "1.2.0",
"html-to-text": "10.0.0",
"iconv-lite": "0.7.3",
"libmime": "5.4.1",
"linkify-it": "5.0.2",
"nodemailer": "9.0.3",
"punycode.js": "2.3.1",
"tlds": "1.261.0"
}
},
"node_modules/mailparser/node_modules/nodemailer": {
"version": "9.0.3",
"resolved": "https://registry.npmmirror.com/nodemailer/-/nodemailer-9.0.3.tgz",
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/mammoth": {
"version": "1.12.0",
"resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.0.tgz",
@@ -847,6 +1146,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz",
@@ -880,6 +1188,19 @@
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/parseley": {
"version": "0.13.1",
"resolved": "https://registry.npmmirror.com/parseley/-/parseley-0.13.1.tgz",
"integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==",
"license": "MIT",
"dependencies": {
"leac": "^0.7.0",
"peberminta": "^0.10.0"
},
"funding": {
"url": "https://github.com/sponsors/KillyMXI"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
@@ -924,6 +1245,15 @@
"url": "https://github.com/sponsors/mehmet-kozan"
}
},
"node_modules/peberminta": {
"version": "0.10.0",
"resolved": "https://registry.npmmirror.com/peberminta/-/peberminta-0.10.0.tgz",
"integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/KillyMXI"
}
},
"node_modules/pg": {
"version": "8.22.0",
"resolved": "https://registry.npmmirror.com/pg/-/pg-8.22.0.tgz",
@@ -1013,6 +1343,43 @@
"split2": "^4.1.0"
}
},
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmmirror.com/pino/-/pino-10.3.1.tgz",
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmmirror.com/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -1058,6 +1425,22 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1071,6 +1454,15 @@
"node": ">= 0.10"
}
},
"node_modules/punycode.js": {
"version": "2.3.1",
"resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz",
@@ -1087,6 +1479,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmmirror.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/range-parser": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.3.0.tgz",
@@ -1136,6 +1534,15 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmmirror.com/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmmirror.com/router/-/router-2.2.0.tgz",
@@ -1172,12 +1579,33 @@
],
"license": "MIT"
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/selderee": {
"version": "0.12.0",
"resolved": "https://registry.npmmirror.com/selderee/-/selderee-0.12.0.tgz",
"integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==",
"license": "MIT",
"dependencies": {
"parseley": "~0.13.1"
},
"funding": {
"url": "https://github.com/sponsors/KillyMXI"
}
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
@@ -1319,6 +1747,39 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/smart-buffer": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/smart-buffer/-/smart-buffer-4.2.0.tgz",
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/socks": {
"version": "2.8.9",
"resolved": "https://registry.npmmirror.com/socks/-/socks-2.8.9.tgz",
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
"license": "MIT",
"dependencies": {
"ip-address": "^10.1.1",
"smart-buffer": "^4.2.0"
},
"engines": {
"node": ">= 10.0.0",
"npm": ">= 3.0.0"
}
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmmirror.com/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz",
@@ -1358,6 +1819,33 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/thread-stream": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/thread-stream/-/thread-stream-4.2.0.tgz",
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
"license": "MIT",
"dependencies": {
"real-require": "^1.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/thread-stream/node_modules/real-require": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/real-require/-/real-require-1.0.0.tgz",
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
"license": "MIT"
},
"node_modules/tlds": {
"version": "1.261.0",
"resolved": "https://registry.npmmirror.com/tlds/-/tlds-1.261.0.tgz",
"integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==",
"license": "MIT",
"bin": {
"tlds": "bin.js"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1398,6 +1886,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"license": "MIT"
},
"node_modules/underscore": {
"version": "1.13.8",
"resolved": "https://registry.npmmirror.com/underscore/-/underscore-1.13.8.tgz",
+8 -3
View File
@@ -5,18 +5,23 @@
"type": "module",
"scripts": {
"start": "node src/index.js",
"test": "node --test test/business.test.js"
"test": "node --test test/business.test.js",
"test:email-protocol": "node --test test/email-protocol.test.js",
"test:email-api": "node --test test/email-api-e2e.test.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"express": "^5.1.0",
"helmet": "^8.1.0",
"imapflow": "^1.6.1",
"jsonwebtoken": "^9.0.2",
"libphonenumber-js": "^1.11.20",
"mailparser": "^3.9.14",
"mammoth": "^1.9.0",
"nodemailer": "^7.0.5",
"pdf-parse": "^1.1.1",
"pg": "^8.13.1"
,"ws": "^8.18.3"
"pg": "^8.13.1",
"ws": "^8.18.3"
}
}
+41 -10
View File
@@ -7,13 +7,26 @@ function parseJson(text) {
return JSON.parse(cleaned)
}
export async function deepseek(system, user, json = false) {
export async function deepseek(system, user, jsonOrOptions = false) {
const options = typeof jsonOrOptions === 'boolean'
? { json: jsonOrOptions }
: { ...(jsonOrOptions || {}) }
const json = Boolean(options.json)
const apiKey = process.env.DEEPSEEK_API_KEY
if (!apiKey) throw Object.assign(new Error('DEEPSEEK_API_KEY 尚未配置'), { status: 503 })
const endpoint = `${(process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/anthropic').replace(/\/$/, '')}/v1/messages`
const attempts = json ? 2 : 1
let prompt = user
let lastError
for (let attempt = 0; attempt < attempts; attempt += 1) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), Number(process.env.AI_TIMEOUT_MS || 45000))
const timeout = setTimeout(
() => controller.abort(),
Number(options.timeoutMs || process.env.AI_TIMEOUT_MS || 30000),
)
try {
const response = await fetch(`${(process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/anthropic').replace(/\/$/, '')}/v1/messages`, {
const response = await fetch(endpoint, {
method: 'POST',
signal: controller.signal,
headers: {
@@ -23,23 +36,41 @@ export async function deepseek(system, user, json = false) {
},
body: JSON.stringify({
model: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash',
max_tokens: Number(process.env.AI_MAX_TOKENS || 4096),
temperature: json ? 0.1 : 0.3,
max_tokens: Number(options.maxTokens || process.env.AI_MAX_TOKENS || 4096),
temperature: options.temperature ?? (json ? 0 : 0.3),
thinking: options.thinking === true
? { type: 'enabled' }
: { type: 'disabled' },
system,
messages: [{ role: 'user', content: [{ type: 'text', text: user }] }],
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
}),
})
const body = await response.json()
if (!response.ok) throw Object.assign(new Error(body?.error?.message || `AI 请求失败 (${response.status})`), { status: 502 })
if (!response.ok) {
throw Object.assign(new Error(body?.error?.message || `AI 请求失败 (${response.status})`), { status: 502 })
}
const text = contentText(body)
return json ? parseJson(text) : text
if (!json) return text
try {
return parseJson(text)
} catch (error) {
lastError = error
prompt = `${user}\n\n上一次响应不是合法 JSON。请重新生成完整结果:只允许输出一个可被 JSON.parse 直接解析的 JSON 值;不得使用 Markdown;字符串中的双引号、换行和反斜杠必须正确转义。`
}
} catch (error) {
if (error?.name === 'AbortError') {
throw Object.assign(new Error('AI 响应超时,请稍后重试'), { status: 504 })
}
throw error
} finally {
clearTimeout(timeout)
}
}
throw Object.assign(new Error('AI 返回格式异常,自动重试后仍无法解析'), { status: 502, cause: lastError })
}
export function conversationContext(messages) {
return messages.slice(-80).map((m) => {
export function conversationContext(messages, limit = 30) {
return messages.slice(-limit).map((m) => {
const role = m.sender === 'agent' ? '坐席' : m.sender === 'system' ? '系统' : '客户'
const content = m.originalText || m.attachmentName || `[${m.type || '消息'}]`
const translation = m.translatedText && m.translatedText !== content ? `(译文:${m.translatedText})` : ''
+85
View File
@@ -0,0 +1,85 @@
import { parsePhoneNumberFromString } from 'libphonenumber-js'
const VALID_SOURCE = new Set(['manual', 'account-profile', 'phone-prefix', 'unknown'])
export function normalizeCountryCode(value) {
const code = String(value || '').trim().toUpperCase()
return /^[A-Z]{2}$/.test(code) ? code : ''
}
export function inferCountryFromPhone(value) {
const raw = String(value || '').trim()
if (!raw) return ''
const normalized = raw.startsWith('+') ? raw : `+${raw.replace(/[^\d]/g, '')}`
try {
return normalizeCountryCode(parsePhoneNumberFromString(normalized)?.country)
} catch {
return ''
}
}
export function resolveCustomerCountry(input = {}, existing = {}, options = {}) {
const manualCountry = normalizeCountryCode(input.country)
if (options.manual === true) {
return {
country: manualCountry,
countrySource: manualCountry ? 'manual' : 'unknown',
countryConfidence: manualCountry ? 1 : 0,
countryInferredAt: new Date().toISOString(),
}
}
const existingSource = VALID_SOURCE.has(existing.countrySource)
? existing.countrySource
: 'unknown'
if (existingSource === 'manual' && normalizeCountryCode(existing.country)) {
return {
country: normalizeCountryCode(existing.country),
countrySource: 'manual',
countryConfidence: 1,
countryInferredAt: existing.countryInferredAt || existing.updatedAt,
}
}
const accountCountry = normalizeCountryCode(
input.accountCountry || input.countryCode || input.profileCountry,
)
if (accountCountry) {
return {
country: accountCountry,
countrySource: 'account-profile',
countryConfidence: 0.98,
countryInferredAt: new Date().toISOString(),
}
}
const phoneCountry = inferCountryFromPhone(input.phone || existing.phone)
if (phoneCountry) {
return {
country: phoneCountry,
countrySource: 'phone-prefix',
countryConfidence: 0.92,
countryInferredAt: new Date().toISOString(),
}
}
const preserved = normalizeCountryCode(input.country || existing.country)
const preservedSource = VALID_SOURCE.has(input.countrySource)
? input.countrySource
: existingSource
if (preserved && preservedSource !== 'unknown') {
return {
country: preserved,
countrySource: preservedSource,
countryConfidence: Number(input.countryConfidence ?? existing.countryConfidence ?? 0.7),
countryInferredAt: input.countryInferredAt || existing.countryInferredAt,
}
}
return {
country: '',
countrySource: 'unknown',
countryConfidence: 0,
countryInferredAt: null,
}
}
+4 -1
View File
@@ -90,8 +90,11 @@ export async function deleteResource(tenantId, kind, id) {
}
export async function audit(req, action, target, detail = {}) {
// pg 会把顶层 JavaScript 数组编码成 PostgreSQL 数组文本(如 {ai}),
// jsonb 无法解析该格式。显式 JSON 序列化可同时兼容对象、数组和标量。
const serializedDetail = JSON.stringify(detail ?? {})
await pool.query(
'INSERT INTO audit_logs(id,tenant_id,user_id,action,target,detail,ip) VALUES($1,$2,$3,$4,$5,$6,$7)',
[uid(), req.auth?.tenantId ?? null, req.auth?.userId ?? null, action, target, detail, req.ip],
[uid(), req.auth?.tenantId ?? null, req.auth?.userId ?? null, action, target, serializedDetail, req.ip],
)
}
+176
View File
@@ -0,0 +1,176 @@
import crypto from 'node:crypto'
import nodemailer from 'nodemailer'
import { ImapFlow } from 'imapflow'
import { simpleParser } from 'mailparser'
function encryptionKey() {
const secret = process.env.EMAIL_CREDENTIAL_KEY || process.env.JWT_SECRET
if (!secret) throw Object.assign(new Error('EMAIL_CREDENTIAL_KEY 或 JWT_SECRET 尚未配置'), { status: 503 })
return crypto.createHash('sha256').update(secret).digest()
}
export function encryptEmailPassword(password) {
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey(), iv)
const encrypted = Buffer.concat([cipher.update(String(password), 'utf8'), cipher.final()])
return `v1:${iv.toString('base64url')}:${cipher.getAuthTag().toString('base64url')}:${encrypted.toString('base64url')}`
}
export function decryptEmailPassword(value) {
const [version, ivValue, tagValue, encryptedValue] = String(value || '').split(':')
if (version !== 'v1' || !ivValue || !tagValue || !encryptedValue) {
throw Object.assign(new Error('Email 凭据无效,请重新连接邮箱'), { status: 409 })
}
const decipher = crypto.createDecipheriv('aes-256-gcm', encryptionKey(), Buffer.from(ivValue, 'base64url'))
decipher.setAuthTag(Buffer.from(tagValue, 'base64url'))
return Buffer.concat([
decipher.update(Buffer.from(encryptedValue, 'base64url')),
decipher.final(),
]).toString('utf8')
}
function imapClient(channel, password) {
return new ImapFlow({
host: channel.imapHost,
port: Number(channel.imapPort || 993),
secure: channel.imapSecure !== false,
auth: { user: channel.account, pass: password },
logger: false,
tls: { rejectUnauthorized: channel.allowInvalidCertificate !== true },
})
}
function smtpTransport(channel, password) {
return nodemailer.createTransport({
host: channel.smtpHost,
port: Number(channel.smtpPort || 465),
secure: channel.smtpSecure !== false,
auth: { user: channel.account, pass: password },
tls: { rejectUnauthorized: channel.allowInvalidCertificate !== true },
})
}
export async function verifyEmailConnection(channel, password) {
const startedAt = Date.now()
const imap = imapClient(channel, password)
try {
await imap.connect()
await imap.mailboxOpen(channel.imapFolder || 'INBOX', { readOnly: true })
} finally {
if (imap.usable) await imap.logout().catch(() => undefined)
}
const smtp = smtpTransport(channel, password)
await smtp.verify()
smtp.close()
return { latencyMs: Date.now() - startedAt }
}
function address(value) {
const item = value?.value?.[0]
return {
name: String(item?.name || item?.address || '').trim(),
email: String(item?.address || '').trim().toLowerCase(),
}
}
export async function fetchEmailMessages(channel, { full = false } = {}) {
const password = decryptEmailPassword(channel.encryptedPassword)
const client = imapClient(channel, password)
const messages = []
try {
await client.connect()
await client.mailboxOpen(channel.imapFolder || 'INBOX')
let uids
if (!full && Number(channel.lastUid || 0) > 0) {
uids = await client.search({ uid: `${Number(channel.lastUid) + 1}:*` }, { uid: true })
} else {
const days = Math.max(1, Math.min(365, Number(channel.syncDays || 30)))
uids = await client.search({ since: new Date(Date.now() - days * 86400000) }, { uid: true })
}
uids = (uids || []).slice(-500)
if (!uids.length) return { messages, lastUid: Number(channel.lastUid || 0) }
for await (const row of client.fetch(uids, {
uid: true,
envelope: true,
flags: true,
internalDate: true,
source: true,
}, { uid: true })) {
const parsed = await simpleParser(row.source)
const sender = address(parsed.from)
const recipients = parsed.to?.value || []
const attachments = (parsed.attachments || []).map((attachment, index) => ({
id: `${row.uid}-${index + 1}`,
name: attachment.filename || `attachment-${index + 1}`,
mimeType: attachment.contentType || 'application/octet-stream',
size: attachment.size || attachment.content?.length || 0,
contentId: attachment.contentId,
base64: attachment.content?.length <= 20 * 1024 * 1024
? attachment.content.toString('base64')
: undefined,
}))
messages.push({
uid: Number(row.uid),
messageId: String(parsed.messageId || `imap-${row.uid}`),
inReplyTo: parsed.inReplyTo || undefined,
references: Array.isArray(parsed.references) ? parsed.references : parsed.references ? [parsed.references] : [],
subject: String(parsed.subject || '(无主题)').trim(),
from: sender,
to: recipients.map((item) => ({ name: item.name || item.address || '', email: String(item.address || '').toLowerCase() })),
text: String(parsed.text || '').trim() || String(parsed.html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(),
html: typeof parsed.html === 'string' ? parsed.html : undefined,
date: (parsed.date || row.internalDate || new Date()).toISOString(),
seen: row.flags?.has('\\Seen') || false,
attachments,
})
}
return {
messages: messages.sort((a, b) => Date.parse(a.date) - Date.parse(b.date)),
lastUid: Math.max(Number(channel.lastUid || 0), ...messages.map((message) => message.uid)),
}
} finally {
if (client.usable) await client.logout().catch(() => undefined)
}
}
export async function sendEmailMessage(channel, {
to,
subject,
text,
html,
inReplyTo,
references,
attachments,
}) {
const password = decryptEmailPassword(channel.encryptedPassword)
const transport = smtpTransport(channel, password)
try {
return await transport.sendMail({
from: channel.displayName ? { name: channel.displayName, address: channel.account } : channel.account,
to,
subject,
text,
html,
inReplyTo,
references,
attachments,
})
} finally {
transport.close()
}
}
export async function markEmailUidsSeen(channel, uids) {
const values = [...new Set(uids.map(Number).filter(Number.isFinite))]
if (!values.length) return
const password = decryptEmailPassword(channel.encryptedPassword)
const client = imapClient(channel, password)
try {
await client.connect()
await client.mailboxOpen(channel.imapFolder || 'INBOX')
await client.messageFlagsAdd(values, ['\\Seen'], { uid: true })
} finally {
if (client.usable) await client.logout().catch(() => undefined)
}
}
+3205 -114
View File
File diff suppressed because it is too large Load Diff
+107
View File
@@ -0,0 +1,107 @@
function normalize(value) {
return String(value || '')
.normalize('NFKC')
.toLowerCase()
.replace(/\s+/g, ' ')
.trim()
}
export function knowledgeTokens(value) {
const text = normalize(value)
const tokens = new Set(text.match(/[a-z0-9][a-z0-9._+-]{1,}/g) || [])
const chineseRuns = text.match(/[\u3400-\u9fff]+/g) || []
for (const run of chineseRuns) {
if (run.length === 1) tokens.add(run)
for (let size = 2; size <= 3; size += 1) {
for (let index = 0; index <= run.length - size; index += 1) {
tokens.add(run.slice(index, index + size))
}
}
}
return [...tokens].slice(0, 300)
}
export function chunkKnowledge(content, maximum = 900, overlap = 120) {
const text = String(content || '').replace(/\r\n/g, '\n').trim()
if (!text) return []
const paragraphs = text.split(/\n{2,}/).map((item) => item.trim()).filter(Boolean)
const chunks = []
let current = ''
const flush = () => {
if (!current.trim()) return
chunks.push(current.trim())
current = current.slice(-overlap)
}
for (const paragraph of paragraphs.length ? paragraphs : [text]) {
if (paragraph.length > maximum) {
if (current) flush()
for (let start = 0; start < paragraph.length; start += maximum - overlap) {
chunks.push(paragraph.slice(start, start + maximum).trim())
}
current = ''
continue
}
if (current && current.length + paragraph.length + 2 > maximum) flush()
current = current ? `${current}\n\n${paragraph}` : paragraph
}
if (current.trim()) chunks.push(current.trim())
return [...new Set(chunks)].filter(Boolean)
}
function scoreChunk(item, chunk, queryTokens, normalizedQuery) {
const chunkText = normalize(chunk)
const titleText = normalize(`${item.title || ''} ${(item.tags || []).join(' ')}`)
let matched = 0
let weighted = 0
for (const token of queryTokens) {
if (chunkText.includes(token)) {
matched += 1
weighted += token.length >= 3 ? 1.35 : 1
}
if (titleText.includes(token)) weighted += 1.8
}
if (normalizedQuery.length >= 3 && chunkText.includes(normalizedQuery)) weighted += 4
const coverage = queryTokens.length ? matched / queryTokens.length : 0
return weighted + coverage * 3
}
export function rankKnowledge(items, query, limit = 5) {
const normalizedQuery = normalize(query)
const queryTokens = knowledgeTokens(normalizedQuery)
if (!normalizedQuery || !queryTokens.length) return []
const candidates = []
for (const item of items) {
if (item.enabled === false || item.status === 'failed') continue
const chunks = chunkKnowledge(item.content)
chunks.forEach((chunk, index) => {
const rawScore = scoreChunk(item, chunk, queryTokens, normalizedQuery)
if (rawScore <= 0) return
candidates.push({
item,
chunkIndex: index,
snippet: chunk.slice(0, 560),
rawScore,
})
})
}
candidates.sort((a, b) => b.rawScore - a.rawScore)
const bestByItem = new Map()
for (const candidate of candidates) {
if (!bestByItem.has(candidate.item.id)) bestByItem.set(candidate.item.id, candidate)
}
const selected = [...bestByItem.values()].slice(0, Math.max(1, limit))
const maximum = selected[0]?.rawScore || 1
return selected.map((candidate) => ({
...candidate,
score: Math.max(0.01, Math.min(1, candidate.rawScore / maximum)),
}))
}
export function knowledgeMetadata(content) {
const chunks = chunkKnowledge(content)
return {
characterCount: String(content || '').length,
chunkCount: chunks.length,
indexedAt: new Date().toISOString(),
}
}
+38
View File
@@ -0,0 +1,38 @@
const base = () => (process.env.WHATSAPP_GATEWAY_URL || 'http://whatsapp-gateway:3200').replace(/\/$/, '')
export async function whatsappRequest(path, options = {}) {
const token = String(process.env.WHATSAPP_GATEWAY_TOKEN || '')
if (token.length < 24) {
throw Object.assign(new Error('WhatsApp 网关令牌尚未配置'), { status: 503 })
}
const response = await fetch(`${base()}${path}`, {
...options,
headers: {
...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }),
authorization: `Bearer ${token}`,
...(options.headers || {}),
},
})
const raw = await response.text()
let result
try { result = raw ? JSON.parse(raw) : null } catch { result = raw }
if (!response.ok) {
throw Object.assign(
new Error(result?.message || `WhatsApp 网关请求失败 (${response.status})`),
{ status: response.status, data: result?.data },
)
}
return result?.data ?? result
}
export function postWhatsApp(path, body) {
return whatsappRequest(path, { method: 'POST', body: JSON.stringify(body) })
}
export function putWhatsApp(path, body) {
return whatsappRequest(path, { method: 'PUT', body: JSON.stringify(body) })
}
export function uploadWhatsApp(path, formData) {
return whatsappRequest(path, { method: 'POST', body: formData })
}
+12
View File
@@ -1,6 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { countBy, dateKey, percent, telegramAttachment } from '../src/business.js'
import { decryptEmailPassword, encryptEmailPassword } from '../src/email.js'
test('Telegram 图片、文档、语音和贴纸映射为可下载文件', () => {
assert.deepEqual(
@@ -42,3 +43,14 @@ test('业务指标工具处理百分比、分组和无效日期', () => {
{ new: 2, won: 1 },
)
})
test('Email 渠道凭据使用认证加密且可正确解密', () => {
process.env.EMAIL_CREDENTIAL_KEY = 'lingxi-email-test-key-with-more-than-32-bytes'
const encrypted = encryptEmailPassword('app-password-123')
assert.notEqual(encrypted, 'app-password-123')
assert.match(encrypted, /^v1:/)
assert.equal(decryptEmailPassword(encrypted), 'app-password-123')
const tampered = encrypted.split(':')
tampered[2] = `${tampered[2][0] === 'A' ? 'B' : 'A'}${tampered[2].slice(1)}`
assert.throws(() => decryptEmailPassword(tampered.join(':')))
})
+169
View File
@@ -0,0 +1,169 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
encryptEmailPassword,
fetchEmailMessages,
sendEmailMessage,
} from '../src/email.js'
const enabled = process.env.RUN_EMAIL_API_E2E === '1'
const baseUrl = process.env.EMAIL_API_E2E_BASE_URL || 'http://127.0.0.1:8088'
function localChannel(account) {
return {
account,
encryptedPassword: encryptEmailPassword('pwd'),
imapHost: '127.0.0.1',
imapPort: 3143,
imapSecure: false,
imapFolder: 'INBOX',
smtpHost: '127.0.0.1',
smtpPort: 3025,
smtpSecure: false,
syncDays: 7,
}
}
function unwrap(payload) {
return payload && typeof payload === 'object' && 'data' in payload ? payload.data : payload
}
async function request(path, { token, method = 'GET', body } = {}) {
const response = await fetch(`${baseUrl}${path}`, {
method,
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
})
const text = await response.text()
const payload = text ? JSON.parse(text) : null
if (!response.ok) throw new Error(`${method} ${path} -> ${response.status}: ${payload?.message || text}`)
return unwrap(payload)
}
async function waitForSenderMessage(predicate) {
const sender = localChannel('sender@local.test')
for (let attempt = 0; attempt < 20; attempt += 1) {
const result = await fetchEmailMessages(sender, { full: true })
const message = result.messages.find(predicate)
if (message) return message
await new Promise((resolve) => setTimeout(resolve, 150))
}
throw new Error('等待网关发出的邮件进入 sender IMAP 超时')
}
test('真实 HTTP 网关完成 Email 连接、全量同步、附件读取、回复与已读', { skip: !enabled }, async () => {
process.env.JWT_SECRET ||= 'lingxi-email-api-e2e-secret'
const suffix = Date.now()
const senderChannel = localChannel('sender@local.test')
const olderSubject = `Lingxi sorting older ${suffix}`
const newerSubject = `Lingxi sorting newer ${suffix}`
await sendEmailMessage(senderChannel, {
to: 'receiver@local.test',
subject: olderSubject,
text: 'Older conversation used for stable ordering',
})
await new Promise((resolve) => setTimeout(resolve, 30))
await sendEmailMessage(senderChannel, {
to: 'receiver@local.test',
subject: newerSubject,
text: 'Newer conversation must remain above the opened older conversation',
})
const session = await request('/api/auth/register', {
method: 'POST',
body: {
name: 'Email API E2E',
account: `email-api-${suffix}@local.test`,
password: 'Lingxi-Email-Api-2026!',
company: 'Lingxi Protocol Lab',
industry: '跨境电商',
teamSize: '1-10人',
},
})
assert.ok(session.token)
const connected = await request('/api/channels/email/connect', {
token: session.token,
method: 'POST',
body: {
name: '本地协议邮箱',
displayName: 'Lingxi Receiver',
account: 'receiver@local.test',
password: 'pwd',
imapHost: 'host.docker.internal',
imapPort: 3143,
imapSecure: false,
imapFolder: 'INBOX',
smtpHost: 'host.docker.internal',
smtpPort: 3025,
smtpSecure: false,
syncDays: 7,
},
})
assert.equal(connected.status, 'connected')
assert.equal(connected.encryptedPassword, undefined)
const conversations = await request('/api/conversations?channel=email', { token: session.token })
assert.ok(conversations.list.length >= 2)
const newerConversation = conversations.list.find((item) => item.emailSubject === newerSubject)
const olderConversation = conversations.list.find((item) => item.emailSubject === olderSubject)
assert.ok(newerConversation)
assert.ok(olderConversation)
assert.ok(conversations.list.indexOf(newerConversation) < conversations.list.indexOf(olderConversation))
const olderMessages = await request(`/api/conversations/${olderConversation.id}/messages`, { token: session.token })
await request(`/api/conversations/${olderConversation.id}/read`, {
token: session.token,
method: 'POST',
body: { messageIds: olderMessages.map((message) => message.id) },
})
const afterRead = await request('/api/conversations?channel=email', { token: session.token })
const relevantOrder = afterRead.list
.filter((item) => [newerConversation.id, olderConversation.id].includes(item.id))
.map((item) => item.id)
assert.deepEqual(relevantOrder, [newerConversation.id, olderConversation.id])
let conversation
let incoming
for (const candidate of conversations.list.filter((item) => item.customer?.email === 'sender@local.test')) {
const candidateMessages = await request(`/api/conversations/${candidate.id}/messages`, { token: session.token })
const candidateIncoming = candidateMessages.find((message) => message.sender === 'customer' && message.emailAttachments?.length)
if (candidateIncoming) {
conversation = candidate
incoming = candidateIncoming
break
}
}
assert.ok(conversation)
assert.ok(incoming)
const attachment = await request(
`/api/conversations/${conversation.id}/messages/${incoming.id}/attachment?attachmentId=${encodeURIComponent(incoming.emailAttachments[0].id)}`,
{ token: session.token },
)
assert.equal(attachment.name, 'lingxi-proof.txt')
assert.ok(attachment.dataUrl.startsWith('data:text/plain;base64,'))
const replyText = `Lingxi gateway API reply ${suffix}`
const sent = await request(`/api/conversations/${conversation.id}/messages`, {
token: session.token,
method: 'POST',
body: { text: replyText, autoTranslate: false },
})
assert.equal(sent.status, 'sent')
assert.ok(sent.emailMessageId)
const receivedBySender = await waitForSenderMessage((message) => message.messageId === sent.emailMessageId)
assert.equal(receivedBySender.text, replyText)
assert.ok(receivedBySender.inReplyTo)
await request(`/api/conversations/${conversation.id}/read`, {
token: session.token,
method: 'POST',
body: { messageIds: [incoming.id] },
})
const receiver = await fetchEmailMessages(localChannel('receiver@local.test'), { full: true })
assert.equal(receiver.messages.find((message) => message.uid === incoming.emailUid)?.seen, true)
})
+86
View File
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
encryptEmailPassword,
fetchEmailMessages,
markEmailUidsSeen,
sendEmailMessage,
verifyEmailConnection,
} from '../src/email.js'
const enabled = process.env.RUN_EMAIL_PROTOCOL_TEST === '1'
function channel(account) {
return {
id: `protocol-${account}`,
type: 'email',
account,
displayName: account.startsWith('sender') ? 'Lingxi Sender' : 'Lingxi Receiver',
encryptedPassword: encryptEmailPassword('pwd'),
imapHost: process.env.EMAIL_TEST_HOST || '127.0.0.1',
imapPort: Number(process.env.EMAIL_TEST_IMAP_PORT || 3143),
imapSecure: false,
imapFolder: 'INBOX',
smtpHost: process.env.EMAIL_TEST_HOST || '127.0.0.1',
smtpPort: Number(process.env.EMAIL_TEST_SMTP_PORT || 3025),
smtpSecure: false,
syncDays: 7,
}
}
async function waitForMessage(target, predicate) {
for (let attempt = 0; attempt < 20; attempt += 1) {
const result = await fetchEmailMessages(target, { full: true })
const message = result.messages.find(predicate)
if (message) return { message, result }
await new Promise((resolve) => setTimeout(resolve, 150))
}
throw new Error('等待邮件进入 IMAP INBOX 超时')
}
test('真实 SMTP 发件、IMAP 收件、附件解析、已读与回复头', { skip: !enabled }, async () => {
process.env.JWT_SECRET ||= 'lingxi-email-protocol-test-secret'
const sender = channel('sender@local.test')
const receiver = channel('receiver@local.test')
const subject = `Lingxi protocol ${Date.now()}`
const senderCheck = await verifyEmailConnection(sender, 'pwd')
const receiverCheck = await verifyEmailConnection(receiver, 'pwd')
assert.ok(senderCheck.latencyMs >= 0)
assert.ok(receiverCheck.latencyMs >= 0)
assert.ok(!sender.encryptedPassword.includes('pwd'))
const sent = await sendEmailMessage(sender, {
to: receiver.account,
subject,
text: 'Lingxi IMAP/SMTP real protocol message',
attachments: [{
filename: 'lingxi-proof.txt',
content: Buffer.from('real email attachment'),
contentType: 'text/plain',
}],
})
assert.ok(sent.messageId)
const received = await waitForMessage(receiver, (message) => message.subject === subject)
assert.equal(received.message.text, 'Lingxi IMAP/SMTP real protocol message')
assert.equal(received.message.from.email, sender.account)
assert.equal(received.message.attachments.length, 1)
assert.equal(received.message.attachments[0].name, 'lingxi-proof.txt')
assert.equal(Buffer.from(received.message.attachments[0].base64, 'base64').toString(), 'real email attachment')
await markEmailUidsSeen(receiver, [received.message.uid])
const marked = await fetchEmailMessages(receiver, { full: true })
assert.equal(marked.messages.find((message) => message.uid === received.message.uid)?.seen, true)
const reply = await sendEmailMessage(receiver, {
to: sender.account,
subject: `Re: ${subject}`,
text: 'Reply received by Lingxi',
inReplyTo: received.message.messageId,
references: [received.message.messageId],
})
const replyReceived = await waitForMessage(sender, (message) => message.messageId === reply.messageId)
assert.equal(replyReceived.message.inReplyTo, received.message.messageId)
assert.ok(replyReceived.message.references.includes(received.message.messageId))
})
+70 -37
View File
@@ -1,43 +1,76 @@
import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom'
import { useAuthStore } from '@/app/store/auth'
import { createBrowserRouter, Navigate, RouterProvider, useRouteError } from 'react-router-dom'
import { isSessionTokenExpired, useAuthStore } from '@/app/store/auth'
import { AppLayout } from '@/components/layout/AppLayout'
import type { ReactNode } from 'react'
import { useEffect, type ReactNode } from 'react'
import { LoginPage } from '@/pages/auth/LoginPage'
import { RegisterPage } from '@/pages/auth/RegisterPage'
import { DashboardPage } from '@/pages/dashboard/DashboardPage'
import { InboxPage } from '@/pages/inbox/InboxPage'
import { CustomersPage } from '@/pages/customers/CustomersPage'
import { CustomerDetailPage } from '@/pages/customers/CustomerDetailPage'
import { OpportunitiesPage } from '@/pages/opportunities/OpportunitiesPage'
import { TranslatePage } from '@/pages/translate/TranslatePage'
import { RisksPage } from '@/pages/risks/RisksPage'
import { KnowledgePage } from '@/pages/knowledge/KnowledgePage'
import { AnalyticsPage } from '@/pages/analytics/AnalyticsPage'
import { ChannelsPage } from '@/pages/channels/ChannelsPage'
import { TeamPage } from '@/pages/team/TeamPage'
import { BillingPage } from '@/pages/billing/BillingPage'
import { SettingsPage } from '@/pages/settings/SettingsPage'
import { HelpPage } from '@/pages/help/HelpPage'
import { NotFoundPage } from '@/pages/NotFoundPage'
function RequireAuth({ children }: { children: ReactNode }) {
const token = useAuthStore((s) => s.token)
if (!token) return <Navigate to="/login" replace />
const logout = useAuthStore((s) => s.logout)
const expired = isSessionTokenExpired(token)
useEffect(() => {
if (token && expired) logout()
}, [expired, logout, token])
if (expired) return <Navigate to="/login?reason=session-expired" replace />
return <>{children}</>
}
function GuestOnly({ children }: { children: ReactNode }) {
const token = useAuthStore((s) => s.token)
if (token) return <Navigate to="/dashboard" replace />
const logout = useAuthStore((s) => s.logout)
const expired = Boolean(token) && isSessionTokenExpired(token)
useEffect(() => {
if (expired) logout()
}, [expired, logout])
if (token && !expired) return <Navigate to="/dashboard" replace />
return <>{children}</>
}
function RouteErrorFallback() {
const error = useRouteError()
useEffect(() => {
console.error('Route render failed', error)
}, [error])
return (
<main className="flex min-h-screen items-center justify-center bg-slate-50 px-6">
<section className="w-full max-w-md rounded-3xl border border-slate-200 bg-white p-8 text-center shadow-xl shadow-slate-200/60">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-blue-50 text-xl text-blue-600">
!
</div>
<h1 className="mt-5 text-xl font-semibold tracking-tight text-slate-950">页面暂时无法显示</h1>
<p className="mt-2 text-sm leading-6 text-slate-500">
当前页面遇到异常。你可以重新加载;如果问题仍然存在,请返回工作台后重试。
</p>
<div className="mt-6 flex justify-center gap-3">
<button
type="button"
onClick={() => window.location.reload()}
className="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-blue-700"
>
重新加载
</button>
<a
href="/dashboard"
className="rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50"
>
返回工作台
</a>
</div>
</section>
</main>
)
}
const router = createBrowserRouter([
{ path: '/login', element: <GuestOnly><LoginPage /></GuestOnly> },
{ path: '/register', element: <GuestOnly><RegisterPage /></GuestOnly> },
{ path: '/login', element: <GuestOnly><LoginPage /></GuestOnly>, errorElement: <RouteErrorFallback /> },
{ path: '/register', element: <GuestOnly><RegisterPage /></GuestOnly>, errorElement: <RouteErrorFallback /> },
{
path: '/',
errorElement: <RouteErrorFallback />,
element: (
<RequireAuth>
<AppLayout />
@@ -45,21 +78,21 @@ const router = createBrowserRouter([
),
children: [
{ index: true, element: <Navigate to="/dashboard" replace /> },
{ path: 'dashboard', element: <DashboardPage /> },
{ path: 'inbox', element: <InboxPage /> },
{ path: 'customers', element: <CustomersPage /> },
{ path: 'customers/:id', element: <CustomerDetailPage /> },
{ path: 'opportunities', element: <OpportunitiesPage /> },
{ path: 'translate', element: <TranslatePage /> },
{ path: 'risks', element: <RisksPage /> },
{ path: 'knowledge', element: <KnowledgePage /> },
{ path: 'analytics', element: <AnalyticsPage /> },
{ path: 'channels', element: <ChannelsPage /> },
{ path: 'team', element: <TeamPage /> },
{ path: 'billing', element: <BillingPage /> },
{ path: 'settings', element: <SettingsPage /> },
{ path: 'help', element: <HelpPage /> },
{ path: '*', element: <NotFoundPage /> },
{ path: 'dashboard', lazy: async () => ({ Component: (await import('@/pages/dashboard/DashboardPage')).DashboardPage }) },
{ path: 'inbox', lazy: async () => ({ Component: (await import('@/pages/inbox/InboxPage')).InboxPage }) },
{ path: 'customers', lazy: async () => ({ Component: (await import('@/pages/customers/CustomersPage')).CustomersPage }) },
{ path: 'customers/:id', lazy: async () => ({ Component: (await import('@/pages/customers/CustomerDetailPage')).CustomerDetailPage }) },
{ path: 'opportunities', lazy: async () => ({ Component: (await import('@/pages/opportunities/OpportunitiesPage')).OpportunitiesPage }) },
{ path: 'translate', lazy: async () => ({ Component: (await import('@/pages/translate/TranslatePage')).TranslatePage }) },
{ path: 'risks', lazy: async () => ({ Component: (await import('@/pages/risks/RisksPage')).RisksPage }) },
{ path: 'knowledge', lazy: async () => ({ Component: (await import('@/pages/knowledge/KnowledgePage')).KnowledgePage }) },
{ path: 'analytics', lazy: async () => ({ Component: (await import('@/pages/analytics/AnalyticsPage')).AnalyticsPage }) },
{ path: 'channels', lazy: async () => ({ Component: (await import('@/pages/channels/ChannelsPage')).ChannelsPage }) },
{ path: 'team', lazy: async () => ({ Component: (await import('@/pages/team/TeamPage')).TeamPage }) },
{ path: 'billing', lazy: async () => ({ Component: (await import('@/pages/billing/BillingPage')).BillingPage }) },
{ path: 'settings', lazy: async () => ({ Component: (await import('@/pages/settings/SettingsPage')).SettingsPage }) },
{ path: 'help', lazy: async () => ({ Component: (await import('@/pages/help/HelpPage')).HelpPage }) },
{ path: '*', lazy: async () => ({ Component: (await import('@/pages/NotFoundPage')).NotFoundPage }) },
],
},
])
+21 -1
View File
@@ -2,7 +2,7 @@ import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { AuthSession, Tenant, User } from '@/types'
import type { RoleKey } from '@/config/roles'
import { setTokenGetter } from '@/services/api/client'
import { setTokenGetter, setUnauthorizedHandler } from '@/services/api/client'
interface AuthState {
token: string | null
@@ -32,6 +32,26 @@ export const useAuthStore = create<AuthState>()(
// 让 API client 能读取 token(真实后端模式使用)
setTokenGetter(() => useAuthStore.getState().token)
setUnauthorizedHandler(() => {
useAuthStore.getState().logout()
if (!window.location.pathname.startsWith('/login')) {
window.location.replace('/login?reason=session-expired')
}
})
export function isSessionTokenExpired(token: string | null): boolean {
if (!token) return true
try {
const encoded = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
const padded = encoded.padEnd(Math.ceil(encoded.length / 4) * 4, '=')
const payload = JSON.parse(window.atob(padded)) as {
exp?: number
}
return !payload.exp || payload.exp * 1_000 <= Date.now()
} catch {
return true
}
}
export function useCurrentRole(): RoleKey {
return useAuthStore((s) => s.user?.role ?? 'viewer')
+126
View File
@@ -0,0 +1,126 @@
import type { ReactNode } from 'react'
import { ArrowUpRight, Check, Languages } from 'lucide-react'
export function BrandMark({ compact = false }: { compact?: boolean }) {
return (
<div className="flex items-center gap-2.5">
<img src="/brand-mark.svg" alt="" className="h-9 w-9 rounded-xl shadow-[0_8px_24px_-12px_rgba(37,99,235,.9)]" />
{!compact && (
<div>
<p className="text-[15px] font-semibold tracking-[-0.02em] text-slate-950">灵犀跨境</p>
<p className="text-[11px] text-slate-500">跨境客户沟通工作台</p>
</div>
)}
</div>
)
}
function ProductMotion() {
return (
<div className="relative mx-auto w-full max-w-[470px]">
<div className="absolute -inset-10 rounded-full bg-blue-200/25 blur-3xl" />
<div className="relative overflow-hidden rounded-[22px] border border-slate-200/90 bg-white/90 shadow-[0_24px_70px_-38px_rgba(15,23,42,.34)] backdrop-blur">
<div className="flex h-12 items-center justify-between border-b border-slate-100 px-4">
<div className="flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-emerald-500 shadow-[0_0_0_4px_rgba(16,185,129,.10)]" />
<span className="text-xs font-medium text-slate-700">Eternity · Telegram</span>
</div>
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-[10px] font-medium text-slate-500">
双语视图
</span>
</div>
<div className="auth-preview-grid relative min-h-[278px] overflow-hidden p-5">
<div className="auth-message auth-message-in max-w-[78%] rounded-2xl rounded-bl-md border border-slate-200 bg-white px-3.5 py-3 shadow-sm">
<p className="text-[11px] font-medium text-slate-400">English</p>
<p className="mt-1 text-xs leading-5 text-slate-700">
Could you confirm the sample delivery date?
</p>
</div>
<div className="my-4 flex items-center gap-2 pl-3">
<span className="flex h-6 w-6 items-center justify-center rounded-full border border-blue-100 bg-blue-50 text-blue-600">
<Languages className="h-3 w-3" />
</span>
<span className="auth-translation-track relative h-px w-24 overflow-visible bg-blue-100">
<span className="auth-translation-dot absolute -top-1 h-2 w-2 rounded-full bg-blue-500" />
</span>
<span className="text-[10px] text-slate-400">已翻译</span>
</div>
<div className="auth-message auth-message-out ml-auto max-w-[80%] rounded-2xl rounded-br-md border border-blue-100 bg-blue-50/90 px-3.5 py-3 shadow-sm">
<p className="text-[11px] font-medium text-blue-500">中文参考</p>
<p className="mt-1 text-xs leading-5 text-slate-700">可以确认一下样品的交付日期吗?</p>
</div>
<div className="mt-5 grid grid-cols-3 gap-2">
{[
['意向', '高'],
['情绪', '积极'],
['风险', '低'],
].map(([label, value]) => (
<div key={label} className="rounded-xl border border-slate-100 bg-white/75 px-3 py-2.5">
<p className="text-[10px] text-slate-400">{label}</p>
<p className="mt-0.5 text-xs font-semibold text-slate-700">{value}</p>
</div>
))}
</div>
</div>
</div>
<div className="auth-floating-chip absolute -bottom-4 -right-3 flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-3 py-2 shadow-lg shadow-slate-900/5">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-emerald-50 text-emerald-600">
<Check className="h-3 w-3" />
</span>
<span className="text-[11px] font-medium text-slate-600">消息已送达</span>
</div>
</div>
)
}
export function AuthShell({
children,
topAction,
heading = '让每一次跨境沟通,都清楚且及时。',
description = '汇集消息、翻译、客户资料与团队跟进,在一个工作台完成日常客户沟通。',
}: {
children: ReactNode
topAction?: ReactNode
heading?: string
description?: string
}) {
return (
<div className="min-h-screen bg-white text-slate-900">
<div className="grid min-h-screen lg:grid-cols-[minmax(420px,46%)_1fr]">
<aside className="relative hidden overflow-hidden border-r border-slate-200 bg-[#f7f9fc] lg:flex lg:flex-col">
<div className="auth-corner-glow absolute inset-0" />
<div className="relative z-10 flex items-center justify-between px-10 py-8 xl:px-14">
<BrandMark />
<span className="flex items-center gap-1 text-[11px] font-medium text-slate-400">
LINGXI OS
<ArrowUpRight className="h-3 w-3" />
</span>
</div>
<div className="relative z-10 flex flex-1 flex-col justify-center px-10 pb-12 xl:px-14">
<div className="mb-10 max-w-[520px]">
<p className="mb-3 text-xs font-semibold tracking-[0.18em] text-blue-600">GLOBAL COMMUNICATION</p>
<h1 className="max-w-[480px] text-[30px] font-semibold leading-[1.28] tracking-[-0.035em] text-slate-950">
{heading}
</h1>
<p className="mt-4 max-w-[460px] text-sm leading-6 text-slate-500">{description}</p>
</div>
<ProductMotion />
</div>
<p className="relative z-10 px-10 pb-7 text-[11px] text-slate-400 xl:px-14">
© 2026 灵犀跨境
</p>
</aside>
<main className="relative flex min-h-screen flex-col">
<header className="flex h-20 items-center justify-between px-6 sm:px-10 lg:px-12">
<div className="lg:hidden"><BrandMark /></div>
<div className="ml-auto">{topAction}</div>
</header>
<div className="flex flex-1 items-center justify-center px-6 pb-16 sm:px-10 lg:px-12">
<div className="w-full max-w-[430px]">{children}</div>
</div>
</main>
</div>
</div>
)
}
+1 -1
View File
@@ -18,7 +18,7 @@ import { Card, CardHeader } from '@/components/ui/card'
import { LiveDataBadge } from '@/components/common/LiveDataBadge'
import { cn } from '@/utils/cn'
export const CHART_COLORS = ['#247b71', '#32998c', '#52b3a5', '#83cfc2', '#f59e0b', '#3b82f6', '#8b5cf6', '#94a3b8']
export const CHART_COLORS = ['#2563eb', '#3b82f6', '#60a5fa', '#8b5cf6', '#06b6d4', '#10b981', '#f59e0b', '#94a3b8']
export function ChartCard({
title,
+1 -1
View File
@@ -8,7 +8,7 @@ export function LiveDataBadge({ className, text = '实时数据' }: { className?
'inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700',
className,
)}
title="指标由当前企业真实业务数据实时计算"
title="指标实时更新"
>
<Radio className="h-3 w-3" />
{text}
+3 -3
View File
@@ -15,11 +15,11 @@ export function PageHeader({
className?: string
}) {
return (
<div className={cn('mb-4', className)}>
<div className={cn('mb-5', className)}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-lg font-semibold text-foreground">{title}</h1>
{description && <p className="mt-0.5 text-sm text-muted-foreground">{description}</p>}
<h1 className="text-[22px] font-semibold tracking-[-0.035em] text-slate-950 md:text-2xl">{title}</h1>
{description && <p className="mt-1 text-sm text-slate-500">{description}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</div>
+8 -5
View File
@@ -27,17 +27,20 @@ export function StatCard({
const DeltaIcon = neutral ? Minus : (delta ?? 0) > 0 ? TrendingUp : TrendingDown
return (
<Card
className={cn('p-4', onClick && 'cursor-pointer transition-shadow hover:shadow-md')}
className={cn(
'group p-5 transition-all duration-200',
onClick && 'cursor-pointer hover:-translate-y-0.5 hover:border-primary-200 hover:shadow-[0_12px_28px_-16px_rgba(37,99,235,.48)]',
)}
onClick={onClick}
>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{title}</span>
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-primary-50">
<span className="text-xs font-medium text-slate-500">{title}</span>
<span className="flex h-9 w-9 items-center justify-center rounded-[10px] border border-primary-100 bg-primary-50 transition-colors group-hover:bg-primary-100">
<Icon className="h-4 w-4 text-primary-600" />
</span>
</div>
<div className="mt-2 flex items-baseline gap-1">
<span className="text-2xl font-semibold tracking-tight text-foreground">{value}</span>
<div className="mt-3 flex items-baseline gap-1">
<span className="text-[28px] font-semibold tracking-[-0.04em] text-slate-950">{value}</span>
{suffix && <span className="text-xs text-muted-foreground">{suffix}</span>}
</div>
{showDelta && (
@@ -0,0 +1,181 @@
import { useId } from 'react'
import {
PolarAngleAxis,
PolarGrid,
PolarRadiusAxis,
Radar,
RadarChart,
ResponsiveContainer,
Tooltip,
} from 'recharts'
import { Sparkles, TrendingUp } from 'lucide-react'
import type { Customer } from '@/types'
import { cn } from '@/utils/cn'
function qualitativeScore(value?: string, fallback = 50) {
const text = String(value || '').toLowerCase()
if (!text) return fallback
if (/高|强|紧急|决策|老板|owner|director|high|urgent/.test(text)) return 86
if (/中|一般|参与|medium|normal/.test(text)) return 62
if (/低|弱|未知|助理|low|unknown/.test(text)) return 36
return fallback
}
export function profileRadarData(customer: Customer) {
const profile = customer.aiProfile || {}
const extracted = [
profile.companyName,
profile.industry,
profile.businessModel,
profile.inquiryQuantity,
profile.targetPrice,
profile.budget,
profile.purchaseTimeline,
profile.payment,
].filter(Boolean).length
const fitScore = Math.min(96, 42 + extracted * 6 + (profile.keyNeeds?.length || 0) * 4)
return [
{ dimension: '购买意向', score: Math.max(0, Math.min(100, Number(customer.intentScore || 0))), hint: '基于询盘强度与购买信号' },
{ dimension: '成交可能', score: Number(profile.dealProbability || 0), hint: '根据当前客户资料和会话信号估算' },
{ dimension: '决策能力', score: qualitativeScore(profile.decisionPower), hint: profile.decisionPower || '尚待确认采购决策权' },
{ dimension: '需求紧迫', score: qualitativeScore(profile.urgency), hint: profile.urgency || '尚待确认采购时间表' },
{ dimension: '客户匹配', score: fitScore, hint: '画像字段、需求与产品匹配度' },
{ dimension: '关系健康', score: Math.max(0, 100 - Number(profile.churnProbability || 0)), hint: '流失风险的反向健康度' },
]
}
export function CustomerProfileRadar({
customer,
compact = false,
className,
}: {
customer: Customer
compact?: boolean
className?: string
}) {
const data = profileRadarData(customer)
const average = Math.round(data.reduce((sum, item) => sum + item.score, 0) / data.length)
const chartId = useId().replace(/:/g, '')
const gradientId = `radar-fill-${chartId}`
const glowId = `radar-glow-${chartId}`
const level = average >= 75 ? '高潜客户' : average >= 55 ? '重点培育' : '持续观察'
const levelTone = average >= 75
? 'text-blue-700 bg-blue-50 border-blue-100'
: average >= 55
? 'text-violet-700 bg-violet-50 border-violet-100'
: 'text-amber-700 bg-amber-50 border-amber-100'
return (
<div className={cn(
'group relative isolate overflow-hidden rounded-2xl border border-blue-100/90 bg-white shadow-[0_18px_55px_-32px_rgba(37,99,235,.32)]',
className,
)}>
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-40 bg-[radial-gradient(circle_at_75%_0%,rgba(96,165,250,.16),transparent_58%)]" />
<div className="flex items-start justify-between gap-3 px-5 pt-5">
<div>
<div className="flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-violet-600 text-white shadow-[0_8px_20px_-8px_rgba(37,99,235,.7)]">
<Sparkles className="h-4 w-4" />
</span>
<div>
<p className="text-sm font-semibold tracking-tight text-slate-950">客户价值雷达</p>
<p className="mt-0.5 text-[11px] text-slate-500">基于当前客户资料和会话信号</p>
</div>
</div>
<span className={cn('mt-3 inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-[11px] font-semibold', levelTone)}>
<TrendingUp className="h-3 w-3" />
{level}
</span>
</div>
<div
className="relative flex h-[70px] w-[70px] shrink-0 items-center justify-center rounded-full p-[5px] shadow-[0_10px_30px_-16px_rgba(37,99,235,.7)]"
style={{ background: `conic-gradient(#2563eb ${average * 3.6}deg, #dbeafe 0deg)` }}
aria-label={`综合指数 ${average} 分`}
>
<div className="flex h-full w-full flex-col items-center justify-center rounded-full border border-white bg-white shadow-inner">
<p className="text-[9px] font-semibold tracking-wider text-blue-600">综合指数</p>
<p className="text-2xl font-bold leading-6 tracking-tight text-slate-950">{average}</p>
</div>
</div>
</div>
<div className={cn(
'customer-radar-stage relative mx-3 mt-1 overflow-hidden rounded-2xl',
'bg-[radial-gradient(circle_at_center,rgba(239,246,255,.96)_0%,rgba(255,255,255,.45)_62%,transparent_72%)]',
compact ? 'h-[240px]' : 'h-[310px]',
)}>
<div className="pointer-events-none absolute left-1/2 top-1/2 h-44 w-44 -translate-x-1/2 -translate-y-1/2 rounded-full border border-blue-100/80 shadow-[0_0_60px_rgba(37,99,235,.10)]" />
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={data} cx="50%" cy="52%" outerRadius={compact ? '66%' : '72%'}>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stopColor="#60a5fa" stopOpacity="0.58" />
<stop offset="55%" stopColor="#3b82f6" stopOpacity="0.34" />
<stop offset="100%" stopColor="#8b5cf6" stopOpacity="0.22" />
</linearGradient>
<filter id={glowId} x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="4" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
<PolarGrid stroke="#dbeafe" strokeOpacity={0.95} gridType="polygon" />
<PolarAngleAxis dataKey="dimension" tick={{ fill: '#334155', fontSize: compact ? 10 : 12, fontWeight: 650 }} />
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={false} axisLine={false} />
<Tooltip
formatter={(value: number) => [`${value} / 100`, '画像指数']}
labelFormatter={(label) => `${label} · 参考值`}
contentStyle={{
borderRadius: 14,
border: '1px solid #dbeafe',
boxShadow: '0 18px 40px rgba(37,99,235,.14)',
fontSize: 12,
}}
/>
<Radar
name="客户画像"
dataKey="score"
isAnimationActive={false}
stroke="#60a5fa"
strokeWidth={8}
strokeOpacity={0.16}
fill="none"
dot={false}
filter={`url(#${glowId})`}
/>
<Radar
name="客户画像"
dataKey="score"
isAnimationActive={false}
stroke="#2563eb"
strokeWidth={3}
fill={`url(#${gradientId})`}
fillOpacity={1}
dot={{ r: compact ? 3.5 : 4.5, fill: '#2563eb', strokeWidth: 3, stroke: '#fff' }}
/>
</RadarChart>
</ResponsiveContainer>
</div>
{!compact && (
<div className="grid grid-cols-2 gap-2 border-t border-slate-100 bg-slate-50/40 px-4 py-4 sm:grid-cols-3">
{data.map((item) => (
<div key={item.dimension} className="min-w-0 rounded-xl border border-slate-100 bg-white px-3 py-2.5 shadow-sm">
<div className="flex items-end justify-between gap-2">
<span className="text-[11px] font-medium text-slate-500">{item.dimension}</span>
<span className="text-base font-bold leading-none text-slate-900">{item.score}</span>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-slate-100">
<div
className="h-full rounded-full bg-gradient-to-r from-blue-400 via-blue-600 to-violet-500 shadow-[0_0_8px_rgba(37,99,235,.28)]"
style={{ width: `${item.score}%` }}
/>
</div>
</div>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,107 @@
import { Tag } from 'lucide-react'
import { cn } from '@/utils/cn'
type TagCategory = 'positive' | 'emotion' | 'risk' | 'aftersales' | 'quality' | 'service' | 'profile'
const categoryConfig: Record<TagCategory, { label: string; className: string; dot: string }> = {
positive: {
label: '积极信号',
className: 'border-emerald-200/90 bg-emerald-50 text-emerald-700',
dot: 'bg-emerald-500 shadow-[0_0_0_2px_rgba(16,185,129,.12)]',
},
emotion: {
label: '客户情绪',
className: 'border-amber-200/90 bg-amber-50 text-amber-700',
dot: 'bg-amber-500 shadow-[0_0_0_2px_rgba(245,158,11,.12)]',
},
risk: {
label: '投诉风险',
className: 'border-rose-200/90 bg-rose-50 text-rose-700',
dot: 'bg-rose-500 shadow-[0_0_0_2px_rgba(244,63,94,.12)]',
},
aftersales: {
label: '售后处理',
className: 'border-orange-200/90 bg-orange-50 text-orange-700',
dot: 'bg-orange-500 shadow-[0_0_0_2px_rgba(249,115,22,.12)]',
},
quality: {
label: '产品质量',
className: 'border-sky-200/90 bg-sky-50 text-sky-700',
dot: 'bg-sky-500 shadow-[0_0_0_2px_rgba(14,165,233,.12)]',
},
service: {
label: '服务沟通',
className: 'border-violet-200/90 bg-violet-50 text-violet-700',
dot: 'bg-violet-500 shadow-[0_0_0_2px_rgba(139,92,246,.12)]',
},
profile: {
label: '客户属性',
className: 'border-slate-200 bg-slate-50 text-slate-600',
dot: 'bg-slate-400 shadow-[0_0_0_2px_rgba(100,116,139,.1)]',
},
}
export function customerTagCategory(tag: string): TagCategory {
const value = tag.toLowerCase().replace(/[_-]+/g, ' ')
// Positive outcomes take priority for phrases such as “投诉处理成功”.
if (/success|satisfaction|repurchase|positive|resolved|回升|缓和|修复|改进|成功|复购|满意|转正/.test(value)) return 'positive'
if (/emotion|anger|angry|insult|情绪|愤怒|高情绪|冲突/.test(value)) return 'emotion'
if (/urgent|escalation|crisis|complaint|投诉|危机|辱骂|欺诈|风险/.test(value)) return 'risk'
if (/after.?sales|refund|return|replace|售后|退款|退货|补偿|换货|更换/.test(value)) return 'aftersales'
if (/quality|defect|product|holes?|smell|品质|质量|瑕疵|破损|异味|制造|尺寸|材质/.test(value)) return 'quality'
if (/agent|service|communication|feedback|客服|坐席|沟通|销售反馈/.test(value)) return 'service'
return 'profile'
}
export function CustomerSemanticTag({
tag,
className,
}: {
tag: string
className?: string
}) {
const category = customerTagCategory(tag)
const config = categoryConfig[category]
return (
<span
data-tag-category={category}
title={`${config.label} · ${tag}`}
className={cn(
'inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-medium leading-none transition-transform hover:-translate-y-px',
config.className,
className,
)}
>
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', config.dot)} />
{tag}
</span>
)
}
export function CustomerTagLegend({ tags }: { tags: string[] }) {
const counts = tags.reduce<Partial<Record<TagCategory, number>>>((result, tag) => {
const category = customerTagCategory(tag)
result[category] = (result[category] || 0) + 1
return result
}, {})
return (
<div className="mb-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[10px] text-slate-500">
<span className="inline-flex items-center gap-1 font-semibold uppercase tracking-[0.12em] text-slate-400">
<Tag className="h-3 w-3" />
标签画像
</span>
{(Object.keys(categoryConfig) as TagCategory[])
.filter((category) => counts[category])
.map((category) => (
<span key={category} className="inline-flex items-center gap-1">
<span className={cn('h-1.5 w-1.5 rounded-full', categoryConfig[category].dot)} />
{categoryConfig[category].label}
<span className="font-semibold text-slate-700">{counts[category]}</span>
</span>
))}
</div>
)
}
+42 -17
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { Sparkles, Copy, Edit, Check, Lightbulb, Globe, AlertTriangle, BookOpen, X } from 'lucide-react'
import { Sparkles, Copy, Edit, Check, Lightbulb, Globe, AlertTriangle, BookOpen, Languages, X } from 'lucide-react'
import { generateReplies } from '@/services/api/ai'
import type { AiReplySuggestion } from '@/types/ai'
import { Button } from '@/components/ui/button'
@@ -20,30 +20,32 @@ export function AiReplies({
onAdopt,
onEdit,
onClose,
targetLanguage = 'auto',
}: {
conversationId: string
onAdopt: (content: string) => void
onEdit: (content: string) => void
onAdopt: (content: string, translationCn: string, suggestionId: string) => void
onEdit: (content: string, translationCn: string) => void
onClose: () => void
targetLanguage?: string
}) {
const [items, setItems] = useState<AiReplySuggestion[] | null>(null)
useEffect(() => setItems(null), [conversationId])
const mutation = useMutation({
mutationFn: () => generateReplies(conversationId),
mutationFn: () => generateReplies(conversationId, targetLanguage),
onSuccess: (data) => {
setItems(Array.isArray(data) ? data : [])
toast.success('AI 回复建议已生成', '根据客户画像、知识库和历史成功回复综合生成')
toast.success('回复建议已生成', '已参考客户资料、知识库和历史回复')
},
onError: (e) => toast.error(errorMessage(e)),
})
return (
<div className="border-t border-border p-4">
<div className="border-t border-slate-100 bg-white px-4 py-3">
<div className="mb-3 flex items-center justify-between">
<h4 className="flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
<Sparkles className="h-3.5 w-3.5 text-primary" />
AI 智能回复
<h4 className="flex items-center gap-2 text-xs font-semibold text-slate-700">
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary-50"><Sparkles className="h-3.5 w-3.5 text-primary" /></span>
回复建议
</h4>
<div className="flex items-center gap-1">
<Button
@@ -61,7 +63,7 @@ export function AiReplies({
'生成回复建议'
)}
</Button>
<Button variant="ghost" size="icon-sm" onClick={onClose} title="关闭 AI 智能回复">
<Button variant="ghost" size="icon-sm" onClick={onClose} title="关闭回复建议">
<X className="h-4 w-4" />
</Button>
</div>
@@ -94,7 +96,29 @@ export function AiReplies({
{languageName(s.targetLang)}
</span>
</div>
<p className="text-sm leading-relaxed whitespace-pre-wrap">{s.content}</p>
<div className="space-y-2">
<div>
<div className="mb-1 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
<Globe className="h-3 w-3" />
发送给客户 · {languageName(s.targetLang)}
</div>
<p className="whitespace-pre-wrap text-sm leading-relaxed text-slate-800">{s.content}</p>
</div>
{s.translationCn && (
<div
data-testid="ai-reply-translation"
className="rounded-lg border border-primary-100 bg-primary-50/60 px-3 py-2"
>
<div className="mb-1 flex items-center gap-1.5 text-[10px] font-semibold text-primary-700">
<Languages className="h-3 w-3" />
中文参考 · 不会发送
</div>
<p className="whitespace-pre-wrap text-xs leading-relaxed text-slate-600">
{s.translationCn}
</p>
</div>
)}
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
{(s.knowledgeRefs || []).slice(0, 2).map((ref) => (
@@ -111,7 +135,7 @@ export function AiReplies({
<span className="text-muted-foreground">{s.reason}</span>
</div>
<div className="flex items-start gap-1">
<Globe className="mt-0.5 h-3 w-3 shrink-0 text-blue-500" />
<Sparkles className="mt-0.5 h-3 w-3 shrink-0 text-blue-500" />
<span className="text-muted-foreground">{s.expectedEffect}</span>
</div>
{s.cultureTip && (
@@ -123,11 +147,11 @@ export function AiReplies({
</div>
<div className="mt-2 flex gap-1.5">
<Button size="sm" variant="default" onClick={() => onAdopt(s.content)}>
<Button size="sm" variant="default" onClick={() => onAdopt(s.content, s.translationCn, s.id)}>
<Check className="h-3.5 w-3.5" />
采用
</Button>
<Button size="sm" variant="outline" onClick={() => onEdit(s.content)}>
<Button size="sm" variant="outline" onClick={() => onEdit(s.content, s.translationCn)}>
<Edit className="h-3.5 w-3.5" />
编辑后采用
</Button>
@@ -146,9 +170,10 @@ export function AiReplies({
)}
{!items && !mutation.isPending && (
<p className="py-3 text-center text-xs text-muted-foreground">
点击「生成回复建议」让 AI 根据客户上下文、知识库与历史成功回复为您生成回复
</p>
<div className="flex items-center justify-between rounded-xl bg-slate-50 px-3 py-2.5">
<p className="text-xs text-slate-500">生成三种回复建议</p>
<span className="hidden text-[10px] font-medium text-primary sm:inline">不会自动发送</span>
</div>
)}
</div>
)
+59 -20
View File
@@ -10,6 +10,7 @@ import {
Shrink,
Expand,
ChevronDown,
Languages,
Reply,
X,
} from 'lucide-react'
@@ -19,7 +20,8 @@ import { Dropdown } from '@/components/ui/dropdown'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import { toast } from '@/app/store/toast'
import { cn } from '@/utils/cn'
import type { ChannelType } from '@/types'
import { LANGUAGES, languageName } from '@/config/languages'
const QUICK_PHRASES = [
{ label: '报价催促', text: 'Friendly reminder: the quotation is valid until this Friday. Please confirm at your earliest convenience.' },
@@ -47,14 +49,22 @@ export function Composer({
disabled,
replyingTo,
onCancelReply,
channel = 'telegram',
targetLanguage = 'auto',
detectedLanguage,
onTargetLanguageChange,
}: {
onSend: (text: string, autoTranslate?: boolean) => Promise<void>
onSend: (text: string, autoTranslate?: boolean, targetLang?: string) => Promise<void>
onMedia?: (files: File[], kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker') => Promise<void>
value?: string
onChange?: (text: string) => void
disabled?: boolean
replyingTo?: { id: string; text: string } | null
onCancelReply?: () => void
channel?: ChannelType
targetLanguage?: string
detectedLanguage?: string
onTargetLanguageChange?: (language: string) => void
}) {
const [internalText, setInternalText] = useState('')
const [sending, setSending] = useState(false)
@@ -73,7 +83,12 @@ export function Composer({
setSending(true)
try {
await onMedia(Array.from(files), kind)
toast.success(`${files.length} 个文件已发送到 Telegram`)
const destination = channel === 'email'
? '客户邮箱'
: channel === 'whatsapp'
? ' WhatsApp'
: ' Telegram'
toast.success(`${files.length} 个文件已发送到${destination}`)
} catch {
toast.error('媒体发送失败')
} finally {
@@ -85,7 +100,7 @@ export function Composer({
if (!text.trim()) return
setSending(true)
try {
await onSend(text, autoTranslate)
await onSend(text, autoTranslate, targetLanguage)
setText('')
} finally {
setSending(false)
@@ -109,7 +124,7 @@ export function Composer({
setAiLoading(true)
try {
const { textAction } = await import('@/services/api/ai')
const res = await textAction({ text, action: action as any, tone, targetLang: 'en' })
const res = await textAction({ text, action: action as any, tone, targetLang: targetLanguage })
setText(res.result)
toast.success('AI 处理完成')
} catch {
@@ -120,11 +135,11 @@ export function Composer({
}
const toolbar = (
<div className="flex flex-wrap items-center gap-0.5 border-b border-border px-2 py-1.5">
<div className="flex items-center gap-1 overflow-x-auto border-b border-slate-100 px-3 py-2 scrollbar-thin">
{/* 话术 */}
<Dropdown
trigger={
<button className="flex items-center gap-0.5 rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground">
<button className="flex shrink-0 items-center gap-1 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50 hover:text-slate-900">
<FileText className="h-3.5 w-3.5" />
常用话术
<ChevronDown className="h-3 w-3" />
@@ -134,7 +149,7 @@ export function Composer({
/>
<span className="mx-1 h-4 w-px bg-border" />
<button
className="rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
className="shrink-0 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50 hover:text-slate-900"
onClick={() => documentInput.current?.click()}
title="上传文件"
type="button"
@@ -142,15 +157,17 @@ export function Composer({
<Paperclip className="inline h-3.5 w-3.5" /> 文件
</button>
<button
className="rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
className="shrink-0 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50 hover:text-slate-900"
onClick={() => imageInput.current?.click()}
title="上传图片"
type="button"
>
<Image className="inline h-3.5 w-3.5" /> 图片
</button>
{channel !== 'email' && (
<>
<button
className="rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
className="shrink-0 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50 hover:text-slate-900"
onClick={() => audioInput.current?.click()}
title="语音输入"
type="button"
@@ -158,13 +175,15 @@ export function Composer({
<Mic className="inline h-3.5 w-3.5" /> 语音
</button>
<button
className="rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
className="shrink-0 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50 hover:text-slate-900"
onClick={() => stickerInput.current?.click()}
title="发送贴纸"
type="button"
>
贴纸
</button>
</>
)}
<input ref={documentInput} type="file" className="hidden" onChange={(e) => uploadFiles(e.target.files, 'document')} />
<input ref={imageInput} type="file" accept="image/*" multiple className="hidden" onChange={(e) => uploadFiles(e.target.files, 'photo')} />
<input ref={audioInput} type="file" accept="audio/*" className="hidden" onChange={(e) => uploadFiles(e.target.files, 'voice')} />
@@ -182,7 +201,7 @@ export function Composer({
type="button"
disabled={aiLoading || !text.trim()}
onClick={() => aiAction(t.action)}
className="rounded px-1.5 py-1 text-xs text-teal-600 hover:bg-teal-50 disabled:opacity-50"
className="shrink-0 rounded-lg px-2 py-1.5 text-xs font-medium text-primary-700 hover:bg-primary-50 disabled:opacity-50"
>
<t.icon className="inline h-3.5 w-3.5" /> {t.label}
</button>
@@ -190,7 +209,7 @@ export function Composer({
<span className="mx-1 h-4 w-px bg-border" />
<Dropdown
trigger={
<button className="flex items-center gap-0.5 rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted">
<button className="flex shrink-0 items-center gap-0.5 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50">
修改语气 <ChevronDown className="h-3 w-3" />
</button>
}
@@ -200,9 +219,9 @@ export function Composer({
)
return (
<div className="border-t border-border bg-card">
<div className="border-t border-slate-100 bg-white">
{toolbar}
<div className="p-3">
<div className="p-3.5">
{replyingTo && (
<div className="mb-2 flex items-center gap-2 rounded-lg border-s-2 border-primary bg-primary-50/70 px-3 py-2">
<Reply className="h-3.5 w-3.5 shrink-0 text-primary" />
@@ -220,18 +239,38 @@ export function Composer({
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入回复内容,Enter 发送,Shift+Enter 换行"
placeholder={channel === 'email' ? '撰写邮件回复,Enter 发送,Shift+Enter 换行' : '输入回复内容,Enter 发送,Shift+Enter 换行'}
rows={3}
className="w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground outline-none focus:border-primary focus:ring-1 focus:ring-ring scrollbar-thin"
className="min-h-[86px] w-full resize-none rounded-2xl border border-slate-200 bg-slate-50/60 px-4 py-3.5 text-sm leading-6 placeholder:text-slate-400 outline-none transition-all focus:border-primary-300 focus:bg-white focus:ring-4 focus:ring-primary-50 scrollbar-thin"
disabled={sending || disabled}
/>
<div className="mt-2 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<Switch checked={autoTranslate} onCheckedChange={setAutoTranslate} />
中文自动译为英文发送
双向翻译
</label>
{autoTranslate && (
<label className="relative flex items-center">
<Languages className="pointer-events-none absolute left-2.5 h-3.5 w-3.5 text-primary" />
<select
value={targetLanguage}
onChange={(event) => onTargetLanguageChange?.(event.target.value)}
aria-label="客户目标语言"
className="h-8 max-w-[190px] appearance-none rounded-lg border border-primary-100 bg-primary-50/70 py-1 pl-8 pr-7 text-xs font-medium text-slate-700 outline-none transition hover:border-primary-200 focus:border-primary-300 focus:ring-2 focus:ring-primary-100"
>
{LANGUAGES.map((language) => (
<option key={language.code} value={language.code}>
{language.code === 'auto' && detectedLanguage
? `自动 · ${languageName(detectedLanguage)}`
: `${language.name} · ${language.nativeName}`}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2 h-3 w-3 text-slate-400" />
</label>
)}
{aiLoading && (
<Badge variant="primary" className="animate-pulse">
正在调用 AI…
@@ -240,7 +279,7 @@ export function Composer({
</div>
<Button onClick={send} size="sm" disabled={!text.trim() || sending || disabled} loading={sending}>
<Send className="h-4 w-4" />
发 送 (↵)
{channel === 'email' ? '发送邮件' : '发 送'} (↵)
</Button>
</div>
</div>
+36 -30
View File
@@ -1,8 +1,6 @@
import { Brain, Building2, ChevronRight } from 'lucide-react'
import type { Conversation, Customer, CustomerStage } from '@/types'
import { Avatar } from '@/components/ui/avatar'
import { Brain, ChevronRight, LoaderCircle } from 'lucide-react'
import type { AiBackfillStatus, Conversation, Customer, CustomerStage } from '@/types'
import { Button } from '@/components/ui/button'
import { CountryFlag } from '@/components/common/CountryFlag'
import {
CustomerStageBadge,
IntentScoreBadge,
@@ -19,39 +17,41 @@ export function ConversationInsightBar({
customer,
conversation,
onOpenProfile,
aiBackfill,
}: {
customer: Customer
conversation: Conversation
onOpenProfile: () => void
aiBackfill?: AiBackfillStatus
}) {
const stage = validStages.has(customer.stage) ? customer.stage : 'initial'
const summary = customer.aiProfile?.summary || conversation.summary || 'AI 正在根据会话完善客户画像'
const summary = customer.aiProfile?.summary || conversation.summary || '收到更多客户消息后将补充客户画像'
return (
<div data-testid="conversation-insight-bar" className="border-b border-border bg-gradient-to-r from-primary-50/80 via-card to-card px-3 py-2">
<div className="flex min-w-0 items-center gap-2.5">
<Avatar name={customer.name} src={customer.avatarUrl} size="sm" />
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="max-w-40 truncate text-sm font-semibold">{customer.name}</span>
{customer.telegramUsername && (
<span className="max-w-32 truncate text-[11px] text-muted-foreground">
@{customer.telegramUsername}
<div
data-testid="conversation-insight-bar"
className="shrink-0 border-b border-slate-100 bg-white px-3 py-2 shadow-[0_1px_0_rgba(15,23,42,.02)] sm:px-4"
>
<div className="flex min-w-0 items-center gap-2">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border border-violet-100 bg-violet-50 text-violet-600">
<Brain className="h-4 w-4" />
</span>
<div className="flex min-w-0 flex-1 items-center gap-2">
<span className="hidden shrink-0 text-[10px] font-semibold uppercase tracking-[0.12em] text-violet-600 sm:inline">
客户洞察
</span>
<p className="truncate text-xs leading-5 text-slate-600">{summary}</p>
</div>
<div className="hidden shrink-0 items-center gap-2 md:flex">
{aiBackfill && ['queued', 'running'].includes(aiBackfill.status) && (
<span
title={`历史消息后台分析:${aiBackfill.completedCount}/${aiBackfill.totalCount}`}
className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2 py-1 text-[10px] font-medium text-blue-700"
>
<LoaderCircle className="h-3 w-3 animate-spin" />
历史分析 {Math.round(Number(aiBackfill.progress || 0) * 100)}%
</span>
)}
<CountryFlag code={customer.country} withName={false} />
{customer.company && (
<span className="hidden items-center gap-1 truncate text-xs text-muted-foreground md:flex">
<Building2 className="h-3 w-3" /> {customer.company}
</span>
)}
</div>
<p className="mt-0.5 max-w-[60vw] truncate text-[11px] text-muted-foreground">
<Brain className="mr-1 inline h-3 w-3 text-primary" />
{summary}
</p>
</div>
<div className="ms-auto hidden items-center gap-2 sm:flex">
<span className="flex items-center gap-1 text-xs text-muted-foreground">
意向 <IntentScoreBadge score={Number(customer.intentScore || 0)} />
</span>
@@ -59,13 +59,19 @@ export function ConversationInsightBar({
<CustomerStageBadge stage={stage as CustomerStage} />
<RiskLevelBadge level={customer.riskLevel || 'none'} />
</div>
<Button variant="ghost" size="sm" onClick={onOpenProfile}>
<Button variant="ghost" size="sm" onClick={onOpenProfile} title="打开客户画像">
<span className="hidden lg:inline">客户画像</span>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="mt-1.5 flex items-center gap-3 overflow-x-auto sm:hidden">
<span className="flex shrink-0 items-center gap-1 text-xs">意向 <IntentScoreBadge score={Number(customer.intentScore || 0)} /></span>
<div className="mt-1.5 flex items-center gap-2 overflow-x-auto pb-0.5 md:hidden">
{aiBackfill && ['queued', 'running'].includes(aiBackfill.status) && (
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-50 px-2 py-1 text-[10px] font-medium text-blue-700">
<LoaderCircle className="h-3 w-3 animate-spin" />
历史分析 {Math.round(Number(aiBackfill.progress || 0) * 100)}%
</span>
)}
<span className="flex shrink-0 items-center gap-1 text-[11px]">意向 <IntentScoreBadge score={Number(customer.intentScore || 0)} /></span>
<SentimentBadge sentiment={customer.sentiment || 'neutral'} />
<CustomerStageBadge stage={stage as CustomerStage} />
<RiskLevelBadge level={customer.riskLevel || 'none'} />
+151 -25
View File
@@ -1,4 +1,5 @@
import { Search, ShieldAlert } from 'lucide-react'
import { useRef, useState } from 'react'
import { Eraser, Mail, MessageCircle, Pin, Plus, Search, Send, ShieldAlert, Trash2 } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { getTeam } from '@/services/api/org'
import type { ConversationListItem } from '@/services/api/conversations'
@@ -21,10 +22,11 @@ const FILTERS = [
{ value: 'risk', label: '有风险' },
{ value: 'unassigned', label: '无负责人' },
{ value: 'mine', label: '我的客户' },
{ value: 'archived', label: '已归档' },
]
export function ConversationList({
items,
items = [],
loading,
activeId,
onSelect,
@@ -32,6 +34,11 @@ export function ConversationList({
onFilterChange,
keyword,
onKeywordChange,
channel,
onChannelChange,
onAdd,
onClear,
onDelete,
}: {
items: ConversationListItem[]
loading: boolean
@@ -41,34 +48,83 @@ export function ConversationList({
onFilterChange: (f: string) => void
keyword: string
onKeywordChange: (k: string) => void
channel: string
onChannelChange: (channel: string) => void
onAdd: () => void
onClear: (item: ConversationListItem) => void
onDelete: (item: ConversationListItem) => void
}) {
const { t } = useI18n()
const { data: team } = useQuery({ queryKey: ['team'], queryFn: getTeam, staleTime: 300_000 })
const memberName = (id: string | null) => (id ? (team?.find((m) => m.id === id)?.name ?? '—') : null)
const [swipedId, setSwipedId] = useState<string | null>(null)
const [dragOffset, setDragOffset] = useState(0)
const touchStart = useRef<{ x: number; y: number; id: string } | null>(null)
return (
<div className="flex h-full flex-col">
{/* 搜索 */}
<div className="border-b border-border p-3">
<div className="border-b border-slate-200 bg-white px-3 pb-3 pt-3">
<div className="mb-3 flex items-center justify-between px-1">
<div>
<h1 className="text-xl font-bold tracking-[-0.03em] text-slate-950">消息</h1>
<p className="mt-0.5 text-[11px] text-slate-400">{items.length} 个活跃会话</p>
</div>
<div className="flex items-center gap-1.5">
<span className="rounded-full bg-emerald-50 px-2.5 py-1 text-[10px] font-semibold text-emerald-700">
实时连接
</span>
<button
type="button"
onClick={onAdd}
title="添加联系人或群组"
aria-label="添加联系人或群组"
className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-white shadow-sm shadow-primary-200 transition hover:-translate-y-0.5 hover:bg-primary-700"
>
<Plus className="h-4 w-4" />
</button>
</div>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground rtl:left-auto rtl:right-2.5" />
<input
value={keyword}
onChange={(e) => onKeywordChange(e.target.value)}
placeholder="搜索客户/国家/产品/消息内容"
className="h-9 w-full rounded-md border border-input bg-muted/40 pl-8 pr-2 text-sm placeholder:text-muted-foreground focus:border-primary focus:bg-card focus:outline-none rtl:pl-2 rtl:pr-8"
className="h-10 w-full rounded-full border border-transparent bg-slate-100/80 pl-9 pr-3 text-sm placeholder:text-slate-400 focus:border-sky-200 focus:bg-white focus:outline-none focus:ring-4 focus:ring-sky-50 rtl:pl-2 rtl:pr-8"
/>
</div>
{/* 筛选 */}
<div className="mt-2.5 flex flex-wrap gap-1">
<div className="mt-2.5 flex items-center gap-1 rounded-xl bg-slate-50 p-1">
{[
{ value: '', label: '全部', icon: null },
{ value: 'telegram', label: 'Telegram', icon: Send },
{ value: 'whatsapp', label: 'WhatsApp', icon: MessageCircle },
{ value: 'email', label: 'Email', icon: Mail },
].map((item) => (
<button
key={item.value}
type="button"
onClick={() => onChannelChange(item.value)}
className={cn(
'flex flex-1 items-center justify-center gap-1 rounded-lg px-2 py-1.5 text-[11px] font-semibold transition-all',
channel === item.value
? 'bg-white text-slate-900 shadow-sm ring-1 ring-slate-200/70'
: 'text-slate-400 hover:text-slate-700',
)}
>
{item.icon && <item.icon className="h-3 w-3" />}
{item.label}
</button>
))}
</div>
<div className="mt-2 flex gap-1 overflow-x-auto pb-0.5 scrollbar-none">
{FILTERS.map((f) => (
<button
key={f.value}
onClick={() => onFilterChange(f.value)}
className={cn(
'rounded-full px-2.5 py-1 text-xs transition-colors',
'shrink-0 rounded-full px-3 py-1.5 text-[11px] font-medium transition-colors',
filter === f.value
? 'bg-primary font-medium text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-border',
? 'bg-primary font-semibold text-white shadow-sm shadow-primary-200'
: 'text-slate-500 hover:bg-slate-100 hover:text-slate-800',
)}
>
{f.label}
@@ -78,10 +134,10 @@ export function ConversationList({
</div>
{/* 列表 */}
<div className="flex-1 overflow-y-auto scrollbar-thin">
<div className="flex-1 overflow-y-auto bg-white px-1.5 py-1.5 scrollbar-thin">
{loading &&
Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex gap-3 border-b border-border px-3 py-3">
<div key={i} className="flex gap-3 rounded-xl px-3 py-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3.5 w-2/3" />
@@ -97,15 +153,80 @@ export function ConversationList({
const c = item.customer
const active = item.id === activeId
const intent = intentLevelMeta(item.intentLevel)
const isTelegramChannel = String(item.telegramChatType || '').toLowerCase() === 'channel'
const supportsSwipeActions = item.channel === 'telegram' || item.channel === 'whatsapp'
return (
<div key={item.id} className="relative mb-0.5 overflow-hidden rounded-xl">
{supportsSwipeActions && (
<div
data-testid="conversation-swipe-actions"
aria-hidden={swipedId !== item.id && touchStart.current?.id !== item.id}
className={cn(
'absolute inset-y-0 right-0 flex w-[144px] overflow-hidden rounded-xl transition-opacity duration-150',
swipedId === item.id || touchStart.current?.id === item.id
? 'opacity-100'
: 'pointer-events-none opacity-0',
)}
>
{!isTelegramChannel && (
<button
type="button"
onClick={() => onClear(item)}
className="flex w-[72px] flex-col items-center justify-center gap-1 bg-amber-500 text-[11px] font-semibold text-white"
>
<Eraser className="h-4 w-4" />清空
</button>
)}
<button
type="button"
onClick={() => onDelete(item)}
className="flex flex-1 flex-col items-center justify-center gap-1 bg-red-500 text-[11px] font-semibold text-white"
>
<Trash2 className="h-4 w-4" />{isTelegramChannel ? '退出并删除' : '删除'}
</button>
</div>
)}
<button
key={item.id}
data-testid="conversation-item"
data-conversation-id={item.id}
onClick={() => onSelect(item.id)}
data-channel={item.channel}
data-telegram-chat-type={item.telegramChatType || ''}
data-last-sender={item.lastMessage?.sender || ''}
onTouchStart={(event) => {
if (!supportsSwipeActions) return
const touch = event.touches[0]
touchStart.current = { x: touch.clientX, y: touch.clientY, id: item.id }
if (swipedId !== item.id) setDragOffset(0)
}}
onTouchMove={(event) => {
const start = touchStart.current
if (!start || start.id !== item.id) return
const touch = event.touches[0]
const dx = touch.clientX - start.x
const dy = touch.clientY - start.y
if (Math.abs(dx) <= Math.abs(dy)) return
setDragOffset(Math.max(-144, Math.min(0, (swipedId === item.id ? -144 : 0) + dx)))
}}
onTouchEnd={() => {
setSwipedId(dragOffset < -54 ? item.id : null)
setDragOffset(0)
touchStart.current = null
}}
onClick={() => {
if (swipedId === item.id) {
setSwipedId(null)
return
}
onSelect(item.id)
}}
style={{
transform: `translateX(${swipedId === item.id ? -144 : touchStart.current?.id === item.id ? dragOffset : 0}px)`,
}}
className={cn(
'flex w-full gap-2.5 border-b border-border px-3 py-3 text-start transition-colors',
active ? 'bg-primary-50/70' : 'hover:bg-muted/50',
'relative flex w-full gap-3 overflow-hidden rounded-xl px-3 py-3 text-start transition-[transform,background-color,box-shadow] duration-200',
active
? 'conversation-selected bg-gradient-to-br from-primary-200/80 via-primary-100/90 to-white text-foreground ring-1 ring-inset ring-primary-300'
: 'bg-white hover:bg-slate-50',
)}
>
<div className="relative shrink-0">
@@ -116,7 +237,8 @@ export function ConversationList({
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">{c.name}</span>
<span className={cn('truncate text-sm font-semibold', active && 'text-primary-900')}>{c.name}</span>
{item.isPinned && <Pin className="h-3 w-3 shrink-0 fill-primary-100 text-primary" />}
{c.telegramUsername && (
<span className="hidden max-w-24 truncate text-[10px] text-muted-foreground xl:inline">
@{c.telegramUsername}
@@ -124,30 +246,34 @@ export function ConversationList({
)}
<CountryFlag code={c.country} withName={false} />
{item.hasRisk && <ShieldAlert className="h-3.5 w-3.5 shrink-0 text-red-500" />}
<span className="ms-auto shrink-0 text-[11px] text-muted-foreground">
{formatRelative(item.updatedAt)}
<span className={cn('ms-auto shrink-0 text-[11px] text-muted-foreground', active && 'text-primary-600')}>
{formatRelative(item.lastMessage?.createdAt || item.updatedAt)}
</span>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
<p className={cn('mt-0.5 truncate text-xs text-muted-foreground', active && 'text-slate-700')}>
{item.lastMessage?.sender === 'agent' ? '我:' : ''}
{item.lastMessage?.translatedText ?? item.lastMessage?.originalText ?? item.summary}
</p>
<div className="mt-1.5 flex items-center gap-1.5">
<Badge className={cn('text-[10px] px-1 py-0', intent.color)}>{intent.label}</Badge>
<div className="mt-2 flex items-center gap-1.5">
{!active && <Badge className={cn('rounded-md px-1.5 py-0 text-[10px]', intent.color)}>{intent.label}</Badge>}
<SentimentBadge sentiment={item.sentiment} emojiOnly />
{item.assigneeId ? (
<span className="text-[10px] text-muted-foreground">负责人:{memberName(item.assigneeId)}</span>
<span className={cn('text-[10px] text-muted-foreground', active && 'text-slate-500')}>负责人:{memberName(item.assigneeId)}</span>
) : (
<span className="text-[10px] text-amber-600">未分配</span>
<span className="text-[10px] text-amber-700">未分配</span>
)}
{item.unreadCount > 0 && (
<span className="ms-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-white">
<span className={cn(
'ms-auto flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-semibold',
'bg-primary text-white',
)}>
{item.unreadCount}
</span>
)}
</div>
</div>
</button>
</div>
)
})}
</div>
+106 -36
View File
@@ -14,7 +14,9 @@ import {
} from 'lucide-react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { updateCustomer } from '@/services/api/customers'
import { createTask } from '@/services/api/crm'
import { analyzeConversation } from '@/services/api/ai'
import { useAuthStore } from '@/app/store/auth'
import type { Customer, Conversation } from '@/types'
import { Avatar } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
@@ -29,6 +31,8 @@ import { IntentScoreBadge, CustomerStageBadge, SentimentBadge, RiskLevelBadge }
import { CUSTOMER_STAGES, NEXT_ACTIONS, countryMeta, INTENT_LEVELS } from '@/config/constants'
import { toast, errorMessage } from '@/app/store/toast'
import { cn } from '@/utils/cn'
import { CustomerProfileRadar } from '@/components/customer/CustomerProfileRadar'
import { CustomerSemanticTag } from '@/components/customer/CustomerSemanticTag'
export function InsightPanel({
customer,
@@ -38,12 +42,21 @@ export function InsightPanel({
conversation: Conversation
}) {
const queryClient = useQueryClient()
const user = useAuthStore((state) => state.user)
const [analyzing, setAnalyzing] = useState(false)
const [analysis, setAnalysis] = useState(customer.aiProfile.summary)
const c = countryMeta(customer.country)
const localTime = new Date(
const localTime = Number.isFinite(c.tzOffset)
? new Date(
new Date().getTime() + new Date().getTimezoneOffset() * 60000 + c.tzOffset * 3600000,
).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
: ''
const countrySourceLabel = {
manual: '人工确认',
'account-profile': '账户资料',
'phone-prefix': '手机号区号',
unknown: '待确认',
}[customer.countrySource || 'unknown']
const updateMut = useMutation({
mutationFn: (data: Partial<Customer>) => updateCustomer(customer.id, data),
@@ -54,22 +67,41 @@ export function InsightPanel({
},
onError: (e) => toast.error(errorMessage(e)),
})
const taskMut = useMutation({
mutationFn: (title: string) => createTask({
title,
type: 'revisit',
priority: customer.riskLevel === 'urgent' || customer.riskLevel === 'high' ? 'high' : 'medium',
status: 'pending',
customerId: customer.id,
customerName: customer.name,
conversationId: conversation.id,
ownerId: user?.id || '',
dueAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
note: `由客户洞察建议创建:${title}`,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] })
toast.success('已创建跟进任务', '默认明天到期')
},
onError: (error) => toast.error(errorMessage(error)),
})
const handleAnalyze = async () => {
setAnalyzing(true)
try {
const result = await analyzeConversation(conversation.id)
setAnalysis(result.summary)
toast.success('AI 分析完成', `意向评分 ${result.intentScore} · ${INTENT_LEVELS.find(l => l.value === result.intentLevel)?.label}`)
toast.success('会话分析完成', `意向评分 ${result.intentScore} · ${INTENT_LEVELS.find(l => l.value === result.intentLevel)?.label}`)
} catch {
toast.error('分析失败,请稍后重试')
} finally { setAnalyzing(false) }
}
return (
<div className="flex h-full flex-col overflow-y-auto scrollbar-thin">
<div className="flex h-full flex-col overflow-y-auto bg-slate-50/40 scrollbar-thin">
{/* 客户基本信息 */}
<div className="border-b border-border p-4">
<div className="border-b border-slate-100 bg-white p-5">
<div className="mb-3 flex items-center gap-3">
<Avatar name={customer.name} src={customer.avatarUrl} size="lg" />
<div className="min-w-0 flex-1">
@@ -90,11 +122,14 @@ export function InsightPanel({
<div className="flex items-center gap-2">
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
<CountryFlag code={customer.country} />
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-500">
{countrySourceLabel}
</span>
</div>
<div className="flex items-center gap-2">
{localTime && <div className="flex items-center gap-2">
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">当地时间:{localTime}</span>
</div>
</div>}
{customer.email && (
<div className="flex items-center gap-2">
<Mail className="h-3.5 w-3.5 text-muted-foreground" />
@@ -120,7 +155,7 @@ export function InsightPanel({
<Badge key={p} variant="primary">{p}</Badge>
))}
{customer.tags?.map((t) => (
<Badge key={t} variant="outline">{t}</Badge>
<CustomerSemanticTag key={t} tag={t} />
))}
</div>
@@ -151,9 +186,59 @@ export function InsightPanel({
</div>
{/* AI 分析 */}
<div className="border-b border-border p-4">
<div className="space-y-4 border-b border-slate-100 p-3">
<CustomerProfileRadar customer={customer} compact />
<div className="rounded-2xl border border-blue-100 bg-gradient-to-br from-white to-blue-50/60 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-900">会话长期记忆</p>
<p className="mt-0.5 text-[11px] text-slate-400">
{conversation.aiContextStatus === 'compressing'
? '正在压缩较早的会话内容…'
: conversation.aiContextMemory
? `已压缩 ${conversation.aiContextSourceMessageCount || conversation.aiContextMemory.sourceMessageCount || 0} 条较早消息`
: '会话达到长度阈值后自动生成'}
</p>
</div>
<span className={cn(
'rounded-full px-2 py-1 text-[10px] font-medium',
conversation.aiContextStatus === 'failed'
? 'bg-red-50 text-red-600'
: conversation.aiContextMemory
? 'bg-blue-100 text-blue-700'
: 'bg-slate-100 text-slate-500',
)}>
{conversation.aiContextStatus === 'compressing'
? '压缩中'
: conversation.aiContextMemory
? '已启用'
: '等待触发'}
</span>
</div>
{conversation.aiContextMemory?.summary && (
<p className="mt-3 text-xs leading-5 text-slate-600">
{conversation.aiContextMemory.summary}
</p>
)}
{conversation.aiContextMemory?.unresolved?.length ? (
<div className="mt-3 border-t border-blue-100 pt-3">
<p className="text-[10px] font-semibold uppercase tracking-wide text-blue-600">未决事项</p>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{conversation.aiContextMemory.unresolved.slice(0, 4).map((item) => (
<span key={item} className="rounded-full bg-white px-2 py-1 text-[10px] text-slate-600 shadow-sm">
{item}
</span>
))}
</div>
</div>
) : null}
</div>
<div className="rounded-2xl border border-slate-200/80 bg-white p-4">
<div className="flex items-center justify-between">
<h4 className="text-xs font-semibold text-muted-foreground">AI 分析摘要</h4>
<div>
<p className="text-sm font-semibold tracking-tight text-slate-900">会话摘要</p>
<p className="text-[11px] text-slate-400">根据已同步的会话内容生成</p>
</div>
<Button variant="ghost" size="sm" onClick={handleAnalyze} disabled={analyzing}>
{analyzing ? <Spinner className="h-3 w-3" /> : '重新分析'}
</Button>
@@ -164,41 +249,24 @@ export function InsightPanel({
<span className="ml-2 text-sm text-muted-foreground">正在分析会话上下文…</span>
</div>
) : (
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">{analysis}</p>
<p className="mt-3 text-xs leading-6 text-slate-600">{analysis || '收到更多客户消息后将补充客户画像。'}</p>
)}
{/* AI 提取关键信息 */}
{Object.values(customer.aiProfile || {}).some(Boolean) && (
<div className="mt-3 space-y-1 border-t border-border pt-3">
<div className="mt-4 grid grid-cols-2 gap-2 border-t border-slate-100 pt-4">
{Object.entries({
公司: customer.aiProfile.companyName,
行业: customer.aiProfile.industry,
商业模式: customer.aiProfile.businessModel,
采购角色: customer.aiProfile.purchasingRole,
决策权: customer.aiProfile.decisionPower,
询价数量: customer.aiProfile.inquiryQuantity,
目标价格: customer.aiProfile.targetPrice,
预算: customer.aiProfile.budget,
MOQ: customer.aiProfile.moqRequirement,
样品: customer.aiProfile.sampleNeed,
定制: customer.aiProfile.customRequirement,
交付: customer.aiProfile.deliveryTime,
紧迫程度: customer.aiProfile.urgency,
采购周期: customer.aiProfile.purchaseTimeline,
物流: customer.aiProfile.logistics,
付款: customer.aiProfile.payment,
核心需求: customer.aiProfile.keyNeeds?.join('、'),
痛点: customer.aiProfile.painPoints?.join('、'),
顾虑: customer.aiProfile.concerns?.join('、'),
异议: customer.aiProfile.objections?.join('、'),
购买信号: customer.aiProfile.buyingSignals?.join('、'),
竞品: customer.aiProfile.competitorMentions?.join('、'),
沟通风格: customer.aiProfile.communicationStyle,
建议语气: customer.aiProfile.recommendedTone,
跟进时机: customer.aiProfile.followUpTiming,
}).filter(([, v]) => v).map(([k, v]) => (
<div key={k} className="flex gap-2 text-xs">
<span className="shrink-0 text-muted-foreground">{k}</span>
<span className="text-foreground">{v as string}</span>
<div key={k} className="min-w-0 rounded-xl bg-slate-50 px-3 py-2">
<p className="text-[10px] font-medium text-slate-400">{k}</p>
<p className="mt-0.5 truncate text-xs font-medium text-slate-700">{v as string}</p>
</div>
))}
</div>
@@ -209,7 +277,7 @@ export function InsightPanel({
['流失概率', customer.aiProfile.churnProbability],
['分析可信度', customer.aiProfile.confidenceScore],
].map(([label, value]) => (
<div key={String(label)} className="rounded-lg bg-muted/70 px-2 py-2 text-center">
<div key={String(label)} className="rounded-xl bg-slate-50 px-2 py-2 text-center">
<p className="text-[10px] text-muted-foreground">{label}</p>
<p className="text-sm font-semibold">{Number(value || 0)}%</p>
</div>
@@ -224,16 +292,18 @@ export function InsightPanel({
</div>
) : null}
</div>
</div>
{/* 推荐动作 */}
<div className="p-4">
<h4 className="text-xs font-semibold text-muted-foreground">AI 推荐下一步</h4>
<div className="mt-2 flex flex-wrap gap-1.5">
<h4 className="text-xs font-semibold text-slate-500">下一步建议</h4>
<div className="mt-2 space-y-2">
{customer.aiProfile.nextActions.map((a) => (
<button
key={a}
onClick={() => toast.info(`建议操作:${a}`)}
className="rounded-full border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs text-primary-700 hover:bg-primary-100"
onClick={() => taskMut.mutate(a)}
disabled={taskMut.isPending}
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-start text-xs text-slate-700 hover:border-primary-200 hover:bg-primary-50"
>
{a}
</button>
+231 -24
View File
@@ -13,14 +13,18 @@ import {
BookOpen,
MoreHorizontal,
Languages,
Globe,
ChevronDown,
ChevronUp,
Download,
FileText,
LoaderCircle,
SmilePlus,
Trash2,
Brain,
} from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import type { Message } from '@/types'
import type { ChannelType, Message } from '@/types'
import { cn } from '@/utils/cn'
import { toast } from '@/app/store/toast'
import { formatTime } from '@/utils/format'
@@ -31,6 +35,48 @@ import { getMessageAttachment } from '@/services/api/conversations'
export type ViewMode = 'original' | 'translated' | 'bilingual'
function EmailAttachmentLink({
conversationId,
messageId,
attachment,
}: {
conversationId: string
messageId: string
attachment: NonNullable<Message['emailAttachments']>[number]
}) {
const { data, isLoading, error } = useQuery({
queryKey: ['email-attachment', conversationId, messageId, attachment.id],
queryFn: () => getMessageAttachment(conversationId, messageId, attachment.id),
enabled: attachment.available,
staleTime: Infinity,
retry: 1,
})
const size = attachment.size >= 1024 * 1024
? `${(attachment.size / 1024 / 1024).toFixed(1)} MB`
: `${Math.max(1, Math.round(attachment.size / 1024))} KB`
return (
<a
href={data?.dataUrl}
download={data?.name || attachment.name}
onClick={(event) => {
if (!data?.dataUrl) event.preventDefault()
}}
className={cn(
'mt-2 flex items-center gap-2 rounded-xl border border-slate-200/80 bg-white/75 px-3 py-2 transition-colors',
data?.dataUrl && 'hover:border-sky-200 hover:bg-sky-50',
)}
>
{isLoading ? <LoaderCircle className="h-5 w-5 animate-spin text-sky-500" /> : <FileText className="h-5 w-5 text-sky-500" />}
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-medium text-slate-700">{attachment.name}</span>
<span className="text-[10px] text-slate-400">{error ? '附件暂不可用' : size}</span>
</span>
{data?.dataUrl && <Download className="h-4 w-4 text-slate-400" />}
</a>
)
}
export function MessageBubble({
msg,
viewMode,
@@ -39,8 +85,12 @@ export function MessageBubble({
onConvertToTask,
onAddToKnowledge,
onAddNote,
onReact,
onDelete,
customerName,
customerAvatar,
channel,
showIntent = true,
}: {
msg: Message
viewMode: ViewMode
@@ -49,24 +99,52 @@ export function MessageBubble({
onConvertToTask: () => void
onAddToKnowledge: () => void
onAddNote: () => void
onReact?: (emoji: string) => void
onDelete?: () => void
customerName?: string
customerAvatar?: string
channel?: ChannelType
showIntent?: boolean
}) {
const [showActions, setShowActions] = useState(false)
const [translationExpanded, setTranslationExpanded] = useState(false)
const isMe = msg.sender === 'agent'
const isSystem = msg.sender === 'system'
const originalText = String(msg.originalText || '')
const rawTranslation = String(msg.translatedText || '')
const translationCorrupted =
/(?:^|\n)\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:[\d.]+Z\s+(?:客户|坐席):/m.test(rawTranslation) ||
rawTranslation.length > (msg.originalText.length <= 20 ? 160 : Math.max(500, msg.originalText.length * 8))
rawTranslation.length > (originalText.length <= 20 ? 160 : Math.max(500, originalText.length * 8))
const translatedText = translationCorrupted ? '' : rawTranslation
const hasTranslation = translatedText && translatedText !== msg.originalText
const hasTranslation = translatedText && translatedText !== originalText
const outgoingChineseNote = isMe
? String(
msg.translationCn ||
(hasTranslation && /[\u3400-\u9fff]/u.test(originalText) ? originalText : ''),
).trim()
: ''
const outgoingSentText = isMe && translatedText ? translatedText : originalText
const longTranslation = translatedText.length > 260 || translatedText.split('\n').length > 6
const hasKnownLanguage = Boolean(msg.originalLang && !['und', 'auto', 'unknown'].includes(msg.originalLang))
const sourceLanguage = hasKnownLanguage ? languageName(msg.originalLang) : '原文'
const targetLanguage = msg.targetLang && !['und', 'auto', 'unknown'].includes(msg.targetLang)
? languageName(msg.targetLang)
: '客户语言'
const providerName = channel === 'whatsapp' ? 'WhatsApp' : channel === 'email' ? 'Email' : 'Telegram'
const contextualTranslation = msg.translationMethod === 'ai-contextual'
&& Number(msg.translationContextMessages || 0) > 0
const translationTrace = contextualTranslation
? `AI 结合最近 ${msg.translationContextMessages} 条会话消息${msg.translationConversationSummaryApplied ? '及客户画像摘要' : ''}完成翻译`
: 'AI 直接翻译当前消息'
const isMediaMessage = ['image', 'audio', 'file'].includes(msg.type)
const hasProviderAttachment = Boolean(
msg.telegramFileId
|| (msg.whatsappMessageId && isMediaMessage),
)
const { data: attachment, isLoading: attachmentLoading, error: attachmentError } = useQuery({
queryKey: ['message-attachment', msg.conversationId, msg.id, msg.telegramFileId],
queryKey: ['message-attachment', msg.conversationId, msg.id, msg.telegramFileId, msg.whatsappMessageId],
queryFn: () => getMessageAttachment(msg.conversationId, msg.id),
enabled: Boolean(msg.telegramFileId),
enabled: hasProviderAttachment,
staleTime: Infinity,
retry: 1,
})
@@ -74,7 +152,7 @@ export function MessageBubble({
if (isSystem) {
return (
<div className="flex justify-center py-2">
<span className="rounded-full bg-amber-50 px-3 py-1 text-xs text-amber-700">{msg.originalText}</span>
<span className="rounded-full bg-amber-50 px-3 py-1 text-xs text-amber-700">{originalText}</span>
</div>
)
}
@@ -104,6 +182,28 @@ export function MessageBubble({
)
const renderContent = () => {
if (msg.isDeleted) {
return (
<p className="flex items-center gap-1.5 text-xs italic text-slate-400">
<AlertCircle className="h-3.5 w-3.5" />
该消息已撤回
</p>
)
}
if (msg.type !== 'text') {
const labels = { image: '图片', audio: '语音', file: '文件', product: '产品卡片' }
return originalText ? (
<p className="mb-2 whitespace-pre-wrap break-words text-sm leading-6 text-slate-700">{originalText}</p>
) : (
<div className="mb-1 flex items-center gap-2 text-xs font-medium text-slate-500">
<span className="h-1.5 w-1.5 rounded-full bg-primary-400" />
{labels[msg.type]}
</div>
)
}
if (!originalText.trim()) {
return <p className="text-xs text-slate-500">{providerName} 服务通知</p>
}
if (
(viewMode === 'bilingual' || viewMode === 'translated') &&
msg.sender === 'customer' &&
@@ -112,7 +212,7 @@ export function MessageBubble({
) {
return (
<div className="space-y-2.5">
<p className="whitespace-pre-wrap break-words text-sm leading-6">{msg.originalText}</p>
<p className="whitespace-pre-wrap break-words text-sm leading-6">{originalText}</p>
<div className="flex items-center gap-1.5 border-t border-border/60 pt-2 text-xs text-muted-foreground">
<Languages className="h-3.5 w-3.5 text-primary" />
{translationCorrupted
@@ -124,19 +224,64 @@ export function MessageBubble({
</div>
)
}
if (isMe && viewMode === 'bilingual') {
return (
<div className="space-y-2.5">
<div>
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100/80 px-2 py-0.5 text-[10px] font-medium text-blue-700">
<Globe className="h-3 w-3" />
客户收到 · {translatedText ? targetLanguage : '原文'}
</span>
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{outgoingSentText}</p>
</div>
{outgoingChineseNote ? (
<div data-testid="outgoing-chinese-note" className="border-t border-blue-200/60 pt-2">
<span className="inline-flex items-center gap-1 rounded-full bg-white/65 px-2 py-0.5 text-[10px] font-medium text-slate-600">
<Languages className="h-3 w-3 text-primary" />
中文备注 · 仅内部可见
</span>
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6 text-slate-600">
{outgoingChineseNote}
</p>
</div>
) : msg.translationStatus === 'pending' || msg.translationStatus === 'failed' ? (
<div className="flex items-center gap-1.5 border-t border-blue-200/60 pt-2 text-xs text-slate-500">
<Languages className="h-3.5 w-3.5 text-primary" />
{msg.translationStatus === 'failed' ? '中文备注生成失败,将自动重试' : 'AI 正在生成中文备注…'}
</div>
) : null}
</div>
)
}
if (isMe && viewMode === 'translated' && !translatedText && outgoingChineseNote) {
return (
<div>
<span className="inline-flex items-center gap-1 rounded-full bg-white/65 px-2 py-0.5 text-[10px] font-medium text-slate-600">
<Languages className="h-3 w-3 text-primary" />
中文备注 · 仅内部可见
</span>
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6 text-slate-700">
{outgoingChineseNote}
</p>
</div>
)
}
if (viewMode === 'bilingual' && hasTranslation) {
return (
<div className="space-y-2.5">
<div>
<span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
原文 · {languageName(msg.originalLang)}
{hasKnownLanguage ? `原文 · ${sourceLanguage}` : '原文'}
</span>
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{msg.originalText}</p>
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{originalText}</p>
</div>
<div className="border-t border-border/60 pt-2">
<span className="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-0.5 text-[10px] font-medium text-primary">
<span
title={translationTrace}
className="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-0.5 text-[10px] font-medium text-primary"
>
<Languages className="h-3 w-3" />
{isMe ? '发送内容 · 英文' : 'AI 译文 · 中文'}
{isMe ? `发送内容 · ${targetLanguage}` : contextualTranslation ? 'AI 语境译文 · 中文' : 'AI 译文 · 中文'}
</span>
<div className="mt-1.5">{translationContent()}</div>
</div>
@@ -146,9 +291,12 @@ export function MessageBubble({
if (viewMode === 'translated' && translatedText) {
return (
<div>
<span className="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-0.5 text-[10px] font-medium text-primary">
<span
title={translationTrace}
className="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-0.5 text-[10px] font-medium text-primary"
>
<Languages className="h-3 w-3" />
{isMe ? `${languageName(msg.originalLang)} → 英文发送内容` : `${languageName(msg.originalLang)} → 中文`}
{isMe ? `${sourceLanguage} → ${targetLanguage}` : `${sourceLanguage} → 中文`}
</span>
<div className="mt-1.5">{translationContent(false)}</div>
</div>
@@ -156,10 +304,14 @@ export function MessageBubble({
}
return (
<div>
{hasKnownLanguage && (
<span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{languageName(msg.originalLang)}
{sourceLanguage}
</span>
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{msg.originalText}</p>
)}
<p className={cn('whitespace-pre-wrap break-words text-sm leading-6', hasKnownLanguage && 'mt-1.5')}>
{originalText}
</p>
</div>
)
}
@@ -182,12 +334,18 @@ export function MessageBubble({
}
const moreActions = [
{ key: 'copy', label: '复制', icon: Copy, onSelect: () => { navigator.clipboard.writeText(msg.originalText); toast.success('已复制') } },
{ key: 'copy', label: '复制', icon: Copy, onSelect: () => { void navigator.clipboard.writeText(originalText); toast.success('已复制') } },
{ key: 'reply', label: '引用回复', icon: Reply, onSelect: onQuote },
...(channel === 'whatsapp' && onReact
? [{ key: 'react', label: '👍 回应', icon: SmilePlus, onSelect: () => onReact('👍') }]
: []),
{ key: 'note', label: '添加内部备注', icon: StickyNote, onSelect: onAddNote },
{ key: 'important', label: msg.isImportant ? '取消重要' : '标记重要', icon: Star, onSelect: onMarkImportant },
{ key: 'task', label: '转为待办', icon: ListTodo, onSelect: onConvertToTask },
{ key: 'knowledge', label: '添加到知识库', icon: BookOpen, onSelect: onAddToKnowledge },
...(channel === 'whatsapp' && isMe && onDelete
? [{ key: 'delete', label: '撤回消息', icon: Trash2, danger: true, onSelect: onDelete }]
: []),
]
return (
@@ -195,7 +353,7 @@ export function MessageBubble({
data-testid="message-bubble"
data-sender={msg.sender}
className={cn(
'group flex gap-2 px-4 py-2.5 will-change-transform',
'group flex gap-2 px-3 py-2 will-change-transform sm:px-4 sm:py-2.5',
isMe ? 'justify-end motion-safe:animate-message-in-right' : 'justify-start motion-safe:animate-message-in-left',
)}
onMouseEnter={() => setShowActions(true)}
@@ -203,7 +361,7 @@ export function MessageBubble({
>
{!isMe && (
<Avatar
name={customerName || msg.senderName || 'Telegram 用户'}
name={customerName || msg.senderName || `${providerName} 用户`}
src={customerAvatar}
size="xs"
className="order-1 mt-1"
@@ -217,11 +375,13 @@ export function MessageBubble({
)}
{/* 消息气泡 */}
<div
data-testid="message-surface"
data-direction={isMe ? 'outgoing' : 'incoming'}
className={cn(
'relative rounded-2xl px-3.5 py-3 shadow-sm',
'relative isolate rounded-2xl px-3.5 py-2.5 transition-shadow',
isMe
? 'rounded-br-md border border-primary/15 bg-gradient-to-br from-primary-50 to-emerald-50/80'
: 'rounded-bl-md border border-border/90 bg-card',
? 'telegram-bubble-out rounded-br-[5px]'
: 'telegram-bubble-in rounded-bl-[5px]',
msg.isImportant && 'ring-1 ring-amber-400',
)}
>
@@ -234,9 +394,34 @@ export function MessageBubble({
</div>
)}
{renderContent()}
{msg.telegramFileId && attachmentLoading && (
{!isMe && showIntent && msg.intentAnalysis && (
<div
data-testid="message-intent"
title={msg.intentAnalysis.rationale}
className="mt-2 flex flex-wrap items-center gap-1.5 border-t border-slate-200/70 pt-2"
>
<span className="inline-flex items-center gap-1 rounded-full bg-violet-50 px-2 py-0.5 text-[10px] font-semibold text-violet-700">
<Brain className="h-3 w-3" />
意图 · {msg.intentAnalysis.label}
</span>
<span className="text-[10px] text-slate-400">
可信度 {Math.round(Number(msg.intentAnalysis.confidence || 0) * 100)}%
</span>
{['high', 'urgent'].includes(msg.intentAnalysis.urgency) && (
<span className="rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-medium text-amber-700">
{msg.intentAnalysis.urgency === 'urgent' ? '紧急' : '高优先级'}
</span>
)}
{msg.intentAnalysis.entities?.slice(0, 3).map((entity) => (
<span key={entity} className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-600">
{entity}
</span>
))}
</div>
)}
{hasProviderAttachment && attachmentLoading && (
<div className="mt-2 flex items-center gap-2 rounded-lg bg-muted/70 px-3 py-2 text-xs text-muted-foreground">
<LoaderCircle className="h-3.5 w-3.5 animate-spin" /> 正在读取 Telegram 文件…
<LoaderCircle className="h-3.5 w-3.5 animate-spin" /> 正在读取 {providerName} 文件…
</div>
)}
{attachment && msg.type === 'image' && (
@@ -264,14 +449,36 @@ export function MessageBubble({
<Download className="h-4 w-4 text-muted-foreground" />
</a>
)}
{msg.telegramFileId && attachmentError && (
{hasProviderAttachment && attachmentError && (
<p className="mt-2 rounded-lg bg-red-50 px-2.5 py-1.5 text-xs text-red-600">文件暂时无法下载,请稍后重试</p>
)}
{msg.emailAttachments?.map((emailAttachment) => (
<EmailAttachmentLink
key={emailAttachment.id}
conversationId={msg.conversationId}
messageId={msg.id}
attachment={emailAttachment}
/>
))}
{Boolean(msg.reactions?.length) && (
<div className="mt-2 flex flex-wrap gap-1">
{msg.reactions?.map((reaction, index) => (
<span
key={`${reaction.actor}-${reaction.emoji}-${index}`}
title={reaction.fromMe ? '我' : reaction.actor}
className="inline-flex min-w-7 items-center justify-center rounded-full border border-blue-200 bg-blue-50/90 px-2 py-0.5 text-sm shadow-sm"
>
{reaction.emoji}
</span>
))}
</div>
)}
</div>
{/* 底栏:时间 + 状态 */}
<div className={cn('mt-0.5 flex items-center gap-1.5 text-[10px] text-muted-foreground', isMe ? 'justify-end' : 'justify-start')}>
<span>{formatTime(msg.createdAt)}</span>
{msg.editedAt && !msg.isDeleted && <span>已编辑</span>}
{isMe && statusIcon()}
{msg.isImportant && <Star className="h-2.5 w-2.5 fill-amber-400 text-amber-400" />}
{msg.internalNote && <StickyNote className="h-2.5 w-2.5 text-blue-500" />}
+2 -2
View File
@@ -9,7 +9,7 @@ export function AppLayout() {
const { mobileNavOpen, setMobileNavOpen } = useUiStore()
return (
<div className="flex h-screen overflow-hidden bg-background">
<div className="flex h-screen overflow-hidden bg-[#f7f9fc]">
<GlobalRealtime />
{/* 桌面端固定侧边栏 */}
<div className="hidden shrink-0 lg:block">
@@ -25,7 +25,7 @@ export function AppLayout() {
<div className="flex min-w-0 flex-1 flex-col">
<Topbar />
<main className="flex-1 overflow-y-auto scrollbar-thin" id="main-scroll">
<main className="flex-1 overflow-y-auto bg-[#f7f9fc] scrollbar-thin" id="main-scroll">
<Outlet />
</main>
</div>
+5 -3
View File
@@ -12,7 +12,9 @@ export function GlobalRealtime() {
const queryClient = useQueryClient()
useEffect(() => {
if (!token) return
// Inbox owns the richer message socket so it can update message and
// conversation caches atomically. Keep only one live socket per page.
if (!token || location.pathname.startsWith('/inbox')) return
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
let socket: WebSocket | undefined
let reconnectTimer: ReturnType<typeof setTimeout> | undefined
@@ -34,12 +36,12 @@ export function GlobalRealtime() {
socket.onmessage = (raw) => {
let event: { type?: string; message?: Message; customer?: Customer; conversationId?: string }
try { event = JSON.parse(String(raw.data)) } catch { return }
if (event.type !== 'message.created' || event.message?.sender !== 'customer') return
if (!['message.created', 'message.received'].includes(event.type || '') || event.message?.sender !== 'customer') return
void queryClient.invalidateQueries({ queryKey: ['notifications'] })
void queryClient.invalidateQueries({ queryKey: ['conversations'] })
if (!location.pathname.startsWith('/inbox')) {
const preview = event.message.translatedText || event.message.originalText || `[${event.message.type}]`
toast.info(`${event.customer?.name || event.message.senderName || 'Telegram 客户'} 发来新消息`, preview.slice(0, 120))
toast.info(`${event.customer?.name || event.message.senderName || '客户'} 发来新消息`, preview.slice(0, 120))
}
}
socket.onerror = () => socket?.close()
+33 -15
View File
@@ -1,5 +1,5 @@
import { NavLink, useNavigate } from 'react-router-dom'
import { PanelLeftClose, PanelLeftOpen, Sparkles } from 'lucide-react'
import { PanelLeftClose, PanelLeftOpen } from 'lucide-react'
import { NAV_ITEMS } from '@/config/navigation'
import { useUiStore } from '@/app/store/ui'
import { useAuthStore } from '@/app/store/auth'
@@ -26,33 +26,47 @@ export function Sidebar() {
const usagePct = msgUsage ? Math.round((msgUsage.used / msgUsage.total) * 100) : 0
const items = NAV_ITEMS.filter((item) => !item.permission || can(item.permission))
const groups = [
{ key: 'core', label: '核心业务' },
{ key: 'intelligence', label: '知识与洞察' },
{ key: 'admin', label: '企业管理' },
] as const
return (
<aside
className={cn(
'flex h-full flex-col border-e border-border bg-card transition-[width] duration-200',
sidebarCollapsed ? 'w-16' : 'w-60',
'relative z-30 flex h-full flex-col border-e border-slate-200 bg-white shadow-[6px_0_24px_-22px_rgba(15,23,42,.45)] transition-[width] duration-200',
sidebarCollapsed ? 'w-[68px]' : 'w-[236px]',
)}
>
{/* Logo */}
<div
className="flex h-14 cursor-pointer items-center gap-2.5 border-b border-border px-4"
className="flex h-16 cursor-pointer items-center gap-2.5 border-b border-slate-100 px-4"
onClick={() => navigate('/dashboard')}
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Sparkles className="h-4.5 w-4.5 h-[18px] w-[18px]" />
</span>
<img src="/brand-mark.svg" alt="" className="h-9 w-9 shrink-0 rounded-xl shadow-sm shadow-primary-900/10" />
{!sidebarCollapsed && (
<div className="min-w-0">
<p className="truncate text-sm font-semibold leading-tight">{t('app.name')}</p>
<p className="truncate text-[15px] font-semibold tracking-tight leading-tight">{t('app.name')}</p>
<p className="truncate text-[11px] text-muted-foreground leading-tight">{t('app.slogan')}</p>
</div>
)}
</div>
{/* 导航 */}
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 py-3 scrollbar-thin">
{items.map((item) => (
<nav className="flex-1 overflow-y-auto px-2.5 py-3 scrollbar-thin">
{groups.map((group) => {
const groupItems = items.filter((item) => item.group === group.key)
if (!groupItems.length) return null
return (
<div key={group.key} className="mb-3">
{!sidebarCollapsed && (
<p className="mb-1.5 px-3 pt-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
{group.label}
</p>
)}
<div className="space-y-1">
{groupItems.map((item) => (
<NavLink
key={item.key}
to={item.path}
@@ -60,10 +74,10 @@ export function Sidebar() {
title={sidebarCollapsed ? t(item.labelKey as never) : undefined}
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm transition-colors',
'group relative flex items-center gap-3 rounded-[10px] px-3 py-2.5 text-[13px] transition-all duration-150',
isActive
? 'bg-primary-50 font-medium text-primary-700'
: 'text-secondary-foreground hover:bg-muted',
? 'bg-primary-50 font-semibold text-primary-800 shadow-[inset_0_0_0_1px_rgba(37,99,235,.1)] before:absolute before:inset-y-2 before:left-0 before:w-[3px] before:rounded-r-full before:bg-primary-600'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-950',
sidebarCollapsed && 'justify-center px-0',
)
}
@@ -72,12 +86,16 @@ export function Sidebar() {
{!sidebarCollapsed && <span className="truncate">{t(item.labelKey as never)}</span>}
</NavLink>
))}
</div>
</div>
)
})}
</nav>
{/* 底部:套餐与企业信息 */}
{!sidebarCollapsed && (
<div className="border-t border-border p-3">
<div className="rounded-md bg-muted/60 p-3">
<div className="border-t border-slate-100 p-3">
<div className="rounded-[12px] border border-slate-200 bg-slate-50/80 p-3">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{t('sidebar.currentPlan')}</span>
<span className="text-xs font-medium text-primary-700">{plan.name}</span>
+9 -8
View File
@@ -29,6 +29,7 @@ import { cn } from '@/utils/cn'
const NOTIF_TYPE_COLOR: Record<string, string> = {
inquiry: 'bg-emerald-100 text-emerald-700',
message: 'bg-sky-100 text-sky-700',
risk: 'bg-red-100 text-red-700',
task: 'bg-amber-100 text-amber-700',
channel: 'bg-blue-100 text-blue-700',
@@ -61,10 +62,10 @@ export function Topbar() {
}
return (
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-border bg-card px-3 md:px-4">
<header className="relative z-20 flex h-16 shrink-0 items-center gap-3 border-b border-slate-200 bg-white/95 px-3 shadow-[0_6px_24px_-24px_rgba(15,23,42,.7)] backdrop-blur-xl md:px-5">
{/* 移动端菜单 */}
<button
className="rounded-md p-2 text-muted-foreground hover:bg-muted lg:hidden"
className="rounded-[10px] p-2 text-muted-foreground hover:bg-slate-100 lg:hidden"
onClick={() => setMobileNavOpen(true)}
aria-label="打开导航"
>
@@ -74,7 +75,7 @@ export function Topbar() {
{/* 企业切换 */}
<Dropdown
trigger={
<button className="hidden items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium hover:bg-muted md:flex">
<button className="hidden items-center gap-1.5 rounded-[10px] border border-transparent px-2.5 py-1.5 text-sm font-medium hover:border-slate-200 hover:bg-slate-50 md:flex">
<Building2 className="h-4 w-4 text-muted-foreground" />
<span className="max-w-[180px] truncate">{tenant?.name ?? '—'}</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
@@ -95,7 +96,7 @@ export function Topbar() {
{/* 语言切换 */}
<Dropdown
trigger={
<button className="flex items-center gap-1 rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground" title={t('lang.name')}>
<button className="flex items-center gap-1 rounded-[10px] p-2 text-muted-foreground hover:bg-slate-100 hover:text-foreground" title={t('lang.name')}>
<Globe className="h-[18px] w-[18px]" />
</button>
}
@@ -117,7 +118,7 @@ export function Topbar() {
{/* 帮助 */}
<button
className="rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground"
className="rounded-[10px] p-2 text-muted-foreground hover:bg-slate-100 hover:text-foreground"
title={t('topbar.help')}
onClick={() => navigate('/help')}
>
@@ -127,7 +128,7 @@ export function Topbar() {
{/* 通知 */}
<Dropdown
trigger={
<button className="relative rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground" title={t('topbar.notifications')}>
<button className="relative rounded-[10px] p-2 text-muted-foreground hover:bg-slate-100 hover:text-foreground" title={t('topbar.notifications')}>
<Bell className="h-[18px] w-[18px]" />
{unread > 0 && (
<span className="absolute right-1 top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium text-white">
@@ -155,7 +156,7 @@ export function Topbar() {
label: (
<span className="flex flex-col gap-0.5 py-0.5">
<span className="flex items-center gap-1.5">
<Badge className={NOTIF_TYPE_COLOR[n.type]}>{n.type === 'inquiry' ? '询盘' : n.type === 'risk' ? '风险' : n.type === 'task' ? '任务' : n.type === 'channel' ? '渠道' : '系统'}</Badge>
<Badge className={NOTIF_TYPE_COLOR[n.type]}>{n.type === 'inquiry' ? '询盘' : n.type === 'message' ? '消息' : n.type === 'risk' ? '风险' : n.type === 'task' ? '任务' : n.type === 'channel' ? '渠道' : '系统'}</Badge>
{!n.read && <span className="h-1.5 w-1.5 rounded-full bg-red-500" />}
<span className="text-[11px] text-muted-foreground">{formatRelative(n.createdAt)}</span>
</span>
@@ -171,7 +172,7 @@ export function Topbar() {
{/* 用户菜单 */}
<Dropdown
trigger={
<button className="flex items-center gap-2 rounded-md px-1.5 py-1 hover:bg-muted">
<button className="flex items-center gap-2 rounded-[10px] border border-transparent px-1.5 py-1 hover:border-slate-200 hover:bg-slate-50">
<Avatar name={user?.name ?? '?'} size="sm" online />
<span className="hidden text-start md:block">
<span className="block max-w-[90px] truncate text-sm font-medium leading-tight">{user?.name}</span>
+5 -5
View File
@@ -4,13 +4,13 @@ import { Loader2 } from 'lucide-react'
import { cn } from '@/utils/cn'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 select-none',
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-[10px] text-sm font-medium transition-[color,background-color,border-color,box-shadow,transform] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 select-none active:translate-y-px',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary-700 active:bg-primary-800',
secondary: 'bg-secondary text-secondary-foreground hover:bg-border',
outline: 'border border-input bg-card hover:bg-muted',
default: 'bg-primary text-primary-foreground shadow-[0_1px_2px_rgba(37,99,235,.22)] hover:bg-primary-700 hover:shadow-[0_6px_16px_-8px_rgba(37,99,235,.65)] active:bg-primary-800',
secondary: 'bg-slate-100 text-slate-700 hover:bg-slate-200',
outline: 'border border-slate-200 bg-white text-slate-700 shadow-sm hover:border-slate-300 hover:bg-slate-50',
ghost: 'hover:bg-muted text-foreground',
destructive: 'bg-destructive text-destructive-foreground hover:bg-red-600',
dangerOutline: 'border border-red-200 bg-card text-red-600 hover:bg-red-50',
@@ -19,7 +19,7 @@ const buttonVariants = cva(
size: {
sm: 'h-8 px-3 text-xs',
default: 'h-9 px-4',
lg: 'h-10 px-5',
lg: 'h-11 px-5',
icon: 'h-9 w-9',
'icon-sm': 'h-7 w-7',
},
+4 -4
View File
@@ -4,7 +4,7 @@ import { cn } from '@/utils/cn'
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('rounded-lg border border-border bg-card shadow-[0_1px_2px_rgba(16,24,40,0.04)]', className)}
className={cn('rounded-[14px] border border-slate-200/90 bg-card shadow-[0_1px_2px_rgba(15,23,42,0.025),0_8px_24px_rgba(15,23,42,0.025)]', className)}
{...props}
/>
)
@@ -19,9 +19,9 @@ export function CardHeader({
...props
}: HTMLAttributes<HTMLDivElement> & { title?: ReactNode; description?: ReactNode; action?: ReactNode }) {
return (
<div className={cn('flex items-start justify-between gap-3 border-b border-border px-4 py-3', className)} {...props}>
<div className={cn('flex items-start justify-between gap-3 border-b border-slate-100 px-5 py-4', className)} {...props}>
<div className="min-w-0">
{title && <h3 className="text-sm font-semibold text-foreground">{title}</h3>}
{title && <h3 className="text-[14px] font-semibold tracking-[-0.01em] text-slate-900">{title}</h3>}
{description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
{children}
</div>
@@ -31,7 +31,7 @@ export function CardHeader({
}
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('p-4', className)} {...props} />
return <div className={cn('p-5', className)} {...props} />
}
export function CardFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
+1 -1
View File
@@ -47,7 +47,7 @@ export function Dropdown({
{open && (
<div
className={cn(
'absolute z-40 mt-1 min-w-[160px] overflow-hidden rounded-md border border-border bg-popover py-1 shadow-lg animate-slide-in-top',
'absolute z-[80] mt-1 min-w-[160px] overflow-hidden rounded-md border border-border bg-popover py-1 shadow-lg animate-slide-in-top',
align === 'end' ? 'right-0 rtl:right-auto rtl:left-0' : 'left-0 rtl:left-auto rtl:right-0',
)}
>
+3
View File
@@ -5,17 +5,20 @@ export function Switch({
onCheckedChange,
disabled,
className,
ariaLabel,
}: {
checked: boolean
onCheckedChange?: (v: boolean) => void
disabled?: boolean
className?: string
ariaLabel?: string
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
disabled={disabled}
onClick={() => onCheckedChange?.(!checked)}
className={cn(
+5 -5
View File
@@ -10,26 +10,26 @@ export function Table({ className, ...props }: HTMLAttributes<HTMLTableElement>)
}
export function THead({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return <thead className={cn('border-b border-border bg-muted/50', className)} {...props} />
return <thead className={cn('border-b border-slate-200 bg-slate-50/80', className)} {...props} />
}
export function TBody({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return <tbody className={cn('divide-y divide-border', className)} {...props} />
return <tbody className={cn('divide-y divide-slate-100', className)} {...props} />
}
export function Tr({ className, ...props }: HTMLAttributes<HTMLTableRowElement>) {
return <tr className={cn('transition-colors hover:bg-muted/40', className)} {...props} />
return <tr className={cn('transition-colors hover:bg-primary-50/35', className)} {...props} />
}
export function Th({ className, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
return (
<th
className={cn('whitespace-nowrap px-3 py-2.5 text-start text-xs font-medium text-muted-foreground', className)}
className={cn('whitespace-nowrap px-4 py-3 text-start text-[11px] font-semibold uppercase tracking-[0.04em] text-slate-500', className)}
{...props}
/>
)
}
export function Td({ className, colSpan, ...props }: TdHTMLAttributes<HTMLTableCellElement>) {
return <td className={cn('px-3 py-2.5 align-middle', className)} colSpan={colSpan} {...props} />
return <td className={cn('px-4 py-3 align-middle text-slate-700', className)} colSpan={colSpan} {...props} />
}
+15 -3
View File
@@ -57,23 +57,35 @@ export function Segmented<T extends string>({
options,
size = 'default',
className,
ariaLabel = '切换选项',
}: {
value: T
onChange: (v: T) => void
options: { value: T; label: ReactNode }[]
size?: 'sm' | 'default'
className?: string
ariaLabel?: string
}) {
return (
<div className={cn('inline-flex items-center rounded-md bg-muted p-0.5', className)}>
<div
role="radiogroup"
aria-label={ariaLabel}
className={cn('inline-flex items-center rounded-lg border border-slate-200 bg-slate-100/90 p-0.5 shadow-inner', className)}
>
{options.map((o) => (
<button
type="button"
role="radio"
aria-checked={o.value === value}
data-state={o.value === value ? 'active' : 'inactive'}
key={o.value}
onClick={() => onChange(o.value)}
className={cn(
'rounded px-2.5 text-xs font-medium transition-colors',
'relative rounded-md px-2.5 text-xs font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40',
size === 'sm' ? 'h-6' : 'h-7',
o.value === value ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
o.value === value
? 'bg-primary text-white shadow-sm shadow-primary/20'
: 'text-slate-500 hover:bg-white/70 hover:text-slate-900',
)}
>
{o.label}
+12 -1
View File
@@ -26,7 +26,18 @@ export const COUNTRIES: CountryMeta[] = [
]
export function countryMeta(code: string): CountryMeta {
return COUNTRIES.find((c) => c.code === code) ?? { code, zh: code, en: code, flag: '🏳️', tzOffset: 0, lang: 'en' }
const normalized = String(code || '').trim().toUpperCase()
const known = COUNTRIES.find((c) => c.code === normalized)
if (known) return known
if (!/^[A-Z]{2}$/.test(normalized)) {
return { code: '', zh: '国家待确认', en: 'Country not confirmed', flag: '🌐', tzOffset: Number.NaN, lang: '' }
}
const flag = String.fromCodePoint(
...[...normalized].map((character) => 127397 + character.charCodeAt(0)),
)
const zh = new Intl.DisplayNames(['zh-CN'], { type: 'region' }).of(normalized) || normalized
const en = new Intl.DisplayNames(['en'], { type: 'region' }).of(normalized) || normalized
return { code: normalized, zh, en, flag, tzOffset: Number.NaN, lang: '' }
}
/** 行业 */
+43
View File
@@ -53,6 +53,49 @@ export const LANGUAGES: LanguageOption[] = [
{ code: 'ro', name: '罗马尼亚语', nativeName: 'Română' },
{ code: 'cs', name: '捷克语', nativeName: 'Čeština' },
{ code: 'el', name: '希腊语', nativeName: 'Ελληνικά' },
{ code: 'da', name: '丹麦语', nativeName: 'Dansk' },
{ code: 'no', name: '挪威语', nativeName: 'Norsk' },
{ code: 'fi', name: '芬兰语', nativeName: 'Suomi' },
{ code: 'hu', name: '匈牙利语', nativeName: 'Magyar' },
{ code: 'bg', name: '保加利亚语', nativeName: 'Български' },
{ code: 'sr', name: '塞尔维亚语', nativeName: 'Српски' },
{ code: 'hr', name: '克罗地亚语', nativeName: 'Hrvatski' },
{ code: 'sk', name: '斯洛伐克语', nativeName: 'Slovenčina' },
{ code: 'sl', name: '斯洛文尼亚语', nativeName: 'Slovenščina' },
{ code: 'lt', name: '立陶宛语', nativeName: 'Lietuvių' },
{ code: 'lv', name: '拉脱维亚语', nativeName: 'Latviešu' },
{ code: 'et', name: '爱沙尼亚语', nativeName: 'Eesti' },
{ code: 'ca', name: '加泰罗尼亚语', nativeName: 'Català' },
{ code: 'sq', name: '阿尔巴尼亚语', nativeName: 'Shqip' },
{ code: 'mk', name: '马其顿语', nativeName: 'Македонски' },
{ code: 'ka', name: '格鲁吉亚语', nativeName: 'ქართული' },
{ code: 'hy', name: '亚美尼亚语', nativeName: 'Հայերեն' },
{ code: 'az', name: '阿塞拜疆语', nativeName: 'Azərbaycanca' },
{ code: 'kk', name: '哈萨克语', nativeName: 'Қазақша' },
{ code: 'uz', name: '乌兹别克语', nativeName: 'Oʻzbekcha' },
{ code: 'mn', name: '蒙古语', nativeName: 'Монгол' },
{ code: 'ne', name: '尼泊尔语', nativeName: 'नेपाली' },
{ code: 'si', name: '僧伽罗语', nativeName: 'සිංහල' },
{ code: 'ta', name: '泰米尔语', nativeName: 'தமிழ்' },
{ code: 'te', name: '泰卢固语', nativeName: 'తెలుగు' },
{ code: 'mr', name: '马拉地语', nativeName: 'मराठी' },
{ code: 'gu', name: '古吉拉特语', nativeName: 'ગુજરાતી' },
{ code: 'pa', name: '旁遮普语', nativeName: 'ਪੰਜਾਬੀ' },
{ code: 'kn', name: '卡纳达语', nativeName: 'ಕನ್ನಡ' },
{ code: 'ml', name: '马拉雅拉姆语', nativeName: 'മലയാളം' },
{ code: 'fil', name: '菲律宾语', nativeName: 'Filipino' },
{ code: 'km', name: '高棉语', nativeName: 'ខ្មែរ' },
{ code: 'lo', name: '老挝语', nativeName: 'ລາວ' },
{ code: 'my', name: '缅甸语', nativeName: 'မြန်မာ' },
{ code: 'am', name: '阿姆哈拉语', nativeName: 'አማርኛ' },
{ code: 'af', name: '南非荷兰语', nativeName: 'Afrikaans' },
{ code: 'zu', name: '祖鲁语', nativeName: 'isiZulu' },
{ code: 'ha', name: '豪萨语', nativeName: 'Hausa' },
{ code: 'yo', name: '约鲁巴语', nativeName: 'Yorùbá' },
{ code: 'ig', name: '伊博语', nativeName: 'Igbo' },
{ code: 'so', name: '索马里语', nativeName: 'Soomaali' },
{ code: 'ps', name: '普什图语', nativeName: 'پښتو', rtl: true },
{ code: 'ku', name: '库尔德语', nativeName: 'Kurdî' },
]
export const RTL_LANG_CODES = LANGUAGES.filter((l) => l.rtl).map((l) => l.code)
+11 -16
View File
@@ -3,15 +3,12 @@ import {
MessagesSquare,
Users,
KanbanSquare,
Languages,
ShieldAlert,
BookOpen,
BarChart3,
Cable,
UsersRound,
CreditCard,
Settings,
GraduationCap,
type LucideIcon,
} from 'lucide-react'
import type { Permission } from './roles'
@@ -23,20 +20,18 @@ export interface NavItem {
labelKey: string
icon: LucideIcon
permission?: Permission
group: 'core' | 'intelligence' | 'admin'
}
export const NAV_ITEMS: NavItem[] = [
{ key: 'dashboard', path: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard },
{ key: 'inbox', path: '/inbox', labelKey: 'nav.inbox', icon: MessagesSquare },
{ key: 'customers', path: '/customers', labelKey: 'nav.customers', icon: Users, permission: 'customer:view' },
{ key: 'opportunities', path: '/opportunities', labelKey: 'nav.opportunities', icon: KanbanSquare },
{ key: 'translate', path: '/translate', labelKey: 'nav.translate', icon: Languages },
{ key: 'risks', path: '/risks', labelKey: 'nav.risks', icon: ShieldAlert },
{ key: 'knowledge', path: '/knowledge', labelKey: 'nav.knowledge', icon: BookOpen },
{ key: 'analytics', path: '/analytics', labelKey: 'nav.analytics', icon: BarChart3, permission: 'analytics:view' },
{ key: 'channels', path: '/channels', labelKey: 'nav.channels', icon: Cable },
{ key: 'team', path: '/team', labelKey: 'nav.team', icon: UsersRound },
{ key: 'billing', path: '/billing', labelKey: 'nav.billing', icon: CreditCard },
{ key: 'settings', path: '/settings', labelKey: 'nav.settings', icon: Settings },
{ key: 'help', path: '/help', labelKey: 'nav.help', icon: GraduationCap },
{ key: 'dashboard', path: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard, group: 'core' },
{ key: 'inbox', path: '/inbox', labelKey: 'nav.inbox', icon: MessagesSquare, group: 'core' },
{ key: 'customers', path: '/customers', labelKey: 'nav.customers', icon: Users, permission: 'customer:view', group: 'core' },
{ key: 'opportunities', path: '/opportunities', labelKey: 'nav.opportunities', icon: KanbanSquare, permission: 'opportunity:manage', group: 'core' },
{ key: 'risks', path: '/risks', labelKey: 'nav.risks', icon: ShieldAlert, permission: 'risk:handle', group: 'core' },
{ key: 'knowledge', path: '/knowledge', labelKey: 'nav.knowledge', icon: BookOpen, permission: 'knowledge:manage', group: 'intelligence' },
{ key: 'analytics', path: '/analytics', labelKey: 'nav.analytics', icon: BarChart3, permission: 'analytics:view', group: 'intelligence' },
{ key: 'channels', path: '/channels', labelKey: 'nav.channels', icon: Cable, permission: 'channel:manage', group: 'admin' },
{ key: 'team', path: '/team', labelKey: 'nav.team', icon: UsersRound, permission: 'team:manage', group: 'admin' },
{ key: 'settings', path: '/settings', labelKey: 'nav.settings', icon: Settings, permission: 'settings:manage', group: 'admin' },
]
+3 -3
View File
@@ -57,7 +57,7 @@ export const PRICING_PLANS: PricingPlan[] = [
{
key: 'pro',
name: '专业版',
tagline: '5—20 人团队的 AI 经营中枢',
tagline: '多渠道客户沟通与团队协作',
priceNote: '¥49,800/年',
monthlyPrice: null,
yearlyPrice: 49800,
@@ -66,9 +66,9 @@ export const PRICING_PLANS: PricingPlan[] = [
features: [
'包含标准版全部能力',
'客户情感分析与意图预测',
'AI 智能回复建议(多策略)',
'多策略回复建议',
'风险预警与文化敏感检测',
'老板经营看板',
'业务数据看板',
],
limits: { members: 20, messagesPerMonth: 100000, translationsPerMonth: 200000, knowledgeStorageMb: 5120 },
},
+1 -1
View File
@@ -24,7 +24,7 @@ export const ROLE_META: Record<RoleKey, { label: string; description: string; co
admin: { label: '企业管理员', description: '管理企业、成员、权限、渠道、套餐与系统设置', color: 'bg-violet-100 text-violet-700' },
manager: { label: '业务主管', description: '查看全部客户、团队数据、经营看板与客户分配', color: 'bg-blue-100 text-blue-700' },
sales: { label: '外贸业务员', description: '查看分配给自己的客户、消息与跟进任务', color: 'bg-teal-100 text-teal-700' },
service: { label: '客服人员', description: '处理跨渠道消息、翻译、AI回复与售后咨询', color: 'bg-amber-100 text-amber-700' },
service: { label: '客服人员', description: '处理跨渠道消息、翻译、回复建议与售后咨询', color: 'bg-amber-100 text-amber-700' },
viewer: { label: '只读成员', description: '仅可查看被授权的数据,不能编辑或发送消息', color: 'bg-gray-100 text-gray-600' },
}
+1 -1
View File
@@ -6,7 +6,7 @@ export const en: LocaleDict = {
'app.company': 'Xuzhou Ouhui Technology Co., Ltd.',
'nav.dashboard': 'Workspace',
'nav.inbox': 'Unified Inbox',
'nav.inbox': 'Messages',
'nav.customers': 'Customers',
'nav.opportunities': 'Deals & Follow-ups',
'nav.translate': 'AI Translation',
+3 -3
View File
@@ -4,15 +4,15 @@ export const zh = {
'app.company': '徐州欧汇科技有限公司',
'nav.dashboard': '工作台',
'nav.inbox': '统一消息',
'nav.inbox': '消息中心',
'nav.customers': '客户管理',
'nav.opportunities': '商机与跟进',
'nav.translate': 'AI翻译',
'nav.interpret': '同声传译',
'nav.risks': '风险预警',
'nav.risks': '风险工单',
'nav.knowledge': '企业知识库',
'nav.analytics': '数据分析',
'nav.channels': '渠道中心',
'nav.channels': '渠道与账号',
'nav.team': '团队协作',
'nav.billing': '套餐与账单',
'nav.settings': '系统设置',
+88 -31
View File
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { PageLoading, ErrorState } from '@/components/ui/feedback'
import { PageHeader } from '@/components/common/PageHeader'
@@ -8,13 +9,20 @@ import { LiveDataBadge } from '@/components/common/LiveDataBadge'
import { CountryFlag } from '@/components/common/CountryFlag'
import { ChartCard, TrendChart, DonutChart, FunnelChart, RankBars, SimpleBarChart } from '@/components/charts'
import { getAnalytics } from '@/services/api/org'
import { formatNumber } from '@/utils/format'
import { Activity, BadgePercent, TrendingUp, Users, Zap, Timer, AlertTriangle, Target } from 'lucide-react'
import { Activity, BadgePercent, Users, Zap, Target } from 'lucide-react'
const PERIOD_OPTIONS = [
{ value: 'today', label: '今日' },
{ value: '7d', label: '近 7 天' },
{ value: '30d', label: '近 30 天' },
{ value: 'quarter', label: '本季度' },
] as const
export function AnalyticsPage() {
const [period, setPeriod] = useState<(typeof PERIOD_OPTIONS)[number]['value']>('30d')
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['analytics'],
queryFn: () => getAnalytics(),
queryKey: ['analytics', period],
queryFn: () => getAnalytics({ period }),
})
if (isLoading) return <PageLoading />
@@ -23,21 +31,35 @@ export function AnalyticsPage() {
const k = data.kpis
return (
<div className="p-4 md:p-6">
<div className="page-shell">
<PageHeader
title="数据分析中心"
description="企业经营数据全景 · 基于当前企业实时数据"
actions={<LiveDataBadge />}
extra={
<div className="flex flex-wrap items-center gap-2">
{['today', '7d', '30d', 'quarter'].map((p) => (
<button key={p} className="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground hover:bg-border">{p === '30d' ? '近30天' : p === '7d' ? '近7天' : p === 'today' ? '今日' : '本季度'}</button>
title="业务分析"
description="从客户获取、沟通效率到成交转化,查看完整经营链路"
actions={
<div className="flex items-center gap-2">
<LiveDataBadge />
<div className="flex rounded-[10px] border border-slate-200 bg-white p-1 shadow-sm">
{PERIOD_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={period === option.value}
onClick={() => setPeriod(option.value)}
className={`rounded-[7px] px-3 py-1.5 text-xs font-medium transition ${
period === option.value
? 'bg-slate-900 text-white shadow-sm'
: 'text-slate-500 hover:bg-slate-50 hover:text-slate-900'
}`}
>
{option.label}
</button>
))}
</div>
</div>
}
/>
<div className="grid grid-cols-2 gap-3 md:grid-cols-5 mb-4">
<div className="grid grid-cols-2 gap-3 xl:grid-cols-5">
<StatCard title="新增客户" value={k.newCustomers.value} delta={k.newCustomers.delta} icon={Users} />
<StatCard title="有效询盘" value={k.validInquiries.value} delta={k.validInquiries.delta} icon={Activity} />
<StatCard title="高意向客户" value={k.highIntent.value} delta={k.highIntent.delta} icon={Target} />
@@ -45,27 +67,62 @@ export function AnalyticsPage() {
<StatCard title="AI采纳率" value={`${k.aiAdoptionRate.value}%`} delta={k.aiAdoptionRate.delta} icon={Zap} />
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<ChartCard title="客户增长趋势" height={260}><TrendChart data={data.customerGrowth} series={[{ key: 'count', name: '新增客户', color: '#247b71' }]} /></ChartCard>
<ChartCard title="各渠道询盘数量" height={260}><SimpleBarChart data={data.channelInquiries} xKey="name" barKey="count" name="询盘数" /></ChartCard>
<ChartCard title="客户国家/地区分布" height={260}><DonutChart data={data.countryDistribution} /></ChartCard>
<ChartCard title="客户阶段转化漏斗" height={260}><FunnelChart data={data.funnel} /></ChartCard>
<ChartCard title="意向分数分布" height={260}><DonutChart data={data.intentDistribution} nameKey="range" /></ChartCard>
<ChartCard title="产品关注度排行" height={260}><RankBars data={data.productRank.map((p) => ({ name: p.product, value: p.inquiries }))} /></ChartCard>
<ChartCard title="团队回复效率排行" height={240}><RankBars data={data.teamReplyRank.map((r) => ({ name: r.name, value: r.avgResponseMin }))} unit="分钟" max={30} /></ChartCard>
<ChartCard title="团队客户转化排行" height={240}><RankBars data={data.teamConversionRank.map((r) => ({ name: r.name, value: r.rate }))} unit="%" /></ChartCard>
<section className="page-section">
<div className="section-heading">
<div>
<h2 className="section-title">增长与转化</h2>
<p className="section-description">观察客户增长速度、渠道贡献和阶段流失</p>
</div>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-12">
<ChartCard className="xl:col-span-7" title="客户增长趋势" description="所选周期内的新增客户变化" height={290}>
<TrendChart data={data.customerGrowth} series={[{ key: 'count', name: '新增客户', color: '#2563eb' }]} />
</ChartCard>
<ChartCard className="xl:col-span-5" title="客户阶段转化漏斗" description="识别转化链路中的主要流失阶段" height={290}>
<FunnelChart data={data.funnel} />
</ChartCard>
<ChartCard className="xl:col-span-4" title="各渠道询盘数量" height={255}>
<SimpleBarChart data={data.channelInquiries} xKey="name" barKey="count" name="询盘数" />
</ChartCard>
<ChartCard className="xl:col-span-4" title="客户国家/地区分布" height={255}>
<DonutChart data={data.countryDistribution} />
</ChartCard>
<ChartCard className="xl:col-span-4" title="意向分数分布" height={255}>
<DonutChart data={data.intentDistribution} nameKey="range" />
</ChartCard>
</div>
</section>
{/* 老板看板 */}
<h2 className="mt-6 mb-3 text-lg font-semibold">老板看板 <LiveDataBadge className="align-middle" /></h2>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card><CardHeader title="🔥 今日需处理的高价值客户" /><div className="divide-y divide-border">{data.bossBoard.urgentHighValue.map((c) => (<div key={c.id} className="flex items-center justify-between px-4 py-2.5 text-sm"><span>{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="text-xs text-muted-foreground">{c.reason.slice(0, 20)}…</span></div>))}</div></Card>
<Card><CardHeader title="⚠️ 存在逾期跟进的业务员" /><div className="divide-y divide-border">{data.bossBoard.overdueMembers.map((m) => (<div key={m.id} className="flex items-center justify-between px-4 py-2.5 text-sm"><span>{m.name}</span><Badge variant="danger">{m.overdueCount} 项逾期</Badge></div>))}</div></Card>
<Card><CardHeader title="📈 客户增长最快的国家" /><div className="divide-y divide-border">{data.bossBoard.fastestCountries.map((c) => (<div key={c.country} className="flex items-center justify-between px-4 py-2.5 text-sm"><CountryFlag code={c.country} /><Badge variant="success">+{(c.growth * 100).toFixed(0)}%</Badge></div>))}</div></Card>
<Card><CardHeader title="🏅 最优渠道" /><div className="p-4 text-center"><p className="text-2xl font-semibold text-primary-700">{data.bossBoard.bestChannel.name}</p><p className="text-sm text-muted-foreground">转化率 {data.bossBoard.bestChannel.conversion}%</p></div></Card>
<Card><CardHeader title="📊 最热门产品" /><div className="space-y-2 p-4">{data.bossBoard.topProducts.map((p) => (<div key={p.product} className="flex items-center justify-between text-sm"><span>{p.product}</span><span className="text-muted-foreground">{p.inquiries} 次询盘</span></div>))}</div></Card>
<Card><CardHeader title="🚨 正在流失的高意向客户" /><div className="divide-y divide-border">{data.bossBoard.churningHighIntent.map((c) => (<div key={c.id} className="px-4 py-2.5 text-sm"><span>{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="text-xs text-muted-foreground ml-2">{c.daysSilent}天未联系 · 意向{c.intentScore}分</span></div>))}</div></Card>
<section className="page-section">
<div className="section-heading">
<div>
<h2 className="section-title">产品与团队效率</h2>
<p className="section-description">比较产品热度、响应速度和团队转化结果</p>
</div>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<ChartCard title="产品关注度排行" height={250}><RankBars data={data.productRank.map((p) => ({ name: p.product, value: p.inquiries }))} /></ChartCard>
<ChartCard title="团队回复效率" height={250}><RankBars data={data.teamReplyRank.map((r) => ({ name: r.name, value: r.avgResponseMin }))} unit="分钟" max={30} /></ChartCard>
<ChartCard title="团队客户转化" height={250}><RankBars data={data.teamConversionRank.map((r) => ({ name: r.name, value: r.rate }))} unit="%" /></ChartCard>
</div>
</section>
<section className="page-section">
<div className="section-heading">
<div>
<h2 className="section-title">经营提醒</h2>
<p className="section-description">需要优先处理的客户、跟进和市场变化</p>
</div>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card><CardHeader title="重点跟进客户" /><div className="divide-y divide-slate-100">{data.bossBoard.urgentHighValue.map((c) => (<div key={c.id} className="flex items-center justify-between px-5 py-3 text-sm"><span className="font-medium text-slate-900">{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="max-w-[55%] truncate text-xs text-slate-500">{c.reason}</span></div>))}</div></Card>
<Card><CardHeader title="逾期跟进" /><div className="divide-y divide-slate-100">{data.bossBoard.overdueMembers.map((m) => (<div key={m.id} className="flex items-center justify-between px-5 py-3 text-sm"><span className="font-medium text-slate-900">{m.name}</span><Badge variant="danger">{m.overdueCount} 项逾期</Badge></div>))}</div></Card>
<Card><CardHeader title="增长较快的市场" /><div className="divide-y divide-slate-100">{data.bossBoard.fastestCountries.map((c) => (<div key={c.country} className="flex items-center justify-between px-5 py-3 text-sm"><CountryFlag code={c.country} /><Badge variant="success">+{(c.growth * 100).toFixed(0)}%</Badge></div>))}</div></Card>
<Card><CardHeader title="渠道转化" /><div className="px-5 py-6"><p className="text-2xl font-semibold tracking-tight text-slate-950">{data.bossBoard.bestChannel.name}</p><p className="mt-1 text-sm text-slate-500">当前转化率 {data.bossBoard.bestChannel.conversion}%</p></div></Card>
<Card><CardHeader title="产品询盘" /><div className="divide-y divide-slate-100 px-5">{data.bossBoard.topProducts.map((p) => (<div key={p.product} className="flex items-center justify-between py-3 text-sm"><span className="font-medium text-slate-900">{p.product}</span><span className="text-slate-500">{p.inquiries} 次</span></div>))}</div></Card>
<Card><CardHeader title="久未联系的高意向客户" /><div className="divide-y divide-slate-100">{data.bossBoard.churningHighIntent.map((c) => (<div key={c.id} className="px-5 py-3 text-sm"><span className="font-medium text-slate-900">{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="ml-2 text-xs text-slate-500">{c.daysSilent} 天未联系 · 意向 {c.intentScore} 分</span></div>))}</div></Card>
</div>
</section>
</div>
)
}
+90 -150
View File
@@ -1,9 +1,9 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useEffect, useState } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { Sparkles, MessageCircle, Languages, ShieldCheck, UsersRound } from 'lucide-react'
import { Globe, LockKeyhole, UserRound } from 'lucide-react'
import { login } from '@/services/api/auth'
import { useAuthStore } from '@/app/store/auth'
import { useI18n } from '@/i18n'
@@ -11,210 +11,150 @@ import { toast, errorMessage } from '@/app/store/toast'
import { Button } from '@/components/ui/button'
import { Input, Field } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { Tabs } from '@/components/ui/tabs'
import { Dialog } from '@/components/ui/dialog'
import { UI_LOCALES, type UiLocale } from '@/config/languages'
import { Dropdown } from '@/components/ui/dropdown'
import { Globe } from 'lucide-react'
import { AuthShell } from '@/components/auth/AuthShell'
import { UI_LOCALES, type UiLocale } from '@/config/languages'
const schema = z.object({
account: z.string().min(4, '请输入正确的手机号或邮箱'),
password: z.string().optional(),
code: z.string().optional(),
password: z.string().min(1, '请输入密码'),
})
type FormValues = z.infer<typeof schema>
export function LoginPage() {
const { t, locale, setLocale } = useI18n()
const { locale, setLocale } = useI18n()
const navigate = useNavigate()
const setSession = useAuthStore((s) => s.setSession)
const [mode, setMode] = useState<'code' | 'password'>('password')
const [searchParams, setSearchParams] = useSearchParams()
const setSession = useAuthStore((state) => state.setSession)
const [remember, setRemember] = useState(true)
const [agreed, setAgreed] = useState(true)
const [loading, setLoading] = useState(false)
const [codeSent, setCodeSent] = useState(0)
const [agreementOpen, setAgreementOpen] = useState<'privacy' | 'terms' | null>(null)
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { account: '', password: '', code: '' } })
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { account: '', password: '' },
})
const doLogin = async (values: FormValues) => {
useEffect(() => {
if (searchParams.get('reason') !== 'session-expired') return
toast.warning('登录状态已过期,请重新登录')
setSearchParams({}, { replace: true })
}, [searchParams, setSearchParams])
const onSubmit = async (values: FormValues) => {
if (!agreed) {
toast.warning('请先勾选并同意《隐私政策》和《服务协议》')
return
}
if (mode === 'password' && !values.password) {
toast.warning('请输入密码')
return
}
if (mode === 'code' && (values.code ?? '').length !== 6) {
toast.warning('请输入6位验证码')
toast.warning('请先阅读并同意隐私政策和服务协议')
return
}
try {
setLoading(true)
const session = await login({ ...values, mode, remember })
const session = await login({ ...values, mode: 'password', remember })
setSession(session)
toast.success(`欢迎回来,${session.user.name}`)
navigate('/dashboard')
} catch (e) {
toast.error(errorMessage(e))
} catch (error) {
toast.error(errorMessage(error))
} finally {
setLoading(false)
}
}
const sendCode = () => {
setCodeSent(60)
const timer = setInterval(() => {
setCodeSent((s) => {
if (s <= 1) clearInterval(timer)
return s - 1
})
}, 1000)
toast.success('验证码已发送')
}
return (
<div className="flex min-h-screen bg-background">
{/* 品牌区 */}
<div className="hidden w-[46%] flex-col justify-between bg-primary-900 p-10 text-primary-50 lg:flex">
<div className="flex items-center gap-2.5">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/10">
<Sparkles className="h-5 w-5" />
</span>
<div>
<p className="text-base font-semibold">{t('app.name')}</p>
<p className="text-xs text-primary-200">{t('app.slogan')}</p>
</div>
</div>
<div className="space-y-8">
<h1 className="text-2xl font-semibold leading-snug">
把分散在全球渠道的客户对话,
<br />
沉淀为可成交的企业资产。
</h1>
<div className="space-y-4">
{[
{ icon: MessageCircle, text: 'WhatsApp / Telegram / 邮件 / 独立站,统一收件箱' },
{ icon: Languages, text: 'AI 翻译与行业术语,跨语种沟通专业自然' },
{ icon: ShieldCheck, text: '情感分析、风险预警、文化敏感检测' },
{ icon: UsersRound, text: '客户自动建档、意向评分与团队协同跟进' },
].map((f) => (
<div key={f.text} className="flex items-center gap-3 text-sm text-primary-100">
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-white/10">
<f.icon className="h-4 w-4" />
</span>
{f.text}
</div>
))}
</div>
</div>
<p className="text-xs text-primary-300">© 2026 {t('app.company')}</p>
</div>
{/* 表单区 */}
<div className="flex flex-1 items-center justify-center p-6">
<div className="w-full max-w-[400px]">
<div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-2 lg:hidden">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-white">
<Sparkles className="h-4 w-4" />
</span>
<span className="font-semibold">{t('app.name')}</span>
</div>
const localeMenu = (
<Dropdown
trigger={
<button className="flex items-center gap-1 rounded-md px-2 py-1 text-sm text-muted-foreground hover:bg-muted">
<Globe className="h-4 w-4" />
{UI_LOCALES.find((l) => l.code === locale)?.nativeName}
trigger={(
<button className="flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 text-xs font-medium text-slate-600 transition hover:border-slate-300 hover:bg-slate-50">
<Globe className="h-3.5 w-3.5" />
{UI_LOCALES.find((item) => item.code === locale)?.nativeName}
</button>
}
items={UI_LOCALES.map((l) => ({
key: l.code,
label: l.nativeName,
onSelect: () => setLocale(l.code as UiLocale),
)}
items={UI_LOCALES.map((item) => ({
key: item.code,
label: item.nativeName,
onSelect: () => setLocale(item.code as UiLocale),
}))}
/>
)
return (
<AuthShell topAction={localeMenu}>
<div className="mb-8">
<p className="text-xs font-semibold tracking-[0.12em] text-blue-600">欢迎回来</p>
<h2 className="mt-3 text-[28px] font-semibold tracking-[-0.035em] text-slate-950">登录工作台</h2>
<p className="mt-2 text-sm text-slate-500">继续处理客户消息与团队跟进。</p>
</div>
<h2 className="text-xl font-semibold">{t('login.title')}</h2>
<p className="mt-1 text-sm text-muted-foreground">{t('login.subtitle')}</p>
<div className="mt-6">
<Tabs
value={mode}
onChange={(v) => setMode(v as 'code' | 'password')}
items={[{ value: 'password', label: t('login.passwordTab') }]}
<form className="space-y-5" onSubmit={handleSubmit(onSubmit)}>
<Field label="手机号或邮箱" error={errors.account?.message}>
<div className="relative">
<UserRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
className="h-11 rounded-xl border-slate-200 bg-white pl-10 focus-visible:ring-blue-100"
placeholder="请输入手机号或邮箱"
autoComplete="username"
{...register('account')}
/>
</div>
<form className="mt-5 space-y-4" onSubmit={handleSubmit((v) => doLogin(v))}>
<Field error={errors.account?.message}>
<Input placeholder={t('login.accountPlaceholder')} autoComplete="username" {...register('account')} />
</Field>
{mode === 'code' ? (
<div className="flex gap-2">
<Input placeholder={t('login.codePlaceholder')} maxLength={6} {...register('code')} />
<Button type="button" variant="outline" className="shrink-0" disabled={codeSent > 0} onClick={sendCode}>
{codeSent > 0 ? `${codeSent}s` : t('login.getCode')}
</Button>
<Field label="密码" error={errors.password?.message}>
<div className="relative">
<LockKeyhole className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<Input
className="h-11 rounded-xl border-slate-200 bg-white pl-10 focus-visible:ring-blue-100"
type="password"
placeholder="请输入登录密码"
autoComplete="current-password"
{...register('password')}
/>
</div>
) : (
<Field error={errors.password?.message}>
<Input type="password" placeholder={t('login.passwordPlaceholder')} autoComplete="current-password" {...register('password')} />
</Field>
)}
<div className="flex items-center justify-between text-sm">
<Checkbox label={t('login.remember')} checked={remember} onCheckedChange={setRemember} />
<div className="flex items-center justify-between">
<Checkbox label="保持登录" checked={remember} onCheckedChange={setRemember} />
<span className="text-xs text-slate-400">安全会话 · 12 小时</span>
</div>
<Button type="submit" className="w-full" size="lg" loading={loading}>
{t('login.submit')}
<Button type="submit" className="h-11 w-full rounded-xl shadow-[0_10px_24px_-14px_rgba(37,99,235,.8)]" loading={loading}>
登录
</Button>
<div className="text-center text-sm text-muted-foreground">
<p className="text-center text-sm text-slate-500">
还没有账号?{' '}
<Link to="/register" className="font-medium text-primary hover:underline">
{t('login.register')}
<Link to="/register" className="font-medium text-blue-600 hover:text-blue-700">
创建企业
</Link>
</div>
</p>
</form>
<p className="mt-6 text-center text-xs text-muted-foreground">
<Checkbox
checked={agreed}
onCheckedChange={setAgreed}
className="me-1 align-middle"
/>
{t('login.agreementPrefix')}
<button type="button" className="text-primary hover:underline" onClick={() => setAgreementOpen('privacy')}>
{t('login.privacy')}
</button>
{t('login.and')}
<button type="button" className="text-primary hover:underline" onClick={() => setAgreementOpen('terms')}>
{t('login.terms')}
</button>
</p>
</div>
<div className="mt-8 border-t border-slate-100 pt-5">
<label className="flex items-start gap-2 text-xs leading-5 text-slate-400">
<Checkbox checked={agreed} onCheckedChange={setAgreed} className="mt-0.5" />
<span>
登录即表示同意
<button type="button" className="mx-1 text-slate-600 hover:text-blue-600" onClick={() => setAgreementOpen('privacy')}>隐私政策</button>
和
<button type="button" className="ml-1 text-slate-600 hover:text-blue-600" onClick={() => setAgreementOpen('terms')}>服务协议</button>
</span>
</label>
</div>
{/* 协议弹窗 */}
<Dialog open={!!agreementOpen} onOpenChange={() => setAgreementOpen(null)} title={agreementOpen === 'privacy' ? '隐私政策' : '服务协议'}>
<div className="space-y-3 text-sm leading-relaxed text-muted-foreground">
<Dialog
open={Boolean(agreementOpen)}
onOpenChange={() => setAgreementOpen(null)}
title={agreementOpen === 'privacy' ? '隐私政策' : '服务协议'}
>
<div className="space-y-3 text-sm leading-7 text-slate-600">
<p>使用本服务前,请阅读并确认以下数据处理与服务条款。</p>
<p>1. 我们仅收集为您提供跨境沟通与客户管理服务所必需的信息,包括账号信息、企业信息与您主动录入的客户数据。</p>
<p>2. 您的会话数据采用全链路加密存储,未经您授权,我们不会向任何第三方披露。</p>
<p>3. 您可以随时导出或删除您的数据;企业注销后,相关数据将在 30 天内完成清理。</p>
<p>4. 使用 AI 功能时,相关内容仅用于为您生成翻译与回复建议,不会用于其他用途。</p>
<p>我们仅处理提供跨境沟通、客户管理和团队协作所必需的数据。</p>
<p>使用翻译与回复建议时,相关内容会发送至企业已配置的模型服务商处理。</p>
<p>数据导出、删除和账号注销按照正式发布的政策与协议执行。</p>
</div>
</Dialog>
</div>
</AuthShell>
)
}
+36 -39
View File
@@ -3,7 +3,6 @@ import { Link, useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { Sparkles } from 'lucide-react'
import { register as registerApi } from '@/services/api/auth'
import { useAuthStore } from '@/app/store/auth'
import { toast, errorMessage } from '@/app/store/toast'
@@ -11,24 +10,25 @@ import { Button } from '@/components/ui/button'
import { Input, Field } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { AuthShell } from '@/components/auth/AuthShell'
import { INDUSTRIES } from '@/config/constants'
const schema = z.object({
name: z.string().min(2, '请输入您的姓名'),
account: z.string().min(5, '请输入手机号或邮箱'),
password: z.string().min(6, '密码至少6位').max(32, '密码最长32位'),
password: z.string().min(8, '密码至少 8 位').max(32, '密码最长 32 位'),
company: z.string().min(2, '请输入企业名称'),
industry: z.string().min(1, '请选择所属行业'),
teamSize: z.string().min(1, '请选择团队规模'),
})
type FormValues = z.infer<typeof schema>
const TEAM_SIZES = ['仅我一人', '2—5人', '6—20人', '21—50人', '50人以上']
const fieldClass = 'h-10 rounded-xl border-slate-200 bg-white focus-visible:ring-blue-100'
export function RegisterPage() {
const navigate = useNavigate()
const setSession = useAuthStore((s) => s.setSession)
const setSession = useAuthStore((state) => state.setSession)
const [agreed, setAgreed] = useState(false)
const [loading, setLoading] = useState(false)
@@ -43,77 +43,74 @@ export function RegisterPage() {
const onSubmit = async (values: FormValues) => {
if (!agreed) {
toast.warning('请先同意《隐私政策》和《服务协议》')
toast.warning('请先同意隐私政策和服务协议')
return
}
try {
setLoading(true)
const session = await registerApi(values)
setSession(session)
toast.success('企业创建成功', '接下来请完成企业初始化配置')
navigate('/channels')
} catch (e) {
toast.error(errorMessage(e))
navigate('/channels?setup=choose')
} catch (error) {
toast.error(errorMessage(error))
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-background p-6">
<div className="w-full max-w-[440px]">
<div className="mb-8 flex items-center gap-2.5">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary text-white">
<Sparkles className="h-5 w-5" />
</span>
<div>
<p className="text-base font-semibold">灵犀跨境</p>
<p className="text-xs text-muted-foreground">注册并创建您的企业</p>
</div>
<AuthShell
heading="从第一条消息开始,建立清晰的客户关系。"
description="创建企业工作台后,可按需连接 Telegram、WhatsApp 或企业邮箱,并邀请团队成员协作。"
topAction={(
<p className="text-xs text-slate-500">
已有账号? <Link to="/login" className="font-medium text-blue-600 hover:text-blue-700">登录</Link>
</p>
)}
>
<div className="mb-7">
<p className="text-xs font-semibold tracking-[0.15em] text-blue-600">CREATE WORKSPACE</p>
<h2 className="mt-3 text-[28px] font-semibold tracking-[-0.035em] text-slate-950">创建企业工作台</h2>
<p className="mt-2 text-sm text-slate-500">填写基础信息,稍后再连接消息渠道。</p>
</div>
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="姓名" required error={errors.name?.message}>
<Input placeholder="您的姓名" {...register('name')} />
<Input className={fieldClass} placeholder="您的姓名" autoComplete="name" {...register('name')} />
</Field>
<Field label="手机号或邮箱" required error={errors.account?.message}>
<Input placeholder="用于登录与接收通知" {...register('account')} />
<Input className={fieldClass} placeholder="用于登录" autoComplete="username" {...register('account')} />
</Field>
<Field label="设置密码" required error={errors.password?.message} hint="6—32位,建议包含字母与数字">
<Input type="password" placeholder="请设置登录密码" {...register('password')} />
</div>
<Field label="设置密码" required error={errors.password?.message} hint="8—32 位">
<Input className={fieldClass} type="password" placeholder="请设置登录密码" autoComplete="new-password" {...register('password')} />
</Field>
<Field label="企业名称" required error={errors.company?.message}>
<Input placeholder="如:徐州欧汇科技有限公司" {...register('company')} />
<Input className={fieldClass} placeholder="请输入企业名称" {...register('company')} />
</Field>
<div className="grid grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="所属行业" required error={errors.industry?.message}>
<Select placeholder="请选择" options={INDUSTRIES.map((i) => ({ value: i, label: i }))} {...register('industry')} />
<Select placeholder="请选择" options={INDUSTRIES.map((item) => ({ value: item, label: item }))} {...register('industry')} />
</Field>
<Field label="团队规模" required error={errors.teamSize?.message}>
<Select placeholder="请选择" options={TEAM_SIZES.map((s) => ({ value: s, label: s }))} {...register('teamSize')} />
<Select placeholder="请选择" options={TEAM_SIZES.map((item) => ({ value: item, label: item }))} {...register('teamSize')} />
</Field>
</div>
<div className="pt-1">
<Checkbox
checked={agreed}
onCheckedChange={setAgreed}
label="我已阅读并同意《隐私政策》和《服务协议》"
/>
</div>
<Button type="submit" className="w-full" size="lg" loading={loading}>
创建企业并开始使用
<Button type="submit" className="h-11 w-full rounded-xl shadow-[0_10px_24px_-14px_rgba(37,99,235,.8)]" loading={loading}>
创建企业
</Button>
<p className="text-center text-sm text-muted-foreground">
已有账号?{' '}
<Link to="/login" className="font-medium text-primary hover:underline">
直接登录
</Link>
<p className="text-center text-[11px] leading-5 text-slate-400">
注册后先选择需要接入的渠道,也可以暂时跳过。
</p>
</form>
</div>
</div>
</AuthShell>
)
}
+1 -1
View File
@@ -33,7 +33,7 @@ export function BillingPage() {
return (
<div className="p-4 md:p-6">
<PageHeader title="套餐与账单" description="通过 Stripe Checkout 管理真实订阅与支付" />
<PageHeader title="套餐与账单" description="管理套餐、订阅和支付记录" />
{info && (
<Card className="mb-4 p-4">
+778 -56
View File
@@ -1,6 +1,26 @@
import { useState, useEffect } from 'react'
import { useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Cable, Plus, Pause, Play, RefreshCw, Trash2, History, Settings } from 'lucide-react'
import {
Activity,
ArrowUpRight,
AlertTriangle,
Cable,
CheckCircle2,
History,
Mail,
MessageCircle,
Plus,
MessageSquareText,
Pause,
Play,
RefreshCw,
Settings,
ShieldCheck,
Star,
Trash2,
Workflow,
} from 'lucide-react'
import { toast, errorMessage } from '@/app/store/toast'
import { confirmDialog } from '@/app/store/confirm'
import { PageHeader } from '@/components/common/PageHeader'
@@ -15,12 +35,13 @@ import { Input, Field } from '@/components/ui/input'
import { PermissionGate } from '@/components/common/PermissionGate'
import { ChannelIcon } from '@/components/common/ChannelIcon'
import {
getChannels, connectChannel, testChannel, updateChannel, deleteChannel, getChannelLogs,
startTelegramLogin, submitTelegramCode, submitTelegramPassword, syncTelegramChannel,
getChannels, testChannel, updateChannel, deleteChannel, getChannelLogs,
connectEmailChannel, startTelegramLogin, submitTelegramCode, submitTelegramPassword,
getTelegramLoginState, syncChannel, startWhatsAppLogin, getWhatsAppLoginState,
setDefaultChannel,
} from '@/services/api/channels'
import { CHANNEL_META } from '@/config/constants'
import { formatRelative } from '@/utils/format'
import { cn } from '@/utils/cn'
import type { ChannelType, ChannelConnection, ChannelSyncLog } from '@/types'
const STATUS_MAP: Record<string, { label: string; color: string }> = {
@@ -31,15 +52,48 @@ const STATUS_MAP: Record<string, { label: string; color: string }> = {
unconnected: { label: '未连接', color: 'bg-gray-100 text-gray-400' },
}
const EMAIL_PRESETS = {
gmail: { label: 'Gmail', imapHost: 'imap.gmail.com', imapPort: 993, imapSecure: true, smtpHost: 'smtp.gmail.com', smtpPort: 465, smtpSecure: true },
outlook: { label: 'Outlook / Microsoft 365', imapHost: 'outlook.office365.com', imapPort: 993, imapSecure: true, smtpHost: 'smtp.office365.com', smtpPort: 587, smtpSecure: false },
qq: { label: 'QQ 企业邮箱', imapHost: 'imap.exmail.qq.com', imapPort: 993, imapSecure: true, smtpHost: 'smtp.exmail.qq.com', smtpPort: 465, smtpSecure: true },
netease: { label: '网易企业邮箱', imapHost: 'imap.qiye.163.com', imapPort: 993, imapSecure: true, smtpHost: 'smtp.qiye.163.com', smtpPort: 465, smtpSecure: true },
custom: { label: '自定义服务器', imapHost: '', imapPort: 993, imapSecure: true, smtpHost: '', smtpPort: 465, smtpSecure: true },
} as const
export function ChannelsPage() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const queryClient = useQueryClient()
const { data: channels } = useQuery({ queryKey: ['channels'], queryFn: getChannels })
const { data: channels } = useQuery({
queryKey: ['channels'],
queryFn: getChannels,
refetchInterval: 15_000,
})
const [wizard, setWizard] = useState<{ open: boolean; type: ChannelType | null; step: number }>({ open: false, type: null, step: 0 })
const [telegramAccount, setTelegramAccount] = useState('')
const [telegramCode, setTelegramCode] = useState('')
const [telegramPassword, setTelegramPassword] = useState('')
const [loginChannelId, setLoginChannelId] = useState('')
const [authState, setAuthState] = useState('')
const [whatsappAccount, setWhatsAppAccount] = useState('')
const [whatsappChannelId, setWhatsAppChannelId] = useState('')
const [whatsappPairingCode, setWhatsAppPairingCode] = useState('')
const [whatsappQr, setWhatsAppQr] = useState('')
const [connecting, setConnecting] = useState(false)
const [emailPreset, setEmailPreset] = useState<keyof typeof EMAIL_PRESETS>('custom')
const [emailForm, setEmailForm] = useState({
displayName: '',
account: '',
password: '',
imapHost: '',
imapPort: 993,
imapSecure: true,
imapFolder: 'INBOX',
smtpHost: '',
smtpPort: 465,
smtpSecure: true,
syncDays: 30,
})
const [logDrawer, setLogDrawer] = useState<string | null>(null)
const [settingsDrawer, setSettingsDrawer] = useState<ChannelConnection | null>(null)
const [logItems, setLogItems] = useState<ChannelSyncLog[]>([])
@@ -58,116 +112,717 @@ export function ChannelsPage() {
const list = channels ?? []
return (
<div className="p-4 md:p-6">
<PageHeader title="渠道中心" description={`${list.filter((c) => c.status === 'connected').length} 个渠道已连接 · 共 ${list.length} 个可连接渠道`} />
const resetTelegramWizard = () => {
setTelegramAccount('')
setTelegramCode('')
setTelegramPassword('')
setLoginChannelId('')
setAuthState('')
}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
const setTelegramStepFromState = (state: string) => {
if (state === 'waiting_password') {
setAuthState('waiting_password')
setWizard({ open: true, type: 'telegram', step: 2 })
return
}
setAuthState('waiting_code')
setWizard({ open: true, type: 'telegram', step: 1 })
}
const completeTelegramBinding = async (channelId: string) => {
let syncSummary = ''
try {
const result = await syncChannel(channelId)
syncSummary = `,已同步 ${result.chats} 个会话和 ${result.messages} 条消息`
} catch (error) {
syncSummary = `;账号已连接,但首次同步失败:${errorMessage(error)}`
}
toast.success(`Telegram 绑定成功${syncSummary}`)
resetTelegramWizard()
setWizard({ open: false, type: null, step: 0 })
await queryClient.invalidateQueries()
}
const openTelegramWizard = async (channel?: ChannelConnection) => {
resetTelegramWizard()
setTelegramAccount(channel?.account || '')
setWizard({ open: true, type: 'telegram', step: 0 })
if (!channel || channel.id === 'telegram-available') return
setConnecting(true)
try {
const result = await getTelegramLoginState(channel.id)
setLoginChannelId(channel.id)
if (result.status === 'ready' && result.connectionState === 'ready') {
await completeTelegramBinding(channel.id)
return
}
setTelegramStepFromState(result.status)
toast.info(result.status === 'waiting_password' ? '请继续输入二步验证密码' : '请继续输入 Telegram 验证码')
} catch (error) {
toast.error(errorMessage(error))
} finally {
setConnecting(false)
}
}
const openWhatsAppWizard = async (channel?: ChannelConnection) => {
setWhatsAppAccount(channel?.account || '')
setWhatsAppChannelId(channel && channel.id !== 'whatsapp-available' ? channel.id : '')
setWhatsAppPairingCode(channel?.pairingCode || '')
setWhatsAppQr(channel?.qrDataUrl || '')
setWizard({ open: true, type: 'whatsapp', step: channel?.pairingCode ? 1 : 0 })
if (!channel || channel.id === 'whatsapp-available') return
setConnecting(true)
try {
const current = await getWhatsAppLoginState(channel.id)
if (current.status === 'connected') {
await syncChannel(channel.id, { full: true })
toast.success('WhatsApp 已连接并完成首次会话同步')
setWizard({ open: false, type: null, step: 0 })
await queryClient.invalidateQueries()
} else {
const restarted = await startWhatsAppLogin(channel.account || '')
setWhatsAppChannelId(restarted.id)
setWhatsAppPairingCode(restarted.pairingCode || '')
setWhatsAppQr(restarted.qrDataUrl || '')
setWizard({ open: true, type: 'whatsapp', step: 1 })
toast.success('已生成新的配对码,请在手机 WhatsApp 中完成关联设备')
await queryClient.invalidateQueries({ queryKey: ['channels'] })
}
} catch (error) {
toast.error(errorMessage(error))
} finally {
setConnecting(false)
}
}
const openChannelWizard = (type: 'telegram' | 'whatsapp' | 'email', channel?: ChannelConnection) => {
setSearchParams({}, { replace: true })
if (type === 'telegram') {
void openTelegramWizard(channel)
} else if (type === 'whatsapp') {
void openWhatsAppWizard(channel)
} else {
applyEmailPreset('custom')
setWizard({ open: true, type: 'email', step: 0 })
}
}
const applyEmailPreset = (value: keyof typeof EMAIL_PRESETS) => {
setEmailPreset(value)
const preset = EMAIL_PRESETS[value]
setEmailForm((current) => ({
...current,
imapHost: preset.imapHost,
imapPort: preset.imapPort,
imapSecure: preset.imapSecure,
smtpHost: preset.smtpHost,
smtpPort: preset.smtpPort,
smtpSecure: preset.smtpSecure,
}))
}
const connectedCount = list.filter((channel) => channel.status === 'connected').length
const setupChoice = searchParams.get('setup') === 'choose'
const todayMessages = list.reduce((total, channel) => total + Number(channel.todayMessages || 0), 0)
const latestSync = list
.map((channel) => channel.lastSyncAt)
.filter(Boolean)
.sort()
.at(-1)
return (
<div className="page-shell">
<section className="relative mb-6 overflow-hidden rounded-[16px] border border-slate-200 bg-white px-5 py-6 shadow-[0_8px_28px_-24px_rgba(15,23,42,.55)] md:px-6">
<div className="pointer-events-none absolute -right-20 -top-24 h-64 w-64 rounded-full bg-primary-100/55 blur-3xl" />
<div className="relative flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
<div>
<div className="mb-3 inline-flex items-center gap-2 rounded-[8px] border border-primary-100 bg-primary-50 px-2.5 py-1 text-[11px] font-semibold text-primary-700">
<span className="relative flex h-2 w-2">
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary-500" />
</span>
渠道连接
</div>
<h1 className="text-2xl font-semibold tracking-[-0.035em] text-slate-950 md:text-[28px]">渠道与账号</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
在一个工作台管理 WhatsApp、Telegram 与企业邮箱消息。
</p>
</div>
<div className="grid grid-cols-3 gap-2 sm:min-w-[460px]">
{[
{ label: '在线账号', value: `${connectedCount}/${list.length}`, icon: CheckCircle2 },
{ label: '今日消息', value: String(todayMessages), icon: MessageSquareText },
{ label: '最近同步', value: latestSync ? formatRelative(latestSync) : '等待首次同步', icon: Activity },
].map((stat) => (
<div key={stat.label} className="rounded-[12px] border border-slate-200 bg-slate-50/70 p-3">
<stat.icon className="mb-2 h-4 w-4 text-primary" />
<p className="truncate text-base font-bold tracking-tight text-slate-900">{stat.value}</p>
<p className="mt-0.5 text-[10px] font-medium text-slate-400">{stat.label}</p>
</div>
))}
</div>
</div>
</section>
<PageHeader
title="通信账号"
description="连接状态、同步进度与安全设置"
/>
{setupChoice && (
<Card
data-testid="channel-onboarding"
className="mb-5 overflow-hidden border-primary-200/80 bg-gradient-to-r from-primary-50/90 via-white to-sky-50 shadow-[0_16px_45px_rgba(37,99,235,.10)]"
>
<div className="p-5 lg:p-6">
<Badge variant="primary">企业创建成功 · 渠道可稍后配置</Badge>
<h2 className="mt-3 text-xl font-bold tracking-tight text-slate-950">选择第一个客户沟通渠道</h2>
<p className="mt-1.5 text-sm leading-6 text-slate-600">
不强制绑定 Telegram。你可以连接 WhatsApp、Telegram 或企业邮箱,也可以先进入系统,之后再添加多个账号。
</p>
<div className="mt-5 grid gap-3 md:grid-cols-3">
{[
{ type: 'telegram' as const, icon: Cable, title: 'Telegram', text: '手机号、验证码与可选二步密码' },
{ type: 'whatsapp' as const, icon: MessageCircle, title: 'WhatsApp', text: '普通账号通过关联设备配对' },
{ type: 'email' as const, icon: Mail, title: '企业邮箱', text: 'IMAP 收信与 SMTP 发信' },
].map((item) => (
<button
type="button"
key={item.type}
data-testid={`choose-${item.type}`}
onClick={() => openChannelWizard(item.type)}
className="group rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-primary-300 hover:shadow-lg"
>
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary-50 text-primary transition group-hover:bg-primary group-hover:text-white">
<item.icon className="h-5 w-5" />
</span>
<p className="mt-3 text-sm font-bold text-slate-900">{item.title}</p>
<p className="mt-1 text-xs leading-5 text-slate-500">{item.text}</p>
</button>
))}
</div>
<div className="mt-4 flex justify-end">
<Button variant="ghost" onClick={() => setSearchParams({}, { replace: true })}>
暂不配置,先进入系统
</Button>
</div>
</div>
</Card>
)}
<div className="mb-5 flex flex-wrap items-center justify-between gap-3 rounded-[14px] border border-slate-200 bg-white p-3 shadow-sm">
<div>
<p className="text-sm font-semibold text-slate-900">连接更多工作账号</p>
<p className="mt-0.5 text-xs text-slate-500">每种渠道可绑定多个账号,并指定一个默认账号用于发起新会话。</p>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={() => openChannelWizard('telegram')}>
<Plus className="h-3.5 w-3.5" />Telegram
</Button>
<Button size="sm" variant="outline" onClick={() => openChannelWizard('whatsapp')}>
<Plus className="h-3.5 w-3.5" />WhatsApp
</Button>
<Button size="sm" variant="outline" onClick={() => openChannelWizard('email')}>
<Plus className="h-3.5 w-3.5" />企业邮箱
</Button>
</div>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
{list.map((ch) => {
const meta = CHANNEL_META[ch.type]
const st = STATUS_MAP[ch.status]
return (
<Card key={ch.id} className="p-4">
<Card data-testid={`channel-card-${ch.type}`} key={ch.id} className="group overflow-hidden bg-white transition-all duration-200 hover:-translate-y-0.5 hover:border-primary-200 hover:shadow-[0_12px_28px_-18px_rgba(37,99,235,.5)]">
<div className={
ch.type === 'telegram'
? 'h-[3px] bg-primary-500'
: ch.type === 'whatsapp'
? 'h-[3px] bg-sky-500'
: 'h-[3px] bg-slate-500'
} />
<div className="p-5">
<div className="flex items-start justify-between">
<ChannelIcon channel={ch.type} size="lg" withBg />
<div className="flex h-12 w-12 items-center justify-center rounded-[12px] bg-slate-50 ring-1 ring-slate-100 transition-transform duration-200 group-hover:scale-[1.03]">
<ChannelIcon channel={ch.type} size="lg" />
</div>
<div className="flex flex-wrap justify-end gap-1.5">
{ch.isDefault && <Badge variant="primary"><Star className="mr-1 h-3 w-3 fill-current" />默认账号</Badge>}
<Badge className={st.color}>{st.label}</Badge>
</div>
<p className="mt-3 text-sm font-medium">{meta.name}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{meta.desc}</p>
{ch.account && <p className="mt-1 text-xs text-muted-foreground">账号:{ch.account}</p>}
{ch.todayMessages > 0 && <p className="mt-1 text-xs">今日消息:{ch.todayMessages} 条</p>}
{ch.lastSyncAt && <p className="mt-0.5 text-xs text-muted-foreground">最近同步:{formatRelative(ch.lastSyncAt)}</p>}
</div>
<div className="mt-4">
<p className="text-base font-semibold tracking-tight text-slate-950">{meta.name}</p>
<p className="mt-1 text-xs leading-5 text-slate-500">{meta.desc}</p>
{ch.account && <p className="mt-2 truncate text-xs font-medium text-slate-700">{ch.account}</p>}
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
<div className="rounded-xl bg-slate-50 px-3 py-2.5">
<p className="text-[10px] font-medium text-slate-400">今日消息</p>
<p className="mt-0.5 text-sm font-semibold text-slate-800">{Number(ch.todayMessages || 0)} 条</p>
</div>
<div className="rounded-xl bg-slate-50 px-3 py-2.5">
<p className="text-[10px] font-medium text-slate-400">消息连接</p>
<p className="mt-0.5 truncate text-sm font-semibold text-slate-800">
{ch.status === 'connected'
? '实时同步中'
: ch.lastSyncAt
? formatRelative(ch.lastSyncAt)
: '等待连接'}
</p>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
{(ch.type === 'email'
? ['IMAP 收信', 'SMTP 发信', '附件同步']
: ['实时收发', '历史同步', '媒体消息']
).map((capability) => (
<span key={capability} className="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[10px] font-medium text-slate-500">
{capability}
</span>
))}
</div>
{ch.errorMessage && <p className="mt-1 text-xs text-red-600">{ch.errorMessage}</p>}
<div className="mt-3 flex flex-wrap gap-1">
<div className="mt-5 flex flex-wrap items-center gap-1 border-t border-slate-100 pt-4">
{ch.status === 'connected' ? (
<>
<Button size="sm" variant="outline" onClick={async () => {
const result = await syncTelegramChannel(ch.id)
toast.success(`同步完成:${result.chats} 个会话,${result.messages} 条消息`)
queryClient.invalidateQueries()
}}><RefreshCw className="h-3 w-3" />同步</Button>
<Button size="sm" variant="outline" onClick={() => updateChannel(ch.id, { status: 'paused' }).then(() => queryClient.invalidateQueries({ queryKey: ['channels'] }))}><Pause className="h-3 w-3" />暂停</Button>
try {
await updateChannel(ch.id, { status: 'paused' })
toast.success('渠道已暂停接收实时消息')
queryClient.invalidateQueries({ queryKey: ['channels'] })
} catch (error) {
toast.error(errorMessage(error))
}
}}><Pause className="h-3 w-3" />暂停</Button>
{!ch.isDefault && (
<Button size="sm" variant="outline" onClick={async () => {
try {
await setDefaultChannel(ch.id)
toast.success(`${ch.account || meta.name} 已设为默认账号`)
queryClient.invalidateQueries({ queryKey: ['channels'] })
} catch (error) {
toast.error(errorMessage(error))
}
}}><Star className="h-3 w-3" />设为默认</Button>
)}
<Button size="sm" variant="outline" onClick={() => delMut.mutate(ch.id)}><Trash2 className="h-3 w-3" /></Button>
</>
) : ch.status === 'paused' ? (
<Button size="sm" variant="outline" onClick={() => updateChannel(ch.id, { status: 'connected' }).then(() => queryClient.invalidateQueries({ queryKey: ['channels'] }))}><Play className="h-3 w-3" />恢复</Button>
<Button size="sm" variant="outline" onClick={async () => {
try {
await updateChannel(ch.id, { status: 'connected' })
toast.success('渠道已恢复接收实时消息')
queryClient.invalidateQueries({ queryKey: ['channels'] })
} catch (error) {
toast.error(errorMessage(error))
}
}}><Play className="h-3 w-3" />恢复</Button>
) : ch.status === 'error' ? (
<Button size="sm" variant="outline" onClick={() => { setWizard({ open: true, type: ch.type, step: 0 }); toast.info('重新授权') }}><RefreshCw className="h-3 w-3" />重新授权</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (ch.type === 'email') {
applyEmailPreset('custom')
setWizard({ open: true, type: ch.type, step: 0 })
} else if (ch.type === 'whatsapp') {
void openWhatsAppWizard(ch)
} else {
void openTelegramWizard(ch)
}
}}
><RefreshCw className="h-3 w-3" />重新授权</Button>
) : (
<Button size="sm" variant="default" onClick={() => setWizard({ open: true, type: ch.type, step: 1 })}><Cable className="h-3 w-3" />连接</Button>
<Button
size="sm"
variant="default"
onClick={() => {
if (ch.type === 'email') {
applyEmailPreset('custom')
setWizard({ open: true, type: ch.type, step: 0 })
} else if (ch.type === 'whatsapp') {
void openWhatsAppWizard(ch)
} else {
void openTelegramWizard(ch)
}
}}
>
{ch.type === 'email'
? <Mail className="h-3 w-3" />
: ch.type === 'whatsapp'
? <MessageCircle className="h-3 w-3" />
: <Cable className="h-3 w-3" />}
{['telegram', 'whatsapp'].includes(ch.type) && !ch.id.endsWith('-available') ? '继续授权' : '连接'}
</Button>
)}
{!ch.id.endsWith('-available') && (
<>
<Button size="sm" variant="ghost" onClick={() => loadLogs(ch.id)}><History className="h-3 w-3" /></Button>
<Button size="sm" variant="ghost" onClick={() => setSettingsDrawer(ch)}><Settings className="h-3 w-3" /></Button>
</>
)}
</div>
</div>
</Card>
)
})}
<Card className="relative overflow-hidden border-dashed border-slate-300 bg-slate-50/60 p-5">
<div className="absolute -right-10 -top-10 h-32 w-32 rounded-full bg-primary/5" />
<div className="relative">
<span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary text-white shadow-lg shadow-primary/20">
<Workflow className="h-5 w-5" />
</span>
<h3 className="mt-4 text-base font-semibold text-slate-900">消息处理</h3>
<p className="mt-1 text-xs leading-5 text-slate-500">渠道消息进入收件箱后,可分配负责人、翻译并跟进。</p>
<div className="mt-4 space-y-2">
{[
['01', '接收并同步渠道消息'],
['02', '分配负责人并处理消息'],
['03', '记录客户信息与跟进事项'],
].map(([step, label]) => (
<div key={step} className="flex items-center gap-3 rounded-xl bg-white px-3 py-2.5 shadow-sm ring-1 ring-slate-100">
<span className="text-[10px] font-bold text-primary">{step}</span>
<span className="text-xs font-medium text-slate-700">{label}</span>
</div>
))}
</div>
<Button className="mt-4 w-full" variant="outline" onClick={() => navigate('/inbox')}>
进入消息中心 <ArrowUpRight className="h-3.5 w-3.5" />
</Button>
</div>
</Card>
</div>
{/* 连接向导 */}
<Dialog
open={wizard.open}
onOpenChange={(v) => setWizard({ ...wizard, open: v })}
onOpenChange={(open) => {
setWizard({ ...wizard, open })
if (!open) {
setEmailForm((current) => ({ ...current, password: '' }))
resetTelegramWizard()
setWhatsAppChannelId('')
setWhatsAppPairingCode('')
setWhatsAppQr('')
}
}}
title={`连接 ${wizard.type ? CHANNEL_META[wizard.type].name : ''}`}
footer={
<Button
loading={connecting}
onClick={async () => {
if (!loginChannelId) {
if (telegramAccount.trim()) {
const channel = await startTelegramLogin(telegramAccount.trim())
setLoginChannelId(channel.id)
setAuthState('waiting_code')
toast.success('验证码已发送到 Telegram')
setConnecting(true)
try {
if (wizard.type === 'email') {
await connectEmailChannel(emailForm)
toast.success('企业邮箱连接成功,历史邮件已开始同步')
setWizard({ open: false, type: null, step: 0 })
setEmailForm((current) => ({ ...current, password: '' }))
queryClient.invalidateQueries()
return
}
const channel = await connectChannel({ type: 'telegram', account: '现有网关账号', autoReply: false })
toast.success(channel.status === 'connected' ? '现有 Telegram 账号已连接' : '账号尚未完成授权')
setWizard({ open: false, type: null, step: 0 })
if (wizard.type === 'whatsapp') {
if (!whatsappChannelId) {
const phone = whatsappAccount.replace(/\D/g, '')
if (!/^\d{7,15}$/.test(phone)) {
toast.warning('请输入包含国家/地区代码的 WhatsApp 手机号')
return
}
const channel = await startWhatsAppLogin(phone)
setWhatsAppChannelId(channel.id)
setWhatsAppPairingCode(channel.pairingCode || '')
setWhatsAppQr(channel.qrDataUrl || '')
setWizard({ open: true, type: 'whatsapp', step: 1 })
toast.success('配对码已生成,请在手机 WhatsApp 中完成关联设备')
queryClient.invalidateQueries({ queryKey: ['channels'] })
return
}
const state = await getWhatsAppLoginState(whatsappChannelId)
setWhatsAppPairingCode(state.pairingCode || state.channel.pairingCode || '')
setWhatsAppQr(state.qrDataUrl || state.channel.qrDataUrl || '')
if (state.status !== 'connected') {
toast.info('尚未检测到手机确认,请完成关联设备后再次检查')
return
}
const sync = await syncChannel(whatsappChannelId, { full: true })
toast.success(`WhatsApp 绑定成功,已同步 ${sync.chats} 个会话和 ${sync.messages} 条消息`)
setWizard({ open: false, type: null, step: 0 })
await queryClient.invalidateQueries()
return
}
if (!loginChannelId) {
const phone = telegramAccount.replace(/\s+/g, '')
if (!/^\+\d{7,15}$/.test(phone)) {
toast.warning('请输入国际格式手机号,例如 +8613800138000')
return
}
const channel = await startTelegramLogin(phone)
setTelegramAccount(phone)
setLoginChannelId(channel.id)
if (channel.status === 'connected' || channel.authState === 'ready') {
await completeTelegramBinding(channel.id)
return
}
setTelegramStepFromState(channel.authState || 'waiting_code')
toast.success(channel.authState === 'waiting_password'
? '请继续输入 Telegram 二步验证密码'
: '验证码已发送到 Telegram')
return
}
if (authState === 'waiting_code') {
const result = await submitTelegramCode(loginChannelId, telegramCode)
const code = telegramCode.replace(/\s+/g, '')
if (!/^\d{4,8}$/.test(code)) {
toast.warning('请输入 4—8 位 Telegram 验证码')
return
}
const result = await submitTelegramCode(loginChannelId, code)
if (result.status === 'waiting_password') {
setAuthState('waiting_password')
setTelegramStepFromState('waiting_password')
toast.info('该账号启用了二步验证,请输入密码')
return
}
} else if (authState === 'waiting_password') {
await submitTelegramPassword(loginChannelId, telegramPassword)
if (result.status !== 'ready') {
setTelegramStepFromState(result.status)
toast.info('Telegram 仍在等待验证,请检查验证码后重试')
return
}
} else if (authState === 'waiting_password') {
if (!telegramPassword) {
toast.warning('请输入 Telegram 二步验证密码')
return
}
const result = await submitTelegramPassword(loginChannelId, telegramPassword)
if (result.status !== 'ready') {
setTelegramStepFromState(result.status)
toast.info('Telegram 授权尚未完成,请重试')
return
}
}
await completeTelegramBinding(loginChannelId)
} catch (error) {
toast.error(errorMessage(error))
} finally {
setConnecting(false)
}
toast.success('Telegram 授权完成')
setWizard({ open: false, type: null, step: 0 })
setLoginChannelId('')
setAuthState('')
queryClient.invalidateQueries({ queryKey: ['channels'] })
}}
>{!loginChannelId ? '连接' : authState === 'waiting_password' ? '提交二步密码' : '提交验证码'}</Button>
>
{wizard.type === 'email'
? '验证并连接邮箱'
: wizard.type === 'whatsapp'
? !whatsappChannelId
? '生成 WhatsApp 配对码'
: '我已在手机确认,检查连接'
: !loginChannelId
? '发送 Telegram 验证码'
: authState === 'waiting_password'
? '验证二步密码并完成绑定'
: '验证并完成绑定'}
</Button>
}
>
<div className="space-y-3">
{wizard.type === 'email' ? (
<div className="space-y-4">
<div className="rounded-xl border border-sky-100 bg-sky-50/70 p-3">
<div className="flex items-center gap-2 text-sm font-semibold text-sky-900">
<Mail className="h-4 w-4" />
IMAP 收信 + SMTP 发信
</div>
<p className="mt-1 text-xs leading-5 text-sky-700">
Gmail、Outlook 等邮箱通常需要使用应用专用密码。
</p>
</div>
<Field label="邮箱服务商">
<Select
value={emailPreset}
onChange={(event) => applyEmailPreset(event.target.value as keyof typeof EMAIL_PRESETS)}
options={Object.entries(EMAIL_PRESETS).map(([value, preset]) => ({ value, label: preset.label }))}
/>
</Field>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="发件人名称">
<Input
value={emailForm.displayName}
onChange={(event) => setEmailForm({ ...emailForm, displayName: event.target.value })}
placeholder="例如:Lingxi Sales"
/>
</Field>
<Field label="邮箱地址">
<Input
type="email"
value={emailForm.account}
onChange={(event) => setEmailForm({ ...emailForm, account: event.target.value })}
placeholder="sales@company.com"
/>
</Field>
</div>
<Field label="邮箱密码 / 应用专用密码">
<Input
type="password"
value={emailForm.password}
onChange={(event) => setEmailForm({ ...emailForm, password: event.target.value })}
placeholder="邮箱密码或应用专用密码"
/>
</Field>
<div className="grid grid-cols-[1fr_96px] gap-3">
<Field label="IMAP 服务器">
<Input value={emailForm.imapHost} onChange={(event) => setEmailForm({ ...emailForm, imapHost: event.target.value })} placeholder="imap.company.com" />
</Field>
<Field label="端口">
<Input type="number" value={emailForm.imapPort} onChange={(event) => setEmailForm({ ...emailForm, imapPort: Number(event.target.value) })} />
</Field>
<Field label="SMTP 服务器">
<Input value={emailForm.smtpHost} onChange={(event) => setEmailForm({ ...emailForm, smtpHost: event.target.value })} placeholder="smtp.company.com" />
</Field>
<Field label="端口">
<Input type="number" value={emailForm.smtpPort} onChange={(event) => setEmailForm({ ...emailForm, smtpPort: Number(event.target.value) })} />
</Field>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="同步文件夹">
<Input value={emailForm.imapFolder} onChange={(event) => setEmailForm({ ...emailForm, imapFolder: event.target.value })} />
</Field>
<Field label="首次同步范围">
<Select
value={String(emailForm.syncDays)}
onChange={(event) => setEmailForm({ ...emailForm, syncDays: Number(event.target.value) })}
options={[7, 30, 90, 180, 365].map((days) => ({ value: String(days), label: `最近 ${days} 天` }))}
/>
</Field>
</div>
<div className="flex flex-wrap gap-5">
<label className="flex items-center gap-2 text-sm text-slate-600">
<Switch checked={emailForm.imapSecure} onCheckedChange={(checked) => setEmailForm({ ...emailForm, imapSecure: checked })} />
IMAP SSL/TLS
</label>
<label className="flex items-center gap-2 text-sm text-slate-600">
<Switch checked={emailForm.smtpSecure} onCheckedChange={(checked) => setEmailForm({ ...emailForm, smtpSecure: checked })} />
SMTP 直连 SSL
</label>
</div>
</div>
) : wizard.type === 'whatsapp' ? (
<div className="space-y-4">
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3.5">
<div className="flex items-center gap-2 text-sm font-semibold text-amber-900">
<AlertTriangle className="h-4 w-4" />
普通 WhatsApp 关联设备接入
</div>
<p className="mt-1 text-xs leading-5 text-amber-800">
该方式通过 WhatsApp 的“关联设备”连接普通个人或 Business App 账号,并非 Meta 官方 Business Cloud API。
请勿用于垃圾营销或违反 WhatsApp 条款的自动化行为。
</p>
</div>
{!whatsappChannelId ? (
<Field label="WhatsApp 手机号" required hint="包含国家/地区代码,例如中国号码输入 +8613800138000">
<Input
type="tel"
autoComplete="tel"
value={whatsappAccount}
onChange={(event) => setWhatsAppAccount(event.target.value)}
placeholder="+8613800138000"
/>
</Field>
) : (
<>
<div className="rounded-2xl border border-primary-200 bg-primary-50/70 p-5 text-center">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary-600">关联设备配对码</p>
<p className="mt-3 font-mono text-3xl font-black tracking-[0.2em] text-primary-950">
{whatsappPairingCode || '等待生成'}
</p>
<p className="mt-3 text-xs leading-5 text-primary-700">
手机 WhatsApp → 设置 → 关联设备 → 关联设备 → 使用手机号关联,然后输入上方配对码。
</p>
</div>
{whatsappQr && (
<div className="flex justify-center rounded-2xl border border-slate-200 bg-white p-4">
<img src={whatsappQr} alt="WhatsApp 关联设备二维码" className="h-48 w-48 rounded-xl" />
</div>
)}
</>
)}
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-xs leading-5 text-slate-600">
手机确认后将自动同步历史会话、联系人和媒体消息。
</div>
</div>
) : (
<div className="space-y-4">
<div className="rounded-xl border border-primary-100 bg-primary-50/70 p-3.5">
<div className="flex items-center gap-2">
<Badge variant="primary">步骤 {wizard.step + 1}/7</Badge>
<span className="text-sm font-medium">连接现有 Telegram 网关账号</span>
<ShieldCheck className="h-4 w-4 text-primary" />
<span className="text-sm font-semibold text-primary-900">绑定你自己的 Telegram 工作账号</span>
</div>
<p className="mt-1 text-xs leading-5 text-primary-700">
可以继续添加其他工作账号,并选择一个默认账号发起新会话。
</p>
</div>
<div aria-label="Telegram 绑定进度" className="grid grid-cols-3 gap-2">
{[
{ step: 0, label: '手机号' },
{ step: 1, label: '验证码' },
{ step: 2, label: '二步密码' },
].map((item) => {
const active = wizard.step === item.step
const complete = wizard.step > item.step
return (
<div
key={item.step}
className={`rounded-lg border px-2 py-2 text-center text-[11px] font-semibold transition-colors ${
active
? 'border-primary bg-primary text-white'
: complete
? 'border-primary-200 bg-primary-50 text-primary'
: 'border-slate-200 bg-white text-slate-400'
}`}
>
{complete ? <CheckCircle2 className="mx-auto mb-1 h-3.5 w-3.5" /> : <span className="mb-1 block">{item.step + 1}</span>}
{item.label}
</div>
)
})}
</div>
{!loginChannelId && (
<Field label="Telegram 手机号(留空则连接服务器现有账号)">
<Input value={telegramAccount} onChange={(e) => setTelegramAccount(e.target.value)} placeholder="+8613800138000" />
<Field label="Telegram 手机号" required hint="必须包含国家/地区代码,不要输入空格或短横线">
<Input
type="tel"
autoComplete="tel"
value={telegramAccount}
onChange={(e) => setTelegramAccount(e.target.value)}
placeholder="+8613800138000"
/>
</Field>
)}
{authState === 'waiting_code' && (
<Field label="Telegram 验证码">
<Input value={telegramCode} onChange={(e) => setTelegramCode(e.target.value)} placeholder="请输入验证码" />
<Field label="Telegram 验证码" required hint="验证码由 Telegram 官方发送,请勿转发给他人">
<Input
inputMode="numeric"
autoComplete="one-time-code"
value={telegramCode}
onChange={(e) => setTelegramCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
placeholder="请输入 4—8 位验证码"
/>
</Field>
)}
{authState === 'waiting_password' && (
<Field label="Telegram 二步验证密码">
<Input type="password" value={telegramPassword} onChange={(e) => setTelegramPassword(e.target.value)} />
<Field label="Telegram 二步验证密码" required hint="仅当该 Telegram 账号已启用两步验证时需要">
<Input
type="password"
autoComplete="current-password"
value={telegramPassword}
onChange={(e) => setTelegramPassword(e.target.value)}
placeholder="输入 Telegram 二步验证密码"
/>
</Field>
)}
<p className="text-sm text-muted-foreground">手机号、验证码和二步密码只发送到服务端 Telegram Gateway,不在浏览器持久化。</p>
<p className="text-xs leading-5 text-muted-foreground">
完成验证后将自动同步历史会话、联系人和媒体消息。
</p>
</div>
)}
</Dialog>
{/* 同步日志 */}
@@ -180,6 +835,73 @@ export function ChannelsPage() {
</div>
))}
</Drawer>
<Drawer
open={!!settingsDrawer}
onOpenChange={() => setSettingsDrawer(null)}
title={settingsDrawer ? `${CHANNEL_META[settingsDrawer.type].name} 连接详情` : '连接详情'}
width="w-[420px]"
>
{settingsDrawer && (
<div className="space-y-5 p-4">
<div className="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 p-3">
<ChannelIcon channel={settingsDrawer.type} size="lg" withBg />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{settingsDrawer.account || settingsDrawer.name}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{STATUS_MAP[settingsDrawer.status]?.label} · 最近同步 {settingsDrawer.lastSyncAt ? formatRelative(settingsDrawer.lastSyncAt) : '尚未同步'}
</p>
</div>
</div>
{settingsDrawer.type === 'email' && (
<div className="grid grid-cols-2 gap-2 text-xs">
{[
['IMAP', `${settingsDrawer.imapHost}:${settingsDrawer.imapPort}`],
['SMTP', `${settingsDrawer.smtpHost}:${settingsDrawer.smtpPort}`],
['文件夹', settingsDrawer.imapFolder || 'INBOX'],
['同步范围', `最近 ${settingsDrawer.syncDays || 30} 天`],
].map(([label, value]) => (
<div key={label} className="rounded-lg border border-slate-100 p-3">
<p className="text-slate-400">{label}</p>
<p className="mt-1 break-all font-medium text-slate-700">{value}</p>
</div>
))}
</div>
)}
<div className="space-y-2">
<label className="flex items-center justify-between rounded-xl border border-slate-100 px-3 py-2.5 text-sm">
自动回复
<Switch
checked={settingsDrawer.autoReply}
onCheckedChange={async (checked) => {
const updated = await updateChannel(settingsDrawer.id, { autoReply: checked })
setSettingsDrawer(updated)
queryClient.invalidateQueries({ queryKey: ['channels'] })
}}
/>
</label>
</div>
<Button
variant="outline"
className="w-full"
onClick={async () => {
try {
const result = await testChannel(settingsDrawer.id)
toast.success(`${result.message} · ${result.latencyMs}ms`)
} catch (error) {
toast.error(errorMessage(error))
}
}}
>
<Activity className="h-4 w-4" />
测试收发连接
</Button>
</div>
)}
</Drawer>
</div>
)
}
+95 -40
View File
@@ -3,6 +3,8 @@ import { useNavigate, useParams } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, Edit, Send, UserRoundPlus, Tag, GitMerge, Download, ExternalLink } from 'lucide-react'
import { getCustomer, updateCustomer } from '@/services/api/customers'
import { createTask } from '@/services/api/crm'
import { useAuthStore } from '@/app/store/auth'
import { useI18n } from '@/i18n'
import { toast, errorMessage } from '@/app/store/toast'
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback'
@@ -22,6 +24,8 @@ import { CountryFlag } from '@/components/common/CountryFlag'
import { ChannelIcon } from '@/components/common/ChannelIcon'
import { IntentScoreBadge, CustomerStageBadge, SentimentBadge, RiskLevelBadge } from '@/components/common/StatusBadges'
import { ChipPicker } from '@/components/customer/CustomerFormDialog'
import { CustomerProfileRadar } from '@/components/customer/CustomerProfileRadar'
import { CustomerSemanticTag, CustomerTagLegend, customerTagCategory } from '@/components/customer/CustomerSemanticTag'
import { CUSTOMER_STAGES, CUSTOMER_TAGS, NEXT_ACTIONS } from '@/config/constants'
import { formatRelative, formatDateTime } from '@/utils/format'
import { cn } from '@/utils/cn'
@@ -31,6 +35,7 @@ export function CustomerDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const user = useAuthStore((state) => state.user)
const [tab, setTab] = useState('overview')
const [tagDialog, setTagDialog] = useState(false)
@@ -52,22 +57,42 @@ export function CustomerDetailPage() {
if (isError || !data) return <ErrorState onRetry={() => refetch()} />
const c = data.customer
const createSuggestedTask = async (title: string) => {
try {
await createTask({
title,
type: 'revisit',
priority: c.riskLevel === 'urgent' || c.riskLevel === 'high' ? 'high' : 'medium',
status: 'pending',
customerId: c.id,
customerName: c.name,
ownerId: user?.id || '',
dueAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
note: `由客户画像建议创建:${title}`,
})
await queryClient.invalidateQueries({ queryKey: ['tasks'] })
toast.success('已创建跟进任务', '默认明天到期,可在商机与跟进中调整')
} catch (error) {
toast.error(errorMessage(error))
}
}
const FILE_ICONS: Record<string, string> = { quote: '📄', contract: '📝', image: '🖼️', doc: '📁', other: '📎' }
return (
<div className="p-4 md:p-6">
<div className="mx-auto max-w-[1500px] p-4 md:p-7">
<button onClick={() => navigate(-1)} className="mb-3 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="h-4 w-4 rtl-flip" /> 返回
</button>
{/* 顶部客户画像卡片 */}
<Card className="p-4 md:p-5">
<Card className="overflow-hidden border-slate-200/80 p-5 md:p-6">
<div className="flex flex-wrap gap-4">
<Avatar name={c.name} src={c.avatarUrl} size="xl" />
<div className="min-w-0 flex-1">
<p className="eyebrow mb-1">Customer intelligence</p>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-lg font-semibold">{c.name}</h1>
<h1 className="text-2xl font-semibold tracking-[-0.035em] text-slate-950">{c.name}</h1>
<CountryFlag code={c.country} />
<ChannelIcon channel={c.channel} size="xs" />
{c.isKey && <Badge variant="warning">⭐ 重点客户</Badge>}
@@ -80,8 +105,19 @@ export function CustomerDetailPage() {
{c.riskLevel !== 'none' && <RiskLevelBadge level={c.riskLevel} />}
<span className="text-muted-foreground">负责人:{c.ownerId === 'u-003' ? '李婷' : c.ownerId === 'u-004' ? '赵磊' : c.ownerId === 'u-005' ? '周敏' : c.ownerId === 'u-008' ? '郑楠' : '未分配'}</span>
</div>
<div className="mt-2 flex flex-wrap gap-1">
{c.tags.map((t) => <Badge key={t} variant="outline">{t}</Badge>)}
<div className="mt-3 rounded-xl border border-slate-100 bg-slate-50/50 p-3">
<CustomerTagLegend tags={c.tags} />
{c.tags.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{[...c.tags]
.sort((a, b) => customerTagCategory(a).localeCompare(customerTagCategory(b)))
.map((tag) => <CustomerSemanticTag key={tag} tag={tag} />)}
</div>
) : (
<p className="text-xs leading-5 text-slate-400">
暂无标签,收到更多客户消息后将自动补充。
</p>
)}
</div>
</div>
<div className="flex flex-wrap items-start gap-2">
@@ -119,43 +155,46 @@ export function CustomerDetailPage() {
<div className="mt-4">
{tab === 'overview' && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader title="自动建档流程" description="客户从渠道识别到完整建档案的自动化历程" />
<div className="space-y-3 px-4 pb-4">
<div className="grid grid-cols-1 gap-4 xl:grid-cols-12">
<CustomerProfileRadar customer={c} className="xl:col-span-5" />
<Card className="xl:col-span-7">
<CardHeader title="客户洞察" description="参考已同步的会话、购买信号和风险变化" />
<div className="p-5">
<div className="grid grid-cols-3 gap-2">
{[
'✅ 识别来源渠道(WhatsApp)',
'✅ 记录来源参数(官网按钮带入)',
'✅ 匹配客户身份(手机号 + 姓名字段)',
'✅ 自动发现重复客户(未匹配到重复)',
'✅ 人工确认建档(管理员 / 业务主管确认)',
'✅ 完整客户档案已建立',
].map((step, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span className={cn('flex h-5 w-5 items-center justify-center rounded-full text-xs', step.startsWith('✅') ? 'bg-emerald-100 text-emerald-700' : 'bg-muted text-muted-foreground')}>{i + 1}</span>
{step.replace('✅ ', '')}
['成交概率', c.aiProfile.dealProbability || 0, 'text-emerald-700 bg-emerald-50'],
['流失风险', c.aiProfile.churnProbability || 0, 'text-amber-700 bg-amber-50'],
['分析可信度', c.aiProfile.confidenceScore || 0, 'text-blue-700 bg-blue-50'],
].map(([label, value, color]) => (
<div key={String(label)} className={cn('rounded-2xl px-3 py-3', String(color))}>
<p className="text-[11px] opacity-75">{label}</p>
<p className="mt-1 text-xl font-semibold tracking-tight">{Number(value)}%</p>
</div>
))}
</div>
</Card>
<Card>
<CardHeader title="AI 客户画像摘要" />
<p className="px-4 pb-4 text-sm text-muted-foreground leading-relaxed">{c.aiProfile.summary}</p>
{c.aiProfile.nextActions.length > 0 && (
<div className="px-4 pb-4">
<h4 className="text-xs font-semibold mb-2">推荐动作</h4>
<div className="flex flex-wrap gap-1.5">
{c.aiProfile.nextActions.map((a) => (
<button key={a} onClick={() => toast.info(a)} className="rounded-full border border-primary-200 bg-primary-50 px-2 py-1 text-xs text-primary-700">{a}</button>
<p className="mt-5 text-sm leading-7 text-slate-600">
{c.aiProfile.summary || '收到更多客户消息后将补充客户画像。'}
</p>
{c.aiProfile.evidence?.length ? (
<div className="mt-5 border-t border-slate-100 pt-4">
<p className="text-xs font-semibold text-slate-900">关键判断依据</p>
<div className="mt-2 space-y-2">
{c.aiProfile.evidence.slice(0, 4).map((item) => (
<div key={item} className="flex gap-2 text-xs leading-5 text-slate-500">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary-400" />
{item}
</div>
))}
</div>
</div>
)}
) : null}
</div>
</Card>
{/* AI 提取信息 */}
<Card className="lg:col-span-2">
<CardHeader title="AI 自动提取信息" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 px-4 pb-4 text-sm">
<Card className="xl:col-span-8">
<CardHeader title="采购信息" description="根据已同步的会话内容整理" />
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-b-2xl bg-slate-100 md:grid-cols-4">
{Object.entries({
询价数量: c.aiProfile.inquiryQuantity,
目标价格: c.aiProfile.targetPrice,
@@ -165,16 +204,32 @@ export function CustomerDetailPage() {
交付时间: c.aiProfile.deliveryTime,
物流方式: c.aiProfile.logistics,
付款方式: c.aiProfile.payment,
售后诉求: c.aiProfile.aftersales,
主要顾虑: c.aiProfile.concerns?.join('、'),
}).filter(([, v]) => v).map(([k, v]) => (
<div key={k} className="rounded-md bg-muted/60 p-2">
<span className="text-xs text-muted-foreground">{k}</span>
<p className="text-sm font-medium mt-0.5">{v as string}</p>
}).map(([k, v]) => (
<div key={k} className="min-h-24 bg-white p-4">
<span className="text-[11px] font-medium text-slate-400">{k}</span>
<p className={cn('mt-2 text-sm font-medium leading-5', v ? 'text-slate-800' : 'text-slate-300')}>
{(v as string) || '待识别'}
</p>
</div>
))}
</div>
</Card>
<Card className="xl:col-span-4">
<CardHeader title="建议下一步" description={c.aiProfile.followUpTiming || '按当前客户热度排序'} />
<div className="space-y-2 p-4">
{(c.aiProfile.nextActions.length ? c.aiProfile.nextActions : ['补充确认采购数量', '确认目标交期']).slice(0, 5).map((action, index) => (
<button
key={action}
onClick={() => void createSuggestedTask(action)}
className="flex w-full items-center gap-3 rounded-xl border border-slate-100 px-3 py-2.5 text-start text-sm text-slate-700 transition-colors hover:border-primary-200 hover:bg-primary-50"
>
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-[11px] font-semibold text-slate-500">{index + 1}</span>
{action}
</button>
))}
</div>
</Card>
</div>
)}
+9 -9
View File
@@ -175,10 +175,10 @@ export function CustomersPage() {
if (isError) return <ErrorState onRetry={() => refetch()} />
return (
<div className="p-4 md:p-6">
<div className="page-shell">
<PageHeader
title="客户管理"
description={`共 ${total} 个客户 · 统一管理与跟进`}
description={`共 ${total} 个客户`}
actions={
<>
<PermissionGate permission="customer:export">
@@ -204,27 +204,27 @@ export function CustomersPage() {
</>
}
extra={
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-2 rounded-[14px] border border-slate-200 bg-white p-3 shadow-sm">
<div className="relative min-w-[200px] max-w-xs">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
placeholder="搜索客户名称/公司/国家/产品"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-card pl-8 pr-3 text-sm placeholder:text-muted-foreground focus:border-primary focus:outline-none"
className="h-9 w-full rounded-[9px] border border-slate-200 bg-slate-50/60 pl-8 pr-3 text-sm placeholder:text-slate-400 focus:border-primary focus:bg-white focus:outline-none focus:ring-2 focus:ring-primary-100"
/>
</div>
<Select placeholder="国家" value={country} onChange={(e) => setCountry(e.target.value)} options={COUNTRIES.map((c) => ({ value: c.code, label: `${c.flag} ${c.zh}` }))} className="w-32" />
<Select placeholder="渠道" value={channel} onChange={(e) => setChannel(e.target.value)} options={Object.entries(CHANNEL_META).map(([k, v]) => ({ value: k, label: v.name }))} className="w-28" />
<Select placeholder="阶段" value={stage} onChange={(e) => setStage(e.target.value)} options={CUSTOMER_STAGES.map((s) => ({ value: s.value, label: s.label }))} className="w-28" />
<Select placeholder="意向" value={intentLevel} onChange={(e) => setIntentLevel(e.target.value)} options={INTENT_LEVELS.map((l) => ({ value: l.value, label: l.label }))} className="w-28" />
<div className="flex items-center gap-1 rounded-md border border-border bg-card p-0.5 ml-auto">
<div className="ml-auto flex items-center gap-1 rounded-[9px] border border-slate-200 bg-slate-50 p-0.5">
{[
{ v: 'table' as const, icon: TableProperties },
{ v: 'card' as const, icon: LayoutGrid },
{ v: 'kanban' as const, icon: List },
].map((opt) => (
<button key={opt.v} onClick={() => setView(opt.v)} className={cn('rounded p-1.5', view === opt.v ? 'bg-muted text-foreground' : 'text-muted-foreground hover:text-foreground')}>
<button key={opt.v} onClick={() => setView(opt.v)} className={cn('rounded-[7px] p-1.5 transition', view === opt.v ? 'bg-white text-primary-700 shadow-sm' : 'text-slate-400 hover:text-slate-800')}>
<opt.icon className="h-4 w-4" />
</button>
))}
@@ -235,7 +235,7 @@ export function CustomersPage() {
{/* 批量操作 */}
{selected.size > 0 && (
<div className="mb-3 flex items-center gap-2 rounded-lg bg-primary-50 px-3 py-2">
<div className="mb-3 flex items-center gap-2 rounded-[12px] border border-primary-200 bg-primary-50 px-3 py-2 shadow-sm">
<span className="text-sm font-medium text-primary-700">已选 {selected.size} 位客户</span>
<div className="flex-1" />
<Button size="sm" variant="outline" onClick={() => { setBatchValue(''); setBatchDialog('assign') }}><UserRoundPlus className="h-4 w-4" />批量分配</Button>
@@ -246,7 +246,7 @@ export function CustomersPage() {
{/* 表格视图 */}
{view === 'table' && (
<div className="rounded-lg border border-border bg-card">
<div className="overflow-hidden rounded-[14px] border border-slate-200 bg-white shadow-[0_1px_2px_rgba(15,23,42,.03)]">
<Table>
<THead>
<Tr>
@@ -270,7 +270,7 @@ export function CustomersPage() {
<Tr><Td colSpan={13}><EmptyState title="暂无匹配客户" description="尝试调整筛选条件" /></Td></Tr>
)}
{list.map((customer: Customer) => (
<Tr key={customer.id} className="cursor-pointer">
<Tr key={customer.id} data-testid="customer-row" className="cursor-pointer">
<Td onClick={(e) => e.stopPropagation()}><Checkbox checked={selected.has(customer.id)} onCheckedChange={(v) => setSelected((s) => { const ns = new Set(s); v ? ns.add(customer.id) : ns.delete(customer.id); return ns })} /></Td>
<Td onClick={() => navigate(`/customers/${customer.id}`)}>
<div className="flex items-center gap-2">
+66 -21
View File
@@ -92,51 +92,87 @@ export function DashboardPage() {
const s = data.stats
return (
<div className="p-4 md:p-6">
<div className="page-shell">
{/* 顶部欢迎区 */}
<div className="mb-5 flex flex-wrap items-start justify-between gap-3">
<div className="mb-6 overflow-hidden rounded-[16px] border border-slate-200 bg-white px-5 py-5 shadow-[0_8px_28px_-24px_rgba(15,23,42,.55)] md:px-6">
<div className="flex flex-wrap items-start justify-between gap-5">
<div>
<h1 className="text-xl font-semibold">
<p className="eyebrow">今日工作概览</p>
<h1 className="mt-1.5 text-2xl font-semibold tracking-[-0.035em] text-slate-950 md:text-[28px]">
{greeting},{user?.name}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{dateStr} · {tenant?.name} · {t('dashboard.updatedAt')} {formatDateTime(data.updatedAt).slice(11)}
<p className="mt-1.5 text-sm text-slate-500">
{dateStr} · 数据更新于 {formatDateTime(data.updatedAt).slice(11)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="primary" className="h-7 px-2.5">{plan.name}</Badge>
<Badge variant="primary" className="h-7 rounded-full px-3">{plan.name}</Badge>
<LiveDataBadge />
<Button size="sm" onClick={() => navigate('/channels')}>
<Cable className="h-4 w-4" />
快速连接渠道
<Button size="sm" onClick={() => navigate('/inbox')}>
<Send className="h-4 w-4" />
处理客户消息
</Button>
</div>
</div>
{/* 快捷操作 */}
<div className="mb-5 flex flex-wrap gap-2">
{quickActions.map((a) => (
<Button key={a.label} variant="outline" size="sm" onClick={a.onClick}>
<div className="mt-5 flex flex-wrap gap-2 border-t border-slate-100 pt-4">
{quickActions.slice(0, 5).map((a) => (
<Button key={a.label} variant="ghost" size="sm" className="rounded-[9px] bg-slate-50 text-slate-600 hover:bg-primary-50 hover:text-primary-800" onClick={a.onClick}>
<a.icon className="h-3.5 w-3.5" />
{a.label}
</Button>
))}
</div>
</div>
{/* 核心指标 */}
<div className="section-heading">
<div>
<h2 className="section-title">今日重点</h2>
<p className="section-description">优先处理待回复、跟进与高意向客户</p>
</div>
<button onClick={() => navigate('/analytics')} className="text-xs font-medium text-primary hover:text-primary-700">查看完整分析 →</button>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatCard title="今日新增询盘" value={s.newInquiriesToday.value} delta={s.newInquiriesToday.delta} icon={MessageSquarePlus} onClick={() => navigate('/inbox')} />
<StatCard title="待回复消息" value={s.pendingReply.value} delta={s.pendingReply.delta} icon={MessageSquareWarning} onClick={() => navigate('/inbox?filter=pending')} />
<StatCard title="高意向客户" value={s.highIntentCustomers.value} delta={s.highIntentCustomers.delta} icon={Target} onClick={() => navigate('/customers?intentLevel=high')} />
<StatCard title="今日待跟进" value={s.todayFollowUps.value} delta={s.todayFollowUps.delta} icon={CalendarClock} onClick={() => navigate('/opportunities?tab=tasks')} />
<StatCard title="风险预警" value={s.openRisks.value} delta={s.openRisks.delta} icon={AlertTriangle} onClick={() => navigate('/risks')} />
<StatCard title="平均首次响应" value={s.avgFirstResponseMin.value} suffix="分钟" delta={s.avgFirstResponseMin.delta} icon={Timer} invertDelta />
<StatCard title="本月客户转化率" value={`${s.monthConversionRate.value}%`} delta={s.monthConversionRate.delta} icon={BadgePercent} />
<StatCard title="AI回复采纳率" value={`${s.aiAdoptionRate.value}%`} delta={s.aiAdoptionRate.delta} icon={Zap} />
</div>
<Card className="mt-3 overflow-hidden">
<div className="grid grid-cols-2 divide-x divide-y divide-slate-100 md:grid-cols-4 md:divide-y-0">
{[
{ label: '风险预警', value: s.openRisks.value, icon: AlertTriangle, onClick: () => navigate('/risks') },
{ label: '平均首次响应', value: `${s.avgFirstResponseMin.value} 分钟`, icon: Timer },
{ label: '本月客户转化率', value: `${s.monthConversionRate.value}%`, icon: BadgePercent },
{ label: '智能回复采纳率', value: `${s.aiAdoptionRate.value}%`, icon: Zap },
].map((item) => (
<button
type="button"
key={item.label}
onClick={item.onClick}
className="flex min-h-[86px] items-center gap-3 px-4 py-3 text-left transition hover:bg-slate-50 disabled:cursor-default"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[10px] bg-slate-100 text-slate-600">
<item.icon className="h-4 w-4" />
</span>
<span>
<span className="block text-[11px] font-medium text-slate-500">{item.label}</span>
<span className="mt-0.5 block text-lg font-semibold tracking-tight text-slate-950">{item.value}</span>
</span>
</button>
))}
</div>
</Card>
{/* 中部三栏 */}
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-3">
<section className="page-section">
<div className="section-heading">
<div>
<h2 className="section-title">待处理工作</h2>
<p className="section-description">集中查看任务、重点客户与各渠道消息</p>
</div>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
{/* 今日待办 */}
<Card>
<CardHeader
@@ -242,14 +278,22 @@ export function DashboardPage() {
<div className="border-t border-border p-3">
<Button variant="outline" className="w-full" onClick={() => navigate('/inbox')}>
<Send className="h-4 w-4" />
进入统一消息中心
查看全部消息
</Button>
</div>
</Card>
</div>
</section>
{/* 图表区 */}
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
<section className="page-section">
<div className="section-heading">
<div>
<h2 className="section-title">经营趋势</h2>
<p className="section-description">客户来源、转化阶段与团队响应表现</p>
</div>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<ChartCard title="询盘趋势(近14天)" height={240}>
<TrendChart data={data.inquiryTrend} series={[{ key: 'inquiries', name: '询盘数' }, { key: 'customers', name: '新增客户' }]} />
</ChartCard>
@@ -267,6 +311,7 @@ export function DashboardPage() {
/>
</ChartCard>
</div>
</section>
{/* 最近风险 */}
<Card className="mt-4">
+2 -2
View File
@@ -7,8 +7,8 @@ const HELP_ITEMS = [
{ icon: Cable, title: '连接 Telegram', desc: '手机号、验证码和二步密码登录,并同步历史会话', path: '/channels' },
{ icon: BookOpen, title: '建设知识库', desc: '上传 TXT、Markdown、PDF 或 DOCX 并解析为企业知识', path: '/knowledge' },
{ icon: FileUp, title: '导入与管理客户', desc: '维护客户资料、标签、负责人和跟进状态', path: '/customers' },
{ icon: Languages, title: 'AI 上下文翻译', desc: '结合当前会话上下文进行跨境商务翻译', path: '/translate' },
{ icon: UsersRound, title: '邀请团队成员', desc: '通过已配置的企业 SMTP 发送真实邀请邮件', path: '/team' },
{ icon: Languages, title: '消息翻译', desc: '翻译当前消息并保留双语对照', path: '/translate' },
{ icon: UsersRound, title: '邀请团队成员', desc: '向同事发送团队邀请邮件', path: '/team' },
{ icon: CreditCard, title: '订阅与支付', desc: '通过 Stripe Checkout 管理套餐订阅', path: '/billing' },
]
File diff suppressed because it is too large Load Diff
+54 -20
View File
@@ -29,20 +29,28 @@ export function KnowledgePage() {
const [createOpen, setCreateOpen] = useState(false)
const [editItem, setEditItem] = useState<KnowledgeItem | null>(null)
const [searchQ, setSearchQ] = useState('')
const [createDraft, setCreateDraft] = useState({ title: '', content: '' })
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['knowledge', category, keyword],
queryFn: () => getKnowledge({ category, keyword }),
})
const { data: stats } = useQuery({ queryKey: ['knowledge-stats'], queryFn: getKnowledgeStats, enabled: tab === 'stats' })
const { data: stats } = useQuery({ queryKey: ['knowledge-stats'], queryFn: getKnowledgeStats })
const [searchResult, setSearchResult] = useState<any>(null)
const [searchLoading, setSearchLoading] = useState(false)
const [uploadFiles, setUploadFiles] = useState<File[]>([])
const [uploadDialog, setUploadDialog] = useState(false)
const [uploadLoading, setUploadLoading] = useState(false)
const createMut = useMutation({
mutationFn: (p: any) => createKnowledge(p),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['knowledge'] }); toast.success('知识已创建'); setCreateOpen(false) },
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['knowledge'] })
queryClient.invalidateQueries({ queryKey: ['knowledge-stats'] })
toast.success('知识已创建并完成索引')
setCreateDraft({ title: '', content: '' })
setCreateOpen(false)
},
onError: (e) => toast.error(errorMessage(e)),
})
@@ -65,7 +73,7 @@ export function KnowledgePage() {
<div className="p-4 md:p-6">
<PageHeader
title="企业知识库"
description={`${list.length} 条知识 · AI 回复与翻译的精准来源`}
description="把产品、价格、物流和售后资料变成可检索、可引用的企业答案"
actions={
<>
<Button size="sm" variant="outline" onClick={() => setUploadDialog(true)}><Upload className="h-4 w-4" />上传文件</Button>
@@ -74,6 +82,20 @@ export function KnowledgePage() {
}
/>
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
{[
['当前条目', data?.total || 0],
['已分段索引', list.reduce((sum, item) => sum + Number(item.chunkCount || 0), 0)],
['真实引用', stats?.totalCitations || 0],
['待优化内容', (stats?.lowQuality?.length || 0) + (stats?.outdated?.length || 0)],
].map(([label, value]) => (
<Card key={String(label)} className="px-4 py-3">
<p className="text-[11px] font-medium text-slate-400">{label}</p>
<p className="mt-1 text-xl font-semibold tracking-tight text-slate-900">{value}</p>
</Card>
))}
</div>
<Tabs value={tab} onChange={setTab} className="mb-4" items={[
{ value: 'list', label: '知识列表' },
{ value: 'search', label: '检索测试' },
@@ -109,10 +131,14 @@ export function KnowledgePage() {
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
<span>引用 {item.citedCount} 次</span>
<span>·</span>
<span>{formatRelative(item.updatedAt)}</span>
<span>{item.chunkCount || 0} 个检索片段</span>
<span>·</span>
<span>{item.status === 'parsing' ? '解析中…' : item.status === 'failed' ? '解析失败' : ''}</span>
<span>{Number(item.characterCount || item.content?.length || 0).toLocaleString('zh-CN')} 字符</span>
<span>·</span>
<span>{formatRelative(item.updatedAt)}</span>
{item.status !== 'ready' && <><span>·</span><span>{item.status === 'parsing' ? '解析中…' : '解析失败'}</span></>}
</div>
{item.parseMessage && <p className="mt-1 text-[11px] text-emerald-600">{item.parseMessage}</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
<Switch checked={item.enabled} onCheckedChange={(v) => toggleMut.mutate({ id: item.id, enabled: v })} />
@@ -129,7 +155,7 @@ export function KnowledgePage() {
{tab === 'search' && (
<Card className="p-4">
<div className="flex gap-2">
<Input placeholder="输入跨境问题,测试知识库检索与 AI 答案生成" value={searchQ} onChange={(e) => setSearchQ(e.target.value)} className="flex-1" />
<Input placeholder="输入问题,测试知识库检索和答案生成" value={searchQ} onChange={(e) => setSearchQ(e.target.value)} className="flex-1" />
<Button loading={searchLoading} onClick={async () => {
if (!searchQ.trim()) return
setSearchLoading(true)
@@ -142,14 +168,15 @@ export function KnowledgePage() {
{searchResult && (
<div className="mt-4 space-y-3">
<Card className="p-3 bg-primary-50/30">
<p className="text-xs font-semibold text-muted-foreground mb-1">AI 答案</p>
<p className="text-xs font-semibold text-muted-foreground mb-1">参考答案</p>
<p className="text-sm">{searchResult.answer}</p>
</Card>
<p className="text-xs font-semibold text-muted-foreground">命中知识 ({searchResult.hits.length})</p>
{searchResult.hits.map((h: any) => (
<div key={h.item.id} className="rounded-md border border-border p-3">
<div className="flex items-center justify-between"><span className="text-sm font-medium">{h.item.title}</span><Badge variant="primary">{(h.score * 100).toFixed(0)}%</Badge></div>
<div className="flex items-center justify-between"><span className="text-sm font-medium">{h.item.title}</span><Badge variant="primary">相关度 {(h.score * 100).toFixed(0)}%</Badge></div>
<p className="mt-1 text-xs text-muted-foreground">{h.snippet}</p>
<p className="mt-1 text-[10px] text-slate-400">命中片段 #{Number(h.chunkIndex || 0) + 1}</p>
</div>
))}
</div>
@@ -175,18 +202,19 @@ export function KnowledgePage() {
<Dialog open={createOpen} onOpenChange={setCreateOpen} title="新建知识条目" footer={
<Button onClick={() => {
const title = (document.querySelector('#k-title') as HTMLInputElement)?.value
const content = (document.querySelector('#k-content') as HTMLTextAreaElement)?.value
if (!title) { toast.warning('请输入标题'); return }
createMut.mutate({ title, content, category: category || 'faq' })
}}>创建</Button>
if (!createDraft.title.trim()) { toast.warning('请输入标题'); return }
if (createDraft.content.trim().length < 20) { toast.warning('知识内容至少需要 20 个字符'); return }
createMut.mutate({ ...createDraft, category: category || 'faq' })
}} loading={createMut.isPending}>创建并索引</Button>
}>
<Field label="标题" required><Input id="k-title" placeholder="知识条目标题" /></Field>
<Field label="内容" className="mt-3"><Textarea id="k-content" rows={5} placeholder="知识内容…" /></Field>
<Field label="标题" required><Input value={createDraft.title} onChange={(event) => setCreateDraft((value) => ({ ...value, title: event.target.value }))} placeholder="例如:欧洲地区退换货政策" /></Field>
<Field label="内容" className="mt-3"><Textarea value={createDraft.content} onChange={(event) => setCreateDraft((value) => ({ ...value, content: event.target.value }))} rows={8} placeholder="写明适用范围、条件、时效、例外和处理方式。系统会自动分段并用于回复建议。" /></Field>
</Dialog>
<Dialog open={uploadDialog} onOpenChange={setUploadDialog} title="上传文件" footer={
<Button disabled={!uploadFiles.length} onClick={async () => {
<Button disabled={!uploadFiles.length} loading={uploadLoading} onClick={async () => {
setUploadLoading(true)
try {
const payload = await Promise.all(uploadFiles.map(async (file) => ({
name: file.name,
type: file.type,
@@ -197,12 +225,18 @@ export function KnowledgePage() {
reader.readAsDataURL(file)
}),
})))
await uploadKnowledgeFiles(payload)
toast.success(`${payload.length} 个知识文件解析完成`)
const result = await uploadKnowledgeFiles(payload)
toast.success(`${result.items.length} 个文件解析并建立检索索引`)
queryClient.invalidateQueries({ queryKey: ['knowledge'] })
queryClient.invalidateQueries({ queryKey: ['knowledge-stats'] })
setUploadFiles([])
setUploadDialog(false)
}}>上传并解析</Button>
} catch (error) {
toast.error(errorMessage(error))
} finally {
setUploadLoading(false)
}
}}>上传、解析并索引</Button>
}>
<input
type="file"
@@ -211,7 +245,7 @@ export function KnowledgePage() {
onChange={(e) => setUploadFiles(Array.from(e.target.files || []))}
className="block w-full rounded-md border border-input p-2 text-sm"
/>
<p className="mt-2 text-sm text-muted-foreground">支持 PDF、DOCX、TXT、Markdown、CSV、JSON 和 HTML,文件内容由服务端真实解析并写入知识库。</p>
<p className="mt-2 text-sm text-muted-foreground">支持 PDF、DOCX、TXT、Markdown、CSV、JSON 和 HTML,上传后自动解析并写入知识库。</p>
</Dialog>
</div>
)
+24 -13
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useAuthStore } from '@/app/store/auth'
import { toast, errorMessage } from '@/app/store/toast'
@@ -16,6 +17,7 @@ import { CountryFlag } from '@/components/common/CountryFlag'
import { IntentScoreBadge, OpportunityStageBadge, TaskPriorityBadge } from '@/components/common/StatusBadges'
import { TaskFormDialog } from '@/components/crm/TaskFormDialog'
import { getOpportunities, updateOpportunity, getTasks, updateTask } from '@/services/api/crm'
import { getTeam } from '@/services/api/org'
import { OPPORTUNITY_STAGES, taskTypeLabel, TASK_TYPES, TASK_PRIORITIES } from '@/config/constants'
import { formatDateTime, formatMoney } from '@/utils/format'
import { cn } from '@/utils/cn'
@@ -24,7 +26,11 @@ import type { FollowTask, Opportunity } from '@/types'
export function OpportunitiesPage() {
const queryClient = useQueryClient()
const user = useAuthStore((s) => s.user)
const [view, setView] = useState('kanban') // 'kanban' | 'tasks' | 'calendar'
const [searchParams, setSearchParams] = useSearchParams()
const requestedView = searchParams.get('tab')
const [view, setView] = useState(
requestedView === 'tasks' || requestedView === 'calendar' ? requestedView : 'kanban',
)
const [taskFilter, setTaskFilter] = useState('all')
const [taskDialog, setTaskDialog] = useState(false)
@@ -36,6 +42,8 @@ export function OpportunitiesPage() {
queryKey: ['tasks', taskFilter],
queryFn: () => getTasks({ filter: taskFilter, ownerId: user?.id }),
})
const { data: team = [] } = useQuery({ queryKey: ['team'], queryFn: getTeam })
const memberNames = new Map(team.map((member) => [member.id, member.name]))
const oppMut = useMutation({
mutationFn: ({ id, stage }: { id: string; stage: string }) => updateOpportunity(id, { stage: stage as any }),
@@ -77,7 +85,7 @@ export function OpportunitiesPage() {
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1).getDay()
return (
<div className="p-4 md:p-6">
<div className="page-shell">
<PageHeader
title="商机与跟进"
description="管理商机流转与团队跟进任务"
@@ -90,8 +98,11 @@ export function OpportunitiesPage() {
<Tabs
value={view}
onChange={setView}
className="mb-4"
onChange={(next) => {
setView(next)
setSearchParams(next === 'kanban' ? {} : { tab: next }, { replace: true })
}}
className="mb-5"
items={[
{ value: 'kanban', label: '商机看板', count: opps.length },
{ value: 'tasks', label: '跟进任务', count: tasks.filter((t) => t.status !== 'done').length },
@@ -100,17 +111,17 @@ export function OpportunitiesPage() {
/>
{view === 'kanban' && (
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-thin" style={{ minHeight: '60vh' }}>
<div className="flex gap-4 overflow-x-auto pb-3 scrollbar-thin" style={{ minHeight: '60vh' }}>
{groupedOpps.map((col) => (
<div
key={col.value}
className="w-64 shrink-0 rounded-lg bg-muted/40"
className="w-[272px] shrink-0 rounded-[14px] border border-slate-200 bg-slate-100/65"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => handleDrop(e, col.value)}
>
<div className="sticky top-0 z-10 flex items-center justify-between rounded-t-lg bg-muted px-3 py-2">
<span className="text-sm font-medium">{col.label}</span>
<Badge variant="primary">{col.items.length}</Badge>
<div className="sticky top-0 z-10 flex items-center justify-between rounded-t-[14px] border-b border-slate-200 bg-white/90 px-3.5 py-3 backdrop-blur">
<span className="text-sm font-semibold text-slate-800">{col.label}</span>
<Badge variant="outline">{col.items.length}</Badge>
</div>
<div className="space-y-2 p-2">
{col.items.map((opp) => (
@@ -118,7 +129,7 @@ export function OpportunitiesPage() {
key={opp.id}
draggable
onDragStart={(e) => handleDragStart(e, opp)}
className="cursor-grab p-3 active:cursor-grabbing hover:shadow-sm"
className="cursor-grab p-3.5 active:cursor-grabbing hover:border-primary-200 hover:shadow-[0_8px_20px_-16px_rgba(37,99,235,.55)]"
>
<p className="text-sm font-medium truncate">{opp.name}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{opp.customerName} <CountryFlag code={opp.country} withName={false} /></p>
@@ -138,13 +149,13 @@ export function OpportunitiesPage() {
{view === 'tasks' && (
<div>
<div className="mb-3 flex flex-wrap gap-1">
<div className="mb-4 flex flex-wrap gap-1 rounded-[12px] border border-slate-200 bg-white p-1.5 shadow-sm">
{[
{ v: 'all', l: '全部' }, { v: 'mine', l: '我的待办' },
{ v: 'today', l: '今日' }, { v: 'overdue', l: '已逾期' },
{ v: 'done', l: '已完成' },
].map((f) => (
<button key={f.v} onClick={() => setTaskFilter(f.v)} className={cn('rounded-full px-2.5 py-1 text-xs transition-colors', taskFilter === f.v ? 'bg-primary font-medium text-white' : 'bg-muted text-muted-foreground hover:bg-border')}>{f.l}</button>
<button key={f.v} onClick={() => setTaskFilter(f.v)} className={cn('rounded-[8px] px-3 py-1.5 text-xs transition-colors', taskFilter === f.v ? 'bg-slate-900 font-medium text-white' : 'text-slate-500 hover:bg-slate-100 hover:text-slate-900')}>{f.l}</button>
))}
</div>
<Card>
@@ -160,7 +171,7 @@ export function OpportunitiesPage() {
<Td><span className="text-xs">{t.customerName}</span></Td>
<Td><TaskPriorityBadge priority={t.priority} /></Td>
<Td><span className={cn('text-xs', t.status === 'overdue' && 'text-red-600 font-medium')}>{formatDateTime(t.dueAt)}</span></Td>
<Td><span className="text-xs">{t.ownerId === 'u-003' ? '李婷' : t.ownerId === 'u-004' ? '赵磊' : t.ownerId === 'u-005' ? '周敏' : t.ownerId === 'u-008' ? '郑楠' : '其他'}</span></Td>
<Td><span className="text-xs">{memberNames.get(t.ownerId) ?? '未分配'}</span></Td>
<Td>
{t.status !== 'done' && (
<Button size="sm" variant="ghost" onClick={() => taskMut.mutate({ id: t.id, data: { status: 'done' } })}>完成</Button>
+20 -7
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast, errorMessage } from '@/app/store/toast'
import { PageLoading, ErrorState } from '@/components/ui/feedback'
@@ -20,10 +21,12 @@ import { AlertTriangle, Settings } from 'lucide-react'
export function RisksPage() {
const queryClient = useQueryClient()
const navigate = useNavigate()
const [level, setLevel] = useState('')
const [type, setType] = useState('')
const [status, setStatus] = useState('')
const [detailId, setDetailId] = useState<string | null>(null)
const [noteDraft, setNoteDraft] = useState('')
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['risks', level, type, status],
@@ -52,10 +55,10 @@ export function RisksPage() {
<div className="p-4 md:p-6">
<PageHeader
title="风险预警中心"
description="智能识别交易风险、客户流失信号与敏感表达,帮助团队主动应对"
description="查看交易风险、客户流失信号和敏感表达"
actions={
<PermissionGate permission="risk:handle">
<Button size="sm" onClick={() => toast.info('风险处理操作已可用')}><Settings className="h-4 w-4" />规则设置</Button>
<PermissionGate permission="settings:manage">
<Button size="sm" variant="outline" onClick={() => navigate('/settings')}><Settings className="h-4 w-4" />风险识别设置</Button>
</PermissionGate>
}
/>
@@ -77,7 +80,10 @@ export function RisksPage() {
{list.map((r) => {
const meta = riskLevelMeta(r.level)
return (
<Card key={r.id} className="cursor-pointer p-4 hover:bg-muted/40 transition-colors" onClick={() => setDetailId(r.id)}>
<Card key={r.id} className="cursor-pointer p-4 hover:bg-muted/40 transition-colors" onClick={() => {
setDetailId(r.id)
setNoteDraft(r.note || '')
}}>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2 min-w-0">
<span className={cn('mt-0.5 h-2 w-2 shrink-0 rounded-full', meta.dot)} />
@@ -110,7 +116,7 @@ export function RisksPage() {
<span className="text-xs text-muted-foreground">{formatRelative(detail.createdAt)}</span>
</div>
<div>
<h4 className="text-sm font-semibold mb-1">AI 判断说明</h4>
<h4 className="text-sm font-semibold mb-1">判断依据</h4>
<p className="text-sm text-muted-foreground leading-relaxed">{detail.aiExplanation}</p>
</div>
<div>
@@ -119,8 +125,15 @@ export function RisksPage() {
</div>
<div>
<h4 className="text-sm font-semibold mb-1">添加备注</h4>
<Textarea rows={3} placeholder="添加处理备注…" />
<Button size="sm" className="mt-2" onClick={() => { updateMut.mutate({ id: detail.id, data: { note: '已备注' } }); toast.success('备注已保存') }}>保存备注</Button>
<Textarea rows={3} placeholder="添加处理备注…" value={noteDraft} onChange={(event) => setNoteDraft(event.target.value)} />
<Button
size="sm"
className="mt-2"
disabled={!noteDraft.trim()}
onClick={() => updateMut.mutate({ id: detail.id, data: { note: noteDraft.trim() } })}
>
保存备注
</Button>
</div>
</div>
)}
+43 -23
View File
@@ -17,7 +17,7 @@ const SECTIONS = [
{ key: 'company', label: '企业资料', icon: UserCog },
{ key: 'lang', label: '语言与时区', icon: Globe },
{ key: 'notifications', label: '消息通知', icon: Bell },
{ key: 'ai', label: 'AI回复设置', icon: Languages },
{ key: 'ai', label: '智能回复', icon: Languages },
{ key: 'safety', label: '安全设置', icon: ShieldCheck },
{ key: 'api', label: 'API Key', icon: KeyRound },
]
@@ -55,19 +55,33 @@ export function SettingsPage() {
})
return (
<div className="flex h-[calc(100vh-56px)]">
<div className="w-48 shrink-0 border-e border-border p-3 space-y-1 overflow-y-auto scrollbar-thin hidden md:block">
<div className="page-shell">
<PageHeader title="设置" description="管理企业资料、通知、智能能力与账号安全" />
<div className="mb-4 rounded-[12px] border border-slate-200 bg-white p-2 shadow-sm md:hidden">
<Select
aria-label="设置分类"
value={section}
onChange={(event) => setSection(event.target.value)}
className="w-full"
options={SECTIONS.map((item) => ({ value: item.key, label: item.label }))}
/>
</div>
<div className="grid items-start gap-5 md:grid-cols-[220px_minmax(0,1fr)]">
<div className="sticky top-4 hidden space-y-1 rounded-[14px] border border-slate-200 bg-white p-2 shadow-sm md:block">
<p className="px-3 pb-2 pt-1 text-[10px] font-semibold uppercase tracking-[0.08em] text-slate-400">设置分类</p>
{SECTIONS.map((s) => (
<button key={s.key} onClick={() => setSection(s.key)} className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm ${section === s.key ? 'bg-primary-50 font-medium text-primary-700' : 'text-muted-foreground hover:text-foreground'}`}>
<button key={s.key} onClick={() => setSection(s.key)} className={`flex w-full items-center gap-2 rounded-[9px] px-3 py-2.5 text-sm transition ${section === s.key ? 'bg-primary-50 font-semibold text-primary-700 shadow-[inset_0_0_0_1px_rgba(37,99,235,.08)]' : 'text-slate-500 hover:bg-slate-50 hover:text-slate-900'}`}>
<s.icon className="h-4 w-4" />{s.label}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-4 md:p-6 scrollbar-thin">
<div className="min-w-0">
{section === 'company' && settings && (
<Card>
<CardHeader title="企业资料" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4">
<CardHeader title="企业资料" description="用于团队空间、通知邮件和对外业务资料" />
<div className="grid grid-cols-1 gap-5 p-5 md:grid-cols-2">
<Field label="企业名称"><Input value={companyDraft.name} onChange={(e) => setCompanyDraft((v) => ({ ...v, name: e.target.value }))} /></Field>
<Field label="所属行业"><Input value={companyDraft.industry} onChange={(e) => setCompanyDraft((v) => ({ ...v, industry: e.target.value }))} /></Field>
<Field label="联系方式"><Input value={companyDraft.contact} onChange={(e) => setCompanyDraft((v) => ({ ...v, contact: e.target.value }))} /></Field>
@@ -79,20 +93,25 @@ export function SettingsPage() {
)}
{section === 'ai' && settings && (
<Card>
<CardHeader title="AI 回复设置" />
<div className="space-y-4 p-4">
<CardHeader title="智能回复" description="控制回复建议、分析和发送前确认规则" />
<div className="divide-y divide-slate-100 px-5">
{[
{ key: 'autoGenerate', label: '自动生成回复建议' },
{ key: 'requireConfirm', label: '发送前必须人工确认' },
{ key: 'sentimentAnalysis', label: '启用情感分析' },
{ key: 'messageIntentAnalysis', label: '逐条客户消息意图识别' },
{ key: 'contextualTranslation', label: '翻译时参考会话上下文' },
{ key: 'riskAlert', label: '启用风险预警' },
{ key: 'cultureCheck', label: '启用文化敏感检测' },
{ key: 'allowHistoryRef', label: '允许AI引用历史成功回复' },
].map((item) => (
<div key={item.key} className="flex items-center justify-between">
<div key={item.key} className="flex items-center justify-between py-4">
<span className="text-sm">{item.label}</span>
<Switch
checked={(settings.ai as any)[item.key]}
ariaLabel={item.label}
checked={['messageIntentAnalysis', 'contextualTranslation'].includes(item.key)
? (settings.ai as any)[item.key] !== false
: Boolean((settings.ai as any)[item.key])}
onCheckedChange={(v) => updateMut.mutate({ ai: { [item.key]: v } })}
/>
</div>
@@ -102,8 +121,8 @@ export function SettingsPage() {
)}
{section === 'notifications' && settings && (
<Card>
<CardHeader title="消息通知设置" />
<div className="space-y-4 p-4">
<CardHeader title="消息通知" description="选择需要发送到通知中心的业务事件" />
<div className="divide-y divide-slate-100 px-5">
{[
{ key: 'newInquiry', label: '新询盘提醒' },
{ key: 'highIntent', label: '高意向客户提醒' },
@@ -113,7 +132,7 @@ export function SettingsPage() {
{ key: 'channelOffline', label: '渠道断开提醒' },
{ key: 'planExpire', label: '套餐到期提醒' },
].map((item) => (
<div key={item.key} className="flex items-center justify-between">
<div key={item.key} className="flex items-center justify-between py-4">
<span className="text-sm">{item.label}</span>
<Switch
checked={(settings.notification as any)[item.key]}
@@ -126,12 +145,12 @@ export function SettingsPage() {
)}
{section === 'safety' && (
<Card>
<CardHeader title="安全设置" />
<div className="space-y-4 p-4">
<CardHeader title="安全设置" description="修改密码后会撤销其他设备上的登录会话" />
<div className="space-y-5 p-5">
<Field label="当前密码"><Input type="password" value={passwords.currentPassword} onChange={(e) => setPasswords((v) => ({ ...v, currentPassword: e.target.value }))} /></Field>
<Field label="新密码"><Input type="password" placeholder="至少10位,包含字母和数字" value={passwords.newPassword} onChange={(e) => setPasswords((v) => ({ ...v, newPassword: e.target.value }))} /></Field>
<Field label="确认新密码"><Input type="password" value={passwords.confirmPassword} onChange={(e) => setPasswords((v) => ({ ...v, confirmPassword: e.target.value }))} /></Field>
<div className="rounded bg-emerald-50 p-3 text-sm text-emerald-700"><ShieldCheck className="inline h-4 w-4 mr-1" />密码使用 bcrypt 加密;修改后所有旧登录 Token 会立即失效。</div>
<div className="rounded-[10px] border border-primary-100 bg-primary-50 p-3 text-sm text-primary-700"><ShieldCheck className="mr-1 inline h-4 w-4" />修改密码后,其他已登录设备需要重新登录。</div>
<Button onClick={async () => {
if (passwords.newPassword !== passwords.confirmPassword) return toast.error('两次输入的新密码不一致')
try {
@@ -146,9 +165,9 @@ export function SettingsPage() {
)}
{section === 'api' && (
<Card>
<CardHeader title="API 与 Webhook" />
<div className="space-y-3 p-4">
<p className="text-sm text-muted-foreground">密钥只在创建时完整显示一次,数据库仅保存 SHA-256 摘要。</p>
<CardHeader title="API Key" description="为自有系统或服务端集成创建访问凭据" />
<div className="space-y-4 p-5">
<p className="text-sm text-muted-foreground">密钥只在创建时完整显示一次,请立即复制并妥善保存。</p>
<div className="flex gap-2">
<Input value={apiName} onChange={(e) => setApiName(e.target.value)} placeholder="密钥名称" />
<Button variant="outline" onClick={async () => {
@@ -168,7 +187,7 @@ export function SettingsPage() {
</div>
</div>
)}
<div className="divide-y divide-border rounded-lg border border-border">
<div className="divide-y divide-slate-100 overflow-hidden rounded-[10px] border border-slate-200">
{(apiKeys || []).map((key) => (
<div key={key.id} className="flex items-center gap-3 px-3 py-2">
<div className="min-w-0 flex-1"><p className="text-sm font-medium">{key.name}</p><p className="text-xs text-muted-foreground">{key.prefix}•••••• · {key.revoked ? '已撤销' : '有效'}</p></div>
@@ -181,8 +200,8 @@ export function SettingsPage() {
)}
{section === 'lang' && (
<Card>
<CardHeader title="语言与时区" />
<div className="space-y-4 p-4">
<CardHeader title="语言与时区" description="调整界面显示语言与业务时间基准" />
<div className="space-y-5 p-5">
<Field label="界面语言">
<Select value={localeDraft} options={[{value:'zh',label:'简体中文'},{value:'en',label:'English'},{value:'ar',label:'العربية (RTL)'}]} onChange={(e) => setLocaleDraft(e.target.value as any)} />
</Field>
@@ -196,5 +215,6 @@ export function SettingsPage() {
)}
</div>
</div>
</div>
)
}
+26 -12
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { ArrowLeftRight, Copy, FileText, History, Languages, RefreshCw, Save } from 'lucide-react'
import { translate } from '@/services/api/ai'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowLeftRight, Copy, Languages, RefreshCw, Save } from 'lucide-react'
import { favoriteTranslation, getTranslationHistory, translate } from '@/services/api/ai'
import { toast, errorMessage } from '@/app/store/toast'
import { PageHeader } from '@/components/common/PageHeader'
import { Button } from '@/components/ui/button'
@@ -19,6 +19,7 @@ import { cn } from '@/utils/cn'
const SAMPLE_TEXT = '你好,非常感谢贵司对500个定制双层玻璃杯的询盘。我们很高兴为您提供报价:单价$1.85/个(含双色Logo印刷),标准交期12-15天,加急10天可交付。如有任何问题或需要调整,请随时告知。期待与贵司建立长期合作。'
export function TranslatePage() {
const queryClient = useQueryClient()
const [tab, setTab] = useState('text')
const [source, setSource] = useState('')
const [target, setTarget] = useState('en')
@@ -26,7 +27,11 @@ export function TranslatePage() {
const [result, setResult] = useState('')
const [detected, setDetected] = useState('')
const [terms, setTerms] = useState<any[]>([])
const [history, setHistory] = useState<{ source: string; target: string; text: string; result: string }[]>([])
const [currentRecordId, setCurrentRecordId] = useState('')
const { data: history = [] } = useQuery({
queryKey: ['translation-history'],
queryFn: getTranslationHistory,
})
const translateMut = useMutation({
mutationFn: () => translate({ text, sourceLang: source || undefined, targetLang: target }),
@@ -34,10 +39,19 @@ export function TranslatePage() {
setResult(r.translated)
setDetected(r.detectedLang)
setTerms(r.terms ?? [])
setHistory((h) => [{ source: source || r.detectedLang, target, text, result: r.translated }, ...h].slice(0, 20))
setCurrentRecordId(r.historyItem?.id || '')
void queryClient.invalidateQueries({ queryKey: ['translation-history'] })
},
onError: (e) => toast.error(errorMessage(e)),
})
const favoriteMut = useMutation({
mutationFn: () => favoriteTranslation(currentRecordId, true),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['translation-history'] })
toast.success('已收藏到翻译历史')
},
onError: (error) => toast.error(errorMessage(error)),
})
const swap = () => {
if (detected && !source) setSource(detected)
@@ -52,8 +66,8 @@ export function TranslatePage() {
return (
<div className="p-4 md:p-6">
<PageHeader
title="AI 翻译中心"
description={`跨境商务场景专用翻译引擎,支持全球主流贸易国家和地区语言(可配置 ${supportedLanguageCount} 种)。`}
title="翻译中心"
description={`支持 ${supportedLanguageCount} 种已配置语言,可用于消息翻译与回复。`}
actions={<LiveDataBadge />}
/>
@@ -131,8 +145,7 @@ export function TranslatePage() {
<Button size="sm" variant="ghost" disabled={!result} onClick={() => { navigator.clipboard.writeText(result); toast.success('已复制') }}>
<Copy className="h-3.5 w-3.5" /> 复制
</Button>
<Button size="sm" variant="ghost" disabled={!result} onClick={() => { toast.success('已收藏');
}}>
<Button size="sm" variant="ghost" disabled={!result || !currentRecordId || favoriteMut.isPending} onClick={() => favoriteMut.mutate()}>
<Save className="h-3.5 w-3.5" /> 收藏
</Button>
<Button size="sm" variant="ghost" onClick={() => translateMut.mutate()}>
@@ -154,10 +167,11 @@ export function TranslatePage() {
<div key={i} className="p-3 text-sm">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<Languages className="h-3 w-3" />
{LANGUAGES.find(l => l.code === h.source)?.name ?? '自动识别'} → {LANGUAGES.find(l => l.code === h.target)?.name}
{LANGUAGES.find(l => l.code === h.sourceLang)?.name ?? '自动识别'} → {LANGUAGES.find(l => l.code === h.targetLang)?.name}
{h.favorite && <Badge className="ml-auto bg-amber-50 text-amber-700">已收藏</Badge>}
</div>
<p className="text-xs truncate">{h.text.slice(0, 80)}</p>
<p className="text-xs text-muted-foreground mt-0.5 truncate">{h.result.slice(0, 80)}</p>
<p className="text-xs truncate">{h.originalText.slice(0, 80)}</p>
<p className="text-xs text-muted-foreground mt-0.5 truncate">{h.translatedText.slice(0, 80)}</p>
</div>
))}
</div>
+11 -3
View File
@@ -1,19 +1,27 @@
import { http } from './client'
import type { AiAnalysis, AiReplySuggestion, AiTextActionPayload, TranslatePayload, TranslateResult } from '@/types'
import type { AiAnalysis, AiReplySuggestion, AiTextActionPayload, TranslatePayload, TranslateResult, TranslationHistoryItem } from '@/types'
/** POST /api/ai/translate */
export function translate(payload: TranslatePayload) {
return http.post<TranslateResult>('/api/ai/translate', payload)
}
export function getTranslationHistory() {
return http.get<TranslationHistoryItem[]>('/api/ai/translations')
}
export function favoriteTranslation(id: string, favorite: boolean) {
return http.patch<TranslationHistoryItem>(`/api/ai/translations/${id}`, { favorite })
}
/** POST /api/ai/analyze */
export function analyzeConversation(conversationId: string) {
return http.post<AiAnalysis>('/api/ai/analyze', { conversationId })
}
/** POST /api/ai/replies */
export function generateReplies(conversationId: string) {
return http.post<AiReplySuggestion[]>('/api/ai/replies', { conversationId })
export function generateReplies(conversationId: string, targetLang = 'auto') {
return http.post<AiReplySuggestion[]>('/api/ai/replies', { conversationId, targetLang })
}
/** POST /api/ai/summarize */
+125 -2
View File
@@ -21,6 +21,23 @@ export function startTelegramLogin(phone: string) {
return http.post<ChannelConnection>('/api/channels/telegram/start', { phone })
}
export function connectEmailChannel(payload: {
name?: string
displayName?: string
account: string
password: string
imapHost: string
imapPort: number
imapSecure: boolean
imapFolder?: string
smtpHost: string
smtpPort: number
smtpSecure: boolean
syncDays?: number
}) {
return http.post<ChannelConnection>('/api/channels/email/connect', payload)
}
export function submitTelegramCode(channelId: string, code: string) {
return http.post<{ status: string; channel: ChannelConnection }>(`/api/channels/telegram/${channelId}/code`, { code })
}
@@ -29,8 +46,36 @@ export function submitTelegramPassword(channelId: string, password: string) {
return http.post<{ status: string; channel: ChannelConnection }>(`/api/channels/telegram/${channelId}/password`, { password })
}
export function syncTelegramChannel(channelId: string) {
return http.post<{ chats: number; messages: number }>(`/api/channels/${channelId}/sync`)
export function getTelegramLoginState(channelId: string) {
return http.post<{ status: string; connectionState?: string; channel: ChannelConnection }>(
`/api/channels/telegram/${channelId}/state`,
)
}
export function startWhatsAppLogin(phone: string, name?: string) {
return http.post<ChannelConnection>('/api/channels/whatsapp/start', {
phone,
name,
acceptedUnofficialRisk: true,
})
}
export function getWhatsAppLoginState(channelId: string) {
return http.post<{
status: string
connectionState?: string
pairingCode?: string
qrDataUrl?: string
channel: ChannelConnection
}>(`/api/channels/whatsapp/${channelId}/state`)
}
export function setDefaultChannel(channelId: string) {
return http.post<ChannelConnection>(`/api/channels/${channelId}/default`)
}
export function syncChannel(channelId: string, options: { full?: boolean } = {}) {
return http.post<{ chats: number; messages: number }>(`/api/channels/${channelId}/sync`, options)
}
/** POST /api/channels/test */
@@ -52,3 +97,81 @@ export function deleteChannel(id: string) {
export function getChannelLogs(id: string) {
return http.get<ChannelSyncLog[]>(`/api/channels/${id}/logs`)
}
export function getWhatsAppContacts(
channelId: string,
query: { query?: string; cursor?: string; limit?: number } = {},
) {
return http.get<{
items: Array<{ id: string; name: string; phone: string }>
nextCursor: string | null
total: number
}>(`/api/channels/${channelId}/whatsapp/contacts`, query)
}
export function updateWhatsAppProxy(channelId: string, url: string) {
return http.put<ChannelConnection>(`/api/channels/${channelId}/whatsapp/proxy`, {
url,
test: true,
})
}
export function updateWhatsAppProfile(
channelId: string,
payload: { name?: string; status?: string },
) {
return http.patch<ChannelConnection>(`/api/channels/${channelId}/whatsapp/profile`, payload)
}
export function updateWhatsAppProfilePicture(
channelId: string,
payload: { name: string; mimeType: string; base64: string },
) {
return http.post<ChannelConnection>(
`/api/channels/${channelId}/whatsapp/profile/picture`,
payload,
)
}
export function reauthorizeWhatsApp(channelId: string) {
return http.post<ChannelConnection>(`/api/channels/${channelId}/whatsapp/reauthorize`)
}
export function getWhatsAppDevices(channelId: string) {
return http.get<Array<{
id: string
name: string
platform: string
current: boolean
status: string
connectedAt?: string
}>>(`/api/channels/${channelId}/whatsapp/devices`)
}
export function terminateWhatsAppDevice(channelId: string, deviceId: string) {
return http.del<{ success: boolean; deviceId: string }>(
`/api/channels/${channelId}/whatsapp/devices/${encodeURIComponent(deviceId)}`,
)
}
export function getWhatsAppGatewayMetrics() {
return http.get<{
accounts: { total: number; connected: number; disconnected: number }
outbound: { queued: number; dead: number; sent: number; riskBackoff: number; averageLatencyMs: number }
webhooks: { queued: number; dead: number; delivered: number }
alerts: Array<{ severity: string; code: string; accountId: string; message: string }>
}>('/api/channels/whatsapp/metrics')
}
export function createWhatsAppBackup(channelId: string) {
return http.get<{ version: number; accountId: string; createdAt: string; blob: string }>(
`/api/channels/${channelId}/whatsapp/backup`,
)
}
export function restoreWhatsAppBackup(channelId: string, blob: string) {
return http.post<{ restored: boolean }>(
`/api/channels/${channelId}/whatsapp/backup/restore`,
{ blob },
)
}
+12
View File
@@ -17,10 +17,17 @@ export interface RequestOptions {
/** 由 auth store 注入,避免循环依赖 */
let tokenGetter: () => string | null = () => null
let unauthorizedHandler: (() => void) | null = null
let handlingUnauthorized = false
export function setTokenGetter(fn: () => string | null) {
tokenGetter = fn
}
export function setUnauthorizedHandler(fn: () => void) {
unauthorizedHandler = fn
}
export async function api<T = unknown>(path: string, options: RequestOptions = {}): Promise<T> {
const method = options.method ?? 'GET'
@@ -41,6 +48,11 @@ export async function api<T = unknown>(path: string, options: RequestOptions = {
})
if (res.status === 401) {
if (token && unauthorizedHandler && !handlingUnauthorized) {
handlingUnauthorized = true
unauthorizedHandler()
window.setTimeout(() => { handlingUnauthorized = false }, 1_000)
}
throw new ApiError(401, '登录状态已失效,请重新登录')
}
if (!res.ok) {
+185 -10
View File
@@ -1,5 +1,5 @@
import { http } from './client'
import type { Conversation, Customer, Message, SendMessagePayload } from '@/types'
import type { AiBackfillStatus, Conversation, Customer, Message, SendMessagePayload } from '@/types'
export type ConversationListItem = Conversation & { customer: Customer }
@@ -10,6 +10,15 @@ export interface ConversationQuery {
userId?: string
}
export function normalizeMessage(message: Message): Message {
return {
...message,
originalText: String(message?.originalText ?? ''),
originalLang: String(message?.originalLang || 'und'),
reactions: Array.isArray(message?.reactions) ? message.reactions : [],
}
}
/** GET /api/conversations */
export function getConversations(query: ConversationQuery = {}) {
return http.get<{ list: ConversationListItem[]; total: number }>('/api/conversations', { ...query })
@@ -17,12 +26,43 @@ export function getConversations(query: ConversationQuery = {}) {
/** GET /api/conversations/:id */
export function getConversation(id: string) {
return http.get<{ conversation: Conversation; customer: Customer }>(`/api/conversations/${id}`)
return http.get<{ conversation: Conversation; customer: Customer; aiBackfill?: AiBackfillStatus }>(`/api/conversations/${id}`)
}
export function getConversationAiStatus(id: string) {
return http.get<{
backfill: AiBackfillStatus
context: {
status: string
sourceMessageCount: number
pendingMessages: number
compressedAt: string | null
memory: Conversation['aiContextMemory'] | null
}
}>(`/api/conversations/${id}/ai-status`)
}
/** GET /api/conversations/:id/messages */
export function getMessages(id: string) {
return http.get<Message[]>(`/api/conversations/${id}/messages`)
export async function getMessages(id: string) {
const messages = await http.get<Message[]>(`/api/conversations/${id}/messages`)
return Array.isArray(messages) ? messages.map(normalizeMessage) : []
}
export async function getMessagePage(
id: string,
query: { before?: string; after?: string; cursor?: string; limit?: number } = {},
) {
const page = await http.get<{
items: Message[]
beforeCursor: string | null
afterCursor: string | null
hasMoreBefore: boolean
hasMoreAfter: boolean
}>(`/api/conversations/${id}/messages/page`, query)
return {
...page,
items: Array.isArray(page?.items) ? page.items.map(normalizeMessage) : [],
}
}
export function updateMessage(id: string, messageId: string, payload: { isImportant?: boolean; internalNote?: string }) {
@@ -33,22 +73,56 @@ export function markConversationRead(id: string, messageIds?: string[]) {
return http.post<Conversation>(`/api/conversations/${id}/read`, { messageIds })
}
export function getMessageAttachment(id: string, messageId: string) {
export function setConversationPresence(
id: string,
presence: 'composing' | 'recording' | 'paused' | 'available' | 'unavailable',
) {
return http.post<{ success: boolean; presence: string }>(`/api/conversations/${id}/presence`, { presence })
}
export function getMessageAttachment(id: string, messageId: string, attachmentId?: string) {
return http.get<{ dataUrl: string; mimeType: string; name: string }>(
`/api/conversations/${id}/messages/${messageId}/attachment`,
attachmentId ? { attachmentId } : undefined,
)
}
/** POST /api/conversations/:id/messages */
export function sendMessage(id: string, payload: SendMessagePayload & { translatedText?: string; autoTranslate?: boolean }) {
return http.post<Message>(`/api/conversations/${id}/messages`, payload)
export function sendMessage(
id: string,
payload: SendMessagePayload & {
translatedText?: string
autoTranslate?: boolean
targetLang?: string
idempotencyKey?: string
clientMessageId?: string
},
) {
const idempotencyKey = payload.idempotencyKey || crypto.randomUUID()
return http.post<Message>(`/api/conversations/${id}/messages`, {
...payload,
idempotencyKey,
clientMessageId: payload.clientMessageId || idempotencyKey.replace(/-/g, '').slice(0, 20).toUpperCase(),
})
}
export function sendMedia(
id: string,
payload: { name: string; mimeType: string; base64: string; kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker' },
payload: {
name: string
mimeType: string
base64: string
kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker'
idempotencyKey?: string
clientMessageId?: string
},
) {
return http.post<unknown>(`/api/conversations/${id}/media`, payload)
const idempotencyKey = payload.idempotencyKey || crypto.randomUUID()
return http.post<unknown>(`/api/conversations/${id}/media`, {
...payload,
idempotencyKey,
clientMessageId: payload.clientMessageId || idempotencyKey.replace(/-/g, '').slice(0, 20).toUpperCase(),
})
}
export function sendAlbum(
@@ -59,6 +133,107 @@ export function sendAlbum(
}
/** PATCH /api/conversations/:id(标记已读/分配/状态) */
export function updateConversation(id: string, payload: { assigneeId?: string | null; markRead?: boolean; status?: string }) {
export type WhatsAppMessageAction =
| { action: 'react'; messageId: string; emoji: string }
| { action: 'edit'; messageId: string; text: string }
| { action: 'delete'; messageId: string }
| { action: 'forward'; messageId: string; destinationConversationId: string }
| { action: 'poll'; name: string; values: string[]; selectableCount?: number }
| { action: 'location'; latitude: number; longitude: number; name?: string; address?: string }
| { action: 'contact'; displayName: string; phone: string }
| { action: 'disappearing'; seconds: false | 0 | 86400 | 604800 | 7776000 }
export function runWhatsAppMessageAction(id: string, payload: WhatsAppMessageAction) {
return http.post<Message | { success: true }>(
`/api/conversations/${id}/messages/action`,
payload,
)
}
export function updateConversation(
id: string,
payload: {
assigneeId?: string | null
markRead?: boolean
status?: string
translationTargetLang?: string
messageIntentEnabled?: boolean
},
) {
return http.patch<Conversation>(`/api/conversations/${id}`, payload)
}
export type ConversationAction =
| 'clear'
| 'delete'
| 'pin'
| 'unpin'
| 'archive'
| 'unarchive'
| 'mute'
| 'unmute'
| 'mark-unread'
| 'leave'
| 'block'
| 'unblock'
export function runConversationAction(
id: string,
action: ConversationAction,
options: { seconds?: number } = {},
) {
return http.post<Conversation | { success: true; deleted: true }>(
`/api/conversations/${id}/actions`,
{ action, ...options },
)
}
export function openTelegramConversation(payload: {
identifier: string
targetType: 'user' | 'chat' | 'invite'
channelId?: string
}) {
return http.post<{ conversation: Conversation; customer: Customer }>(
'/api/conversations/telegram/open',
payload,
)
}
export function openWhatsAppConversation(payload: {
identifier?: string
targetType: 'user' | 'group' | 'invite'
channelId?: string
subject?: string
participants?: string[]
}) {
return http.post<{ conversation: Conversation; customer: Customer }>(
'/api/conversations/whatsapp/open',
payload,
)
}
export interface WhatsAppGroupInfo {
id: string
name: string
description?: string
participants: Array<{
id: string
admin?: 'admin' | 'superadmin' | null
}>
}
export function getWhatsAppGroup(id: string) {
return http.get<WhatsAppGroupInfo>(`/api/conversations/${id}/whatsapp/group`)
}
export function runWhatsAppGroupAction(
id: string,
payload: {
action: 'add' | 'remove' | 'promote' | 'demote' | 'subject' | 'description' | 'invite-code' | 'revoke-invite'
participants?: string[]
subject?: string
description?: string
},
) {
return http.post<unknown>(`/api/conversations/${id}/whatsapp/group/action`, payload)
}
+224 -12
View File
@@ -4,26 +4,26 @@
@layer base {
:root {
--background: 220 14% 96%;
--foreground: 215 25% 16%;
--background: 210 20% 98%;
--foreground: 215 28% 13%;
--card: 0 0% 100%;
--card-foreground: 215 25% 16%;
--popover: 0 0% 100%;
--popover-foreground: 215 25% 16%;
--primary: 172 59% 30%;
--primary: 221 83% 53%;
--primary-foreground: 0 0% 100%;
--secondary: 210 16% 93%;
--secondary: 210 20% 96%;
--secondary-foreground: 215 20% 24%;
--muted: 210 16% 93%;
--muted-foreground: 215 12% 46%;
--accent: 170 40% 93%;
--accent-foreground: 172 60% 22%;
--muted: 210 20% 96%;
--muted-foreground: 215 12% 43%;
--accent: 214 100% 97%;
--accent-foreground: 221 83% 40%;
--destructive: 0 70% 50%;
--destructive-foreground: 0 0% 100%;
--border: 214 15% 88%;
--input: 214 15% 84%;
--ring: 172 59% 34%;
--radius: 0.5rem;
--border: 214 22% 91%;
--input: 214 18% 86%;
--ring: 221 83% 53%;
--radius: 0.75rem;
}
* {
@@ -40,6 +40,10 @@
@apply bg-background text-foreground antialiased;
font-size: 14px;
line-height: 1.6;
letter-spacing: -0.006em;
background-image:
radial-gradient(circle at 76% -12%, rgba(37, 99, 235, 0.055), transparent 28rem),
linear-gradient(180deg, #ffffff 0, #f7f9fc 23rem);
}
::selection {
@@ -65,6 +69,128 @@
.text-balance {
text-wrap: balance;
}
.surface-panel {
@apply rounded-[14px] border border-slate-200/90 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.025),0_8px_24px_rgba(15,23,42,0.025)];
}
.eyebrow {
@apply text-[11px] font-semibold tracking-[0.08em] text-primary-700;
}
.page-shell {
@apply mx-auto w-full max-w-[1600px] px-4 py-5 md:px-7 md:py-7;
}
.page-section {
@apply mt-5;
}
.section-heading {
@apply mb-3 flex flex-wrap items-end justify-between gap-3;
}
.section-title {
@apply text-[15px] font-semibold tracking-[-0.015em] text-slate-900;
}
.section-description {
@apply mt-0.5 text-xs text-slate-500;
}
.quiet-grid {
background-image:
linear-gradient(rgba(148, 163, 184, .045) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, .045) 1px, transparent 1px);
background-size: 24px 24px;
}
.focus-surface {
@apply border-primary-200/80 bg-primary-50/45 shadow-[0_8px_24px_-18px_rgba(37,99,235,.5),inset_0_0_0_1px_rgba(255,255,255,.8)];
}
}
.chat-wallpaper {
transition: background-color .28s ease, background-image .28s ease;
background-position: center, center, center, center, 0 0, center;
background-size: 100% 100%, 100% 100%, 100% 100%, 100% 100%, 320px 320px, 100% 100%;
}
.chat-wallpaper-ocean {
background-color: #eaf2f9;
background-image:
radial-gradient(ellipse at 8% 10%, rgba(125, 211, 252, .42), transparent 45%),
radial-gradient(ellipse at 92% 8%, rgba(96, 165, 250, .34), transparent 44%),
radial-gradient(ellipse at 12% 92%, rgba(45, 212, 191, .24), transparent 43%),
radial-gradient(ellipse at 90% 90%, rgba(129, 140, 248, .25), transparent 46%),
url('/chat-pattern.svg'),
linear-gradient(145deg, rgba(245, 251, 255, .88), rgba(224, 236, 250, .94));
}
.chat-wallpaper-aurora {
background-color: #eff0ff;
background-image:
radial-gradient(ellipse at 8% 8%, rgba(167, 139, 250, .38), transparent 46%),
radial-gradient(ellipse at 92% 12%, rgba(244, 114, 182, .3), transparent 43%),
radial-gradient(ellipse at 10% 92%, rgba(34, 211, 238, .3), transparent 45%),
radial-gradient(ellipse at 90% 90%, rgba(99, 102, 241, .32), transparent 47%),
url('/chat-pattern.svg'),
linear-gradient(145deg, rgba(249, 244, 255, .9), rgba(228, 246, 255, .88));
}
.chat-wallpaper-sunset {
background-color: #fff3ed;
background-image:
radial-gradient(ellipse at 8% 10%, rgba(251, 191, 36, .34), transparent 44%),
radial-gradient(ellipse at 92% 8%, rgba(251, 113, 133, .33), transparent 45%),
radial-gradient(ellipse at 9% 92%, rgba(251, 146, 60, .28), transparent 43%),
radial-gradient(ellipse at 90% 90%, rgba(192, 132, 252, .28), transparent 47%),
url('/chat-pattern.svg'),
linear-gradient(145deg, rgba(255, 249, 232, .92), rgba(250, 235, 255, .9));
}
.chat-wallpaper-mint {
background-color: #eaf8f4;
background-image:
radial-gradient(ellipse at 8% 9%, rgba(52, 211, 153, .34), transparent 46%),
radial-gradient(ellipse at 92% 10%, rgba(34, 211, 238, .28), transparent 44%),
radial-gradient(ellipse at 10% 92%, rgba(163, 230, 53, .22), transparent 43%),
radial-gradient(ellipse at 90% 90%, rgba(96, 165, 250, .3), transparent 47%),
url('/chat-pattern.svg'),
linear-gradient(145deg, rgba(237, 253, 247, .92), rgba(229, 244, 255, .9));
}
.telegram-bubble-in {
border: 1px solid rgba(226, 232, 240, .96);
background: rgba(255, 255, 255, .97);
box-shadow: 0 2px 5px rgba(15, 23, 42, .06), 0 10px 24px -18px rgba(15, 23, 42, .22);
}
.telegram-bubble-in::before {
content: '';
position: absolute;
bottom: -1px;
left: -7px;
width: 12px;
height: 14px;
background: rgba(255, 255, 255, .97);
clip-path: polygon(100% 0, 0 100%, 100% 100%);
}
.telegram-bubble-out {
border: 1px solid rgba(147, 197, 253, .75);
background: linear-gradient(135deg, #dbeafe 0%, #e0f2fe 52%, #eff6ff 100%);
box-shadow: 0 3px 7px rgba(37, 99, 235, .1), 0 12px 28px -18px rgba(37, 99, 235, .38);
}
.telegram-bubble-out::before {
content: '';
position: absolute;
right: -7px;
bottom: -1px;
width: 12px;
height: 14px;
background: #e8f3ff;
clip-path: polygon(0 0, 0 100%, 100% 100%);
}
.group:hover .telegram-bubble-in {
box-shadow: 0 3px 8px rgba(15, 23, 42, .08), 0 14px 30px -20px rgba(15, 23, 42, .28);
}
.group:hover .telegram-bubble-out {
box-shadow: 0 4px 10px rgba(37, 99, 235, .13), 0 16px 32px -20px rgba(37, 99, 235, .45);
}
@keyframes message-in-left {
@@ -85,12 +211,98 @@
to { opacity: 1; transform: translate3d(0, 0, 0) scale(1); }
}
@keyframes conversation-select {
0% {
transform: translate3d(0, 2px, 0) scale(.988);
box-shadow: 0 0 0 rgba(37, 99, 235, 0);
}
58% {
transform: translate3d(0, -1px, 0) scale(1.003);
box-shadow: 0 12px 28px -15px rgba(37, 99, 235, .48);
}
100% {
transform: translate3d(0, 0, 0) scale(1);
box-shadow: 0 9px 24px -15px rgba(37, 99, 235, .52), 0 0 0 1px rgba(96, 165, 250, .1);
}
}
@keyframes conversation-sheen {
from { transform: translate3d(-170%, 0, 0) skewX(-18deg); opacity: 0; }
18% { opacity: .7; }
to { transform: translate3d(470%, 0, 0) skewX(-18deg); opacity: 0; }
}
.conversation-selected {
animation: conversation-select .42s cubic-bezier(.2, .82, .2, 1) both;
}
.conversation-selected::after {
content: '';
pointer-events: none;
position: absolute;
inset-block: 0;
left: 0;
width: 24%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .78), transparent);
animation: conversation-sheen .62s cubic-bezier(.2, .7, .2, 1) .04s both;
}
@layer utilities {
.animate-message-in-left { animation: message-in-left .34s cubic-bezier(.2,.8,.2,1) both; }
.animate-message-in-right { animation: message-in-right .34s cubic-bezier(.2,.8,.2,1) both; }
.animate-incoming-notice { animation: incoming-notice .42s cubic-bezier(.2,.9,.2,1) both; }
}
@media (prefers-reduced-motion: reduce) {
.conversation-selected,
.conversation-selected::after,
.auth-message,
.auth-translation-dot,
.auth-floating-chip {
animation: none;
}
}
.auth-corner-glow {
background:
radial-gradient(circle at 12% 12%, rgba(96, 165, 250, .16), transparent 28rem),
radial-gradient(circle at 92% 86%, rgba(125, 211, 252, .12), transparent 24rem);
}
.auth-preview-grid {
background-image:
linear-gradient(rgba(148, 163, 184, .055) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 184, .055) 1px, transparent 1px);
background-size: 24px 24px;
}
@keyframes auth-message-in {
from { opacity: .62; transform: translate3d(-8px, 6px, 0); }
to { opacity: 1; transform: translate3d(0, 0, 0); }
}
@keyframes auth-message-out {
from { opacity: .62; transform: translate3d(8px, 6px, 0); }
to { opacity: 1; transform: translate3d(0, 0, 0); }
}
@keyframes auth-translation {
0%, 17% { opacity: 0; transform: translateX(0); }
24% { opacity: 1; }
40% { opacity: 1; transform: translateX(92px); }
48%, 100% { opacity: 0; transform: translateX(92px); }
}
@keyframes auth-chip-float {
0%, 100% { transform: translate3d(0, 0, 0); }
50% { transform: translate3d(0, -4px, 0); }
}
.auth-message-in { animation: auth-message-in .48s cubic-bezier(.2,.8,.2,1) both; }
.auth-message-out { animation: auth-message-out .48s cubic-bezier(.2,.8,.2,1) .16s both; }
.auth-translation-dot { animation: auth-translation 8s ease-in-out infinite; }
.auth-floating-chip { animation: auth-chip-float 4s ease-in-out infinite; }
/* RTL 微调 */
[dir='rtl'] .rtl-flip {
transform: scaleX(-1);
+21
View File
@@ -8,9 +8,11 @@ export interface AiReplySuggestion {
id: string
style: ReplyStyle
content: string
translationCn: string
targetLang: string
reason: string
knowledgeRefs: string[]
knowledgeRefIds?: string[]
expectedEffect: string
cultureTip?: string
}
@@ -40,6 +42,25 @@ export interface TranslateResult {
translated: string
detectedLang: string
terms: { source: string; target: string }[]
historyItem?: TranslationHistoryItem
meta?: {
method: 'ai-contextual' | 'ai-direct'
contextApplied: boolean
contextMessageCount: number
conversationSummaryApplied?: boolean
model: string
}
}
export interface TranslationHistoryItem {
id: string
sourceLang: string
targetLang: string
originalText: string
translatedText: string
detectedLang: string
favorite: boolean
createdAt: string
}
export type AiTextAction = 'polish' | 'translate' | 'shorten' | 'expand' | 'tone'
+16
View File
@@ -46,6 +46,18 @@ export interface AiCustomerProfile {
dealProbability?: number
churnProbability?: number
confidenceScore?: number
intentBreakdown?: Array<{
intent: string
label: string
count: number
share: number
}>
lastMessageIntent?: {
intent: string
label: string
urgency: string
}
profileVersion?: number
evidence?: string[]
}
@@ -54,6 +66,9 @@ export interface Customer {
name: string
company: string
country: string
countrySource?: 'manual' | 'account-profile' | 'phone-prefix' | 'unknown'
countryConfidence?: number
countryInferredAt?: string | null
city?: string
email: string
phone: string
@@ -77,6 +92,7 @@ export interface Customer {
telegramUserId?: string
avatarUrl?: string
telegramPhotoFileId?: number
telegramChatType?: string
}
export interface CustomerFilters {
+5
View File
@@ -33,12 +33,17 @@ export interface KnowledgeItem {
citedCount: number
languages: string[]
quality: 'good' | 'normal' | 'low'
characterCount?: number
chunkCount?: number
indexedAt?: string
parseMessage?: string
}
export interface KnowledgeSearchHit {
item: KnowledgeItem
score: number
snippet: string
chunkIndex?: number
}
export interface KnowledgeSearchResult {
+105
View File
@@ -28,9 +28,28 @@ export interface Message {
type: MessageType
originalText: string
translatedText?: string
/** 发出消息的中文客服备注,不会发送到外部渠道 */
translationCn?: string
translationStatus?: 'pending' | 'completed' | 'failed'
translatedAt?: string
translationMethod?: 'ai-contextual' | 'ai-direct' | 'source'
translationContextMessages?: number
translationConversationSummaryApplied?: boolean
translationModel?: string
intentStatus?: 'pending' | 'completed' | 'failed'
intentAnalysis?: {
primary: string
label: string
confidence: number
urgency: 'low' | 'medium' | 'high' | 'urgent'
sentiment: 'positive' | 'neutral' | 'negative' | 'angry'
entities: string[]
rationale: string
analyzedAt: string
model: string
}
originalLang: string
targetLang?: string
status: MessageStatus
createdAt: string
isImportant?: boolean
@@ -39,10 +58,31 @@ export interface Message {
productCard?: { name: string; sku: string; price: string }
replyToId?: string
replyPreview?: string
editedAt?: string
isDeleted?: boolean
deletedAt?: string
reactions?: Array<{
actor: string
emoji: string
fromMe?: boolean
updatedAt?: string
}>
telegramFileId?: number
whatsappMessageId?: string
whatsappContentType?: string
attachmentMimeType?: string
attachmentSize?: number
stickerEmoji?: string
emailUid?: number
emailMessageId?: string
emailSubject?: string
emailAttachments?: Array<{
id: string
name: string
mimeType: string
size: number
available: boolean
}>
}
export interface Conversation {
@@ -58,6 +98,52 @@ export interface Conversation {
lastMessage?: Message
summary: string
updatedAt: string
emailSubject?: string
channelId?: string
telegramChatId?: string
telegramChatType?: string
whatsappJid?: string
whatsappChatType?: string
isPinned?: boolean
archived?: boolean
mutedUntil?: string | null
markedUnread?: boolean
isBlocked?: boolean
leftAt?: string
presence?: 'available' | 'unavailable' | 'composing' | 'recording' | 'paused'
presenceUpdatedAt?: string
lastSeenAt?: string
translationTargetLang?: string
messageIntentEnabled?: boolean
aiContextStatus?: 'waiting' | 'queued' | 'compressing' | 'ready' | 'failed'
aiContextSourceMessageCount?: number
aiContextPendingMessages?: number
aiContextCompressedAt?: string
aiContextCompactedMessageIds?: string[]
aiContextMemory?: {
summary: string
facts: string[]
commitments: string[]
products: string[]
unresolved: string[]
preferences: string[]
updatedThroughMessageId: string
sourceMessageCount: number
updatedAt: string
model: string
version: number
}
}
export interface AiBackfillStatus {
status: 'not-started' | 'queued' | 'running' | 'completed' | 'failed'
totalCount: number
completedCount: number
pendingCount: number
progress: number
startedAt?: string
completedAt?: string | null
lastError?: string
}
export type ChannelStatus = 'connected' | 'disconnected' | 'error' | 'paused' | 'unconnected'
@@ -72,7 +158,22 @@ export interface ChannelConnection {
todayMessages: number
errorMessage?: string
autoReply: boolean
authState?: string
defaultOwnerId?: string
displayName?: string
imapHost?: string
imapPort?: number
imapSecure?: boolean
imapFolder?: string
smtpHost?: string
smtpPort?: number
smtpSecure?: boolean
syncDays?: number
lastVerifiedAt?: string
isDefault?: boolean
provider?: string
pairingCode?: string
qrDataUrl?: string
}
export interface ChannelSyncLog {
@@ -87,6 +188,10 @@ export interface ChannelSyncLog {
/** 发送消息请求 */
export interface SendMessagePayload {
text: string
/** 已确认的中文参考译文,仅在内部消息记录中保存 */
translationCn?: string
/** auto 表示根据客户最近消息或客户画像自动选择目标语言 */
targetLang?: string
replyToId?: string
replyPreview?: string
type?: MessageType
+3 -1
View File
@@ -95,6 +95,8 @@ export interface AiSettings {
autoGenerate: boolean
requireConfirm: boolean
sentimentAnalysis: boolean
messageIntentAnalysis: boolean
contextualTranslation: boolean
riskAlert: boolean
cultureCheck: boolean
allowHistoryRef: boolean
@@ -138,7 +140,7 @@ export interface SystemSettings {
export interface AppNotification {
id: string
type: 'inquiry' | 'risk' | 'task' | 'channel' | 'system'
type: 'inquiry' | 'message' | 'risk' | 'task' | 'channel' | 'system'
title: string
description: string
createdAt: string
+10 -10
View File
@@ -13,16 +13,16 @@ export default {
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
50: '#effaf7',
100: '#d7f2ec',
200: '#b2e4da',
300: '#83cfc2',
400: '#52b3a5',
500: '#32998c',
600: '#247b71',
700: '#1f635c',
800: '#1c504b',
900: '#1b423f',
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
+75
View File
@@ -0,0 +1,75 @@
import { expect, test, type Page } from '@playwright/test'
const password = 'Lingxi-Design-2026!'
async function login(page: Page, suffix: string) {
const account = `design-${suffix}-${Date.now()}@local.test`
const registration = await page.request.post('/api/auth/register', {
data: {
name: 'Design Audit',
account,
password,
company: 'Design Audit',
industry: '跨境电商',
teamSize: '1-10人',
},
})
expect(registration.ok()).toBeTruthy()
await page.goto('/login')
await expect(page.locator('img[src="/brand-mark.svg"]')).toHaveCount(2)
await page.locator('input[autocomplete="username"]').fill(account)
await page.locator('input[autocomplete="current-password"]').fill(password)
await page.getByRole('button', { name: '登录' }).click()
await expect(page).toHaveURL(/\/dashboard/)
await expect(page.getByText('今日工作概览')).toBeVisible()
}
test('统一设计系统桌面端页面验收', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', '仅在桌面视口执行')
await login(page, 'desktop')
const pages = [
['/dashboard', '今日工作概览', 'dashboard'],
['/inbox', '消息', 'inbox'],
['/channels', '渠道与账号', 'channels'],
['/customers', '客户管理', 'customers'],
['/opportunities', '商机与跟进', 'opportunities'],
['/knowledge', '企业知识库', 'knowledge'],
['/analytics', '业务分析', 'analytics'],
['/settings', '管理企业资料、通知、智能能力与账号安全', 'settings'],
] as const
for (const [url, marker, name] of pages) {
await page.goto(url)
await expect(page.getByText(marker, { exact: false }).first()).toBeVisible()
await expect(page.getByText('Unexpected Application Error!', { exact: true })).toHaveCount(0)
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1)).toBe(true)
await page.screenshot({ path: testInfo.outputPath(`${name}.png`), fullPage: name !== 'inbox' })
}
await page.goto('/analytics')
await page.getByRole('button', { name: '近 7 天', exact: true }).click()
await expect(page.getByRole('button', { name: '近 7 天', exact: true })).toHaveAttribute('aria-pressed', 'true')
await page.goto('/opportunities?tab=tasks')
await expect(page).toHaveURL(/tab=tasks/)
await expect(page.getByText('任务', { exact: false }).first()).toBeVisible()
})
test('统一设计系统移动端页面验收', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'mobile-chromium', '仅在移动视口执行')
await login(page, 'mobile')
await page.screenshot({ path: testInfo.outputPath('dashboard-mobile.png'), fullPage: true })
await page.goto('/settings')
await expect(page.getByLabel('设置分类')).toBeVisible()
await page.getByLabel('设置分类').selectOption('ai')
await expect(page.getByLabel('设置分类')).toHaveValue('ai')
await expect(page.getByText('控制回复建议、分析和发送前确认规则')).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('settings-mobile.png'), fullPage: true })
await page.goto('/inbox')
await expect(page.getByText('消息', { exact: true }).first()).toBeVisible()
await expect(page.getByText('Unexpected Application Error!', { exact: true })).toHaveCount(0)
await page.screenshot({ path: testInfo.outputPath('inbox-mobile.png'), fullPage: false })
})
+8
View File
@@ -32,6 +32,10 @@ test.describe('真实收件箱 AI 交互', () => {
await expect(customerBubble.first()).toHaveClass(/animate-message-in-left/)
const agentBubble = page.locator('[data-testid="message-bubble"][data-sender="agent"]')
await expect(agentBubble.first()).toHaveClass(/animate-message-in-right/)
const outgoingChineseNotes = page.getByTestId('outgoing-chinese-note')
await expect.poll(() => outgoingChineseNotes.count()).toBeGreaterThan(0)
await expect(outgoingChineseNotes.first()).toContainText('中文备注 · 仅内部可见')
await expect(outgoingChineseNotes.first()).toContainText(/[\u3400-\u9fff]/u)
await page.getByTitle('客户洞察').click()
await expect(page.getByText('成交概率', { exact: true })).toBeVisible()
await expect(page.getByText('分析可信度', { exact: true })).toBeVisible()
@@ -49,6 +53,10 @@ test.describe('真实收件箱 AI 交互', () => {
await page.getByRole('button', { name: '生成回复建议', exact: true }).click()
const adoptButtons = page.getByRole('button', { name: '采用', exact: true })
await expect(adoptButtons).toHaveCount(3, { timeout: 30_000 })
const translationPreviews = page.getByTestId('ai-reply-translation')
await expect(translationPreviews).toHaveCount(3)
await expect(translationPreviews.first()).toContainText(/[\u3400-\u9fff]/u)
await expect(translationPreviews.first()).toContainText('中文参考 · 不会发送')
await adoptButtons.first().click()
const composer = page.getByPlaceholder('输入回复内容,Enter 发送,Shift+Enter 换行')
+123
View File
@@ -0,0 +1,123 @@
import { expect, test } from '@playwright/test'
const account = process.env.E2E_ACCOUNT || 'e2e-1785008967@local.test'
const password = process.env.E2E_PASSWORD || 'Lingxi-E2E-2026!'
test('知识库与逐句意图在真实页面可用', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop-chromium', '仅在桌面视口执行')
const loginResponse = await page.request.post('/api/auth/login', {
data: { account, password },
})
expect(loginResponse.ok()).toBeTruthy()
const loginPayload = await loginResponse.json()
const token = loginPayload.data.token as string
const headers = { Authorization: `Bearer ${token}` }
await page.goto('/login')
await page.locator('input[autocomplete="username"]').fill(account)
await page.locator('input[autocomplete="current-password"]').fill(password)
await page.getByRole('button', { name: '登录' }).click()
await expect(page).toHaveURL(/\/dashboard/)
await page.goto('/knowledge')
await expect(page.getByText('已分段索引', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: '上传文件' })).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('knowledge-index.png'), fullPage: true })
const conversationsResponse = await page.request.get('/api/conversations', { headers })
const conversations = (await conversationsResponse.json()).data.list as Array<{ id: string }>
let conversationId = ''
let messageId = ''
let messageCount = 0
for (const conversation of conversations.slice(0, 20)) {
const messagesResponse = await page.request.get(
`/api/conversations/${encodeURIComponent(conversation.id)}/messages`,
{ headers },
)
const messages = (await messagesResponse.json()).data as Array<{
id: string
sender: string
type: string
originalText: string
intentAnalysis?: unknown
}>
const candidate = [...messages].reverse().find((message) => (
message.sender === 'customer'
&& message.type === 'text'
&& /[\p{L}]/u.test(message.originalText || '')
))
if (candidate) {
conversationId = conversation.id
messageId = candidate.id
messageCount = messages.length
break
}
}
expect(conversationId).not.toBe('')
const aiStatusResponse = await page.request.get(
`/api/conversations/${encodeURIComponent(conversationId)}/ai-status`,
{ headers },
)
expect(aiStatusResponse.ok()).toBeTruthy()
const aiStatus = (await aiStatusResponse.json()).data
expect(['not-started', 'queued', 'running', 'completed']).toContain(aiStatus.backfill.status)
if (messageCount > 80) {
await expect.poll(async () => {
const response = await page.request.get(
`/api/conversations/${encodeURIComponent(conversationId)}/ai-status`,
{ headers },
)
return (await response.json()).data.context.status
}, { timeout: 30_000 }).toBe('ready')
const readyResponse = await page.request.get(
`/api/conversations/${encodeURIComponent(conversationId)}/ai-status`,
{ headers },
)
const readyStatus = (await readyResponse.json()).data
expect(readyStatus.context.sourceMessageCount).toBeGreaterThan(0)
expect(readyStatus.context.memory.summary).toBeTruthy()
}
const analyzeResponse = await page.request.post(
`/api/conversations/${encodeURIComponent(conversationId)}/messages/${encodeURIComponent(messageId)}/analyze`,
{ headers, data: { force: true } },
)
expect(analyzeResponse.ok()).toBeTruthy()
await page.goto(`/inbox?c=${encodeURIComponent(conversationId)}`)
await expect(page.getByTestId('conversation-insight-bar')).toBeVisible()
await expect(page.getByTestId('conversation-intent-toggle')).toBeVisible()
await expect(
page.getByRole('switch', { name: '当前会话逐句意图识别' }),
).toHaveAttribute('aria-checked', 'true')
await expect.poll(() => page.getByTestId('message-intent').count()).toBeGreaterThan(0)
const intentCount = await page.getByTestId('message-intent').count()
const visibleIntent = page.getByTestId('message-intent').nth(intentCount - 1)
await visibleIntent.scrollIntoViewIfNeeded()
await expect(visibleIntent).toBeVisible()
const intentSwitch = page.getByRole('switch', { name: '当前会话逐句意图识别' })
await intentSwitch.click()
await expect(intentSwitch).toHaveAttribute('aria-checked', 'false')
await expect(page.getByTestId('message-intent')).toHaveCount(0)
await page.reload()
await expect(
page.getByRole('switch', { name: '当前会话逐句意图识别' }),
).toHaveAttribute('aria-checked', 'false')
await page.getByRole('switch', { name: '当前会话逐句意图识别' }).click()
await expect(
page.getByRole('switch', { name: '当前会话逐句意图识别' }),
).toHaveAttribute('aria-checked', 'true')
await expect.poll(() => page.getByTestId('message-intent').count()).toBeGreaterThan(0)
await expect(page.getByText('Unexpected Application Error!', { exact: true })).toHaveCount(0)
await page.screenshot({ path: testInfo.outputPath('message-intent.png'), fullPage: false })
await page.goto('/settings')
await page.getByRole('button', { name: '智能回复' }).click()
const globalIntentSwitch = page.getByRole('switch', { name: '逐条客户消息意图识别' })
await expect(globalIntentSwitch).toBeVisible()
await globalIntentSwitch.click()
await expect(globalIntentSwitch).toHaveAttribute('aria-checked', 'false')
await globalIntentSwitch.click()
await expect(globalIntentSwitch).toHaveAttribute('aria-checked', 'true')
await expect(page.getByText('invalid input syntax for type json')).toHaveCount(0)
})

Some files were not shown because too many files have changed in this diff Show More