7 Commits

Author SHA1 Message Date
wmq 00b0ebb775 fix(whatsapp): preserve history across reauthorization 2026-08-01 23:13:28 +08:00
wmq 785a5b196c fix(whatsapp): complete history synchronization 2026-08-01 20:23:17 +08:00
wmq cf25d71fcf feat(gateway): add scoped proxy egress deployment 2026-08-01 19:53:29 +08:00
wmq 06112602ec feat(gateway): include production Telegram user gateway
Version the NestJS TDLib gateway with Docker deployment, migrations, automated tests and platform integration documentation while excluding all account sessions, credentials, runtime databases and backups.
2026-08-01 18:38:20 +08:00
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
245 changed files with 38818 additions and 1156 deletions
+16
View File
@@ -2,6 +2,9 @@
LX_DB_PASSWORD=replace-with-strong-database-password LX_DB_PASSWORD=replace-with-strong-database-password
LX_JWT_SECRET=replace-with-at-least-32-random-characters LX_JWT_SECRET=replace-with-at-least-32-random-characters
LX_WEB_PORT=8088 LX_WEB_PORT=8088
DEBIAN_MIRROR=https://deb.debian.org/debian
DEBIAN_SECURITY_MIRROR=https://deb.debian.org/debian-security
FFMPEG_BINARIES_URL=https://github.com/eugeneware/ffmpeg-static/releases/download
# DeepSeek Anthropic-compatible API # DeepSeek Anthropic-compatible API
DEEPSEEK_API_KEY=replace-with-rotated-deepseek-key DEEPSEEK_API_KEY=replace-with-rotated-deepseek-key
@@ -15,6 +18,14 @@ TELEGRAM_GATEWAY_SERVICE_EMAIL=replace-with-gateway-service-email
TELEGRAM_GATEWAY_SERVICE_PASSWORD=replace-with-random-service-password TELEGRAM_GATEWAY_SERVICE_PASSWORD=replace-with-random-service-password
TELEGRAM_GATEWAY_ACCOUNT_ID=replace-with-default-account-id TELEGRAM_GATEWAY_ACCOUNT_ID=replace-with-default-account-id
TELEGRAM_WEBHOOK_SECRET=replace-with-random-webhook-secret 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
# 可选。只影响 WhatsApp 网关出站,不设置系统或平台全局代理。
WHATSAPP_DEFAULT_PROXY_URL=
PUBLIC_WEBHOOK_BASE_URL=http://host.docker.internal:8088 PUBLIC_WEBHOOK_BASE_URL=http://host.docker.internal:8088
APP_PUBLIC_URL=http://127.0.0.1:8088 APP_PUBLIC_URL=http://127.0.0.1:8088
@@ -26,6 +37,11 @@ SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_FROM= SMTP_FROM=
# 企业邮箱渠道(IMAP 收信 + SMTP 发信)
# 建议使用 32 字节以上独立随机值;未配置时回退到 LX_JWT_SECRET 派生密钥。
EMAIL_CREDENTIAL_KEY=
EMAIL_POLL_MS=30000
# 真实 Stripe Checkout # 真实 Stripe Checkout
STRIPE_SECRET_KEY= STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET= 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
+52 -8
View File
@@ -1,13 +1,57 @@
node_modules # Dependencies and build output
dist node_modules/
dist-ssr **/node_modules/
dist/
**/dist/
dist-ssr/
coverage/
.vite/
# Local configuration and secrets
.env .env
server/.env .env.*
!.env.example
*.local *.local
.env.local *.pem
.env.*.local *.key
id_rsa*
id_ed25519*
# Runtime data
.smoke-data/
**/.smoke-data/
data/
uploads/
storage/
*.db
*.sqlite
*.sqlite3
*.dump
*.sql
!telegram-gateway/docker/scripts/init-db.sql
*.rdb
*.binlog
# 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 .DS_Store
.idea Thumbs.db
.idea/
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
npm-debug.log* .claude/
+78
View File
@@ -0,0 +1,78 @@
# 版本迭代记录
本项目遵循语义化版本。早期版本由经过脱敏的本地源码快照恢复,因此使用带日期的 `snapshot-*` 标签;自 `v1.0.0` 起按正式发布流程维护。
## [Unreleased]
- 修复 WhatsApp 账号重新授权后因网关账号 ID 与联系人名称变化导致历史会话分叉的问题;客户按稳定 JID 复用,旧渠道消息、任务、风险和商机自动归并,并保留最近有效的联系人资料。
### 新增
- Telegram Gateway 支持环境级默认 SOCKS5/HTTP/MTProto 代理,账号专属绑定仍具有更高优先级。
- WhatsApp Gateway 支持环境级默认 HTTP/HTTPS 代理,账号专属代理仍可覆盖默认值。
- 默认代理仅作用于两个消息网关,不改变平台、邮箱、数据库或 Docker 的全局网络出口。
- Docker 构建支持可配置的 Debian 与 FFmpeg 下载镜像,适配受限网络环境。
### 修复
- 将 Telegram Gateway 首次启动所需的 PostgreSQL 初始化脚本纳入版本控制,避免空数据库因挂载目录而启动失败。
- Telegram Gateway 本机调试端口改为 `127.0.0.1:3010`,避免与 Gitea 的 `3000` 端口冲突并阻止公网直连管理接口。
- 修复 WhatsApp `FULL` 历史通知被 Baileys 默认过滤的问题;首次连接和历史阶段完成后自动执行逐会话分页补齐。
- WhatsApp 历史同步增加并发合并、进度持久化、失败状态和平台数据库游标分页,避免重复同步及单次 2000 条截断。
- 修复 WhatsApp 先于平台 API 连通时首次同步被静默丢弃的问题,增加延迟启动、指数重试和重复 Webhook 恢复。
- WhatsApp 媒体与历史文件下载接入 Undici 代理 Dispatcher,并可恢复处理旧版本曾忽略但仍保存在缓存中的历史通知。
## [1.1.0] - 2026-08-01
### 新增
- 将完整 Telegram User Gateway 源码纳入 `telegram-gateway/`,平台与网关可以按同一 Git 版本追踪。
- 提交 NestJS、TDLib、PostgreSQL、Redis、BullMQ、Webhook、限流、审计、代理、会话、消息及群组相关实现。
- 增加 Telegram Gateway 独立 Docker 部署、平台连接、健康检查、凭据隔离和验证说明。
### 安全
- 排除 `.env`、TDLib 登录态、数据库、Redis 备份、上传文件、运行日志与真实账号调试脚本。
- 强化仓库忽略规则,防止后续误提交 `.binlog`、`.rdb` 和会话数据。
## [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. 推送分支和标签,随后按部署文档发布。
+1
View File
@@ -1,4 +1,5 @@
FROM node:20-alpine AS web-build FROM node:20-alpine AS web-build
ARG FFMPEG_BINARIES_URL=https://github.com/eugeneware/ffmpeg-static/releases/download
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
+61 -28
View File
@@ -1,53 +1,86 @@
# 灵犀跨境 # 灵犀跨境
灵犀跨境是面向跨境业务团队的 Telegram 客户沟通与 AI 经营平台。当前版本只开放 Telegram,所有开放功能均访问真实 API、PostgreSQL、DeepSeek 和 Telegram Gateway,不包含前端 Mock 数据。 灵犀跨境是一套面向跨境业务团队的多渠道客户经营 SaaS。系统将 Telegram、普通 WhatsApp 账号和企业邮箱接入统一消息中心,并提供上下文翻译、逐句意图识别、情绪与风险分析、客户画像、商机、任务、知识库和经营分析能力。
## 服务组成 当前仓库包含 Web 前端、业务 API、Telegram Gateway 和 WhatsApp Gateway。Telegram Gateway 源码位于 `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`。 要求:Docker Engine 24+、Docker Compose v2。首次部署应先按 [Telegram Gateway 接入指南](docs/TELEGRAM-GATEWAY.md) 启动网关,再启动平台。
4. 设置 Telegram Gateway 的租户令牌、租户 ID 和账号 ID。
5. 启动:
```powershell ```powershell
Copy-Item .env.example .env
# 编辑 .env,替换所有 replace-with-* 配置
docker compose --env-file .env up -d --build docker compose --env-file .env up -d --build
docker compose ps
``` ```
默认访问地址:`http://127.0.0.1:8088`。 默认入口为 `http://127.0.0.1:8088`。存活与就绪检查:
健康检查:
```powershell ```powershell
Invoke-RestMethod http://127.0.0.1:8088/health 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 ```powershell
npm install npm ci
npm run typecheck npm run typecheck
npm run build npm run build
Set-Location server Push-Location server
npm install npm ci
node --check src/index.js npm test
Pop-Location
Push-Location whatsapp-gateway
npm ci
npm test
Pop-Location
Push-Location telegram-gateway
npm ci
npm run build
npm test
Pop-Location
npm run test:e2e
``` ```
## 安全说明 E2E 默认访问 `http://127.0.0.1:8088`,可通过 `E2E_BASE_URL` 修改。
- `.env` 已加入 `.gitignore`,严禁提交真实密钥。 ## 文档
- AI 回复只提供人工确认建议,不会自动向 Telegram 发送。
- 所有业务资源都通过 JWT 中的租户 ID 隔离。 - [系统架构](docs/ARCHITECTURE.md)
- Webhook 必须配置独立随机密钥;生产部署应进一步使用 HTTPS、反向代理和 IP 白名单。 - [技术选型](docs/TECH-STACK.md)
- [配置说明](docs/CONFIGURATION.md)
- [部署与回滚](docs/DEPLOYMENT.md)
- [接口目录](docs/API.md)
- [Telegram Gateway 接入](docs/TELEGRAM-GATEWAY.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.1.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
+30
View File
@@ -16,6 +16,25 @@ services:
retries: 10 retries: 10
restart: unless-stopped restart: unless-stopped
whatsapp-gateway:
build:
context: ./whatsapp-gateway
args:
DEBIAN_MIRROR: ${DEBIAN_MIRROR:-https://deb.debian.org/debian}
DEBIAN_SECURITY_MIRROR: ${DEBIAN_SECURITY_MIRROR:-https://deb.debian.org/debian-security}
environment:
PORT: 3200
DATA_DIR: /data
WHATSAPP_GATEWAY_TOKEN: ${WHATSAPP_GATEWAY_TOKEN:?WHATSAPP_GATEWAY_TOKEN is required}
WHATSAPP_DEFAULT_PROXY_URL: ${WHATSAPP_DEFAULT_PROXY_URL:-}
LOG_LEVEL: ${WHATSAPP_LOG_LEVEL:-info}
volumes:
- lingxi_whatsapp_data:/data
dns:
- 1.1.1.1
- 8.8.8.8
restart: unless-stopped
api: api:
build: build:
context: ./server context: ./server
@@ -33,6 +52,10 @@ services:
TELEGRAM_GATEWAY_SERVICE_PASSWORD: ${TELEGRAM_GATEWAY_SERVICE_PASSWORD:?TELEGRAM_GATEWAY_SERVICE_PASSWORD is required} TELEGRAM_GATEWAY_SERVICE_PASSWORD: ${TELEGRAM_GATEWAY_SERVICE_PASSWORD:?TELEGRAM_GATEWAY_SERVICE_PASSWORD is required}
TELEGRAM_GATEWAY_ACCOUNT_ID: ${TELEGRAM_GATEWAY_ACCOUNT_ID:-} TELEGRAM_GATEWAY_ACCOUNT_ID: ${TELEGRAM_GATEWAY_ACCOUNT_ID:-}
TELEGRAM_WEBHOOK_SECRET: ${TELEGRAM_WEBHOOK_SECRET:?TELEGRAM_WEBHOOK_SECRET is required} 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} 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} APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://127.0.0.1:8088}
SMTP_HOST: ${SMTP_HOST:-} SMTP_HOST: ${SMTP_HOST:-}
@@ -41,6 +64,8 @@ services:
SMTP_USER: ${SMTP_USER:-} SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM: ${SMTP_FROM:-} SMTP_FROM: ${SMTP_FROM:-}
EMAIL_CREDENTIAL_KEY: ${EMAIL_CREDENTIAL_KEY:-}
EMAIL_POLL_MS: ${EMAIL_POLL_MS:-30000}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
STRIPE_PRICE_LITE: ${STRIPE_PRICE_LITE:-} STRIPE_PRICE_LITE: ${STRIPE_PRICE_LITE:-}
STRIPE_PRICE_STANDARD: ${STRIPE_PRICE_STANDARD:-} STRIPE_PRICE_STANDARD: ${STRIPE_PRICE_STANDARD:-}
@@ -54,6 +79,8 @@ services:
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
whatsapp-gateway:
condition: service_healthy
healthcheck: 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))"] 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 interval: 10s
@@ -66,6 +93,8 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args:
FFMPEG_BINARIES_URL: ${FFMPEG_BINARIES_URL:-https://github.com/eugeneware/ffmpeg-static/releases/download}
ports: ports:
- "${LX_WEB_PORT:-8088}:80" - "${LX_WEB_PORT:-8088}:80"
depends_on: depends_on:
@@ -75,3 +104,4 @@ services:
volumes: volumes:
lingxi_pgdata: 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 文档并进行契约测试。
+80
View File
@@ -0,0 +1,80 @@
# 系统架构
## 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 与数据初始化 |
| `telegram-gateway/` | NestJS + TDLib 多租户 Telegram 普通账号网关,独立部署 |
| `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 源码随本仓库版本化,但使用独立 Compose、数据库、Redis 和 TDLib 数据卷部署。
- 普通 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 日志。
+103
View File
@@ -0,0 +1,103 @@
# 部署与回滚
## 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
# 先配置并启动 Telegram Gateway
cp telegram-gateway/.env.example telegram-gateway/.env
# 编辑 telegram-gateway/.env,配置 TG_API_ID、TG_API_HASH 和强随机密钥
docker compose --env-file telegram-gateway/.env \
-f telegram-gateway/docker/docker-compose.yml up -d --build
# 再启动灵犀跨境平台
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
```
平台的 `TELEGRAM_GATEWAY_URL` 指向网关 `/api` 地址。生产环境应通过私有网络连接,不把 Gateway 管理接口直接暴露公网。详细配置见 [Telegram Gateway 接入指南](TELEGRAM-GATEWAY.md)。
### 2.1 仅消息网关使用出站代理
生产环境可以创建一个不映射宿主机端口的专用 Docker 网络和 V2Ray/Xray 容器,并只将 Telegram、WhatsApp 网关接入该网络。不要设置 Docker Daemon、宿主机或平台 API 的全局 `HTTP_PROXY`/`HTTPS_PROXY`。
```env
# Telegram Gateway
TG_DEFAULT_PROXY_TYPE=socks5
TG_DEFAULT_PROXY_HOST=lingxi-egress
TG_DEFAULT_PROXY_PORT=10808
# 平台 WhatsApp Gateway
WHATSAPP_DEFAULT_PROXY_URL=http://lingxi-egress:10809
```
账号级代理优先于上述默认代理。代理容器应使用 `internal/external Docker network + no host ports` 的方式隔离;配置文件和认证信息仅保存在服务器受限目录,不进入 Git。上线前至少验证 Telegram API 与 WhatsApp Web 的 HTTPS 连接,并确认 API、数据库、邮箱和 AI 服务未配置全局代理变量。
若部署网络无法访问 Debian 官方源或 FFmpeg GitHub Release,可通过 `.env` 的 `DEBIAN_MIRROR`、`DEBIAN_SECURITY_MIRROR` 和 `FFMPEG_BINARIES_URL` 指定构建镜像;这些变量只影响镜像构建,不改变运行时出口。
## 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。
当前生产拓扑使用 Caddy 与 NPS 时,应在 NPS HTTP Host 中将域名指向平台内网 `8088`,Caddy 只反向代理到 NPS 的本机 HTTP 入口。修改前分别备份 NPS Host 配置和 Caddyfile,验证配置后再重启 NPS、平滑重载 Caddy。
## 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 采用异步增强而非消息主链路依赖,模型故障时仍可正常沟通。
+73
View File
@@ -0,0 +1,73 @@
# Telegram Gateway 接入指南
## 定位
`telegram-gateway/` 使用 Telegram TDLib 登录普通用户账号,不是 Bot API。它负责账号授权、历史同步、消息与媒体、联系人、群组/频道、代理、会话设备、Webhook、审计、限流和异步任务;灵犀跨境 API 通过服务凭据调用它。
网关源码与平台在同一 Git 仓库版本化,但运行时保持独立:它拥有自己的 PostgreSQL、Redis 和 TDLib 持久卷,故障与扩缩容不会直接占用平台数据库。
## 配置
```powershell
Copy-Item telegram-gateway/.env.example telegram-gateway/.env
```
至少替换:
- `TG_API_ID`、`TG_API_HASH`:从 Telegram 官方开发者后台申请,不能使用 Bot Token。
- `JWT_SECRET`、`ADMIN_API_KEY`:分别生成强随机值,不得复用。
- `DB_PASSWORD`:生产数据库强密码。
- `CORS_ORIGINS`:仅填写受信任的管理入口;平台服务端调用不依赖浏览器跨域。
- `TG_DEFAULT_PROXY_*`:可选的默认 TDLib 代理;账号绑定的专属代理优先。
生产环境不要使用示例中的 `postgres` 密码。`.env` 不得提交 Git。
## 启动
```powershell
docker compose --env-file telegram-gateway/.env `
-f telegram-gateway/docker/docker-compose.yml up -d --build
docker compose -f telegram-gateway/docker/docker-compose.yml ps
Invoke-RestMethod http://127.0.0.1:3000/api/admin/health
Invoke-RestMethod http://127.0.0.1:3000/api/admin/ready
```
Swagger 默认位于 `http://127.0.0.1:3000/api/docs`。生产环境应限制 Swagger 和管理接口的访问来源。
## 连接灵犀跨境
在平台根目录 `.env` 配置:
```dotenv
TELEGRAM_GATEWAY_URL=http://host.docker.internal:3000/api
TELEGRAM_GATEWAY_TOKEN=replace-with-gateway-jwt
TELEGRAM_GATEWAY_TENANT_ID=replace-with-gateway-tenant-id
TELEGRAM_GATEWAY_SERVICE_EMAIL=replace-with-service-account
TELEGRAM_GATEWAY_SERVICE_PASSWORD=replace-with-service-password
TELEGRAM_WEBHOOK_SECRET=replace-with-independent-webhook-secret
```
Linux 生产部署建议把两个 Compose 接入同一私有 Docker 网络,并把 URL 改为网关服务名。不要通过公网传递服务密码或长期 JWT。
## 数据边界
以下内容只存在部署环境,不能进入 Git 或普通源码备份:
- `telegram-gateway/.env`
- TDLib `db.sqlite`、`td.binlog`、WAL 文件及账号目录
- PostgreSQL/Redis 导出
- 下载媒体、上传文件和业务日志
- 验证码、二步密码、手机号及授权会话
平台数据库保存渠道引用和业务消息;网关数据库保存 Telegram 操作状态。备份与恢复必须同时记录两个系统的版本和时间点。
## 验证
```powershell
npm ci --prefix telegram-gateway
npm run build --prefix telegram-gateway
npm test --prefix telegram-gateway
```
自动化测试通过后,仍需使用无重要数据的测试账号验证手机号、验证码、二步密码、历史同步、双向收发、媒体、Webhook、代理、断网重连和 FloodWait 退避。Mock/内存数据库测试不能证明真实 Telegram 可用。
+46
View File
@@ -0,0 +1,46 @@
# 测试策略
## 测试层次
| 层次 | 命令 | 覆盖 |
| --- | --- | --- |
| 类型检查 | `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` | 路由、状态、消息与可靠性 |
| Telegram 网关 | `npm run build --prefix telegram-gateway`、`npm test --prefix telegram-gateway` | NestJS 编译、业务服务、路由、迁移与网关逻辑 |
| 浏览器 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 charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="灵犀跨境 - 让跨境沟通更简单。面向中小外贸企业的跨境AI经营服务平台。" /> <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> <title>灵犀跨境 · 跨境AI经营服务平台</title>
</head> </head>
<body> <body>
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "lingxi-crossborder-saas", "name": "lingxi-crossborder-saas",
"version": "1.0.0", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lingxi-crossborder-saas", "name": "lingxi-crossborder-saas",
"version": "1.0.0", "version": "1.1.0",
"dependencies": { "dependencies": {
"@hookform/resolvers": "^3.9.1", "@hookform/resolvers": "^3.9.1",
"@tanstack/react-query": "^5.62.0", "@tanstack/react-query": "^5.62.0",
+2 -2
View File
@@ -1,9 +1,9 @@
{ {
"name": "lingxi-crossborder-saas", "name": "lingxi-crossborder-saas",
"private": true, "private": true,
"version": "1.0.0", "version": "1.1.0",
"type": "module", "type": "module",
"description": "灵犀跨境 - Telegram 跨境 AI 客户经营平台", "description": "灵犀跨境 - Telegram、WhatsApp 与企业邮箱 AI 客户经营平台",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
+3
View File
@@ -5,6 +5,9 @@ export default defineConfig({
timeout: 30_000, timeout: 30_000,
expect: { timeout: 10_000 }, expect: { timeout: 10_000 },
fullyParallel: false, 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, retries: 0,
reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]], reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
use: { 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", "cors": "^2.8.5",
"express": "^5.1.0", "express": "^5.1.0",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"imapflow": "^1.6.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"libphonenumber-js": "^1.11.20",
"mailparser": "^3.9.14",
"mammoth": "^1.9.0", "mammoth": "^1.9.0",
"nodemailer": "^7.0.5", "nodemailer": "^7.0.5",
"pdf-parse": "^1.1.1", "pdf-parse": "^1.1.1",
@@ -20,6 +23,28 @@
"ws": "^8.18.3" "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": { "node_modules/@xmldom/xmldom": {
"version": "0.8.13", "version": "0.8.13",
"resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.13.tgz", "resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
@@ -29,6 +54,17 @@
"node": ">=10.0.0" "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": { "node_modules/accepts": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz", "resolved": "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz",
@@ -51,6 +87,15 @@
"sprintf-js": "~1.0.2" "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": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", "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": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
@@ -259,6 +313,61 @@
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==", "integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
"license": "BSD-2-Clause" "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": { "node_modules/duck": {
"version": "0.1.12", "version": "0.1.12",
"resolved": "https://registry.npmmirror.com/duck/-/duck-0.1.12.tgz", "resolved": "https://registry.npmmirror.com/duck/-/duck-0.1.12.tgz",
@@ -306,6 +415,27 @@
"node": ">= 0.8" "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": { "node_modules/es-define-property": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -515,6 +645,15 @@
"node": ">= 0.4" "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": { "node_modules/helmet": {
"version": "8.3.0", "version": "8.3.0",
"resolved": "https://registry.npmmirror.com/helmet/-/helmet-8.3.0.tgz", "resolved": "https://registry.npmmirror.com/helmet/-/helmet-8.3.0.tgz",
@@ -527,6 +666,56 @@
"url": "https://github.com/sponsors/EvanHahn" "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": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
@@ -563,6 +752,22 @@
"url": "https://opencollective.com/express" "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": { "node_modules/immediate": {
"version": "3.0.6", "version": "3.0.6",
"resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz", "resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz",
@@ -575,6 +780,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "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": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -651,6 +865,45 @@
"safe-buffer": "^5.0.1" "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": { "node_modules/lie": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz", "resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",
@@ -660,6 +913,25 @@
"immediate": "~3.0.5" "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": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmmirror.com/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmmirror.com/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -713,6 +985,33 @@
"underscore": "^1.13.1" "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": { "node_modules/mammoth": {
"version": "1.12.0", "version": "1.12.0",
"resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.0.tgz", "resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.0.tgz",
@@ -847,6 +1146,15 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz",
@@ -880,6 +1188,19 @@
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)" "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": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
@@ -924,6 +1245,15 @@
"url": "https://github.com/sponsors/mehmet-kozan" "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": { "node_modules/pg": {
"version": "8.22.0", "version": "8.22.0",
"resolved": "https://registry.npmmirror.com/pg/-/pg-8.22.0.tgz", "resolved": "https://registry.npmmirror.com/pg/-/pg-8.22.0.tgz",
@@ -1013,6 +1343,43 @@
"split2": "^4.1.0" "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": { "node_modules/postgres-array": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmmirror.com/postgres-array/-/postgres-array-2.0.0.tgz", "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==", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT" "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": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1071,6 +1454,15 @@
"node": ">= 0.10" "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": { "node_modules/qs": {
"version": "6.15.3", "version": "6.15.3",
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz", "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz",
@@ -1087,6 +1479,12 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/range-parser": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.3.0.tgz", "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.3.0.tgz",
@@ -1136,6 +1534,15 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT" "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": { "node_modules/router": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmmirror.com/router/-/router-2.2.0.tgz", "resolved": "https://registry.npmmirror.com/router/-/router-2.2.0.tgz",
@@ -1172,12 +1579,33 @@
], ],
"license": "MIT" "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": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "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": { "node_modules/semver": {
"version": "7.8.5", "version": "7.8.5",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz", "resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
@@ -1319,6 +1747,39 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/split2": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz", "resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz",
@@ -1358,6 +1819,33 @@
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT" "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": { "node_modules/toidentifier": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1398,6 +1886,12 @@
"url": "https://opencollective.com/express" "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": { "node_modules/underscore": {
"version": "1.13.8", "version": "1.13.8",
"resolved": "https://registry.npmmirror.com/underscore/-/underscore-1.13.8.tgz", "resolved": "https://registry.npmmirror.com/underscore/-/underscore-1.13.8.tgz",
+8 -3
View File
@@ -5,18 +5,23 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/index.js",
"test": "node --test test/business.test.js" "test": "node --test test/business.test.js test/whatsapp-identity.test.js",
"test:email-protocol": "node --test test/email-protocol.test.js",
"test:email-api": "node --test test/email-api-e2e.test.js"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^5.1.0", "express": "^5.1.0",
"helmet": "^8.1.0", "helmet": "^8.1.0",
"imapflow": "^1.6.1",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"libphonenumber-js": "^1.11.20",
"mailparser": "^3.9.14",
"mammoth": "^1.9.0", "mammoth": "^1.9.0",
"nodemailer": "^7.0.5", "nodemailer": "^7.0.5",
"pdf-parse": "^1.1.1", "pdf-parse": "^1.1.1",
"pg": "^8.13.1" "pg": "^8.13.1",
,"ws": "^8.18.3" "ws": "^8.18.3"
} }
} }
+59 -28
View File
@@ -7,39 +7,70 @@ function parseJson(text) {
return JSON.parse(cleaned) 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 const apiKey = process.env.DEEPSEEK_API_KEY
if (!apiKey) throw Object.assign(new Error('DEEPSEEK_API_KEY 尚未配置'), { status: 503 }) if (!apiKey) throw Object.assign(new Error('DEEPSEEK_API_KEY 尚未配置'), { status: 503 })
const controller = new AbortController() const endpoint = `${(process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/anthropic').replace(/\/$/, '')}/v1/messages`
const timeout = setTimeout(() => controller.abort(), Number(process.env.AI_TIMEOUT_MS || 45000)) const attempts = json ? 2 : 1
try { let prompt = user
const response = await fetch(`${(process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/anthropic').replace(/\/$/, '')}/v1/messages`, { let lastError
method: 'POST',
signal: controller.signal, for (let attempt = 0; attempt < attempts; attempt += 1) {
headers: { const controller = new AbortController()
'content-type': 'application/json', const timeout = setTimeout(
'x-api-key': apiKey, () => controller.abort(),
'anthropic-version': '2023-06-01', Number(options.timeoutMs || process.env.AI_TIMEOUT_MS || 30000),
}, )
body: JSON.stringify({ try {
model: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash', const response = await fetch(endpoint, {
max_tokens: Number(process.env.AI_MAX_TOKENS || 4096), method: 'POST',
temperature: json ? 0.1 : 0.3, signal: controller.signal,
system, headers: {
messages: [{ role: 'user', content: [{ type: 'text', text: user }] }], 'content-type': 'application/json',
}), 'x-api-key': apiKey,
}) 'anthropic-version': '2023-06-01',
const body = await response.json() },
if (!response.ok) throw Object.assign(new Error(body?.error?.message || `AI 请求失败 (${response.status})`), { status: 502 }) body: JSON.stringify({
const text = contentText(body) model: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash',
return json ? parseJson(text) : text max_tokens: Number(options.maxTokens || process.env.AI_MAX_TOKENS || 4096),
} finally { temperature: options.temperature ?? (json ? 0 : 0.3),
clearTimeout(timeout) thinking: options.thinking === true
? { type: 'enabled' }
: { type: 'disabled' },
system,
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 })
}
const text = contentText(body)
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) { export function conversationContext(messages, limit = 30) {
return messages.slice(-80).map((m) => { return messages.slice(-limit).map((m) => {
const role = m.sender === 'agent' ? '坐席' : m.sender === 'system' ? '系统' : '客户' const role = m.sender === 'agent' ? '坐席' : m.sender === 'system' ? '系统' : '客户'
const content = m.originalText || m.attachmentName || `[${m.type || '消息'}]` const content = m.originalText || m.attachmentName || `[${m.type || '消息'}]`
const translation = m.translatedText && m.translatedText !== content ? `(译文:${m.translatedText})` : '' 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 = {}) { export async function audit(req, action, target, detail = {}) {
// pg 会把顶层 JavaScript 数组编码成 PostgreSQL 数组文本(如 {ai}),
// jsonb 无法解析该格式。显式 JSON 序列化可同时兼容对象、数组和标量。
const serializedDetail = JSON.stringify(detail ?? {})
await pool.query( await pool.query(
'INSERT INTO audit_logs(id,tenant_id,user_id,action,target,detail,ip) VALUES($1,$2,$3,$4,$5,$6,$7)', '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)
}
}
+3473 -123
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(),
}
}
+36
View File
@@ -0,0 +1,36 @@
import crypto from 'node:crypto'
export function whatsappCustomerStableId(jid) {
const normalized = String(jid || '').trim().toLowerCase()
const hash = crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 24)
return `wa-customer-${hash}`
}
export function whatsappConversationActivityAt(conversation) {
return Date.parse(
conversation?.lastMessage?.createdAt
|| conversation?.updatedAt
|| conversation?.createdAt
|| '',
) || 0
}
export function isWeakWhatsAppDisplayName(value, jid) {
const name = String(value || '').trim()
const jidName = String(jid || '').split('@')[0].replace(/\D/g, '')
const digits = name.replace(/\D/g, '')
return !name || (digits.length >= 7 && (!jidName || digits === jidName))
}
export function reusableWhatsAppConversations(conversations, activeChannelIds, channelId, jid) {
return conversations
.filter((item) => (
item.channel === 'whatsapp'
&& String(item.whatsappJid || '') === String(jid)
&& (
String(item.channelId || '') === String(channelId)
|| !activeChannelIds.has(String(item.channelId || String(item.id).split(':')[0]))
)
))
.sort((left, right) => whatsappConversationActivityAt(right) - whatsappConversationActivityAt(left))
}
+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 test from 'node:test'
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import { countBy, dateKey, percent, telegramAttachment } from '../src/business.js' import { countBy, dateKey, percent, telegramAttachment } from '../src/business.js'
import { decryptEmailPassword, encryptEmailPassword } from '../src/email.js'
test('Telegram 图片、文档、语音和贴纸映射为可下载文件', () => { test('Telegram 图片、文档、语音和贴纸映射为可下载文件', () => {
assert.deepEqual( assert.deepEqual(
@@ -42,3 +43,14 @@ test('业务指标工具处理百分比、分组和无效日期', () => {
{ new: 2, won: 1 }, { 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))
})
+32
View File
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
isWeakWhatsAppDisplayName,
reusableWhatsAppConversations,
whatsappCustomerStableId,
} from '../src/whatsapp-identity.js'
test('WhatsApp customer id remains stable after gateway reauthorization', () => {
assert.equal(
whatsappCustomerStableId('15551234567@s.whatsapp.net'),
whatsappCustomerStableId('15551234567@s.whatsapp.net'),
)
assert.match(whatsappCustomerStableId('15551234567@s.whatsapp.net'), /^wa-customer-[a-f0-9]{24}$/)
})
test('the latest inactive conversation is reused while another active account stays isolated', () => {
const jid = '15551234567@s.whatsapp.net'
const conversations = [
{ id: 'old:newest', channel: 'whatsapp', channelId: 'old', whatsappJid: jid, customerId: 'abigail', lastMessage: { createdAt: '2026-07-31T07:51:37.000Z' } },
{ id: 'older:history', channel: 'whatsapp', channelId: 'older', whatsappJid: jid, customerId: 'legacy', lastMessage: { createdAt: '2026-07-29T17:25:42.000Z' } },
{ id: 'other:active', channel: 'whatsapp', channelId: 'other', whatsappJid: jid, customerId: 'other-account', lastMessage: { createdAt: '2026-08-01T00:00:00.000Z' } },
]
const result = reusableWhatsAppConversations(conversations, new Set(['current', 'other']), 'current', jid)
assert.deepEqual(result.map((item) => item.customerId), ['abigail', 'legacy'])
})
test('a saved contact name is not replaced by a phone-number fallback', () => {
const jid = '15551234567@s.whatsapp.net'
assert.equal(isWeakWhatsAppDisplayName('Abigail', jid), false)
assert.equal(isWeakWhatsAppDisplayName('+1 555 123 4567', jid), true)
})
+70 -37
View File
@@ -1,43 +1,76 @@
import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom' import { createBrowserRouter, Navigate, RouterProvider, useRouteError } from 'react-router-dom'
import { useAuthStore } from '@/app/store/auth' import { isSessionTokenExpired, useAuthStore } from '@/app/store/auth'
import { AppLayout } from '@/components/layout/AppLayout' import { AppLayout } from '@/components/layout/AppLayout'
import type { ReactNode } from 'react' import { useEffect, type ReactNode } from 'react'
import { LoginPage } from '@/pages/auth/LoginPage' import { LoginPage } from '@/pages/auth/LoginPage'
import { RegisterPage } from '@/pages/auth/RegisterPage' 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 }) { function RequireAuth({ children }: { children: ReactNode }) {
const token = useAuthStore((s) => s.token) 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}</> return <>{children}</>
} }
function GuestOnly({ children }: { children: ReactNode }) { function GuestOnly({ children }: { children: ReactNode }) {
const token = useAuthStore((s) => s.token) 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}</> 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([ const router = createBrowserRouter([
{ path: '/login', element: <GuestOnly><LoginPage /></GuestOnly> }, { path: '/login', element: <GuestOnly><LoginPage /></GuestOnly>, errorElement: <RouteErrorFallback /> },
{ path: '/register', element: <GuestOnly><RegisterPage /></GuestOnly> }, { path: '/register', element: <GuestOnly><RegisterPage /></GuestOnly>, errorElement: <RouteErrorFallback /> },
{ {
path: '/', path: '/',
errorElement: <RouteErrorFallback />,
element: ( element: (
<RequireAuth> <RequireAuth>
<AppLayout /> <AppLayout />
@@ -45,21 +78,21 @@ const router = createBrowserRouter([
), ),
children: [ children: [
{ index: true, element: <Navigate to="/dashboard" replace /> }, { index: true, element: <Navigate to="/dashboard" replace /> },
{ path: 'dashboard', element: <DashboardPage /> }, { path: 'dashboard', lazy: async () => ({ Component: (await import('@/pages/dashboard/DashboardPage')).DashboardPage }) },
{ path: 'inbox', element: <InboxPage /> }, { path: 'inbox', lazy: async () => ({ Component: (await import('@/pages/inbox/InboxPage')).InboxPage }) },
{ path: 'customers', element: <CustomersPage /> }, { path: 'customers', lazy: async () => ({ Component: (await import('@/pages/customers/CustomersPage')).CustomersPage }) },
{ path: 'customers/:id', element: <CustomerDetailPage /> }, { path: 'customers/:id', lazy: async () => ({ Component: (await import('@/pages/customers/CustomerDetailPage')).CustomerDetailPage }) },
{ path: 'opportunities', element: <OpportunitiesPage /> }, { path: 'opportunities', lazy: async () => ({ Component: (await import('@/pages/opportunities/OpportunitiesPage')).OpportunitiesPage }) },
{ path: 'translate', element: <TranslatePage /> }, { path: 'translate', lazy: async () => ({ Component: (await import('@/pages/translate/TranslatePage')).TranslatePage }) },
{ path: 'risks', element: <RisksPage /> }, { path: 'risks', lazy: async () => ({ Component: (await import('@/pages/risks/RisksPage')).RisksPage }) },
{ path: 'knowledge', element: <KnowledgePage /> }, { path: 'knowledge', lazy: async () => ({ Component: (await import('@/pages/knowledge/KnowledgePage')).KnowledgePage }) },
{ path: 'analytics', element: <AnalyticsPage /> }, { path: 'analytics', lazy: async () => ({ Component: (await import('@/pages/analytics/AnalyticsPage')).AnalyticsPage }) },
{ path: 'channels', element: <ChannelsPage /> }, { path: 'channels', lazy: async () => ({ Component: (await import('@/pages/channels/ChannelsPage')).ChannelsPage }) },
{ path: 'team', element: <TeamPage /> }, { path: 'team', lazy: async () => ({ Component: (await import('@/pages/team/TeamPage')).TeamPage }) },
{ path: 'billing', element: <BillingPage /> }, { path: 'billing', lazy: async () => ({ Component: (await import('@/pages/billing/BillingPage')).BillingPage }) },
{ path: 'settings', element: <SettingsPage /> }, { path: 'settings', lazy: async () => ({ Component: (await import('@/pages/settings/SettingsPage')).SettingsPage }) },
{ path: 'help', element: <HelpPage /> }, { path: 'help', lazy: async () => ({ Component: (await import('@/pages/help/HelpPage')).HelpPage }) },
{ path: '*', element: <NotFoundPage /> }, { 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 { persist } from 'zustand/middleware'
import type { AuthSession, Tenant, User } from '@/types' import type { AuthSession, Tenant, User } from '@/types'
import type { RoleKey } from '@/config/roles' import type { RoleKey } from '@/config/roles'
import { setTokenGetter } from '@/services/api/client' import { setTokenGetter, setUnauthorizedHandler } from '@/services/api/client'
interface AuthState { interface AuthState {
token: string | null token: string | null
@@ -32,6 +32,26 @@ export const useAuthStore = create<AuthState>()(
// 让 API client 能读取 token(真实后端模式使用) // 让 API client 能读取 token(真实后端模式使用)
setTokenGetter(() => useAuthStore.getState().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 { export function useCurrentRole(): RoleKey {
return useAuthStore((s) => s.user?.role ?? 'viewer') 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 { LiveDataBadge } from '@/components/common/LiveDataBadge'
import { cn } from '@/utils/cn' 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({ export function ChartCard({
title, 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', 'inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700',
className, className,
)} )}
title="指标由当前企业真实业务数据实时计算" title="指标实时更新"
> >
<Radio className="h-3 w-3" /> <Radio className="h-3 w-3" />
{text} {text}
+3 -3
View File
@@ -15,11 +15,11 @@ export function PageHeader({
className?: string className?: string
}) { }) {
return ( 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="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<h1 className="text-lg font-semibold text-foreground">{title}</h1> <h1 className="text-[22px] font-semibold tracking-[-0.035em] text-slate-950 md:text-2xl">{title}</h1>
{description && <p className="mt-0.5 text-sm text-muted-foreground">{description}</p>} {description && <p className="mt-1 text-sm text-slate-500">{description}</p>}
</div> </div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>} {actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</div> </div>
+8 -5
View File
@@ -27,17 +27,20 @@ export function StatCard({
const DeltaIcon = neutral ? Minus : (delta ?? 0) > 0 ? TrendingUp : TrendingDown const DeltaIcon = neutral ? Minus : (delta ?? 0) > 0 ? TrendingUp : TrendingDown
return ( return (
<Card <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} onClick={onClick}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{title}</span> <span className="text-xs font-medium text-slate-500">{title}</span>
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-primary-50"> <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" /> <Icon className="h-4 w-4 text-primary-600" />
</span> </span>
</div> </div>
<div className="mt-2 flex items-baseline gap-1"> <div className="mt-3 flex items-baseline gap-1">
<span className="text-2xl font-semibold tracking-tight text-foreground">{value}</span> <span className="text-[28px] font-semibold tracking-[-0.04em] text-slate-950">{value}</span>
{suffix && <span className="text-xs text-muted-foreground">{suffix}</span>} {suffix && <span className="text-xs text-muted-foreground">{suffix}</span>}
</div> </div>
{showDelta && ( {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 { useEffect, useState } from 'react'
import { useMutation } from '@tanstack/react-query' 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 { generateReplies } from '@/services/api/ai'
import type { AiReplySuggestion } from '@/types/ai' import type { AiReplySuggestion } from '@/types/ai'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -20,30 +20,32 @@ export function AiReplies({
onAdopt, onAdopt,
onEdit, onEdit,
onClose, onClose,
targetLanguage = 'auto',
}: { }: {
conversationId: string conversationId: string
onAdopt: (content: string) => void onAdopt: (content: string, translationCn: string, suggestionId: string) => void
onEdit: (content: string) => void onEdit: (content: string, translationCn: string) => void
onClose: () => void onClose: () => void
targetLanguage?: string
}) { }) {
const [items, setItems] = useState<AiReplySuggestion[] | null>(null) const [items, setItems] = useState<AiReplySuggestion[] | null>(null)
useEffect(() => setItems(null), [conversationId]) useEffect(() => setItems(null), [conversationId])
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => generateReplies(conversationId), mutationFn: () => generateReplies(conversationId, targetLanguage),
onSuccess: (data) => { onSuccess: (data) => {
setItems(Array.isArray(data) ? data : []) setItems(Array.isArray(data) ? data : [])
toast.success('AI 回复建议已生成', '根据客户画像、知识库和历史成功回复综合生成') toast.success('回复建议已生成', '已参考客户资料、知识库和历史回复')
}, },
onError: (e) => toast.error(errorMessage(e)), onError: (e) => toast.error(errorMessage(e)),
}) })
return ( 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"> <div className="mb-3 flex items-center justify-between">
<h4 className="flex items-center gap-1.5 text-xs font-semibold text-muted-foreground"> <h4 className="flex items-center gap-2 text-xs font-semibold text-slate-700">
<Sparkles className="h-3.5 w-3.5 text-primary" /> <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>
AI 智能回复 回复建议
</h4> </h4>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
@@ -61,7 +63,7 @@ export function AiReplies({
'生成回复建议' '生成回复建议'
)} )}
</Button> </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" /> <X className="h-4 w-4" />
</Button> </Button>
</div> </div>
@@ -94,7 +96,29 @@ export function AiReplies({
{languageName(s.targetLang)} {languageName(s.targetLang)}
</span> </span>
</div> </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"> <div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
{(s.knowledgeRefs || []).slice(0, 2).map((ref) => ( {(s.knowledgeRefs || []).slice(0, 2).map((ref) => (
@@ -111,7 +135,7 @@ export function AiReplies({
<span className="text-muted-foreground">{s.reason}</span> <span className="text-muted-foreground">{s.reason}</span>
</div> </div>
<div className="flex items-start gap-1"> <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> <span className="text-muted-foreground">{s.expectedEffect}</span>
</div> </div>
{s.cultureTip && ( {s.cultureTip && (
@@ -123,11 +147,11 @@ export function AiReplies({
</div> </div>
<div className="mt-2 flex gap-1.5"> <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" /> <Check className="h-3.5 w-3.5" />
采用 采用
</Button> </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" /> <Edit className="h-3.5 w-3.5" />
编辑后采用 编辑后采用
</Button> </Button>
@@ -146,9 +170,10 @@ export function AiReplies({
)} )}
{!items && !mutation.isPending && ( {!items && !mutation.isPending && (
<p className="py-3 text-center text-xs text-muted-foreground"> <div className="flex items-center justify-between rounded-xl bg-slate-50 px-3 py-2.5">
点击「生成回复建议」让 AI 根据客户上下文、知识库与历史成功回复为您生成回复 <p className="text-xs text-slate-500">生成三种回复建议</p>
</p> <span className="hidden text-[10px] font-medium text-primary sm:inline">不会自动发送</span>
</div>
)} )}
</div> </div>
) )
+73 -34
View File
@@ -10,6 +10,7 @@ import {
Shrink, Shrink,
Expand, Expand,
ChevronDown, ChevronDown,
Languages,
Reply, Reply,
X, X,
} from 'lucide-react' } from 'lucide-react'
@@ -19,7 +20,8 @@ import { Dropdown } from '@/components/ui/dropdown'
import { Switch } from '@/components/ui/switch' import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { toast } from '@/app/store/toast' 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 = [ const QUICK_PHRASES = [
{ label: '报价催促', text: 'Friendly reminder: the quotation is valid until this Friday. Please confirm at your earliest convenience.' }, { 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, disabled,
replyingTo, replyingTo,
onCancelReply, 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> onMedia?: (files: File[], kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker') => Promise<void>
value?: string value?: string
onChange?: (text: string) => void onChange?: (text: string) => void
disabled?: boolean disabled?: boolean
replyingTo?: { id: string; text: string } | null replyingTo?: { id: string; text: string } | null
onCancelReply?: () => void onCancelReply?: () => void
channel?: ChannelType
targetLanguage?: string
detectedLanguage?: string
onTargetLanguageChange?: (language: string) => void
}) { }) {
const [internalText, setInternalText] = useState('') const [internalText, setInternalText] = useState('')
const [sending, setSending] = useState(false) const [sending, setSending] = useState(false)
@@ -73,7 +83,12 @@ export function Composer({
setSending(true) setSending(true)
try { try {
await onMedia(Array.from(files), kind) 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 { } catch {
toast.error('媒体发送失败') toast.error('媒体发送失败')
} finally { } finally {
@@ -85,7 +100,7 @@ export function Composer({
if (!text.trim()) return if (!text.trim()) return
setSending(true) setSending(true)
try { try {
await onSend(text, autoTranslate) await onSend(text, autoTranslate, targetLanguage)
setText('') setText('')
} finally { } finally {
setSending(false) setSending(false)
@@ -109,7 +124,7 @@ export function Composer({
setAiLoading(true) setAiLoading(true)
try { try {
const { textAction } = await import('@/services/api/ai') 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) setText(res.result)
toast.success('AI 处理完成') toast.success('AI 处理完成')
} catch { } catch {
@@ -120,11 +135,11 @@ export function Composer({
} }
const toolbar = ( 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 <Dropdown
trigger={ 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" /> <FileText className="h-3.5 w-3.5" />
常用话术 常用话术
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
@@ -134,7 +149,7 @@ export function Composer({
/> />
<span className="mx-1 h-4 w-px bg-border" /> <span className="mx-1 h-4 w-px bg-border" />
<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={() => documentInput.current?.click()} onClick={() => documentInput.current?.click()}
title="上传文件" title="上传文件"
type="button" type="button"
@@ -142,29 +157,33 @@ export function Composer({
<Paperclip className="inline h-3.5 w-3.5" /> 文件 <Paperclip className="inline h-3.5 w-3.5" /> 文件
</button> </button>
<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()} onClick={() => imageInput.current?.click()}
title="上传图片" title="上传图片"
type="button" type="button"
> >
<Image className="inline h-3.5 w-3.5" /> 图片 <Image className="inline h-3.5 w-3.5" /> 图片
</button> </button>
<button {channel !== 'email' && (
className="rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground" <>
onClick={() => audioInput.current?.click()} <button
title="语音输入" className="shrink-0 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50 hover:text-slate-900"
type="button" onClick={() => audioInput.current?.click()}
> title="语音输入"
<Mic className="inline h-3.5 w-3.5" /> 语音 type="button"
</button> >
<button <Mic className="inline h-3.5 w-3.5" /> 语音
className="rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground" </button>
onClick={() => stickerInput.current?.click()} <button
title="发送贴纸" className="shrink-0 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50 hover:text-slate-900"
type="button" onClick={() => stickerInput.current?.click()}
> title="发送贴纸"
贴纸 type="button"
</button> >
贴纸
</button>
</>
)}
<input ref={documentInput} type="file" className="hidden" onChange={(e) => uploadFiles(e.target.files, 'document')} /> <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={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')} /> <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" type="button"
disabled={aiLoading || !text.trim()} disabled={aiLoading || !text.trim()}
onClick={() => aiAction(t.action)} 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} <t.icon className="inline h-3.5 w-3.5" /> {t.label}
</button> </button>
@@ -190,7 +209,7 @@ export function Composer({
<span className="mx-1 h-4 w-px bg-border" /> <span className="mx-1 h-4 w-px bg-border" />
<Dropdown <Dropdown
trigger={ 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" /> 修改语气 <ChevronDown className="h-3 w-3" />
</button> </button>
} }
@@ -200,9 +219,9 @@ export function Composer({
) )
return ( return (
<div className="border-t border-border bg-card"> <div className="border-t border-slate-100 bg-white">
{toolbar} {toolbar}
<div className="p-3"> <div className="p-3.5">
{replyingTo && ( {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"> <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" /> <Reply className="h-3.5 w-3.5 shrink-0 text-primary" />
@@ -220,18 +239,38 @@ export function Composer({
value={text} value={text}
onChange={(e) => setText(e.target.value)} onChange={(e) => setText(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="输入回复内容,Enter 发送,Shift+Enter 换行" placeholder={channel === 'email' ? '撰写邮件回复,Enter 发送,Shift+Enter 换行' : '输入回复内容,Enter 发送,Shift+Enter 换行'}
rows={3} 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} disabled={sending || disabled}
/> />
<div className="mt-2 flex items-center justify-between"> <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"> <label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<Switch checked={autoTranslate} onCheckedChange={setAutoTranslate} /> <Switch checked={autoTranslate} onCheckedChange={setAutoTranslate} />
中文自动译为英文发送 双向翻译
</label> </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 && ( {aiLoading && (
<Badge variant="primary" className="animate-pulse"> <Badge variant="primary" className="animate-pulse">
正在调用 AI… 正在调用 AI…
@@ -240,7 +279,7 @@ export function Composer({
</div> </div>
<Button onClick={send} size="sm" disabled={!text.trim() || sending || disabled} loading={sending}> <Button onClick={send} size="sm" disabled={!text.trim() || sending || disabled} loading={sending}>
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
发 送 (↵) {channel === 'email' ? '发送邮件' : '发 送'} (↵)
</Button> </Button>
</div> </div>
</div> </div>
+37 -31
View File
@@ -1,8 +1,6 @@
import { Brain, Building2, ChevronRight } from 'lucide-react' import { Brain, ChevronRight, LoaderCircle } from 'lucide-react'
import type { Conversation, Customer, CustomerStage } from '@/types' import type { AiBackfillStatus, Conversation, Customer, CustomerStage } from '@/types'
import { Avatar } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { CountryFlag } from '@/components/common/CountryFlag'
import { import {
CustomerStageBadge, CustomerStageBadge,
IntentScoreBadge, IntentScoreBadge,
@@ -19,39 +17,41 @@ export function ConversationInsightBar({
customer, customer,
conversation, conversation,
onOpenProfile, onOpenProfile,
aiBackfill,
}: { }: {
customer: Customer customer: Customer
conversation: Conversation conversation: Conversation
onOpenProfile: () => void onOpenProfile: () => void
aiBackfill?: AiBackfillStatus
}) { }) {
const stage = validStages.has(customer.stage) ? customer.stage : 'initial' 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 ( 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
<div className="flex min-w-0 items-center gap-2.5"> data-testid="conversation-insight-bar"
<Avatar name={customer.name} src={customer.avatarUrl} size="sm" /> 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="min-w-0"> >
<div className="flex items-center gap-1.5"> <div className="flex min-w-0 items-center gap-2">
<span className="max-w-40 truncate text-sm font-semibold">{customer.name}</span> <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">
{customer.telegramUsername && ( <Brain className="h-4 w-4" />
<span className="max-w-32 truncate text-[11px] text-muted-foreground"> </span>
@{customer.telegramUsername} <div className="flex min-w-0 flex-1 items-center gap-2">
</span> <span className="hidden shrink-0 text-[10px] font-semibold uppercase tracking-[0.12em] text-violet-600 sm:inline">
)} 客户洞察
<CountryFlag code={customer.country} withName={false} /> </span>
{customer.company && ( <p className="truncate text-xs leading-5 text-slate-600">{summary}</p>
<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>
<div className="ms-auto hidden items-center gap-2 sm:flex"> <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>
)}
<span className="flex items-center gap-1 text-xs text-muted-foreground"> <span className="flex items-center gap-1 text-xs text-muted-foreground">
意向 <IntentScoreBadge score={Number(customer.intentScore || 0)} /> 意向 <IntentScoreBadge score={Number(customer.intentScore || 0)} />
</span> </span>
@@ -59,13 +59,19 @@ export function ConversationInsightBar({
<CustomerStageBadge stage={stage as CustomerStage} /> <CustomerStageBadge stage={stage as CustomerStage} />
<RiskLevelBadge level={customer.riskLevel || 'none'} /> <RiskLevelBadge level={customer.riskLevel || 'none'} />
</div> </div>
<Button variant="ghost" size="sm" onClick={onOpenProfile}> <Button variant="ghost" size="sm" onClick={onOpenProfile} title="打开客户画像">
<span className="hidden lg:inline">客户画像</span> <span className="hidden lg:inline">客户画像</span>
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
</div> </div>
<div className="mt-1.5 flex items-center gap-3 overflow-x-auto sm:hidden"> <div className="mt-1.5 flex items-center gap-2 overflow-x-auto pb-0.5 md:hidden">
<span className="flex shrink-0 items-center gap-1 text-xs">意向 <IntentScoreBadge score={Number(customer.intentScore || 0)} /></span> {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'} /> <SentimentBadge sentiment={customer.sentiment || 'neutral'} />
<CustomerStageBadge stage={stage as CustomerStage} /> <CustomerStageBadge stage={stage as CustomerStage} />
<RiskLevelBadge level={customer.riskLevel || 'none'} /> <RiskLevelBadge level={customer.riskLevel || 'none'} />
+157 -31
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 { useQuery } from '@tanstack/react-query'
import { getTeam } from '@/services/api/org' import { getTeam } from '@/services/api/org'
import type { ConversationListItem } from '@/services/api/conversations' import type { ConversationListItem } from '@/services/api/conversations'
@@ -21,10 +22,11 @@ const FILTERS = [
{ value: 'risk', label: '有风险' }, { value: 'risk', label: '有风险' },
{ value: 'unassigned', label: '无负责人' }, { value: 'unassigned', label: '无负责人' },
{ value: 'mine', label: '我的客户' }, { value: 'mine', label: '我的客户' },
{ value: 'archived', label: '已归档' },
] ]
export function ConversationList({ export function ConversationList({
items, items = [],
loading, loading,
activeId, activeId,
onSelect, onSelect,
@@ -32,6 +34,11 @@ export function ConversationList({
onFilterChange, onFilterChange,
keyword, keyword,
onKeywordChange, onKeywordChange,
channel,
onChannelChange,
onAdd,
onClear,
onDelete,
}: { }: {
items: ConversationListItem[] items: ConversationListItem[]
loading: boolean loading: boolean
@@ -41,34 +48,83 @@ export function ConversationList({
onFilterChange: (f: string) => void onFilterChange: (f: string) => void
keyword: string keyword: string
onKeywordChange: (k: string) => void onKeywordChange: (k: string) => void
channel: string
onChannelChange: (channel: string) => void
onAdd: () => void
onClear: (item: ConversationListItem) => void
onDelete: (item: ConversationListItem) => void
}) { }) {
const { t } = useI18n() const { t } = useI18n()
const { data: team } = useQuery({ queryKey: ['team'], queryFn: getTeam, staleTime: 300_000 }) 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 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 ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* 搜索 */} <div className="border-b border-slate-200 bg-white px-3 pb-3 pt-3">
<div className="border-b border-border p-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"> <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" /> <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 <input
value={keyword} value={keyword}
onChange={(e) => onKeywordChange(e.target.value)} onChange={(e) => onKeywordChange(e.target.value)}
placeholder="搜索客户/国家/产品/消息内容" 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>
{/* 筛选 */} <div className="mt-2.5 flex items-center gap-1 rounded-xl bg-slate-50 p-1">
<div className="mt-2.5 flex flex-wrap gap-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) => ( {FILTERS.map((f) => (
<button <button
key={f.value} key={f.value}
onClick={() => onFilterChange(f.value)} onClick={() => onFilterChange(f.value)}
className={cn( 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 filter === f.value
? 'bg-primary font-medium text-primary-foreground' ? 'bg-primary font-semibold text-white shadow-sm shadow-primary-200'
: 'bg-muted text-muted-foreground hover:bg-border', : 'text-slate-500 hover:bg-slate-100 hover:text-slate-800',
)} )}
> >
{f.label} {f.label}
@@ -78,10 +134,10 @@ export function ConversationList({
</div> </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 && {loading &&
Array.from({ length: 8 }).map((_, i) => ( 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" /> <Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<Skeleton className="h-3.5 w-2/3" /> <Skeleton className="h-3.5 w-2/3" />
@@ -97,17 +153,82 @@ export function ConversationList({
const c = item.customer const c = item.customer
const active = item.id === activeId const active = item.id === activeId
const intent = intentLevelMeta(item.intentLevel) const intent = intentLevelMeta(item.intentLevel)
const isTelegramChannel = String(item.telegramChatType || '').toLowerCase() === 'channel'
const supportsSwipeActions = item.channel === 'telegram' || item.channel === 'whatsapp'
return ( return (
<button <div key={item.id} className="relative mb-0.5 overflow-hidden rounded-xl">
key={item.id} {supportsSwipeActions && (
data-testid="conversation-item" <div
data-conversation-id={item.id} data-testid="conversation-swipe-actions"
onClick={() => onSelect(item.id)} aria-hidden={swipedId !== item.id && touchStart.current?.id !== item.id}
className={cn( className={cn(
'flex w-full gap-2.5 border-b border-border px-3 py-3 text-start transition-colors', 'absolute inset-y-0 right-0 flex w-[144px] overflow-hidden rounded-xl transition-opacity duration-150',
active ? 'bg-primary-50/70' : 'hover:bg-muted/50', 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
data-testid="conversation-item"
data-conversation-id={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(
'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"> <div className="relative shrink-0">
<Avatar name={c.name} src={c.avatarUrl} size="md" /> <Avatar name={c.name} src={c.avatarUrl} size="md" />
<span className="absolute -bottom-1 -right-1 rounded-full border border-card bg-card"> <span className="absolute -bottom-1 -right-1 rounded-full border border-card bg-card">
@@ -116,7 +237,8 @@ export function ConversationList({
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5"> <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 && ( {c.telegramUsername && (
<span className="hidden max-w-24 truncate text-[10px] text-muted-foreground xl:inline"> <span className="hidden max-w-24 truncate text-[10px] text-muted-foreground xl:inline">
@{c.telegramUsername} @{c.telegramUsername}
@@ -124,30 +246,34 @@ export function ConversationList({
)} )}
<CountryFlag code={c.country} withName={false} /> <CountryFlag code={c.country} withName={false} />
{item.hasRisk && <ShieldAlert className="h-3.5 w-3.5 shrink-0 text-red-500" />} {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"> <span className={cn('ms-auto shrink-0 text-[11px] text-muted-foreground', active && 'text-primary-600')}>
{formatRelative(item.updatedAt)} {formatRelative(item.lastMessage?.createdAt || item.updatedAt)}
</span> </span>
</div> </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?.sender === 'agent' ? '我:' : ''}
{item.lastMessage?.translatedText ?? item.lastMessage?.originalText ?? item.summary} {item.lastMessage?.translatedText ?? item.lastMessage?.originalText ?? item.summary}
</p> </p>
<div className="mt-1.5 flex items-center gap-1.5"> <div className="mt-2 flex items-center gap-1.5">
<Badge className={cn('text-[10px] px-1 py-0', intent.color)}>{intent.label}</Badge> {!active && <Badge className={cn('rounded-md px-1.5 py-0 text-[10px]', intent.color)}>{intent.label}</Badge>}
<SentimentBadge sentiment={item.sentiment} emojiOnly /> <SentimentBadge sentiment={item.sentiment} emojiOnly />
{item.assigneeId ? ( {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 && ( {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} {item.unreadCount}
</span> </span>
)} )}
</div> </div>
</div> </div>
</button> </button>
</div>
) )
})} })}
</div> </div>
+108 -38
View File
@@ -14,7 +14,9 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { updateCustomer } from '@/services/api/customers' import { updateCustomer } from '@/services/api/customers'
import { createTask } from '@/services/api/crm'
import { analyzeConversation } from '@/services/api/ai' import { analyzeConversation } from '@/services/api/ai'
import { useAuthStore } from '@/app/store/auth'
import type { Customer, Conversation } from '@/types' import type { Customer, Conversation } from '@/types'
import { Avatar } from '@/components/ui/avatar' import { Avatar } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button' 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 { CUSTOMER_STAGES, NEXT_ACTIONS, countryMeta, INTENT_LEVELS } from '@/config/constants'
import { toast, errorMessage } from '@/app/store/toast' import { toast, errorMessage } from '@/app/store/toast'
import { cn } from '@/utils/cn' import { cn } from '@/utils/cn'
import { CustomerProfileRadar } from '@/components/customer/CustomerProfileRadar'
import { CustomerSemanticTag } from '@/components/customer/CustomerSemanticTag'
export function InsightPanel({ export function InsightPanel({
customer, customer,
@@ -38,12 +42,21 @@ export function InsightPanel({
conversation: Conversation conversation: Conversation
}) { }) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const user = useAuthStore((state) => state.user)
const [analyzing, setAnalyzing] = useState(false) const [analyzing, setAnalyzing] = useState(false)
const [analysis, setAnalysis] = useState(customer.aiProfile.summary) const [analysis, setAnalysis] = useState(customer.aiProfile.summary)
const c = countryMeta(customer.country) const c = countryMeta(customer.country)
const localTime = new Date( const localTime = Number.isFinite(c.tzOffset)
new Date().getTime() + new Date().getTimezoneOffset() * 60000 + c.tzOffset * 3600000, ? new Date(
).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) 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({ const updateMut = useMutation({
mutationFn: (data: Partial<Customer>) => updateCustomer(customer.id, data), mutationFn: (data: Partial<Customer>) => updateCustomer(customer.id, data),
@@ -54,22 +67,41 @@ export function InsightPanel({
}, },
onError: (e) => toast.error(errorMessage(e)), 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 () => { const handleAnalyze = async () => {
setAnalyzing(true) setAnalyzing(true)
try { try {
const result = await analyzeConversation(conversation.id) const result = await analyzeConversation(conversation.id)
setAnalysis(result.summary) 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 { } catch {
toast.error('分析失败,请稍后重试') toast.error('分析失败,请稍后重试')
} finally { setAnalyzing(false) } } finally { setAnalyzing(false) }
} }
return ( 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"> <div className="mb-3 flex items-center gap-3">
<Avatar name={customer.name} src={customer.avatarUrl} size="lg" /> <Avatar name={customer.name} src={customer.avatarUrl} size="lg" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -90,11 +122,14 @@ export function InsightPanel({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MapPin className="h-3.5 w-3.5 text-muted-foreground" /> <MapPin className="h-3.5 w-3.5 text-muted-foreground" />
<CountryFlag code={customer.country} /> <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>
<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" /> <Clock className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">当地时间:{localTime}</span> <span className="text-muted-foreground">当地时间:{localTime}</span>
</div> </div>}
{customer.email && ( {customer.email && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Mail className="h-3.5 w-3.5 text-muted-foreground" /> <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> <Badge key={p} variant="primary">{p}</Badge>
))} ))}
{customer.tags?.map((t) => ( {customer.tags?.map((t) => (
<Badge key={t} variant="outline">{t}</Badge> <CustomerSemanticTag key={t} tag={t} />
))} ))}
</div> </div>
@@ -151,9 +186,59 @@ export function InsightPanel({
</div> </div>
{/* AI 分析 */} {/* 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"> <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}> <Button variant="ghost" size="sm" onClick={handleAnalyze} disabled={analyzing}>
{analyzing ? <Spinner className="h-3 w-3" /> : '重新分析'} {analyzing ? <Spinner className="h-3 w-3" /> : '重新分析'}
</Button> </Button>
@@ -164,41 +249,24 @@ export function InsightPanel({
<span className="ml-2 text-sm text-muted-foreground">正在分析会话上下文…</span> <span className="ml-2 text-sm text-muted-foreground">正在分析会话上下文…</span>
</div> </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 提取关键信息 */} {/* AI 提取关键信息 */}
{Object.values(customer.aiProfile || {}).some(Boolean) && ( {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({ {Object.entries({
公司: customer.aiProfile.companyName,
行业: customer.aiProfile.industry, 行业: customer.aiProfile.industry,
商业模式: customer.aiProfile.businessModel,
采购角色: customer.aiProfile.purchasingRole, 采购角色: customer.aiProfile.purchasingRole,
决策权: customer.aiProfile.decisionPower,
询价数量: customer.aiProfile.inquiryQuantity, 询价数量: customer.aiProfile.inquiryQuantity,
目标价格: customer.aiProfile.targetPrice, 目标价格: customer.aiProfile.targetPrice,
预算: customer.aiProfile.budget, 预算: customer.aiProfile.budget,
MOQ: customer.aiProfile.moqRequirement, MOQ: customer.aiProfile.moqRequirement,
样品: customer.aiProfile.sampleNeed, 样品: customer.aiProfile.sampleNeed,
定制: customer.aiProfile.customRequirement,
交付: customer.aiProfile.deliveryTime, 交付: 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]) => ( }).filter(([, v]) => v).map(([k, v]) => (
<div key={k} className="flex gap-2 text-xs"> <div key={k} className="min-w-0 rounded-xl bg-slate-50 px-3 py-2">
<span className="shrink-0 text-muted-foreground">{k}</span> <p className="text-[10px] font-medium text-slate-400">{k}</p>
<span className="text-foreground">{v as string}</span> <p className="mt-0.5 truncate text-xs font-medium text-slate-700">{v as string}</p>
</div> </div>
))} ))}
</div> </div>
@@ -209,7 +277,7 @@ export function InsightPanel({
['流失概率', customer.aiProfile.churnProbability], ['流失概率', customer.aiProfile.churnProbability],
['分析可信度', customer.aiProfile.confidenceScore], ['分析可信度', customer.aiProfile.confidenceScore],
].map(([label, value]) => ( ].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-[10px] text-muted-foreground">{label}</p>
<p className="text-sm font-semibold">{Number(value || 0)}%</p> <p className="text-sm font-semibold">{Number(value || 0)}%</p>
</div> </div>
@@ -223,17 +291,19 @@ export function InsightPanel({
</ul> </ul>
</div> </div>
) : null} ) : null}
</div>
</div> </div>
{/* 推荐动作 */} {/* 推荐动作 */}
<div className="p-4"> <div className="p-4">
<h4 className="text-xs font-semibold text-muted-foreground">AI 推荐下一步</h4> <h4 className="text-xs font-semibold text-slate-500">下一步建议</h4>
<div className="mt-2 flex flex-wrap gap-1.5"> <div className="mt-2 space-y-2">
{customer.aiProfile.nextActions.map((a) => ( {customer.aiProfile.nextActions.map((a) => (
<button <button
key={a} key={a}
onClick={() => toast.info(`建议操作:${a}`)} onClick={() => taskMut.mutate(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" 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} {a}
</button> </button>
+233 -26
View File
@@ -13,14 +13,18 @@ import {
BookOpen, BookOpen,
MoreHorizontal, MoreHorizontal,
Languages, Languages,
Globe,
ChevronDown, ChevronDown,
ChevronUp, ChevronUp,
Download, Download,
FileText, FileText,
LoaderCircle, LoaderCircle,
SmilePlus,
Trash2,
Brain,
} from 'lucide-react' } from 'lucide-react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import type { Message } from '@/types' import type { ChannelType, Message } from '@/types'
import { cn } from '@/utils/cn' import { cn } from '@/utils/cn'
import { toast } from '@/app/store/toast' import { toast } from '@/app/store/toast'
import { formatTime } from '@/utils/format' import { formatTime } from '@/utils/format'
@@ -31,6 +35,48 @@ import { getMessageAttachment } from '@/services/api/conversations'
export type ViewMode = 'original' | 'translated' | 'bilingual' 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({ export function MessageBubble({
msg, msg,
viewMode, viewMode,
@@ -39,8 +85,12 @@ export function MessageBubble({
onConvertToTask, onConvertToTask,
onAddToKnowledge, onAddToKnowledge,
onAddNote, onAddNote,
onReact,
onDelete,
customerName, customerName,
customerAvatar, customerAvatar,
channel,
showIntent = true,
}: { }: {
msg: Message msg: Message
viewMode: ViewMode viewMode: ViewMode
@@ -49,24 +99,52 @@ export function MessageBubble({
onConvertToTask: () => void onConvertToTask: () => void
onAddToKnowledge: () => void onAddToKnowledge: () => void
onAddNote: () => void onAddNote: () => void
onReact?: (emoji: string) => void
onDelete?: () => void
customerName?: string customerName?: string
customerAvatar?: string customerAvatar?: string
channel?: ChannelType
showIntent?: boolean
}) { }) {
const [showActions, setShowActions] = useState(false) const [showActions, setShowActions] = useState(false)
const [translationExpanded, setTranslationExpanded] = useState(false) const [translationExpanded, setTranslationExpanded] = useState(false)
const isMe = msg.sender === 'agent' const isMe = msg.sender === 'agent'
const isSystem = msg.sender === 'system' const isSystem = msg.sender === 'system'
const originalText = String(msg.originalText || '')
const rawTranslation = String(msg.translatedText || '') const rawTranslation = String(msg.translatedText || '')
const translationCorrupted = const translationCorrupted =
/(?:^|\n)\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:[\d.]+Z\s+(?:客户|坐席):/m.test(rawTranslation) || /(?:^|\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 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 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({ 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), queryFn: () => getMessageAttachment(msg.conversationId, msg.id),
enabled: Boolean(msg.telegramFileId), enabled: hasProviderAttachment,
staleTime: Infinity, staleTime: Infinity,
retry: 1, retry: 1,
}) })
@@ -74,7 +152,7 @@ export function MessageBubble({
if (isSystem) { if (isSystem) {
return ( return (
<div className="flex justify-center py-2"> <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> </div>
) )
} }
@@ -104,6 +182,28 @@ export function MessageBubble({
) )
const renderContent = () => { 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 ( if (
(viewMode === 'bilingual' || viewMode === 'translated') && (viewMode === 'bilingual' || viewMode === 'translated') &&
msg.sender === 'customer' && msg.sender === 'customer' &&
@@ -112,7 +212,7 @@ export function MessageBubble({
) { ) {
return ( return (
<div className="space-y-2.5"> <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"> <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" /> <Languages className="h-3.5 w-3.5 text-primary" />
{translationCorrupted {translationCorrupted
@@ -124,19 +224,64 @@ export function MessageBubble({
</div> </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) { if (viewMode === 'bilingual' && hasTranslation) {
return ( return (
<div className="space-y-2.5"> <div className="space-y-2.5">
<div> <div>
<span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground"> <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> </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>
<div className="border-t border-border/60 pt-2"> <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" /> <Languages className="h-3 w-3" />
{isMe ? '发送内容 · 英文' : 'AI 译文 · 中文'} {isMe ? `发送内容 · ${targetLanguage}` : contextualTranslation ? 'AI 语境译文 · 中文' : 'AI 译文 · 中文'}
</span> </span>
<div className="mt-1.5">{translationContent()}</div> <div className="mt-1.5">{translationContent()}</div>
</div> </div>
@@ -146,9 +291,12 @@ export function MessageBubble({
if (viewMode === 'translated' && translatedText) { if (viewMode === 'translated' && translatedText) {
return ( return (
<div> <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" /> <Languages className="h-3 w-3" />
{isMe ? `${languageName(msg.originalLang)} → 英文发送内容` : `${languageName(msg.originalLang)} → 中文`} {isMe ? `${sourceLanguage} → ${targetLanguage}` : `${sourceLanguage} → 中文`}
</span> </span>
<div className="mt-1.5">{translationContent(false)}</div> <div className="mt-1.5">{translationContent(false)}</div>
</div> </div>
@@ -156,10 +304,14 @@ export function MessageBubble({
} }
return ( return (
<div> <div>
<span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground"> {hasKnownLanguage && (
{languageName(msg.originalLang)} <span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
</span> {sourceLanguage}
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{msg.originalText}</p> </span>
)}
<p className={cn('whitespace-pre-wrap break-words text-sm leading-6', hasKnownLanguage && 'mt-1.5')}>
{originalText}
</p>
</div> </div>
) )
} }
@@ -182,12 +334,18 @@ export function MessageBubble({
} }
const moreActions = [ 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 }, { 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: 'note', label: '添加内部备注', icon: StickyNote, onSelect: onAddNote },
{ key: 'important', label: msg.isImportant ? '取消重要' : '标记重要', icon: Star, onSelect: onMarkImportant }, { key: 'important', label: msg.isImportant ? '取消重要' : '标记重要', icon: Star, onSelect: onMarkImportant },
{ key: 'task', label: '转为待办', icon: ListTodo, onSelect: onConvertToTask }, { key: 'task', label: '转为待办', icon: ListTodo, onSelect: onConvertToTask },
{ key: 'knowledge', label: '添加到知识库', icon: BookOpen, onSelect: onAddToKnowledge }, { key: 'knowledge', label: '添加到知识库', icon: BookOpen, onSelect: onAddToKnowledge },
...(channel === 'whatsapp' && isMe && onDelete
? [{ key: 'delete', label: '撤回消息', icon: Trash2, danger: true, onSelect: onDelete }]
: []),
] ]
return ( return (
@@ -195,7 +353,7 @@ export function MessageBubble({
data-testid="message-bubble" data-testid="message-bubble"
data-sender={msg.sender} data-sender={msg.sender}
className={cn( 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', isMe ? 'justify-end motion-safe:animate-message-in-right' : 'justify-start motion-safe:animate-message-in-left',
)} )}
onMouseEnter={() => setShowActions(true)} onMouseEnter={() => setShowActions(true)}
@@ -203,7 +361,7 @@ export function MessageBubble({
> >
{!isMe && ( {!isMe && (
<Avatar <Avatar
name={customerName || msg.senderName || 'Telegram 用户'} name={customerName || msg.senderName || `${providerName} 用户`}
src={customerAvatar} src={customerAvatar}
size="xs" size="xs"
className="order-1 mt-1" className="order-1 mt-1"
@@ -217,11 +375,13 @@ export function MessageBubble({
)} )}
{/* 消息气泡 */} {/* 消息气泡 */}
<div <div
data-testid="message-surface"
data-direction={isMe ? 'outgoing' : 'incoming'}
className={cn( 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 isMe
? 'rounded-br-md border border-primary/15 bg-gradient-to-br from-primary-50 to-emerald-50/80' ? 'telegram-bubble-out rounded-br-[5px]'
: 'rounded-bl-md border border-border/90 bg-card', : 'telegram-bubble-in rounded-bl-[5px]',
msg.isImportant && 'ring-1 ring-amber-400', msg.isImportant && 'ring-1 ring-amber-400',
)} )}
> >
@@ -234,9 +394,34 @@ export function MessageBubble({
</div> </div>
)} )}
{renderContent()} {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"> <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> </div>
)} )}
{attachment && msg.type === 'image' && ( {attachment && msg.type === 'image' && (
@@ -264,14 +449,36 @@ export function MessageBubble({
<Download className="h-4 w-4 text-muted-foreground" /> <Download className="h-4 w-4 text-muted-foreground" />
</a> </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> <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>
{/* 底栏:时间 + 状态 */} {/* 底栏:时间 + 状态 */}
<div className={cn('mt-0.5 flex items-center gap-1.5 text-[10px] text-muted-foreground', isMe ? 'justify-end' : 'justify-start')}> <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> <span>{formatTime(msg.createdAt)}</span>
{msg.editedAt && !msg.isDeleted && <span>已编辑</span>}
{isMe && statusIcon()} {isMe && statusIcon()}
{msg.isImportant && <Star className="h-2.5 w-2.5 fill-amber-400 text-amber-400" />} {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" />} {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() const { mobileNavOpen, setMobileNavOpen } = useUiStore()
return ( return (
<div className="flex h-screen overflow-hidden bg-background"> <div className="flex h-screen overflow-hidden bg-[#f7f9fc]">
<GlobalRealtime /> <GlobalRealtime />
{/* 桌面端固定侧边栏 */} {/* 桌面端固定侧边栏 */}
<div className="hidden shrink-0 lg:block"> <div className="hidden shrink-0 lg:block">
@@ -25,7 +25,7 @@ export function AppLayout() {
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<Topbar /> <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 /> <Outlet />
</main> </main>
</div> </div>
+5 -3
View File
@@ -12,7 +12,9 @@ export function GlobalRealtime() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
useEffect(() => { 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:' const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
let socket: WebSocket | undefined let socket: WebSocket | undefined
let reconnectTimer: ReturnType<typeof setTimeout> | undefined let reconnectTimer: ReturnType<typeof setTimeout> | undefined
@@ -34,12 +36,12 @@ export function GlobalRealtime() {
socket.onmessage = (raw) => { socket.onmessage = (raw) => {
let event: { type?: string; message?: Message; customer?: Customer; conversationId?: string } let event: { type?: string; message?: Message; customer?: Customer; conversationId?: string }
try { event = JSON.parse(String(raw.data)) } catch { return } 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: ['notifications'] })
void queryClient.invalidateQueries({ queryKey: ['conversations'] }) void queryClient.invalidateQueries({ queryKey: ['conversations'] })
if (!location.pathname.startsWith('/inbox')) { if (!location.pathname.startsWith('/inbox')) {
const preview = event.message.translatedText || event.message.originalText || `[${event.message.type}]` 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() socket.onerror = () => socket?.close()
+34 -16
View File
@@ -1,5 +1,5 @@
import { NavLink, useNavigate } from 'react-router-dom' 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 { NAV_ITEMS } from '@/config/navigation'
import { useUiStore } from '@/app/store/ui' import { useUiStore } from '@/app/store/ui'
import { useAuthStore } from '@/app/store/auth' 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 usagePct = msgUsage ? Math.round((msgUsage.used / msgUsage.total) * 100) : 0
const items = NAV_ITEMS.filter((item) => !item.permission || can(item.permission)) 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 ( return (
<aside <aside
className={cn( className={cn(
'flex h-full flex-col border-e border-border bg-card transition-[width] duration-200', '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-16' : 'w-60', sidebarCollapsed ? 'w-[68px]' : 'w-[236px]',
)} )}
> >
{/* Logo */} {/* Logo */}
<div <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')} onClick={() => navigate('/dashboard')}
> >
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground"> <img src="/brand-mark.svg" alt="" className="h-9 w-9 shrink-0 rounded-xl shadow-sm shadow-primary-900/10" />
<Sparkles className="h-4.5 w-4.5 h-[18px] w-[18px]" />
</span>
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<div className="min-w-0"> <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> <p className="truncate text-[11px] text-muted-foreground leading-tight">{t('app.slogan')}</p>
</div> </div>
)} )}
</div> </div>
{/* 导航 */} {/* 导航 */}
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 py-3 scrollbar-thin"> <nav className="flex-1 overflow-y-auto px-2.5 py-3 scrollbar-thin">
{items.map((item) => ( {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 <NavLink
key={item.key} key={item.key}
to={item.path} to={item.path}
@@ -60,10 +74,10 @@ export function Sidebar() {
title={sidebarCollapsed ? t(item.labelKey as never) : undefined} title={sidebarCollapsed ? t(item.labelKey as never) : undefined}
className={({ isActive }) => className={({ isActive }) =>
cn( 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 isActive
? 'bg-primary-50 font-medium text-primary-700' ? '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-secondary-foreground hover:bg-muted', : 'text-slate-600 hover:bg-slate-50 hover:text-slate-950',
sidebarCollapsed && 'justify-center px-0', sidebarCollapsed && 'justify-center px-0',
) )
} }
@@ -71,13 +85,17 @@ export function Sidebar() {
<item.icon className="h-[18px] w-[18px] shrink-0" /> <item.icon className="h-[18px] w-[18px] shrink-0" />
{!sidebarCollapsed && <span className="truncate">{t(item.labelKey as never)}</span>} {!sidebarCollapsed && <span className="truncate">{t(item.labelKey as never)}</span>}
</NavLink> </NavLink>
))} ))}
</div>
</div>
)
})}
</nav> </nav>
{/* 底部:套餐与企业信息 */} {/* 底部:套餐与企业信息 */}
{!sidebarCollapsed && ( {!sidebarCollapsed && (
<div className="border-t border-border p-3"> <div className="border-t border-slate-100 p-3">
<div className="rounded-md bg-muted/60 p-3"> <div className="rounded-[12px] border border-slate-200 bg-slate-50/80 p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{t('sidebar.currentPlan')}</span> <span className="text-xs text-muted-foreground">{t('sidebar.currentPlan')}</span>
<span className="text-xs font-medium text-primary-700">{plan.name}</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> = { const NOTIF_TYPE_COLOR: Record<string, string> = {
inquiry: 'bg-emerald-100 text-emerald-700', inquiry: 'bg-emerald-100 text-emerald-700',
message: 'bg-sky-100 text-sky-700',
risk: 'bg-red-100 text-red-700', risk: 'bg-red-100 text-red-700',
task: 'bg-amber-100 text-amber-700', task: 'bg-amber-100 text-amber-700',
channel: 'bg-blue-100 text-blue-700', channel: 'bg-blue-100 text-blue-700',
@@ -61,10 +62,10 @@ export function Topbar() {
} }
return ( 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 <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)} onClick={() => setMobileNavOpen(true)}
aria-label="打开导航" aria-label="打开导航"
> >
@@ -74,7 +75,7 @@ export function Topbar() {
{/* 企业切换 */} {/* 企业切换 */}
<Dropdown <Dropdown
trigger={ 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" /> <Building2 className="h-4 w-4 text-muted-foreground" />
<span className="max-w-[180px] truncate">{tenant?.name ?? '—'}</span> <span className="max-w-[180px] truncate">{tenant?.name ?? '—'}</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" /> <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
@@ -95,7 +96,7 @@ export function Topbar() {
{/* 语言切换 */} {/* 语言切换 */}
<Dropdown <Dropdown
trigger={ 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]" /> <Globe className="h-[18px] w-[18px]" />
</button> </button>
} }
@@ -117,7 +118,7 @@ export function Topbar() {
{/* 帮助 */} {/* 帮助 */}
<button <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')} title={t('topbar.help')}
onClick={() => navigate('/help')} onClick={() => navigate('/help')}
> >
@@ -127,7 +128,7 @@ export function Topbar() {
{/* 通知 */} {/* 通知 */}
<Dropdown <Dropdown
trigger={ 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]" /> <Bell className="h-[18px] w-[18px]" />
{unread > 0 && ( {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"> <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: ( label: (
<span className="flex flex-col gap-0.5 py-0.5"> <span className="flex flex-col gap-0.5 py-0.5">
<span className="flex items-center gap-1.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" />} {!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 className="text-[11px] text-muted-foreground">{formatRelative(n.createdAt)}</span>
</span> </span>
@@ -171,7 +172,7 @@ export function Topbar() {
{/* 用户菜单 */} {/* 用户菜单 */}
<Dropdown <Dropdown
trigger={ 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 /> <Avatar name={user?.name ?? '?'} size="sm" online />
<span className="hidden text-start md:block"> <span className="hidden text-start md:block">
<span className="block max-w-[90px] truncate text-sm font-medium leading-tight">{user?.name}</span> <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' import { cn } from '@/utils/cn'
const buttonVariants = cva( 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: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary-700 active:bg-primary-800', 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-secondary text-secondary-foreground hover:bg-border', secondary: 'bg-slate-100 text-slate-700 hover:bg-slate-200',
outline: 'border border-input bg-card hover:bg-muted', 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', ghost: 'hover:bg-muted text-foreground',
destructive: 'bg-destructive text-destructive-foreground hover:bg-red-600', destructive: 'bg-destructive text-destructive-foreground hover:bg-red-600',
dangerOutline: 'border border-red-200 bg-card text-red-600 hover:bg-red-50', dangerOutline: 'border border-red-200 bg-card text-red-600 hover:bg-red-50',
@@ -19,7 +19,7 @@ const buttonVariants = cva(
size: { size: {
sm: 'h-8 px-3 text-xs', sm: 'h-8 px-3 text-xs',
default: 'h-9 px-4', default: 'h-9 px-4',
lg: 'h-10 px-5', lg: 'h-11 px-5',
icon: 'h-9 w-9', icon: 'h-9 w-9',
'icon-sm': 'h-7 w-7', '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>) { export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return ( return (
<div <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} {...props}
/> />
) )
@@ -19,9 +19,9 @@ export function CardHeader({
...props ...props
}: HTMLAttributes<HTMLDivElement> & { title?: ReactNode; description?: ReactNode; action?: ReactNode }) { }: HTMLAttributes<HTMLDivElement> & { title?: ReactNode; description?: ReactNode; action?: ReactNode }) {
return ( 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"> <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>} {description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
{children} {children}
</div> </div>
@@ -31,7 +31,7 @@ export function CardHeader({
} }
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) { 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>) { export function CardFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
+1 -1
View File
@@ -47,7 +47,7 @@ export function Dropdown({
{open && ( {open && (
<div <div
className={cn( 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', 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, onCheckedChange,
disabled, disabled,
className, className,
ariaLabel,
}: { }: {
checked: boolean checked: boolean
onCheckedChange?: (v: boolean) => void onCheckedChange?: (v: boolean) => void
disabled?: boolean disabled?: boolean
className?: string className?: string
ariaLabel?: string
}) { }) {
return ( return (
<button <button
type="button" type="button"
role="switch" role="switch"
aria-checked={checked} aria-checked={checked}
aria-label={ariaLabel}
disabled={disabled} disabled={disabled}
onClick={() => onCheckedChange?.(!checked)} onClick={() => onCheckedChange?.(!checked)}
className={cn( className={cn(
+5 -5
View File
@@ -10,26 +10,26 @@ export function Table({ className, ...props }: HTMLAttributes<HTMLTableElement>)
} }
export function THead({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) { 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>) { 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>) { 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>) { export function Th({ className, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
return ( return (
<th <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} {...props}
/> />
) )
} }
export function Td({ className, colSpan, ...props }: TdHTMLAttributes<HTMLTableCellElement>) { 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, options,
size = 'default', size = 'default',
className, className,
ariaLabel = '切换选项',
}: { }: {
value: T value: T
onChange: (v: T) => void onChange: (v: T) => void
options: { value: T; label: ReactNode }[] options: { value: T; label: ReactNode }[]
size?: 'sm' | 'default' size?: 'sm' | 'default'
className?: string className?: string
ariaLabel?: string
}) { }) {
return ( 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) => ( {options.map((o) => (
<button <button
type="button"
role="radio"
aria-checked={o.value === value}
data-state={o.value === value ? 'active' : 'inactive'}
key={o.value} key={o.value}
onClick={() => onChange(o.value)} onClick={() => onChange(o.value)}
className={cn( 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', 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} {o.label}
+12 -1
View File
@@ -26,7 +26,18 @@ export const COUNTRIES: CountryMeta[] = [
] ]
export function countryMeta(code: string): 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: 'ro', name: '罗马尼亚语', nativeName: 'Română' },
{ code: 'cs', name: '捷克语', nativeName: 'Čeština' }, { code: 'cs', name: '捷克语', nativeName: 'Čeština' },
{ code: 'el', name: '希腊语', nativeName: 'Ελληνικά' }, { 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) export const RTL_LANG_CODES = LANGUAGES.filter((l) => l.rtl).map((l) => l.code)
+11 -16
View File
@@ -3,15 +3,12 @@ import {
MessagesSquare, MessagesSquare,
Users, Users,
KanbanSquare, KanbanSquare,
Languages,
ShieldAlert, ShieldAlert,
BookOpen, BookOpen,
BarChart3, BarChart3,
Cable, Cable,
UsersRound, UsersRound,
CreditCard,
Settings, Settings,
GraduationCap,
type LucideIcon, type LucideIcon,
} from 'lucide-react' } from 'lucide-react'
import type { Permission } from './roles' import type { Permission } from './roles'
@@ -23,20 +20,18 @@ export interface NavItem {
labelKey: string labelKey: string
icon: LucideIcon icon: LucideIcon
permission?: Permission permission?: Permission
group: 'core' | 'intelligence' | 'admin'
} }
export const NAV_ITEMS: NavItem[] = [ export const NAV_ITEMS: NavItem[] = [
{ key: 'dashboard', path: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard }, { key: 'dashboard', path: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard, group: 'core' },
{ key: 'inbox', path: '/inbox', labelKey: 'nav.inbox', icon: MessagesSquare }, { key: 'inbox', path: '/inbox', labelKey: 'nav.inbox', icon: MessagesSquare, group: 'core' },
{ key: 'customers', path: '/customers', labelKey: 'nav.customers', icon: Users, permission: 'customer:view' }, { key: 'customers', path: '/customers', labelKey: 'nav.customers', icon: Users, permission: 'customer:view', group: 'core' },
{ key: 'opportunities', path: '/opportunities', labelKey: 'nav.opportunities', icon: KanbanSquare }, { key: 'opportunities', path: '/opportunities', labelKey: 'nav.opportunities', icon: KanbanSquare, permission: 'opportunity:manage', group: 'core' },
{ key: 'translate', path: '/translate', labelKey: 'nav.translate', icon: Languages }, { key: 'risks', path: '/risks', labelKey: 'nav.risks', icon: ShieldAlert, permission: 'risk:handle', group: 'core' },
{ key: 'risks', path: '/risks', labelKey: 'nav.risks', icon: ShieldAlert }, { key: 'knowledge', path: '/knowledge', labelKey: 'nav.knowledge', icon: BookOpen, permission: 'knowledge:manage', group: 'intelligence' },
{ key: 'knowledge', path: '/knowledge', labelKey: 'nav.knowledge', icon: BookOpen }, { key: 'analytics', path: '/analytics', labelKey: 'nav.analytics', icon: BarChart3, permission: 'analytics:view', group: 'intelligence' },
{ key: 'analytics', path: '/analytics', labelKey: 'nav.analytics', icon: BarChart3, permission: 'analytics:view' }, { key: 'channels', path: '/channels', labelKey: 'nav.channels', icon: Cable, permission: 'channel:manage', group: 'admin' },
{ key: 'channels', path: '/channels', labelKey: 'nav.channels', icon: Cable }, { key: 'team', path: '/team', labelKey: 'nav.team', icon: UsersRound, permission: 'team:manage', group: 'admin' },
{ key: 'team', path: '/team', labelKey: 'nav.team', icon: UsersRound }, { key: 'settings', path: '/settings', labelKey: 'nav.settings', icon: Settings, permission: 'settings:manage', group: 'admin' },
{ 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 },
] ]
+3 -3
View File
@@ -57,7 +57,7 @@ export const PRICING_PLANS: PricingPlan[] = [
{ {
key: 'pro', key: 'pro',
name: '专业版', name: '专业版',
tagline: '5—20 人团队的 AI 经营中枢', tagline: '多渠道客户沟通与团队协作',
priceNote: '¥49,800/年', priceNote: '¥49,800/年',
monthlyPrice: null, monthlyPrice: null,
yearlyPrice: 49800, yearlyPrice: 49800,
@@ -66,9 +66,9 @@ export const PRICING_PLANS: PricingPlan[] = [
features: [ features: [
'包含标准版全部能力', '包含标准版全部能力',
'客户情感分析与意图预测', '客户情感分析与意图预测',
'AI 智能回复建议(多策略)', '多策略回复建议',
'风险预警与文化敏感检测', '风险预警与文化敏感检测',
'老板经营看板', '业务数据看板',
], ],
limits: { members: 20, messagesPerMonth: 100000, translationsPerMonth: 200000, knowledgeStorageMb: 5120 }, 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' }, admin: { label: '企业管理员', description: '管理企业、成员、权限、渠道、套餐与系统设置', color: 'bg-violet-100 text-violet-700' },
manager: { label: '业务主管', description: '查看全部客户、团队数据、经营看板与客户分配', color: 'bg-blue-100 text-blue-700' }, manager: { label: '业务主管', description: '查看全部客户、团队数据、经营看板与客户分配', color: 'bg-blue-100 text-blue-700' },
sales: { label: '外贸业务员', description: '查看分配给自己的客户、消息与跟进任务', color: 'bg-teal-100 text-teal-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' }, 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.', 'app.company': 'Xuzhou Ouhui Technology Co., Ltd.',
'nav.dashboard': 'Workspace', 'nav.dashboard': 'Workspace',
'nav.inbox': 'Unified Inbox', 'nav.inbox': 'Messages',
'nav.customers': 'Customers', 'nav.customers': 'Customers',
'nav.opportunities': 'Deals & Follow-ups', 'nav.opportunities': 'Deals & Follow-ups',
'nav.translate': 'AI Translation', 'nav.translate': 'AI Translation',
+3 -3
View File
@@ -4,15 +4,15 @@ export const zh = {
'app.company': '徐州欧汇科技有限公司', 'app.company': '徐州欧汇科技有限公司',
'nav.dashboard': '工作台', 'nav.dashboard': '工作台',
'nav.inbox': '统一消息', 'nav.inbox': '消息中心',
'nav.customers': '客户管理', 'nav.customers': '客户管理',
'nav.opportunities': '商机与跟进', 'nav.opportunities': '商机与跟进',
'nav.translate': 'AI翻译', 'nav.translate': 'AI翻译',
'nav.interpret': '同声传译', 'nav.interpret': '同声传译',
'nav.risks': '风险预警', 'nav.risks': '风险工单',
'nav.knowledge': '企业知识库', 'nav.knowledge': '企业知识库',
'nav.analytics': '数据分析', 'nav.analytics': '数据分析',
'nav.channels': '渠道中心', 'nav.channels': '渠道与账号',
'nav.team': '团队协作', 'nav.team': '团队协作',
'nav.billing': '套餐与账单', 'nav.billing': '套餐与账单',
'nav.settings': '系统设置', 'nav.settings': '系统设置',
+91 -34
View File
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { PageLoading, ErrorState } from '@/components/ui/feedback' import { PageLoading, ErrorState } from '@/components/ui/feedback'
import { PageHeader } from '@/components/common/PageHeader' import { PageHeader } from '@/components/common/PageHeader'
@@ -8,13 +9,20 @@ import { LiveDataBadge } from '@/components/common/LiveDataBadge'
import { CountryFlag } from '@/components/common/CountryFlag' import { CountryFlag } from '@/components/common/CountryFlag'
import { ChartCard, TrendChart, DonutChart, FunnelChart, RankBars, SimpleBarChart } from '@/components/charts' import { ChartCard, TrendChart, DonutChart, FunnelChart, RankBars, SimpleBarChart } from '@/components/charts'
import { getAnalytics } from '@/services/api/org' import { getAnalytics } from '@/services/api/org'
import { formatNumber } from '@/utils/format' import { Activity, BadgePercent, Users, Zap, Target } from 'lucide-react'
import { Activity, BadgePercent, TrendingUp, Users, Zap, Timer, AlertTriangle, 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() { export function AnalyticsPage() {
const [period, setPeriod] = useState<(typeof PERIOD_OPTIONS)[number]['value']>('30d')
const { data, isLoading, isError, refetch } = useQuery({ const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['analytics'], queryKey: ['analytics', period],
queryFn: () => getAnalytics(), queryFn: () => getAnalytics({ period }),
}) })
if (isLoading) return <PageLoading /> if (isLoading) return <PageLoading />
@@ -23,21 +31,35 @@ export function AnalyticsPage() {
const k = data.kpis const k = data.kpis
return ( return (
<div className="p-4 md:p-6"> <div className="page-shell">
<PageHeader <PageHeader
title="数据分析中心" title="业务分析"
description="企业经营数据全景 · 基于当前企业实时数据" description="从客户获取、沟通效率到成交转化,查看完整经营链路"
actions={<LiveDataBadge />} actions={
extra={ <div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2"> <LiveDataBadge />
{['today', '7d', '30d', 'quarter'].map((p) => ( <div className="flex rounded-[10px] border border-slate-200 bg-white p-1 shadow-sm">
<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> {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>
} }
/> />
<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.newCustomers.value} delta={k.newCustomers.delta} icon={Users} />
<StatCard title="有效询盘" value={k.validInquiries.value} delta={k.validInquiries.delta} icon={Activity} /> <StatCard title="有效询盘" value={k.validInquiries.value} delta={k.validInquiries.delta} icon={Activity} />
<StatCard title="高意向客户" value={k.highIntent.value} delta={k.highIntent.delta} icon={Target} /> <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} /> <StatCard title="AI采纳率" value={`${k.aiAdoptionRate.value}%`} delta={k.aiAdoptionRate.delta} icon={Zap} />
</div> </div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2"> <section className="page-section">
<ChartCard title="客户增长趋势" height={260}><TrendChart data={data.customerGrowth} series={[{ key: 'count', name: '新增客户', color: '#247b71' }]} /></ChartCard> <div className="section-heading">
<ChartCard title="各渠道询盘数量" height={260}><SimpleBarChart data={data.channelInquiries} xKey="name" barKey="count" name="询盘数" /></ChartCard> <div>
<ChartCard title="客户国家/地区分布" height={260}><DonutChart data={data.countryDistribution} /></ChartCard> <h2 className="section-title">增长与转化</h2>
<ChartCard title="客户阶段转化漏斗" height={260}><FunnelChart data={data.funnel} /></ChartCard> <p className="section-description">观察客户增长速度、渠道贡献和阶段流失</p>
<ChartCard title="意向分数分布" height={260}><DonutChart data={data.intentDistribution} nameKey="range" /></ChartCard> </div>
<ChartCard title="产品关注度排行" height={260}><RankBars data={data.productRank.map((p) => ({ name: p.product, value: p.inquiries }))} /></ChartCard> </div>
<ChartCard title="团队回复效率排行" height={240}><RankBars data={data.teamReplyRank.map((r) => ({ name: r.name, value: r.avgResponseMin }))} unit="分钟" max={30} /></ChartCard> <div className="grid grid-cols-1 gap-4 xl:grid-cols-12">
<ChartCard title="团队客户转化排行" height={240}><RankBars data={data.teamConversionRank.map((r) => ({ name: r.name, value: r.rate }))} unit="%" /></ChartCard> <ChartCard className="xl:col-span-7" title="客户增长趋势" description="所选周期内的新增客户变化" height={290}>
</div> <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>
{/* 老板看板 */} <section className="page-section">
<h2 className="mt-6 mb-3 text-lg font-semibold">老板看板 <LiveDataBadge className="align-middle" /></h2> <div className="section-heading">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3"> <div>
<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> <h2 className="section-title">产品与团队效率</h2>
<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> <p className="section-description">比较产品热度、响应速度和团队转化结果</p>
<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> </div>
<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> </div>
<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> <div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<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> <ChartCard title="产品关注度排行" height={250}><RankBars data={data.productRank.map((p) => ({ name: p.product, value: p.inquiries }))} /></ChartCard>
</div> <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> </div>
) )
} }
+107 -167
View File
@@ -1,9 +1,9 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { z } from 'zod' import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/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 { login } from '@/services/api/auth'
import { useAuthStore } from '@/app/store/auth' import { useAuthStore } from '@/app/store/auth'
import { useI18n } from '@/i18n' import { useI18n } from '@/i18n'
@@ -11,210 +11,150 @@ import { toast, errorMessage } from '@/app/store/toast'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input, Field } from '@/components/ui/input' import { Input, Field } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Tabs } from '@/components/ui/tabs'
import { Dialog } from '@/components/ui/dialog' import { Dialog } from '@/components/ui/dialog'
import { UI_LOCALES, type UiLocale } from '@/config/languages'
import { Dropdown } from '@/components/ui/dropdown' 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({ const schema = z.object({
account: z.string().min(4, '请输入正确的手机号或邮箱'), account: z.string().min(4, '请输入正确的手机号或邮箱'),
password: z.string().optional(), password: z.string().min(1, '请输入密码'),
code: z.string().optional(),
}) })
type FormValues = z.infer<typeof schema> type FormValues = z.infer<typeof schema>
export function LoginPage() { export function LoginPage() {
const { t, locale, setLocale } = useI18n() const { locale, setLocale } = useI18n()
const navigate = useNavigate() const navigate = useNavigate()
const setSession = useAuthStore((s) => s.setSession) const [searchParams, setSearchParams] = useSearchParams()
const [mode, setMode] = useState<'code' | 'password'>('password') const setSession = useAuthStore((state) => state.setSession)
const [remember, setRemember] = useState(true) const [remember, setRemember] = useState(true)
const [agreed, setAgreed] = useState(true) const [agreed, setAgreed] = useState(true)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [codeSent, setCodeSent] = useState(0)
const [agreementOpen, setAgreementOpen] = useState<'privacy' | 'terms' | null>(null) const [agreementOpen, setAgreementOpen] = useState<'privacy' | 'terms' | null>(null)
const { const {
register, register,
handleSubmit, handleSubmit,
formState: { errors }, 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) { if (!agreed) {
toast.warning('请先勾选并同意《隐私政策》和《服务协议》') toast.warning('请先阅读并同意隐私政策和服务协议')
return
}
if (mode === 'password' && !values.password) {
toast.warning('请输入密码')
return
}
if (mode === 'code' && (values.code ?? '').length !== 6) {
toast.warning('请输入6位验证码')
return return
} }
try { try {
setLoading(true) setLoading(true)
const session = await login({ ...values, mode, remember }) const session = await login({ ...values, mode: 'password', remember })
setSession(session) setSession(session)
toast.success(`欢迎回来,${session.user.name}`)
navigate('/dashboard') navigate('/dashboard')
} catch (e) { } catch (error) {
toast.error(errorMessage(e)) toast.error(errorMessage(error))
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
const sendCode = () => { const localeMenu = (
setCodeSent(60) <Dropdown
const timer = setInterval(() => { trigger={(
setCodeSent((s) => { <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">
if (s <= 1) clearInterval(timer) <Globe className="h-3.5 w-3.5" />
return s - 1 {UI_LOCALES.find((item) => item.code === locale)?.nativeName}
}) </button>
}, 1000) )}
toast.success('验证码已发送') items={UI_LOCALES.map((item) => ({
} key: item.code,
label: item.nativeName,
onSelect: () => setLocale(item.code as UiLocale),
}))}
/>
)
return ( return (
<div className="flex min-h-screen bg-background"> <AuthShell topAction={localeMenu}>
{/* 品牌区 */} <div className="mb-8">
<div className="hidden w-[46%] flex-col justify-between bg-primary-900 p-10 text-primary-50 lg:flex"> <p className="text-xs font-semibold tracking-[0.12em] text-blue-600">欢迎回来</p>
<div className="flex items-center gap-2.5"> <h2 className="mt-3 text-[28px] font-semibold tracking-[-0.035em] text-slate-950">登录工作台</h2>
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/10"> <p className="mt-2 text-sm text-slate-500">继续处理客户消息与团队跟进。</p>
<Sparkles className="h-5 w-5" /> </div>
<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>
</Field>
<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>
<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="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-slate-500">
还没有账号?{' '}
<Link to="/register" className="font-medium text-blue-600 hover:text-blue-700">
创建企业
</Link>
</p>
</form>
<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> </span>
<div> </label>
<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>
{/* 表单区 */} <Dialog
<div className="flex flex-1 items-center justify-center p-6"> open={Boolean(agreementOpen)}
<div className="w-full max-w-[400px]"> onOpenChange={() => setAgreementOpen(null)}
<div className="mb-8 flex items-center justify-between"> title={agreementOpen === 'privacy' ? '隐私政策' : '服务协议'}
<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"> <div className="space-y-3 text-sm leading-7 text-slate-600">
<Sparkles className="h-4 w-4" />
</span>
<span className="font-semibold">{t('app.name')}</span>
</div>
<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}
</button>
}
items={UI_LOCALES.map((l) => ({
key: l.code,
label: l.nativeName,
onSelect: () => setLocale(l.code as UiLocale),
}))}
/>
</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') }]}
/>
</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>
</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>
<Button type="submit" className="w-full" size="lg" loading={loading}>
{t('login.submit')}
</Button>
<div className="text-center text-sm text-muted-foreground">
还没有账号?{' '}
<Link to="/register" className="font-medium text-primary hover:underline">
{t('login.register')}
</Link>
</div>
</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>
{/* 协议弹窗 */}
<Dialog open={!!agreementOpen} onOpenChange={() => setAgreementOpen(null)} title={agreementOpen === 'privacy' ? '隐私政策' : '服务协议'}>
<div className="space-y-3 text-sm leading-relaxed text-muted-foreground">
<p>使用本服务前,请阅读并确认以下数据处理与服务条款。</p> <p>使用本服务前,请阅读并确认以下数据处理与服务条款。</p>
<p>1. 我们仅收集为您提供跨境沟通与客户管理服务所必需的信息,包括账号信息、企业信息与您主动录入的客户数据。</p> <p>我们仅处理提供跨境沟通、客户管理和团队协作所必需的数据。</p>
<p>2. 您的会话数据采用全链路加密存储,未经您授权,我们不会向任何第三方披露。</p> <p>使用翻译与回复建议时,相关内容会发送至企业已配置的模型服务商处理。</p>
<p>3. 您可以随时导出或删除您的数据;企业注销后,相关数据将在 30 天内完成清理。</p> <p>数据导出、删除和账号注销按照正式发布的政策与协议执行。</p>
<p>4. 使用 AI 功能时,相关内容仅用于为您生成翻译与回复建议,不会用于其他用途。</p>
</div> </div>
</Dialog> </Dialog>
</AuthShell>
</div>
) )
} }
+52 -55
View File
@@ -3,7 +3,6 @@ import { Link, useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { z } from 'zod' import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { Sparkles } from 'lucide-react'
import { register as registerApi } from '@/services/api/auth' import { register as registerApi } from '@/services/api/auth'
import { useAuthStore } from '@/app/store/auth' import { useAuthStore } from '@/app/store/auth'
import { toast, errorMessage } from '@/app/store/toast' 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 { Input, Field } from '@/components/ui/input'
import { Select } from '@/components/ui/select' import { Select } from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { AuthShell } from '@/components/auth/AuthShell'
import { INDUSTRIES } from '@/config/constants' import { INDUSTRIES } from '@/config/constants'
const schema = z.object({ const schema = z.object({
name: z.string().min(2, '请输入您的姓名'), name: z.string().min(2, '请输入您的姓名'),
account: z.string().min(5, '请输入手机号或邮箱'), 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, '请输入企业名称'), company: z.string().min(2, '请输入企业名称'),
industry: z.string().min(1, '请选择所属行业'), industry: z.string().min(1, '请选择所属行业'),
teamSize: z.string().min(1, '请选择团队规模'), teamSize: z.string().min(1, '请选择团队规模'),
}) })
type FormValues = z.infer<typeof schema> type FormValues = z.infer<typeof schema>
const TEAM_SIZES = ['仅我一人', '2—5人', '6—20人', '21—50人', '50人以上'] 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() { export function RegisterPage() {
const navigate = useNavigate() const navigate = useNavigate()
const setSession = useAuthStore((s) => s.setSession) const setSession = useAuthStore((state) => state.setSession)
const [agreed, setAgreed] = useState(false) const [agreed, setAgreed] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
@@ -43,77 +43,74 @@ export function RegisterPage() {
const onSubmit = async (values: FormValues) => { const onSubmit = async (values: FormValues) => {
if (!agreed) { if (!agreed) {
toast.warning('请先同意《隐私政策》和《服务协议》') toast.warning('请先同意隐私政策和服务协议')
return return
} }
try { try {
setLoading(true) setLoading(true)
const session = await registerApi(values) const session = await registerApi(values)
setSession(session) setSession(session)
toast.success('企业创建成功', '接下来请完成企业初始化配置') navigate('/channels?setup=choose')
navigate('/channels') } catch (error) {
} catch (e) { toast.error(errorMessage(error))
toast.error(errorMessage(e))
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }
return ( return (
<div className="flex min-h-screen items-center justify-center bg-background p-6"> <AuthShell
<div className="w-full max-w-[440px]"> heading="从第一条消息开始,建立清晰的客户关系。"
<div className="mb-8 flex items-center gap-2.5"> description="创建企业工作台后,可按需连接 Telegram、WhatsApp 或企业邮箱,并邀请团队成员协作。"
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary text-white"> topAction={(
<Sparkles className="h-5 w-5" /> <p className="text-xs text-slate-500">
</span> 已有账号? <Link to="/login" className="font-medium text-blue-600 hover:text-blue-700">登录</Link>
<div> </p>
<p className="text-base font-semibold">灵犀跨境</p> )}
<p className="text-xs text-muted-foreground">注册并创建您的企业</p> >
</div> <div className="mb-7">
</div> <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)}> <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}> <Field label="姓名" required error={errors.name?.message}>
<Input placeholder="您的姓名" {...register('name')} /> <Input className={fieldClass} placeholder="您的姓名" autoComplete="name" {...register('name')} />
</Field> </Field>
<Field label="手机号或邮箱" required error={errors.account?.message}> <Field label="手机号或邮箱" required error={errors.account?.message}>
<Input placeholder="用于登录与接收通知" {...register('account')} /> <Input className={fieldClass} placeholder="用于登录" autoComplete="username" {...register('account')} />
</Field> </Field>
<Field label="设置密码" required error={errors.password?.message} hint="6—32位,建议包含字母与数字"> </div>
<Input type="password" placeholder="请设置登录密码" {...register('password')} /> <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 className={fieldClass} placeholder="请输入企业名称" {...register('company')} />
</Field>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="所属行业" required error={errors.industry?.message}>
<Select placeholder="请选择" options={INDUSTRIES.map((item) => ({ value: item, label: item }))} {...register('industry')} />
</Field> </Field>
<Field label="企业名称" required error={errors.company?.message}> <Field label="团队规模" required error={errors.teamSize?.message}>
<Input placeholder="如:徐州欧汇科技有限公司" {...register('company')} /> <Select placeholder="请选择" options={TEAM_SIZES.map((item) => ({ value: item, label: item }))} {...register('teamSize')} />
</Field> </Field>
<div className="grid grid-cols-2 gap-4"> </div>
<Field label="所属行业" required error={errors.industry?.message}>
<Select placeholder="请选择" options={INDUSTRIES.map((i) => ({ value: i, label: i }))} {...register('industry')} />
</Field>
<Field label="团队规模" required error={errors.teamSize?.message}>
<Select placeholder="请选择" options={TEAM_SIZES.map((s) => ({ value: s, label: s }))} {...register('teamSize')} />
</Field>
</div>
<div className="pt-1"> <Checkbox
<Checkbox checked={agreed}
checked={agreed} onCheckedChange={setAgreed}
onCheckedChange={setAgreed} label="我已阅读并同意《隐私政策》和《服务协议》"
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> </Button>
<p className="text-center text-[11px] leading-5 text-slate-400">
<p className="text-center text-sm text-muted-foreground"> 注册后先选择需要接入的渠道,也可以暂时跳过。
已有账号?{' '} </p>
<Link to="/login" className="font-medium text-primary hover:underline"> </form>
直接登录 </AuthShell>
</Link>
</p>
</form>
</div>
</div>
) )
} }
+1 -1
View File
@@ -33,7 +33,7 @@ export function BillingPage() {
return ( return (
<div className="p-4 md:p-6"> <div className="p-4 md:p-6">
<PageHeader title="套餐与账单" description="通过 Stripe Checkout 管理真实订阅与支付" /> <PageHeader title="套餐与账单" description="管理套餐、订阅和支付记录" />
{info && ( {info && (
<Card className="mb-4 p-4"> <Card className="mb-4 p-4">
+798 -76
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 { 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 { toast, errorMessage } from '@/app/store/toast'
import { confirmDialog } from '@/app/store/confirm' import { confirmDialog } from '@/app/store/confirm'
import { PageHeader } from '@/components/common/PageHeader' import { PageHeader } from '@/components/common/PageHeader'
@@ -15,12 +35,13 @@ import { Input, Field } from '@/components/ui/input'
import { PermissionGate } from '@/components/common/PermissionGate' import { PermissionGate } from '@/components/common/PermissionGate'
import { ChannelIcon } from '@/components/common/ChannelIcon' import { ChannelIcon } from '@/components/common/ChannelIcon'
import { import {
getChannels, connectChannel, testChannel, updateChannel, deleteChannel, getChannelLogs, getChannels, testChannel, updateChannel, deleteChannel, getChannelLogs,
startTelegramLogin, submitTelegramCode, submitTelegramPassword, syncTelegramChannel, connectEmailChannel, startTelegramLogin, submitTelegramCode, submitTelegramPassword,
getTelegramLoginState, syncChannel, startWhatsAppLogin, getWhatsAppLoginState,
setDefaultChannel,
} from '@/services/api/channels' } from '@/services/api/channels'
import { CHANNEL_META } from '@/config/constants' import { CHANNEL_META } from '@/config/constants'
import { formatRelative } from '@/utils/format' import { formatRelative } from '@/utils/format'
import { cn } from '@/utils/cn'
import type { ChannelType, ChannelConnection, ChannelSyncLog } from '@/types' import type { ChannelType, ChannelConnection, ChannelSyncLog } from '@/types'
const STATUS_MAP: Record<string, { label: string; color: string }> = { 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' }, 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() { export function ChannelsPage() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const queryClient = useQueryClient() 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 [wizard, setWizard] = useState<{ open: boolean; type: ChannelType | null; step: number }>({ open: false, type: null, step: 0 })
const [telegramAccount, setTelegramAccount] = useState('') const [telegramAccount, setTelegramAccount] = useState('')
const [telegramCode, setTelegramCode] = useState('') const [telegramCode, setTelegramCode] = useState('')
const [telegramPassword, setTelegramPassword] = useState('') const [telegramPassword, setTelegramPassword] = useState('')
const [loginChannelId, setLoginChannelId] = useState('') const [loginChannelId, setLoginChannelId] = useState('')
const [authState, setAuthState] = 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 [logDrawer, setLogDrawer] = useState<string | null>(null)
const [settingsDrawer, setSettingsDrawer] = useState<ChannelConnection | null>(null) const [settingsDrawer, setSettingsDrawer] = useState<ChannelConnection | null>(null)
const [logItems, setLogItems] = useState<ChannelSyncLog[]>([]) const [logItems, setLogItems] = useState<ChannelSyncLog[]>([])
@@ -58,116 +112,717 @@ export function ChannelsPage() {
const list = channels ?? [] const list = channels ?? []
return ( const resetTelegramWizard = () => {
<div className="p-4 md:p-6"> setTelegramAccount('')
<PageHeader title="渠道中心" description={`${list.filter((c) => c.status === 'connected').length} 个渠道已连接 · 共 ${list.length} 个可连接渠道`} /> 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) => { {list.map((ch) => {
const meta = CHANNEL_META[ch.type] const meta = CHANNEL_META[ch.type]
const st = STATUS_MAP[ch.status] const st = STATUS_MAP[ch.status]
return ( 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"> <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]">
<Badge className={st.color}>{st.label}</Badge> <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>
</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> </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>}
{ch.errorMessage && <p className="mt-1 text-xs text-red-600">{ch.errorMessage}</p>} {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' ? ( {ch.status === 'connected' ? (
<> <>
<Button size="sm" variant="outline" onClick={async () => { <Button size="sm" variant="outline" onClick={async () => {
const result = await syncTelegramChannel(ch.id) try {
toast.success(`同步完成:${result.chats} 个会话,${result.messages} 条消息`) await updateChannel(ch.id, { status: 'paused' })
queryClient.invalidateQueries() toast.success('渠道已暂停接收实时消息')
}}><RefreshCw className="h-3 w-3" />同步</Button> queryClient.invalidateQueries({ queryKey: ['channels'] })
<Button size="sm" variant="outline" onClick={() => updateChannel(ch.id, { status: 'paused' }).then(() => queryClient.invalidateQueries({ queryKey: ['channels'] }))}><Pause className="h-3 w-3" />暂停</Button> } 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> <Button size="sm" variant="outline" onClick={() => delMut.mutate(ch.id)}><Trash2 className="h-3 w-3" /></Button>
</> </>
) : ch.status === 'paused' ? ( ) : 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' ? ( ) : 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>
)} )}
<Button size="sm" variant="ghost" onClick={() => loadLogs(ch.id)}><History className="h-3 w-3" /></Button> {!ch.id.endsWith('-available') && (
<Button size="sm" variant="ghost" onClick={() => setSettingsDrawer(ch)}><Settings className="h-3 w-3" /></Button> <>
<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> </div>
</Card> </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> </div>
{/* 连接向导 */} {/* 连接向导 */}
<Dialog <Dialog
open={wizard.open} 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 : ''}`} title={`连接 ${wizard.type ? CHANNEL_META[wizard.type].name : ''}`}
footer={ footer={
<Button <Button
loading={connecting}
onClick={async () => { onClick={async () => {
if (!loginChannelId) { setConnecting(true)
if (telegramAccount.trim()) { try {
const channel = await startTelegramLogin(telegramAccount.trim()) if (wizard.type === 'email') {
await connectEmailChannel(emailForm)
toast.success('企业邮箱连接成功,历史邮件已开始同步')
setWizard({ open: false, type: null, step: 0 })
setEmailForm((current) => ({ ...current, password: '' }))
queryClient.invalidateQueries()
return
}
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) setLoginChannelId(channel.id)
setAuthState('waiting_code') if (channel.status === 'connected' || channel.authState === 'ready') {
toast.success('验证码已发送到 Telegram') await completeTelegramBinding(channel.id)
return
}
setTelegramStepFromState(channel.authState || 'waiting_code')
toast.success(channel.authState === 'waiting_password'
? '请继续输入 Telegram 二步验证密码'
: '验证码已发送到 Telegram')
return return
} }
const channel = await connectChannel({ type: 'telegram', account: '现有网关账号', autoReply: false }) if (authState === 'waiting_code') {
toast.success(channel.status === 'connected' ? '现有 Telegram 账号已连接' : '账号尚未完成授权') const code = telegramCode.replace(/\s+/g, '')
setWizard({ open: false, type: null, step: 0 }) if (!/^\d{4,8}$/.test(code)) {
queryClient.invalidateQueries({ queryKey: ['channels'] }) toast.warning('请输入 4—8 位 Telegram 验证码')
return return
} }
if (authState === 'waiting_code') { const result = await submitTelegramCode(loginChannelId, code)
const result = await submitTelegramCode(loginChannelId, telegramCode) if (result.status === 'waiting_password') {
if (result.status === 'waiting_password') { setTelegramStepFromState('waiting_password')
setAuthState('waiting_password') toast.info('该账号启用了二步验证,请输入密码')
toast.info('该账号启用了二步验证,请输入密码') return
return }
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
}
} }
} else if (authState === 'waiting_password') { await completeTelegramBinding(loginChannelId)
await submitTelegramPassword(loginChannelId, telegramPassword) } 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="flex items-center gap-2"> <div className="space-y-4">
<Badge variant="primary">步骤 {wizard.step + 1}/7</Badge> <div className="rounded-xl border border-sky-100 bg-sky-50/70 p-3">
<span className="text-sm font-medium">连接现有 Telegram 网关账号</span> <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> </div>
{!loginChannelId && ( ) : wizard.type === 'whatsapp' ? (
<Field label="Telegram 手机号(留空则连接服务器现有账号)"> <div className="space-y-4">
<Input value={telegramAccount} onChange={(e) => setTelegramAccount(e.target.value)} placeholder="+8613800138000" /> <div className="rounded-xl border border-amber-200 bg-amber-50 p-3.5">
</Field> <div className="flex items-center gap-2 text-sm font-semibold text-amber-900">
)} <AlertTriangle className="h-4 w-4" />
{authState === 'waiting_code' && ( 普通 WhatsApp 关联设备接入
<Field label="Telegram 验证码"> </div>
<Input value={telegramCode} onChange={(e) => setTelegramCode(e.target.value)} placeholder="请输入验证码" /> <p className="mt-1 text-xs leading-5 text-amber-800">
</Field> 该方式通过 WhatsApp 的“关联设备”连接普通个人或 Business App 账号,并非 Meta 官方 Business Cloud API。
)} 请勿用于垃圾营销或违反 WhatsApp 条款的自动化行为。
{authState === 'waiting_password' && ( </p>
<Field label="Telegram 二步验证密码"> </div>
<Input type="password" value={telegramPassword} onChange={(e) => setTelegramPassword(e.target.value)} /> {!whatsappChannelId ? (
</Field> <Field label="WhatsApp 手机号" required hint="包含国家/地区代码,例如中国号码输入 +8613800138000">
)} <Input
<p className="text-sm text-muted-foreground">手机号、验证码和二步密码只发送到服务端 Telegram Gateway,不在浏览器持久化。</p> type="tel"
</div> 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">
<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 手机号" required hint="必须包含国家/地区代码,不要输入空格或短横线">
<Input
type="tel"
autoComplete="tel"
value={telegramAccount}
onChange={(e) => setTelegramAccount(e.target.value)}
placeholder="+8613800138000"
/>
</Field>
)}
{authState === 'waiting_code' && (
<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 二步验证密码" required hint="仅当该 Telegram 账号已启用两步验证时需要">
<Input
type="password"
autoComplete="current-password"
value={telegramPassword}
onChange={(e) => setTelegramPassword(e.target.value)}
placeholder="输入 Telegram 二步验证密码"
/>
</Field>
)}
<p className="text-xs leading-5 text-muted-foreground">
完成验证后将自动同步历史会话、联系人和媒体消息。
</p>
</div>
)}
</Dialog> </Dialog>
{/* 同步日志 */} {/* 同步日志 */}
@@ -180,6 +835,73 @@ export function ChannelsPage() {
</div> </div>
))} ))}
</Drawer> </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> </div>
) )
} }
+100 -45
View File
@@ -3,6 +3,8 @@ import { useNavigate, useParams } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, Edit, Send, UserRoundPlus, Tag, GitMerge, Download, ExternalLink } from 'lucide-react' import { ArrowLeft, Edit, Send, UserRoundPlus, Tag, GitMerge, Download, ExternalLink } from 'lucide-react'
import { getCustomer, updateCustomer } from '@/services/api/customers' import { getCustomer, updateCustomer } from '@/services/api/customers'
import { createTask } from '@/services/api/crm'
import { useAuthStore } from '@/app/store/auth'
import { useI18n } from '@/i18n' import { useI18n } from '@/i18n'
import { toast, errorMessage } from '@/app/store/toast' import { toast, errorMessage } from '@/app/store/toast'
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback' 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 { ChannelIcon } from '@/components/common/ChannelIcon'
import { IntentScoreBadge, CustomerStageBadge, SentimentBadge, RiskLevelBadge } from '@/components/common/StatusBadges' import { IntentScoreBadge, CustomerStageBadge, SentimentBadge, RiskLevelBadge } from '@/components/common/StatusBadges'
import { ChipPicker } from '@/components/customer/CustomerFormDialog' 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 { CUSTOMER_STAGES, CUSTOMER_TAGS, NEXT_ACTIONS } from '@/config/constants'
import { formatRelative, formatDateTime } from '@/utils/format' import { formatRelative, formatDateTime } from '@/utils/format'
import { cn } from '@/utils/cn' import { cn } from '@/utils/cn'
@@ -31,6 +35,7 @@ export function CustomerDetailPage() {
const { id } = useParams<{ id: string }>() const { id } = useParams<{ id: string }>()
const navigate = useNavigate() const navigate = useNavigate()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const user = useAuthStore((state) => state.user)
const [tab, setTab] = useState('overview') const [tab, setTab] = useState('overview')
const [tagDialog, setTagDialog] = useState(false) const [tagDialog, setTagDialog] = useState(false)
@@ -52,22 +57,42 @@ export function CustomerDetailPage() {
if (isError || !data) return <ErrorState onRetry={() => refetch()} /> if (isError || !data) return <ErrorState onRetry={() => refetch()} />
const c = data.customer 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: '📎' } const FILE_ICONS: Record<string, string> = { quote: '📄', contract: '📝', image: '🖼️', doc: '📁', other: '📎' }
return ( 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"> <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" /> 返回 <ArrowLeft className="h-4 w-4 rtl-flip" /> 返回
</button> </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"> <div className="flex flex-wrap gap-4">
<Avatar name={c.name} src={c.avatarUrl} size="xl" /> <Avatar name={c.name} src={c.avatarUrl} size="xl" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="eyebrow mb-1">Customer intelligence</p>
<div className="flex flex-wrap items-center gap-2"> <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} /> <CountryFlag code={c.country} />
<ChannelIcon channel={c.channel} size="xs" /> <ChannelIcon channel={c.channel} size="xs" />
{c.isKey && <Badge variant="warning">⭐ 重点客户</Badge>} {c.isKey && <Badge variant="warning">⭐ 重点客户</Badge>}
@@ -80,8 +105,19 @@ export function CustomerDetailPage() {
{c.riskLevel !== 'none' && <RiskLevelBadge level={c.riskLevel} />} {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> <span className="text-muted-foreground">负责人:{c.ownerId === 'u-003' ? '李婷' : c.ownerId === 'u-004' ? '赵磊' : c.ownerId === 'u-005' ? '周敏' : c.ownerId === 'u-008' ? '郑楠' : '未分配'}</span>
</div> </div>
<div className="mt-2 flex flex-wrap gap-1"> <div className="mt-3 rounded-xl border border-slate-100 bg-slate-50/50 p-3">
{c.tags.map((t) => <Badge key={t} variant="outline">{t}</Badge>)} <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> </div>
<div className="flex flex-wrap items-start gap-2"> <div className="flex flex-wrap items-start gap-2">
@@ -119,43 +155,46 @@ export function CustomerDetailPage() {
<div className="mt-4"> <div className="mt-4">
{tab === 'overview' && ( {tab === 'overview' && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-4 xl:grid-cols-12">
<Card> <CustomerProfileRadar customer={c} className="xl:col-span-5" />
<CardHeader title="自动建档流程" description="客户从渠道识别到完整建档案的自动化历程" />
<div className="space-y-3 px-4 pb-4"> <Card className="xl:col-span-7">
{[ <CardHeader title="客户洞察" description="参考已同步的会话、购买信号和风险变化" />
'✅ 识别来源渠道(WhatsApp)', <div className="p-5">
'✅ 记录来源参数(官网按钮带入)', <div className="grid grid-cols-3 gap-2">
'✅ 匹配客户身份(手机号 + 姓名字段)', {[
'✅ 自动发现重复客户(未匹配到重复)', ['成交概率', 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((step, i) => ( ].map(([label, value, color]) => (
<div key={i} className="flex items-center gap-2 text-sm"> <div key={String(label)} className={cn('rounded-2xl px-3 py-3', String(color))}>
<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> <p className="text-[11px] opacity-75">{label}</p>
{step.replace('✅ ', '')} <p className="mt-1 text-xl font-semibold tracking-tight">{Number(value)}%</p>
</div>
))}
</div>
<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> </div>
))} ) : null}
</div> </div>
</Card> </Card>
<Card>
<CardHeader title="AI 客户画像摘要" /> <Card className="xl:col-span-8">
<p className="px-4 pb-4 text-sm text-muted-foreground leading-relaxed">{c.aiProfile.summary}</p> <CardHeader title="采购信息" description="根据已同步的会话内容整理" />
{c.aiProfile.nextActions.length > 0 && ( <div className="grid grid-cols-2 gap-px overflow-hidden rounded-b-2xl bg-slate-100 md:grid-cols-4">
<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>
))}
</div>
</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">
{Object.entries({ {Object.entries({
询价数量: c.aiProfile.inquiryQuantity, 询价数量: c.aiProfile.inquiryQuantity,
目标价格: c.aiProfile.targetPrice, 目标价格: c.aiProfile.targetPrice,
@@ -165,16 +204,32 @@ export function CustomerDetailPage() {
交付时间: c.aiProfile.deliveryTime, 交付时间: c.aiProfile.deliveryTime,
物流方式: c.aiProfile.logistics, 物流方式: c.aiProfile.logistics,
付款方式: c.aiProfile.payment, 付款方式: c.aiProfile.payment,
售后诉求: c.aiProfile.aftersales, }).map(([k, v]) => (
主要顾虑: c.aiProfile.concerns?.join('、'), <div key={k} className="min-h-24 bg-white p-4">
}).filter(([, v]) => v).map(([k, v]) => ( <span className="text-[11px] font-medium text-slate-400">{k}</span>
<div key={k} className="rounded-md bg-muted/60 p-2"> <p className={cn('mt-2 text-sm font-medium leading-5', v ? 'text-slate-800' : 'text-slate-300')}>
<span className="text-xs text-muted-foreground">{k}</span> {(v as string) || '待识别'}
<p className="text-sm font-medium mt-0.5">{v as string}</p> </p>
</div> </div>
))} ))}
</div> </div>
</Card> </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> </div>
)} )}
+9 -9
View File
@@ -175,10 +175,10 @@ export function CustomersPage() {
if (isError) return <ErrorState onRetry={() => refetch()} /> if (isError) return <ErrorState onRetry={() => refetch()} />
return ( return (
<div className="p-4 md:p-6"> <div className="page-shell">
<PageHeader <PageHeader
title="客户管理" title="客户管理"
description={`共 ${total} 个客户 · 统一管理与跟进`} description={`共 ${total} 个客户`}
actions={ actions={
<> <>
<PermissionGate permission="customer:export"> <PermissionGate permission="customer:export">
@@ -204,27 +204,27 @@ export function CustomersPage() {
</> </>
} }
extra={ 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"> <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" /> <Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input <input
placeholder="搜索客户名称/公司/国家/产品" placeholder="搜索客户名称/公司/国家/产品"
value={keyword} value={keyword}
onChange={(e) => setKeyword(e.target.value)} 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> </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={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={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={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" /> <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: 'table' as const, icon: TableProperties },
{ v: 'card' as const, icon: LayoutGrid }, { v: 'card' as const, icon: LayoutGrid },
{ v: 'kanban' as const, icon: List }, { v: 'kanban' as const, icon: List },
].map((opt) => ( ].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" /> <opt.icon className="h-4 w-4" />
</button> </button>
))} ))}
@@ -235,7 +235,7 @@ export function CustomersPage() {
{/* 批量操作 */} {/* 批量操作 */}
{selected.size > 0 && ( {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> <span className="text-sm font-medium text-primary-700">已选 {selected.size} 位客户</span>
<div className="flex-1" /> <div className="flex-1" />
<Button size="sm" variant="outline" onClick={() => { setBatchValue(''); setBatchDialog('assign') }}><UserRoundPlus className="h-4 w-4" />批量分配</Button> <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' && ( {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> <Table>
<THead> <THead>
<Tr> <Tr>
@@ -270,7 +270,7 @@ export function CustomersPage() {
<Tr><Td colSpan={13}><EmptyState title="暂无匹配客户" description="尝试调整筛选条件" /></Td></Tr> <Tr><Td colSpan={13}><EmptyState title="暂无匹配客户" description="尝试调整筛选条件" /></Td></Tr>
)} )}
{list.map((customer: Customer) => ( {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={(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}`)}> <Td onClick={() => navigate(`/customers/${customer.id}`)}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+78 -33
View File
@@ -92,51 +92,87 @@ export function DashboardPage() {
const s = data.stats const s = data.stats
return ( 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> <div className="flex flex-wrap items-start justify-between gap-5">
<h1 className="text-xl font-semibold"> <div>
{greeting},{user?.name} <p className="eyebrow">今日工作概览</p>
</h1> <h1 className="mt-1.5 text-2xl font-semibold tracking-[-0.035em] text-slate-950 md:text-[28px]">
<p className="mt-1 text-sm text-muted-foreground"> {greeting},{user?.name}
{dateStr} · {tenant?.name} · {t('dashboard.updatedAt')} {formatDateTime(data.updatedAt).slice(11)} </h1>
</p> <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 rounded-full px-3">{plan.name}</Badge>
<LiveDataBadge />
<Button size="sm" onClick={() => navigate('/inbox')}>
<Send className="h-4 w-4" />
处理客户消息
</Button>
</div>
</div> </div>
<div className="flex flex-wrap items-center gap-2"> <div className="mt-5 flex flex-wrap gap-2 border-t border-slate-100 pt-4">
<Badge variant="primary" className="h-7 px-2.5">{plan.name}</Badge> {quickActions.slice(0, 5).map((a) => (
<LiveDataBadge /> <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}>
<Button size="sm" onClick={() => navigate('/channels')}> <a.icon className="h-3.5 w-3.5" />
<Cable className="h-4 w-4" /> {a.label}
快速连接渠道 </Button>
</Button> ))}
</div> </div>
</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}>
<a.icon className="h-3.5 w-3.5" />
{a.label}
</Button>
))}
</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"> <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.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.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.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.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> </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> <Card>
<CardHeader <CardHeader
@@ -242,14 +278,22 @@ export function DashboardPage() {
<div className="border-t border-border p-3"> <div className="border-t border-border p-3">
<Button variant="outline" className="w-full" onClick={() => navigate('/inbox')}> <Button variant="outline" className="w-full" onClick={() => navigate('/inbox')}>
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
进入统一消息中心 查看全部消息
</Button> </Button>
</div> </div>
</Card> </Card>
</div> </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}> <ChartCard title="询盘趋势(近14天)" height={240}>
<TrendChart data={data.inquiryTrend} series={[{ key: 'inquiries', name: '询盘数' }, { key: 'customers', name: '新增客户' }]} /> <TrendChart data={data.inquiryTrend} series={[{ key: 'inquiries', name: '询盘数' }, { key: 'customers', name: '新增客户' }]} />
</ChartCard> </ChartCard>
@@ -267,6 +311,7 @@ export function DashboardPage() {
/> />
</ChartCard> </ChartCard>
</div> </div>
</section>
{/* 最近风险 */} {/* 最近风险 */}
<Card className="mt-4"> <Card className="mt-4">
+2 -2
View File
@@ -7,8 +7,8 @@ const HELP_ITEMS = [
{ icon: Cable, title: '连接 Telegram', desc: '手机号、验证码和二步密码登录,并同步历史会话', path: '/channels' }, { icon: Cable, title: '连接 Telegram', desc: '手机号、验证码和二步密码登录,并同步历史会话', path: '/channels' },
{ icon: BookOpen, title: '建设知识库', desc: '上传 TXT、Markdown、PDF 或 DOCX 并解析为企业知识', path: '/knowledge' }, { icon: BookOpen, title: '建设知识库', desc: '上传 TXT、Markdown、PDF 或 DOCX 并解析为企业知识', path: '/knowledge' },
{ icon: FileUp, title: '导入与管理客户', desc: '维护客户资料、标签、负责人和跟进状态', path: '/customers' }, { icon: FileUp, title: '导入与管理客户', desc: '维护客户资料、标签、负责人和跟进状态', path: '/customers' },
{ icon: Languages, title: 'AI 上下文翻译', desc: '结合当前会话上下文进行跨境商务翻译', path: '/translate' }, { icon: Languages, title: '消息翻译', desc: '翻译当前消息并保留双语对照', path: '/translate' },
{ icon: UsersRound, title: '邀请团队成员', desc: '通过已配置的企业 SMTP 发送真实邀请邮件', path: '/team' }, { icon: UsersRound, title: '邀请团队成员', desc: '向同事发送团队邀请邮件', path: '/team' },
{ icon: CreditCard, title: '订阅与支付', desc: '通过 Stripe Checkout 管理套餐订阅', path: '/billing' }, { icon: CreditCard, title: '订阅与支付', desc: '通过 Stripe Checkout 管理套餐订阅', path: '/billing' },
] ]
File diff suppressed because it is too large Load Diff
+67 -33
View File
@@ -29,20 +29,28 @@ export function KnowledgePage() {
const [createOpen, setCreateOpen] = useState(false) const [createOpen, setCreateOpen] = useState(false)
const [editItem, setEditItem] = useState<KnowledgeItem | null>(null) const [editItem, setEditItem] = useState<KnowledgeItem | null>(null)
const [searchQ, setSearchQ] = useState('') const [searchQ, setSearchQ] = useState('')
const [createDraft, setCreateDraft] = useState({ title: '', content: '' })
const { data, isLoading, isError, refetch } = useQuery({ const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['knowledge', category, keyword], queryKey: ['knowledge', category, keyword],
queryFn: () => getKnowledge({ 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 [searchResult, setSearchResult] = useState<any>(null)
const [searchLoading, setSearchLoading] = useState(false) const [searchLoading, setSearchLoading] = useState(false)
const [uploadFiles, setUploadFiles] = useState<File[]>([]) const [uploadFiles, setUploadFiles] = useState<File[]>([])
const [uploadDialog, setUploadDialog] = useState(false) const [uploadDialog, setUploadDialog] = useState(false)
const [uploadLoading, setUploadLoading] = useState(false)
const createMut = useMutation({ const createMut = useMutation({
mutationFn: (p: any) => createKnowledge(p), 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)), onError: (e) => toast.error(errorMessage(e)),
}) })
@@ -65,7 +73,7 @@ export function KnowledgePage() {
<div className="p-4 md:p-6"> <div className="p-4 md:p-6">
<PageHeader <PageHeader
title="企业知识库" title="企业知识库"
description={`${list.length} 条知识 · AI 回复与翻译的精准来源`} description="把产品、价格、物流和售后资料变成可检索、可引用的企业答案"
actions={ actions={
<> <>
<Button size="sm" variant="outline" onClick={() => setUploadDialog(true)}><Upload className="h-4 w-4" />上传文件</Button> <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={[ <Tabs value={tab} onChange={setTab} className="mb-4" items={[
{ value: 'list', label: '知识列表' }, { value: 'list', label: '知识列表' },
{ value: 'search', 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"> <div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
<span>引用 {item.citedCount} 次</span> <span>引用 {item.citedCount} 次</span>
<span>·</span> <span>·</span>
<span>{formatRelative(item.updatedAt)}</span> <span>{item.chunkCount || 0} 个检索片段</span>
<span>·</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> </div>
{item.parseMessage && <p className="mt-1 text-[11px] text-emerald-600">{item.parseMessage}</p>}
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<Switch checked={item.enabled} onCheckedChange={(v) => toggleMut.mutate({ id: item.id, enabled: v })} /> <Switch checked={item.enabled} onCheckedChange={(v) => toggleMut.mutate({ id: item.id, enabled: v })} />
@@ -129,7 +155,7 @@ export function KnowledgePage() {
{tab === 'search' && ( {tab === 'search' && (
<Card className="p-4"> <Card className="p-4">
<div className="flex gap-2"> <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 () => { <Button loading={searchLoading} onClick={async () => {
if (!searchQ.trim()) return if (!searchQ.trim()) return
setSearchLoading(true) setSearchLoading(true)
@@ -142,14 +168,15 @@ export function KnowledgePage() {
{searchResult && ( {searchResult && (
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
<Card className="p-3 bg-primary-50/30"> <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> <p className="text-sm">{searchResult.answer}</p>
</Card> </Card>
<p className="text-xs font-semibold text-muted-foreground">命中知识 ({searchResult.hits.length})</p> <p className="text-xs font-semibold text-muted-foreground">命中知识 ({searchResult.hits.length})</p>
{searchResult.hits.map((h: any) => ( {searchResult.hits.map((h: any) => (
<div key={h.item.id} className="rounded-md border border-border p-3"> <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-xs text-muted-foreground">{h.snippet}</p>
<p className="mt-1 text-[10px] text-slate-400">命中片段 #{Number(h.chunkIndex || 0) + 1}</p>
</div> </div>
))} ))}
</div> </div>
@@ -175,34 +202,41 @@ export function KnowledgePage() {
<Dialog open={createOpen} onOpenChange={setCreateOpen} title="新建知识条目" footer={ <Dialog open={createOpen} onOpenChange={setCreateOpen} title="新建知识条目" footer={
<Button onClick={() => { <Button onClick={() => {
const title = (document.querySelector('#k-title') as HTMLInputElement)?.value if (!createDraft.title.trim()) { toast.warning('请输入标题'); return }
const content = (document.querySelector('#k-content') as HTMLTextAreaElement)?.value if (createDraft.content.trim().length < 20) { toast.warning('知识内容至少需要 20 个字符'); return }
if (!title) { toast.warning('请输入标题'); return } createMut.mutate({ ...createDraft, category: category || 'faq' })
createMut.mutate({ title, content, category: category || 'faq' }) }} loading={createMut.isPending}>创建并索引</Button>
}}>创建</Button>
}> }>
<Field label="标题" required><Input id="k-title" 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 id="k-content" rows={5} 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>
<Dialog open={uploadDialog} onOpenChange={setUploadDialog} title="上传文件" footer={ <Dialog open={uploadDialog} onOpenChange={setUploadDialog} title="上传文件" footer={
<Button disabled={!uploadFiles.length} onClick={async () => { <Button disabled={!uploadFiles.length} loading={uploadLoading} onClick={async () => {
const payload = await Promise.all(uploadFiles.map(async (file) => ({ setUploadLoading(true)
name: file.name, try {
type: file.type, const payload = await Promise.all(uploadFiles.map(async (file) => ({
base64: await new Promise<string>((resolve, reject) => { name: file.name,
const reader = new FileReader() type: file.type,
reader.onerror = () => reject(reader.error) base64: await new Promise<string>((resolve, reject) => {
reader.onload = () => resolve(String(reader.result).split(',')[1] || '') const reader = new FileReader()
reader.readAsDataURL(file) reader.onerror = () => reject(reader.error)
}), reader.onload = () => resolve(String(reader.result).split(',')[1] || '')
}))) reader.readAsDataURL(file)
await uploadKnowledgeFiles(payload) }),
toast.success(`${payload.length} 个知识文件解析完成`) })))
queryClient.invalidateQueries({ queryKey: ['knowledge'] }) const result = await uploadKnowledgeFiles(payload)
setUploadFiles([]) toast.success(`${result.items.length} 个文件解析并建立检索索引`)
setUploadDialog(false) queryClient.invalidateQueries({ queryKey: ['knowledge'] })
}}>上传并解析</Button> queryClient.invalidateQueries({ queryKey: ['knowledge-stats'] })
setUploadFiles([])
setUploadDialog(false)
} catch (error) {
toast.error(errorMessage(error))
} finally {
setUploadLoading(false)
}
}}>上传、解析并索引</Button>
}> }>
<input <input
type="file" type="file"
@@ -211,7 +245,7 @@ export function KnowledgePage() {
onChange={(e) => setUploadFiles(Array.from(e.target.files || []))} onChange={(e) => setUploadFiles(Array.from(e.target.files || []))}
className="block w-full rounded-md border border-input p-2 text-sm" 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> </Dialog>
</div> </div>
) )
+24 -13
View File
@@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useAuthStore } from '@/app/store/auth' import { useAuthStore } from '@/app/store/auth'
import { toast, errorMessage } from '@/app/store/toast' 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 { IntentScoreBadge, OpportunityStageBadge, TaskPriorityBadge } from '@/components/common/StatusBadges'
import { TaskFormDialog } from '@/components/crm/TaskFormDialog' import { TaskFormDialog } from '@/components/crm/TaskFormDialog'
import { getOpportunities, updateOpportunity, getTasks, updateTask } from '@/services/api/crm' 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 { OPPORTUNITY_STAGES, taskTypeLabel, TASK_TYPES, TASK_PRIORITIES } from '@/config/constants'
import { formatDateTime, formatMoney } from '@/utils/format' import { formatDateTime, formatMoney } from '@/utils/format'
import { cn } from '@/utils/cn' import { cn } from '@/utils/cn'
@@ -24,7 +26,11 @@ import type { FollowTask, Opportunity } from '@/types'
export function OpportunitiesPage() { export function OpportunitiesPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const user = useAuthStore((s) => s.user) 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 [taskFilter, setTaskFilter] = useState('all')
const [taskDialog, setTaskDialog] = useState(false) const [taskDialog, setTaskDialog] = useState(false)
@@ -36,6 +42,8 @@ export function OpportunitiesPage() {
queryKey: ['tasks', taskFilter], queryKey: ['tasks', taskFilter],
queryFn: () => getTasks({ filter: taskFilter, ownerId: user?.id }), 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({ const oppMut = useMutation({
mutationFn: ({ id, stage }: { id: string; stage: string }) => updateOpportunity(id, { stage: stage as any }), 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() const firstDay = new Date(today.getFullYear(), today.getMonth(), 1).getDay()
return ( return (
<div className="p-4 md:p-6"> <div className="page-shell">
<PageHeader <PageHeader
title="商机与跟进" title="商机与跟进"
description="管理商机流转与团队跟进任务" description="管理商机流转与团队跟进任务"
@@ -90,8 +98,11 @@ export function OpportunitiesPage() {
<Tabs <Tabs
value={view} value={view}
onChange={setView} onChange={(next) => {
className="mb-4" setView(next)
setSearchParams(next === 'kanban' ? {} : { tab: next }, { replace: true })
}}
className="mb-5"
items={[ items={[
{ value: 'kanban', label: '商机看板', count: opps.length }, { value: 'kanban', label: '商机看板', count: opps.length },
{ value: 'tasks', label: '跟进任务', count: tasks.filter((t) => t.status !== 'done').length }, { value: 'tasks', label: '跟进任务', count: tasks.filter((t) => t.status !== 'done').length },
@@ -100,17 +111,17 @@ export function OpportunitiesPage() {
/> />
{view === 'kanban' && ( {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) => ( {groupedOpps.map((col) => (
<div <div
key={col.value} 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()} onDragOver={(e) => e.preventDefault()}
onDrop={(e) => handleDrop(e, col.value)} 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"> <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-medium">{col.label}</span> <span className="text-sm font-semibold text-slate-800">{col.label}</span>
<Badge variant="primary">{col.items.length}</Badge> <Badge variant="outline">{col.items.length}</Badge>
</div> </div>
<div className="space-y-2 p-2"> <div className="space-y-2 p-2">
{col.items.map((opp) => ( {col.items.map((opp) => (
@@ -118,7 +129,7 @@ export function OpportunitiesPage() {
key={opp.id} key={opp.id}
draggable draggable
onDragStart={(e) => handleDragStart(e, opp)} 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="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> <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' && ( {view === 'tasks' && (
<div> <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: 'all', l: '全部' }, { v: 'mine', l: '我的待办' },
{ v: 'today', l: '今日' }, { v: 'overdue', l: '已逾期' }, { v: 'today', l: '今日' }, { v: 'overdue', l: '已逾期' },
{ v: 'done', l: '已完成' }, { v: 'done', l: '已完成' },
].map((f) => ( ].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> </div>
<Card> <Card>
@@ -160,7 +171,7 @@ export function OpportunitiesPage() {
<Td><span className="text-xs">{t.customerName}</span></Td> <Td><span className="text-xs">{t.customerName}</span></Td>
<Td><TaskPriorityBadge priority={t.priority} /></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={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> <Td>
{t.status !== 'done' && ( {t.status !== 'done' && (
<Button size="sm" variant="ghost" onClick={() => taskMut.mutate({ id: t.id, data: { status: 'done' } })}>完成</Button> <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 { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast, errorMessage } from '@/app/store/toast' import { toast, errorMessage } from '@/app/store/toast'
import { PageLoading, ErrorState } from '@/components/ui/feedback' import { PageLoading, ErrorState } from '@/components/ui/feedback'
@@ -20,10 +21,12 @@ import { AlertTriangle, Settings } from 'lucide-react'
export function RisksPage() { export function RisksPage() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const navigate = useNavigate()
const [level, setLevel] = useState('') const [level, setLevel] = useState('')
const [type, setType] = useState('') const [type, setType] = useState('')
const [status, setStatus] = useState('') const [status, setStatus] = useState('')
const [detailId, setDetailId] = useState<string | null>(null) const [detailId, setDetailId] = useState<string | null>(null)
const [noteDraft, setNoteDraft] = useState('')
const { data, isLoading, isError, refetch } = useQuery({ const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['risks', level, type, status], queryKey: ['risks', level, type, status],
@@ -52,10 +55,10 @@ export function RisksPage() {
<div className="p-4 md:p-6"> <div className="p-4 md:p-6">
<PageHeader <PageHeader
title="风险预警中心" title="风险预警中心"
description="智能识别交易风险、客户流失信号与敏感表达,帮助团队主动应对" description="查看交易风险、客户流失信号和敏感表达"
actions={ actions={
<PermissionGate permission="risk:handle"> <PermissionGate permission="settings:manage">
<Button size="sm" onClick={() => toast.info('风险处理操作已可用')}><Settings className="h-4 w-4" />规则设置</Button> <Button size="sm" variant="outline" onClick={() => navigate('/settings')}><Settings className="h-4 w-4" />风险识别设置</Button>
</PermissionGate> </PermissionGate>
} }
/> />
@@ -77,7 +80,10 @@ export function RisksPage() {
{list.map((r) => { {list.map((r) => {
const meta = riskLevelMeta(r.level) const meta = riskLevelMeta(r.level)
return ( 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 justify-between gap-3">
<div className="flex items-start gap-2 min-w-0"> <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)} /> <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> <span className="text-xs text-muted-foreground">{formatRelative(detail.createdAt)}</span>
</div> </div>
<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> <p className="text-sm text-muted-foreground leading-relaxed">{detail.aiExplanation}</p>
</div> </div>
<div> <div>
@@ -119,8 +125,15 @@ export function RisksPage() {
</div> </div>
<div> <div>
<h4 className="text-sm font-semibold mb-1">添加备注</h4> <h4 className="text-sm font-semibold mb-1">添加备注</h4>
<Textarea rows={3} placeholder="添加处理备注…" /> <Textarea rows={3} placeholder="添加处理备注…" value={noteDraft} onChange={(event) => setNoteDraft(event.target.value)} />
<Button size="sm" className="mt-2" onClick={() => { updateMut.mutate({ id: detail.id, data: { note: '已备注' } }); toast.success('备注已保存') }}>保存备注</Button> <Button
size="sm"
className="mt-2"
disabled={!noteDraft.trim()}
onClick={() => updateMut.mutate({ id: detail.id, data: { note: noteDraft.trim() } })}
>
保存备注
</Button>
</div> </div>
</div> </div>
)} )}
+43 -23
View File
@@ -17,7 +17,7 @@ const SECTIONS = [
{ key: 'company', label: '企业资料', icon: UserCog }, { key: 'company', label: '企业资料', icon: UserCog },
{ key: 'lang', label: '语言与时区', icon: Globe }, { key: 'lang', label: '语言与时区', icon: Globe },
{ key: 'notifications', label: '消息通知', icon: Bell }, { key: 'notifications', label: '消息通知', icon: Bell },
{ key: 'ai', label: 'AI回复设置', icon: Languages }, { key: 'ai', label: '智能回复', icon: Languages },
{ key: 'safety', label: '安全设置', icon: ShieldCheck }, { key: 'safety', label: '安全设置', icon: ShieldCheck },
{ key: 'api', label: 'API Key', icon: KeyRound }, { key: 'api', label: 'API Key', icon: KeyRound },
] ]
@@ -55,19 +55,33 @@ export function SettingsPage() {
}) })
return ( return (
<div className="flex h-[calc(100vh-56px)]"> <div className="page-shell">
<div className="w-48 shrink-0 border-e border-border p-3 space-y-1 overflow-y-auto scrollbar-thin hidden md:block"> <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) => ( {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} <s.icon className="h-4 w-4" />{s.label}
</button> </button>
))} ))}
</div> </div>
<div className="flex-1 overflow-y-auto p-4 md:p-6 scrollbar-thin"> <div className="min-w-0">
{section === 'company' && settings && ( {section === 'company' && settings && (
<Card> <Card>
<CardHeader title="企业资料" /> <CardHeader title="企业资料" description="用于团队空间、通知邮件和对外业务资料" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4"> <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.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.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> <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 && ( {section === 'ai' && settings && (
<Card> <Card>
<CardHeader title="AI 回复设置" /> <CardHeader title="智能回复" description="控制回复建议、分析和发送前确认规则" />
<div className="space-y-4 p-4"> <div className="divide-y divide-slate-100 px-5">
{[ {[
{ key: 'autoGenerate', label: '自动生成回复建议' }, { key: 'autoGenerate', label: '自动生成回复建议' },
{ key: 'requireConfirm', label: '发送前必须人工确认' }, { key: 'requireConfirm', label: '发送前必须人工确认' },
{ key: 'sentimentAnalysis', label: '启用情感分析' }, { key: 'sentimentAnalysis', label: '启用情感分析' },
{ key: 'messageIntentAnalysis', label: '逐条客户消息意图识别' },
{ key: 'contextualTranslation', label: '翻译时参考会话上下文' },
{ key: 'riskAlert', label: '启用风险预警' }, { key: 'riskAlert', label: '启用风险预警' },
{ key: 'cultureCheck', label: '启用文化敏感检测' }, { key: 'cultureCheck', label: '启用文化敏感检测' },
{ key: 'allowHistoryRef', label: '允许AI引用历史成功回复' }, { key: 'allowHistoryRef', label: '允许AI引用历史成功回复' },
].map((item) => ( ].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> <span className="text-sm">{item.label}</span>
<Switch <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 } })} onCheckedChange={(v) => updateMut.mutate({ ai: { [item.key]: v } })}
/> />
</div> </div>
@@ -102,8 +121,8 @@ export function SettingsPage() {
)} )}
{section === 'notifications' && settings && ( {section === 'notifications' && settings && (
<Card> <Card>
<CardHeader title="消息通知设置" /> <CardHeader title="消息通知" description="选择需要发送到通知中心的业务事件" />
<div className="space-y-4 p-4"> <div className="divide-y divide-slate-100 px-5">
{[ {[
{ key: 'newInquiry', label: '新询盘提醒' }, { key: 'newInquiry', label: '新询盘提醒' },
{ key: 'highIntent', label: '高意向客户提醒' }, { key: 'highIntent', label: '高意向客户提醒' },
@@ -113,7 +132,7 @@ export function SettingsPage() {
{ key: 'channelOffline', label: '渠道断开提醒' }, { key: 'channelOffline', label: '渠道断开提醒' },
{ key: 'planExpire', label: '套餐到期提醒' }, { key: 'planExpire', label: '套餐到期提醒' },
].map((item) => ( ].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> <span className="text-sm">{item.label}</span>
<Switch <Switch
checked={(settings.notification as any)[item.key]} checked={(settings.notification as any)[item.key]}
@@ -126,12 +145,12 @@ export function SettingsPage() {
)} )}
{section === 'safety' && ( {section === 'safety' && (
<Card> <Card>
<CardHeader title="安全设置" /> <CardHeader title="安全设置" description="修改密码后会撤销其他设备上的登录会话" />
<div className="space-y-4 p-4"> <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" 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" 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> <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 () => { <Button onClick={async () => {
if (passwords.newPassword !== passwords.confirmPassword) return toast.error('两次输入的新密码不一致') if (passwords.newPassword !== passwords.confirmPassword) return toast.error('两次输入的新密码不一致')
try { try {
@@ -146,9 +165,9 @@ export function SettingsPage() {
)} )}
{section === 'api' && ( {section === 'api' && (
<Card> <Card>
<CardHeader title="API 与 Webhook" /> <CardHeader title="API Key" description="为自有系统或服务端集成创建访问凭据" />
<div className="space-y-3 p-4"> <div className="space-y-4 p-5">
<p className="text-sm text-muted-foreground">密钥只在创建时完整显示一次,数据库仅保存 SHA-256 摘要。</p> <p className="text-sm text-muted-foreground">密钥只在创建时完整显示一次,请立即复制并妥善保存。</p>
<div className="flex gap-2"> <div className="flex gap-2">
<Input value={apiName} onChange={(e) => setApiName(e.target.value)} placeholder="密钥名称" /> <Input value={apiName} onChange={(e) => setApiName(e.target.value)} placeholder="密钥名称" />
<Button variant="outline" onClick={async () => { <Button variant="outline" onClick={async () => {
@@ -168,7 +187,7 @@ export function SettingsPage() {
</div> </div>
</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) => ( {(apiKeys || []).map((key) => (
<div key={key.id} className="flex items-center gap-3 px-3 py-2"> <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> <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' && ( {section === 'lang' && (
<Card> <Card>
<CardHeader title="语言与时区" /> <CardHeader title="语言与时区" description="调整界面显示语言与业务时间基准" />
<div className="space-y-4 p-4"> <div className="space-y-5 p-5">
<Field label="界面语言"> <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)} /> <Select value={localeDraft} options={[{value:'zh',label:'简体中文'},{value:'en',label:'English'},{value:'ar',label:'العربية (RTL)'}]} onChange={(e) => setLocaleDraft(e.target.value as any)} />
</Field> </Field>
@@ -195,6 +214,7 @@ export function SettingsPage() {
</Card> </Card>
)} )}
</div> </div>
</div>
</div> </div>
) )
} }
+26 -12
View File
@@ -1,7 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { useMutation } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowLeftRight, Copy, FileText, History, Languages, RefreshCw, Save } from 'lucide-react' import { ArrowLeftRight, Copy, Languages, RefreshCw, Save } from 'lucide-react'
import { translate } from '@/services/api/ai' import { favoriteTranslation, getTranslationHistory, translate } from '@/services/api/ai'
import { toast, errorMessage } from '@/app/store/toast' import { toast, errorMessage } from '@/app/store/toast'
import { PageHeader } from '@/components/common/PageHeader' import { PageHeader } from '@/components/common/PageHeader'
import { Button } from '@/components/ui/button' 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天可交付。如有任何问题或需要调整,请随时告知。期待与贵司建立长期合作。' const SAMPLE_TEXT = '你好,非常感谢贵司对500个定制双层玻璃杯的询盘。我们很高兴为您提供报价:单价$1.85/个(含双色Logo印刷),标准交期12-15天,加急10天可交付。如有任何问题或需要调整,请随时告知。期待与贵司建立长期合作。'
export function TranslatePage() { export function TranslatePage() {
const queryClient = useQueryClient()
const [tab, setTab] = useState('text') const [tab, setTab] = useState('text')
const [source, setSource] = useState('') const [source, setSource] = useState('')
const [target, setTarget] = useState('en') const [target, setTarget] = useState('en')
@@ -26,7 +27,11 @@ export function TranslatePage() {
const [result, setResult] = useState('') const [result, setResult] = useState('')
const [detected, setDetected] = useState('') const [detected, setDetected] = useState('')
const [terms, setTerms] = useState<any[]>([]) 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({ const translateMut = useMutation({
mutationFn: () => translate({ text, sourceLang: source || undefined, targetLang: target }), mutationFn: () => translate({ text, sourceLang: source || undefined, targetLang: target }),
@@ -34,10 +39,19 @@ export function TranslatePage() {
setResult(r.translated) setResult(r.translated)
setDetected(r.detectedLang) setDetected(r.detectedLang)
setTerms(r.terms ?? []) 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)), 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 = () => { const swap = () => {
if (detected && !source) setSource(detected) if (detected && !source) setSource(detected)
@@ -52,8 +66,8 @@ export function TranslatePage() {
return ( return (
<div className="p-4 md:p-6"> <div className="p-4 md:p-6">
<PageHeader <PageHeader
title="AI 翻译中心" title="翻译中心"
description={`跨境商务场景专用翻译引擎,支持全球主流贸易国家和地区语言(可配置 ${supportedLanguageCount} 种)。`} description={`支持 ${supportedLanguageCount} 种已配置语言,可用于消息翻译与回复。`}
actions={<LiveDataBadge />} actions={<LiveDataBadge />}
/> />
@@ -131,8 +145,7 @@ export function TranslatePage() {
<Button size="sm" variant="ghost" disabled={!result} onClick={() => { navigator.clipboard.writeText(result); toast.success('已复制') }}> <Button size="sm" variant="ghost" disabled={!result} onClick={() => { navigator.clipboard.writeText(result); toast.success('已复制') }}>
<Copy className="h-3.5 w-3.5" /> 复制 <Copy className="h-3.5 w-3.5" /> 复制
</Button> </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" /> 收藏 <Save className="h-3.5 w-3.5" /> 收藏
</Button> </Button>
<Button size="sm" variant="ghost" onClick={() => translateMut.mutate()}> <Button size="sm" variant="ghost" onClick={() => translateMut.mutate()}>
@@ -154,10 +167,11 @@ export function TranslatePage() {
<div key={i} className="p-3 text-sm"> <div key={i} className="p-3 text-sm">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1"> <div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<Languages className="h-3 w-3" /> <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> </div>
<p className="text-xs truncate">{h.text.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.result.slice(0, 80)}</p> <p className="text-xs text-muted-foreground mt-0.5 truncate">{h.translatedText.slice(0, 80)}</p>
</div> </div>
))} ))}
</div> </div>
+11 -3
View File
@@ -1,19 +1,27 @@
import { http } from './client' 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 */ /** POST /api/ai/translate */
export function translate(payload: TranslatePayload) { export function translate(payload: TranslatePayload) {
return http.post<TranslateResult>('/api/ai/translate', payload) 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 */ /** POST /api/ai/analyze */
export function analyzeConversation(conversationId: string) { export function analyzeConversation(conversationId: string) {
return http.post<AiAnalysis>('/api/ai/analyze', { conversationId }) return http.post<AiAnalysis>('/api/ai/analyze', { conversationId })
} }
/** POST /api/ai/replies */ /** POST /api/ai/replies */
export function generateReplies(conversationId: string) { export function generateReplies(conversationId: string, targetLang = 'auto') {
return http.post<AiReplySuggestion[]>('/api/ai/replies', { conversationId }) return http.post<AiReplySuggestion[]>('/api/ai/replies', { conversationId, targetLang })
} }
/** POST /api/ai/summarize */ /** 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 }) 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) { export function submitTelegramCode(channelId: string, code: string) {
return http.post<{ status: string; channel: ChannelConnection }>(`/api/channels/telegram/${channelId}/code`, { code }) 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 }) return http.post<{ status: string; channel: ChannelConnection }>(`/api/channels/telegram/${channelId}/password`, { password })
} }
export function syncTelegramChannel(channelId: string) { export function getTelegramLoginState(channelId: string) {
return http.post<{ chats: number; messages: number }>(`/api/channels/${channelId}/sync`) 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 */ /** POST /api/channels/test */
@@ -52,3 +97,81 @@ export function deleteChannel(id: string) {
export function getChannelLogs(id: string) { export function getChannelLogs(id: string) {
return http.get<ChannelSyncLog[]>(`/api/channels/${id}/logs`) 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 注入,避免循环依赖 */ /** 由 auth store 注入,避免循环依赖 */
let tokenGetter: () => string | null = () => null let tokenGetter: () => string | null = () => null
let unauthorizedHandler: (() => void) | null = null
let handlingUnauthorized = false
export function setTokenGetter(fn: () => string | null) { export function setTokenGetter(fn: () => string | null) {
tokenGetter = fn tokenGetter = fn
} }
export function setUnauthorizedHandler(fn: () => void) {
unauthorizedHandler = fn
}
export async function api<T = unknown>(path: string, options: RequestOptions = {}): Promise<T> { export async function api<T = unknown>(path: string, options: RequestOptions = {}): Promise<T> {
const method = options.method ?? 'GET' const method = options.method ?? 'GET'
@@ -41,6 +48,11 @@ export async function api<T = unknown>(path: string, options: RequestOptions = {
}) })
if (res.status === 401) { if (res.status === 401) {
if (token && unauthorizedHandler && !handlingUnauthorized) {
handlingUnauthorized = true
unauthorizedHandler()
window.setTimeout(() => { handlingUnauthorized = false }, 1_000)
}
throw new ApiError(401, '登录状态已失效,请重新登录') throw new ApiError(401, '登录状态已失效,请重新登录')
} }
if (!res.ok) { if (!res.ok) {
+185 -10
View File
@@ -1,5 +1,5 @@
import { http } from './client' 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 } export type ConversationListItem = Conversation & { customer: Customer }
@@ -10,6 +10,15 @@ export interface ConversationQuery {
userId?: string 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 */ /** GET /api/conversations */
export function getConversations(query: ConversationQuery = {}) { export function getConversations(query: ConversationQuery = {}) {
return http.get<{ list: ConversationListItem[]; total: number }>('/api/conversations', { ...query }) return http.get<{ list: ConversationListItem[]; total: number }>('/api/conversations', { ...query })
@@ -17,12 +26,43 @@ export function getConversations(query: ConversationQuery = {}) {
/** GET /api/conversations/:id */ /** GET /api/conversations/:id */
export function getConversation(id: string) { 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 */ /** GET /api/conversations/:id/messages */
export function getMessages(id: string) { export async function getMessages(id: string) {
return http.get<Message[]>(`/api/conversations/${id}/messages`) 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 }) { 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 }) 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 }>( return http.get<{ dataUrl: string; mimeType: string; name: string }>(
`/api/conversations/${id}/messages/${messageId}/attachment`, `/api/conversations/${id}/messages/${messageId}/attachment`,
attachmentId ? { attachmentId } : undefined,
) )
} }
/** POST /api/conversations/:id/messages */ /** POST /api/conversations/:id/messages */
export function sendMessage(id: string, payload: SendMessagePayload & { translatedText?: string; autoTranslate?: boolean }) { export function sendMessage(
return http.post<Message>(`/api/conversations/${id}/messages`, payload) 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( export function sendMedia(
id: string, 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( export function sendAlbum(
@@ -59,6 +133,107 @@ export function sendAlbum(
} }
/** PATCH /api/conversations/:id(标记已读/分配/状态) */ /** 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) 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 { @layer base {
:root { :root {
--background: 220 14% 96%; --background: 210 20% 98%;
--foreground: 215 25% 16%; --foreground: 215 28% 13%;
--card: 0 0% 100%; --card: 0 0% 100%;
--card-foreground: 215 25% 16%; --card-foreground: 215 25% 16%;
--popover: 0 0% 100%; --popover: 0 0% 100%;
--popover-foreground: 215 25% 16%; --popover-foreground: 215 25% 16%;
--primary: 172 59% 30%; --primary: 221 83% 53%;
--primary-foreground: 0 0% 100%; --primary-foreground: 0 0% 100%;
--secondary: 210 16% 93%; --secondary: 210 20% 96%;
--secondary-foreground: 215 20% 24%; --secondary-foreground: 215 20% 24%;
--muted: 210 16% 93%; --muted: 210 20% 96%;
--muted-foreground: 215 12% 46%; --muted-foreground: 215 12% 43%;
--accent: 170 40% 93%; --accent: 214 100% 97%;
--accent-foreground: 172 60% 22%; --accent-foreground: 221 83% 40%;
--destructive: 0 70% 50%; --destructive: 0 70% 50%;
--destructive-foreground: 0 0% 100%; --destructive-foreground: 0 0% 100%;
--border: 214 15% 88%; --border: 214 22% 91%;
--input: 214 15% 84%; --input: 214 18% 86%;
--ring: 172 59% 34%; --ring: 221 83% 53%;
--radius: 0.5rem; --radius: 0.75rem;
} }
* { * {
@@ -40,6 +40,10 @@
@apply bg-background text-foreground antialiased; @apply bg-background text-foreground antialiased;
font-size: 14px; font-size: 14px;
line-height: 1.6; 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 { ::selection {
@@ -65,6 +69,128 @@
.text-balance { .text-balance {
text-wrap: 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 { @keyframes message-in-left {
@@ -85,12 +211,98 @@
to { opacity: 1; transform: translate3d(0, 0, 0) scale(1); } 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 { @layer utilities {
.animate-message-in-left { animation: message-in-left .34s cubic-bezier(.2,.8,.2,1) both; } .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-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; } .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 微调 */ /* RTL 微调 */
[dir='rtl'] .rtl-flip { [dir='rtl'] .rtl-flip {
transform: scaleX(-1); transform: scaleX(-1);
+21
View File
@@ -8,9 +8,11 @@ export interface AiReplySuggestion {
id: string id: string
style: ReplyStyle style: ReplyStyle
content: string content: string
translationCn: string
targetLang: string targetLang: string
reason: string reason: string
knowledgeRefs: string[] knowledgeRefs: string[]
knowledgeRefIds?: string[]
expectedEffect: string expectedEffect: string
cultureTip?: string cultureTip?: string
} }
@@ -40,6 +42,25 @@ export interface TranslateResult {
translated: string translated: string
detectedLang: string detectedLang: string
terms: { source: string; target: 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' export type AiTextAction = 'polish' | 'translate' | 'shorten' | 'expand' | 'tone'
+16
View File
@@ -46,6 +46,18 @@ export interface AiCustomerProfile {
dealProbability?: number dealProbability?: number
churnProbability?: number churnProbability?: number
confidenceScore?: number confidenceScore?: number
intentBreakdown?: Array<{
intent: string
label: string
count: number
share: number
}>
lastMessageIntent?: {
intent: string
label: string
urgency: string
}
profileVersion?: number
evidence?: string[] evidence?: string[]
} }
@@ -54,6 +66,9 @@ export interface Customer {
name: string name: string
company: string company: string
country: string country: string
countrySource?: 'manual' | 'account-profile' | 'phone-prefix' | 'unknown'
countryConfidence?: number
countryInferredAt?: string | null
city?: string city?: string
email: string email: string
phone: string phone: string
@@ -77,6 +92,7 @@ export interface Customer {
telegramUserId?: string telegramUserId?: string
avatarUrl?: string avatarUrl?: string
telegramPhotoFileId?: number telegramPhotoFileId?: number
telegramChatType?: string
} }
export interface CustomerFilters { export interface CustomerFilters {
+5
View File
@@ -33,12 +33,17 @@ export interface KnowledgeItem {
citedCount: number citedCount: number
languages: string[] languages: string[]
quality: 'good' | 'normal' | 'low' quality: 'good' | 'normal' | 'low'
characterCount?: number
chunkCount?: number
indexedAt?: string
parseMessage?: string
} }
export interface KnowledgeSearchHit { export interface KnowledgeSearchHit {
item: KnowledgeItem item: KnowledgeItem
score: number score: number
snippet: string snippet: string
chunkIndex?: number
} }
export interface KnowledgeSearchResult { export interface KnowledgeSearchResult {
+105
View File
@@ -28,9 +28,28 @@ export interface Message {
type: MessageType type: MessageType
originalText: string originalText: string
translatedText?: string translatedText?: string
/** 发出消息的中文客服备注,不会发送到外部渠道 */
translationCn?: string
translationStatus?: 'pending' | 'completed' | 'failed' translationStatus?: 'pending' | 'completed' | 'failed'
translatedAt?: string 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 originalLang: string
targetLang?: string
status: MessageStatus status: MessageStatus
createdAt: string createdAt: string
isImportant?: boolean isImportant?: boolean
@@ -39,10 +58,31 @@ export interface Message {
productCard?: { name: string; sku: string; price: string } productCard?: { name: string; sku: string; price: string }
replyToId?: string replyToId?: string
replyPreview?: string replyPreview?: string
editedAt?: string
isDeleted?: boolean
deletedAt?: string
reactions?: Array<{
actor: string
emoji: string
fromMe?: boolean
updatedAt?: string
}>
telegramFileId?: number telegramFileId?: number
whatsappMessageId?: string
whatsappContentType?: string
attachmentMimeType?: string attachmentMimeType?: string
attachmentSize?: number attachmentSize?: number
stickerEmoji?: string stickerEmoji?: string
emailUid?: number
emailMessageId?: string
emailSubject?: string
emailAttachments?: Array<{
id: string
name: string
mimeType: string
size: number
available: boolean
}>
} }
export interface Conversation { export interface Conversation {
@@ -58,6 +98,52 @@ export interface Conversation {
lastMessage?: Message lastMessage?: Message
summary: string summary: string
updatedAt: 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' export type ChannelStatus = 'connected' | 'disconnected' | 'error' | 'paused' | 'unconnected'
@@ -72,7 +158,22 @@ export interface ChannelConnection {
todayMessages: number todayMessages: number
errorMessage?: string errorMessage?: string
autoReply: boolean autoReply: boolean
authState?: string
defaultOwnerId?: 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 { export interface ChannelSyncLog {
@@ -87,6 +188,10 @@ export interface ChannelSyncLog {
/** 发送消息请求 */ /** 发送消息请求 */
export interface SendMessagePayload { export interface SendMessagePayload {
text: string text: string
/** 已确认的中文参考译文,仅在内部消息记录中保存 */
translationCn?: string
/** auto 表示根据客户最近消息或客户画像自动选择目标语言 */
targetLang?: string
replyToId?: string replyToId?: string
replyPreview?: string replyPreview?: string
type?: MessageType type?: MessageType

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