Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a7c894138e | |||
| 45b94374b0 | |||
| d585fabb7a | |||
| 48d719e520 | |||
| 9017c26f5b | |||
| 82f00ffb43 | |||
| 0109e58160 | |||
| bffd2f4fe6 | |||
| 7e739ad7b8 | |||
| bb696c7de8 | |||
| ad126606f9 | |||
| 00b0ebb775 | |||
| 785a5b196c | |||
| cf25d71fcf | |||
| 06112602ec | |||
| af49d22ca7 |
@@ -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
|
||||||
@@ -20,6 +23,8 @@ TELEGRAM_WEBHOOK_SECRET=replace-with-random-webhook-secret
|
|||||||
WHATSAPP_GATEWAY_TOKEN=replace-with-at-least-32-random-characters
|
WHATSAPP_GATEWAY_TOKEN=replace-with-at-least-32-random-characters
|
||||||
WHATSAPP_WEBHOOK_SECRET=replace-with-another-32-random-secret
|
WHATSAPP_WEBHOOK_SECRET=replace-with-another-32-random-secret
|
||||||
WHATSAPP_LOG_LEVEL=info
|
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
|
||||||
@@ -36,6 +41,9 @@ SMTP_FROM=
|
|||||||
# 建议使用 32 字节以上独立随机值;未配置时回退到 LX_JWT_SECRET 派生密钥。
|
# 建议使用 32 字节以上独立随机值;未配置时回退到 LX_JWT_SECRET 派生密钥。
|
||||||
EMAIL_CREDENTIAL_KEY=
|
EMAIL_CREDENTIAL_KEY=
|
||||||
EMAIL_POLL_MS=30000
|
EMAIL_POLL_MS=30000
|
||||||
|
MESSAGE_RECONCILE_MS=60000
|
||||||
|
WHATSAPP_GATEWAY_TIMEOUT_MS=30000
|
||||||
|
TELEGRAM_GATEWAY_TIMEOUT_MS=30000
|
||||||
|
|
||||||
# 真实 Stripe Checkout
|
# 真实 Stripe Checkout
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
|
|||||||
@@ -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
@@ -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/
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# 版本迭代记录
|
||||||
|
|
||||||
|
本项目遵循语义化版本。早期版本由经过脱敏的本地源码快照恢复,因此使用带日期的 `snapshot-*` 标签;自 `v1.0.0` 起按正式发布流程维护。
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
- 完成双消息网关 P0 可靠性闭环:Webhook 采用 processing/completed/failed 可恢复状态机,Telegram 编辑与撤回落库,60 秒增量游标对账、5 分钟死信自动重放、每日资料刷新和每周受控全量对账。
|
||||||
|
- Telegram 会话发现改为 TDLib 持续加载并持久化全部会话,平台按 offset 分页读取;网关请求增加超时与任务并发合并,避免单个资料接口阻塞全部同步。
|
||||||
|
- 修复 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 产品原型与页面结构。
|
||||||
|
- 作为历史参考保留,不建议部署。
|
||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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
@@ -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-*` 仅用于审计与回溯,不提供安全维护。
|
||||||
@@ -19,10 +19,14 @@ services:
|
|||||||
whatsapp-gateway:
|
whatsapp-gateway:
|
||||||
build:
|
build:
|
||||||
context: ./whatsapp-gateway
|
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:
|
environment:
|
||||||
PORT: 3200
|
PORT: 3200
|
||||||
DATA_DIR: /data
|
DATA_DIR: /data
|
||||||
WHATSAPP_GATEWAY_TOKEN: ${WHATSAPP_GATEWAY_TOKEN:?WHATSAPP_GATEWAY_TOKEN is required}
|
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}
|
LOG_LEVEL: ${WHATSAPP_LOG_LEVEL:-info}
|
||||||
volumes:
|
volumes:
|
||||||
- lingxi_whatsapp_data:/data
|
- lingxi_whatsapp_data:/data
|
||||||
@@ -89,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:
|
||||||
|
|||||||
+81
@@ -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 文档并进行契约测试。
|
||||||
@@ -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 目前集中在单入口文件,业务增长后应按领域拆分路由、服务、仓储和迁移文件。
|
||||||
@@ -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、失败点和改进项。
|
||||||
|
|
||||||
|
仅“备份成功”不等于可恢复;没有完成验收的备份不得视为合格。
|
||||||
@@ -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 日志。
|
||||||
@@ -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 使用共享事件总线或粘性会话。
|
||||||
|
- 将持久任务改用共享队列并确保幂等。
|
||||||
|
- 数据库变更采用“先兼容、再切换、后清理”的多阶段迁移。
|
||||||
@@ -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 小时未成功。
|
||||||
@@ -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 采用异步增强而非消息主链路依赖,模型故障时仍可正常沟通。
|
||||||
@@ -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 可用。
|
||||||
@@ -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、环境、测试命令、通过/跳过/失败数量和已知限制。跳过项必须说明原因,不能等同于通过。
|
||||||
@@ -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>
|
||||||
|
|||||||
Generated
+2
-2
@@ -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",
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"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、WhatsApp 与企业邮箱 AI 客户经营平台",
|
"description": "灵犀跨境 - Telegram、WhatsApp 与企业邮箱 AI 客户经营平台",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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 |
Generated
+7
@@ -14,6 +14,7 @@
|
|||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"imapflow": "^1.6.1",
|
"imapflow": "^1.6.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"libphonenumber-js": "^1.11.20",
|
||||||
"mailparser": "^3.9.14",
|
"mailparser": "^3.9.14",
|
||||||
"mammoth": "^1.9.0",
|
"mammoth": "^1.9.0",
|
||||||
"nodemailer": "^7.0.5",
|
"nodemailer": "^7.0.5",
|
||||||
@@ -891,6 +892,12 @@
|
|||||||
"libqp": "2.1.1"
|
"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": {
|
"node_modules/libqp": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/libqp/-/libqp-2.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/libqp/-/libqp-2.1.1.tgz",
|
||||||
|
|||||||
+2
-1
@@ -5,7 +5,7 @@
|
|||||||
"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/reliability.test.js test/whatsapp-identity.test.js",
|
||||||
"test:email-protocol": "node --test test/email-protocol.test.js",
|
"test:email-protocol": "node --test test/email-protocol.test.js",
|
||||||
"test:email-api": "node --test test/email-api-e2e.test.js"
|
"test:email-api": "node --test test/email-api-e2e.test.js"
|
||||||
},
|
},
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"imapflow": "^1.6.1",
|
"imapflow": "^1.6.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"libphonenumber-js": "^1.11.20",
|
||||||
"mailparser": "^3.9.14",
|
"mailparser": "^3.9.14",
|
||||||
"mammoth": "^1.9.0",
|
"mammoth": "^1.9.0",
|
||||||
"nodemailer": "^7.0.5",
|
"nodemailer": "^7.0.5",
|
||||||
|
|||||||
+22
-7
@@ -7,7 +7,11 @@ 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 endpoint = `${(process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/anthropic').replace(/\/$/, '')}/v1/messages`
|
const endpoint = `${(process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/anthropic').replace(/\/$/, '')}/v1/messages`
|
||||||
@@ -17,7 +21,10 @@ export async function deepseek(system, user, json = false) {
|
|||||||
|
|
||||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
const timeout = setTimeout(() => controller.abort(), Number(process.env.AI_TIMEOUT_MS || 45000))
|
const timeout = setTimeout(
|
||||||
|
() => controller.abort(),
|
||||||
|
Number(options.timeoutMs || process.env.AI_TIMEOUT_MS || 30000),
|
||||||
|
)
|
||||||
try {
|
try {
|
||||||
const response = await fetch(endpoint, {
|
const response = await fetch(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -29,8 +36,11 @@ export async function deepseek(system, user, json = false) {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
model: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash',
|
model: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash',
|
||||||
max_tokens: Number(process.env.AI_MAX_TOKENS || 4096),
|
max_tokens: Number(options.maxTokens || process.env.AI_MAX_TOKENS || 4096),
|
||||||
temperature: json ? 0 : 0.3,
|
temperature: options.temperature ?? (json ? 0 : 0.3),
|
||||||
|
thinking: options.thinking === true
|
||||||
|
? { type: 'enabled' }
|
||||||
|
: { type: 'disabled' },
|
||||||
system,
|
system,
|
||||||
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
||||||
}),
|
}),
|
||||||
@@ -47,6 +57,11 @@ export async function deepseek(system, user, json = false) {
|
|||||||
lastError = error
|
lastError = error
|
||||||
prompt = `${user}\n\n上一次响应不是合法 JSON。请重新生成完整结果:只允许输出一个可被 JSON.parse 直接解析的 JSON 值;不得使用 Markdown;字符串中的双引号、换行和反斜杠必须正确转义。`
|
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 {
|
} finally {
|
||||||
clearTimeout(timeout)
|
clearTimeout(timeout)
|
||||||
}
|
}
|
||||||
@@ -54,9 +69,9 @@ export async function deepseek(system, user, json = false) {
|
|||||||
throw Object.assign(new Error('AI 返回格式异常,自动重试后仍无法解析'), { status: 502, cause: lastError })
|
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})` : ''
|
||||||
return `${m.createdAt || ''} ${role}: ${content}${translation}`
|
return `${m.createdAt || ''} ${role}: ${content}${translation}`
|
||||||
|
|||||||
@@ -74,3 +74,22 @@ export const countBy = (list, key) => {
|
|||||||
}
|
}
|
||||||
return counts
|
return counts
|
||||||
}
|
}
|
||||||
|
export const businessRelationshipLabels = {
|
||||||
|
buyer: '下游买家',
|
||||||
|
supplier: '上游供应商',
|
||||||
|
partner: '合作伙伴',
|
||||||
|
distributor: '经销商',
|
||||||
|
procurement_agent: '采购代理',
|
||||||
|
logistics: '物流服务商',
|
||||||
|
service_provider: '专业服务商',
|
||||||
|
internal: '内部同事',
|
||||||
|
other: '其他',
|
||||||
|
unknown: '待确认',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBusinessRelationship(value) {
|
||||||
|
const type = Object.hasOwn(businessRelationshipLabels, String(value || ''))
|
||||||
|
? String(value)
|
||||||
|
: 'unknown'
|
||||||
|
return { type, label: businessRelationshipLabels[type] }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ export async function gatewayRequest(path, options = {}) {
|
|||||||
if (!gatewayToken || tokenExpiresSoon(gatewayToken)) await refreshGatewayToken()
|
if (!gatewayToken || tokenExpiresSoon(gatewayToken)) await refreshGatewayToken()
|
||||||
const send = () => fetch(`${base()}${path}`, {
|
const send = () => fetch(`${base()}${path}`, {
|
||||||
...options,
|
...options,
|
||||||
|
signal: options.signal || AbortSignal.timeout(
|
||||||
|
Math.max(5_000, Number(process.env.TELEGRAM_GATEWAY_TIMEOUT_MS || 30_000)),
|
||||||
|
),
|
||||||
headers: {
|
headers: {
|
||||||
...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }),
|
...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }),
|
||||||
authorization: `Bearer ${gatewayToken}`,
|
authorization: `Bearer ${gatewayToken}`,
|
||||||
|
|||||||
+1827
-149
File diff suppressed because it is too large
Load Diff
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export function webhookEventClaimDecision(existing, now = Date.now(), leaseMs = 2 * 60_000) {
|
||||||
|
if (existing?.status === 'completed') return 'duplicate'
|
||||||
|
const processingAt = Date.parse(existing?.processingAt || '')
|
||||||
|
if (
|
||||||
|
existing?.status === 'processing'
|
||||||
|
&& Number.isFinite(processingAt)
|
||||||
|
&& processingAt > now - leaseMs
|
||||||
|
) return 'busy'
|
||||||
|
return 'claim'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTimestampOlderThan(value, ageMs, now = Date.now()) {
|
||||||
|
const parsed = Date.parse(value || '')
|
||||||
|
return !Number.isFinite(parsed) || parsed < now - ageMs
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
const TONE_INSTRUCTIONS = {
|
||||||
|
professional: '专业但不官腔,表达清楚、克制,像熟悉业务的真人销售在聊天',
|
||||||
|
friendly: '友好自然,适度口语化,像长期合作伙伴沟通,不刻意热情',
|
||||||
|
concise: '简洁直接,优先给结论和下一步,不重复客户已经知道的信息',
|
||||||
|
warm: '温和、有同理心,但不使用夸张道歉、空泛安慰或营销套话',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replyToneInstruction(value) {
|
||||||
|
return TONE_INSTRUCTIONS[String(value || '').toLowerCase()] || TONE_INSTRUCTIONS.professional
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HUMAN_REPLY_SYSTEM_PROMPT = `你是“灵犀跨境”的商务沟通助手。你的任务不是写客服范文,而是替一位真实、可靠、熟悉业务的销售人员起草可以直接发送的聊天回复。
|
||||||
|
|
||||||
|
写作原则:
|
||||||
|
1. 先回应客户最新一条消息里尚未解决的核心问题,再给必要的下一步;不要复述整段历史。
|
||||||
|
2. 匹配客户的语言、地区表达、正式程度和消息长度。客户只说一句时,通常也用一两句回答;不为显得专业而写长文。
|
||||||
|
3. 延续当前对话。会话已经开始后不要重新问候、重复称呼客户或重新自我介绍。
|
||||||
|
4. 像真人聊天:句式自然、具体,允许使用目标语言中常见的缩写和口语连接词,但保持商务礼貌。不要比客户更随意;客户未使用俚语、感叹号或表情时,不要主动使用。
|
||||||
|
5. 客诉场景只需针对具体问题真诚确认一次,然后说明能做的动作或提出一个关键问题;避免连续道歉和空泛安慰。
|
||||||
|
6. 信息足够时直接作答;确实缺少决定性信息时最多问一个有用的问题。不要在一句回复里堆多个反问。
|
||||||
|
7. 三条建议必须是三种真实策略,而不是同一句话替换近义词:stable=清晰可靠,active=温暖主动,brief=简洁直接。
|
||||||
|
8. 禁止客服和 AI 套话,包括但不限于:Dear customer、尊敬的客户、please rest assured、请放心、we value your feedback、感谢您的耐心与理解、为您带来不便深表歉意、How may I assist you、如有任何问题请随时联系我们。除非上下文确实需要,也不要使用“希望您一切都好”。
|
||||||
|
9. 不得提及“AI、提示词、客户画像、会话记忆、知识库、情绪识别”等内部信息。
|
||||||
|
10. 不得编造价格、库存、产品参数、交期、物流状态、退款结果、政策或任何承诺。缺少事实时自然地说明需要确认,不要假装确定。“今天、稍后、尽快、本周内、几个小时内”等时间词也属于承诺,只有企业知识或会话事实明确给出时才能使用。
|
||||||
|
11. 可以承诺“去确认”,但不能在没有依据时承诺确认结果、完成时间、补偿方案、重新寄样或其他尚未批准的动作。正确写法是“我先确认是否可以调整,确认后告诉你”。
|
||||||
|
|
||||||
|
输出要求:只输出严格 JSON 数组。content 使用客户语言且可直接发送;translationCn 是 content 完整准确的简体中文翻译;knowledgeRefs 只能填写已提供知识的 id;reason、expectedEffect 和 cultureTip 使用简体中文。`
|
||||||
|
|
||||||
|
export function buildHumanReplyUserPrompt({
|
||||||
|
targetInstruction,
|
||||||
|
tone,
|
||||||
|
latestCustomerMessage,
|
||||||
|
latestAgentMessage,
|
||||||
|
conversation,
|
||||||
|
conversationMemory,
|
||||||
|
knowledgeContext,
|
||||||
|
}) {
|
||||||
|
return `客户目标语言:${targetInstruction}
|
||||||
|
企业设定语气:${replyToneInstruction(tone)}
|
||||||
|
客户最新消息(回复重点):${latestCustomerMessage || '未找到明确文本,请根据最近会话判断'}
|
||||||
|
坐席上一条回复(避免重复):${latestAgentMessage || '无'}
|
||||||
|
|
||||||
|
<conversation>
|
||||||
|
${conversation}
|
||||||
|
</conversation>
|
||||||
|
${conversationMemory ? `<conversation_memory>\n${conversationMemory}\n</conversation_memory>` : ''}
|
||||||
|
<enterprise_knowledge>
|
||||||
|
${JSON.stringify(knowledgeContext || [])}
|
||||||
|
</enterprise_knowledge>
|
||||||
|
事实边界:${knowledgeContext?.length ? '只能使用上述企业知识和会话中明确出现的业务事实。' : '当前没有匹配到企业知识;不得加入任何具体时间、结果、方案或承诺,只能说明需要确认的事项。'}
|
||||||
|
|
||||||
|
分别生成以下三种建议:
|
||||||
|
- stable:最稳妥、事实清楚、自然推进下一步;
|
||||||
|
- active:更温暖主动,但不催促、不夸张、不强行促单;
|
||||||
|
- brief:保留必要信息的最短自然回复。
|
||||||
|
|
||||||
|
生成前静默检查:是否直接回应了最新消息;是否重复问候或复述历史;是否像群发模板;是否包含未经证实的承诺;三条是否真正不同。不要输出检查过程。
|
||||||
|
|
||||||
|
输出恰好 3 个元素,每种 style 各一个:
|
||||||
|
{"id":"唯一值","style":"stable|active|brief","content":"可直接发送的客户语言回复","translationCn":"完整中文翻译","targetLang":"实际 ISO 语言代码","reason":"这条回复为何适合当前语境","knowledgeRefs":[],"expectedEffect":"预期沟通效果","cultureTip":"只有确有必要时填写,否则为空字符串"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedWords(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
function similarity(left, right) {
|
||||||
|
const a = new Set(normalizedWords(left))
|
||||||
|
const b = new Set(normalizedWords(right))
|
||||||
|
if (!a.size || !b.size) return 0
|
||||||
|
let overlap = 0
|
||||||
|
for (const word of a) if (b.has(word)) overlap += 1
|
||||||
|
return overlap / Math.min(a.size, b.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
const TEMPLATE_PATTERNS = [
|
||||||
|
/\bdear customer\b/i,
|
||||||
|
/\bplease rest assured\b/i,
|
||||||
|
/\bwe value your feedback\b/i,
|
||||||
|
/\bhow may i assist you\b/i,
|
||||||
|
/尊敬的客户/,
|
||||||
|
/请您放心/,
|
||||||
|
/感谢您的耐心与理解/,
|
||||||
|
/为您带来不便深表歉意/,
|
||||||
|
]
|
||||||
|
|
||||||
|
const UNSUPPORTED_TIMELINE_PATTERNS = [
|
||||||
|
/\b(?:today|tomorrow|shortly|asap)\b/i,
|
||||||
|
/\bby (?:the )?end of\b/i,
|
||||||
|
/\bwithin \d+\s*(?:minutes?|hours?|days?|weeks?)\b/i,
|
||||||
|
/(?:今天|明天|稍后|很快|尽快|本周内|几(?:分钟|小时|天)内)/,
|
||||||
|
]
|
||||||
|
|
||||||
|
export function replySuggestionIssues(suggestions, { knowledgeProvided = false } = {}) {
|
||||||
|
if (!Array.isArray(suggestions) || suggestions.length !== 3) return ['必须返回三条建议']
|
||||||
|
const issues = []
|
||||||
|
const styles = new Set(suggestions.map((item) => String(item?.style || '')))
|
||||||
|
for (const style of ['stable', 'active', 'brief']) {
|
||||||
|
if (!styles.has(style)) issues.push(`缺少 ${style} 风格`)
|
||||||
|
}
|
||||||
|
suggestions.forEach((item, index) => {
|
||||||
|
const content = String(item?.content || '').trim()
|
||||||
|
if (!content) issues.push(`第 ${index + 1} 条没有正文`)
|
||||||
|
if (TEMPLATE_PATTERNS.some((pattern) => pattern.test(content))) {
|
||||||
|
issues.push(`第 ${index + 1} 条包含模板化客服话术`)
|
||||||
|
}
|
||||||
|
if (!knowledgeProvided && UNSUPPORTED_TIMELINE_PATTERNS.some((pattern) => pattern.test(content))) {
|
||||||
|
issues.push(`第 ${index + 1} 条包含无事实依据的时间承诺`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
for (let i = 0; i < suggestions.length; i += 1) {
|
||||||
|
for (let j = i + 1; j < suggestions.length; j += 1) {
|
||||||
|
if (similarity(suggestions[i]?.content, suggestions[j]?.content) >= 0.82) {
|
||||||
|
issues.push(`第 ${i + 1} 条和第 ${j + 1} 条过于相似`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return issues
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import crypto from 'node:crypto'
|
||||||
|
|
||||||
|
export function canonicalWhatsAppJid(jid, identity = {}) {
|
||||||
|
const normalizeDevice = (value) => String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/:\d+(?=@s\.whatsapp\.net$)/, '')
|
||||||
|
const raw = normalizeDevice(jid)
|
||||||
|
if (!raw || raw.endsWith('@g.us') || raw.endsWith('@newsletter')) return raw
|
||||||
|
const canonical = normalizeDevice(identity?.canonicalJid)
|
||||||
|
if (canonical.endsWith('@s.whatsapp.net')) return canonical
|
||||||
|
const phone = String(identity?.phone || '').split(':')[0].replace(/\D/g, '')
|
||||||
|
return phone ? `${phone}@s.whatsapp.net` : raw
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvedWhatsAppJid(jid, identity = {}, cachedJid = '') {
|
||||||
|
const fallback = canonicalWhatsAppJid(jid)
|
||||||
|
const resolved = canonicalWhatsAppJid(jid, identity)
|
||||||
|
if (resolved && resolved !== fallback) return resolved
|
||||||
|
|
||||||
|
const cached = canonicalWhatsAppJid(cachedJid)
|
||||||
|
return cached && cached !== fallback ? cached : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function whatsappCustomerStableId(jid) {
|
||||||
|
const normalized = canonicalWhatsAppJid(jid)
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -7,6 +7,9 @@ export async function whatsappRequest(path, options = {}) {
|
|||||||
}
|
}
|
||||||
const response = await fetch(`${base()}${path}`, {
|
const response = await fetch(`${base()}${path}`, {
|
||||||
...options,
|
...options,
|
||||||
|
signal: options.signal || AbortSignal.timeout(
|
||||||
|
Math.max(5_000, Number(process.env.WHATSAPP_GATEWAY_TIMEOUT_MS || 30_000)),
|
||||||
|
),
|
||||||
headers: {
|
headers: {
|
||||||
...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }),
|
...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }),
|
||||||
authorization: `Bearer ${token}`,
|
authorization: `Bearer ${token}`,
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
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,
|
||||||
|
normalizeBusinessRelationship,
|
||||||
|
percent,
|
||||||
|
telegramAttachment,
|
||||||
|
} from '../src/business.js'
|
||||||
import { decryptEmailPassword, encryptEmailPassword } from '../src/email.js'
|
import { decryptEmailPassword, encryptEmailPassword } from '../src/email.js'
|
||||||
|
import {
|
||||||
|
HUMAN_REPLY_SYSTEM_PROMPT,
|
||||||
|
buildHumanReplyUserPrompt,
|
||||||
|
replySuggestionIssues,
|
||||||
|
replyToneInstruction,
|
||||||
|
} from '../src/reply-suggestions.js'
|
||||||
|
|
||||||
test('Telegram 图片、文档、语音和贴纸映射为可下载文件', () => {
|
test('Telegram 图片、文档、语音和贴纸映射为可下载文件', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -44,6 +56,11 @@ test('业务指标工具处理百分比、分组和无效日期', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('业务关系分类使用受控类型和中文标签', () => {
|
||||||
|
assert.deepEqual(normalizeBusinessRelationship('supplier'), { type: 'supplier', label: '上游供应商' })
|
||||||
|
assert.deepEqual(normalizeBusinessRelationship('unsupported'), { type: 'unknown', label: '待确认' })
|
||||||
|
})
|
||||||
|
|
||||||
test('Email 渠道凭据使用认证加密且可正确解密', () => {
|
test('Email 渠道凭据使用认证加密且可正确解密', () => {
|
||||||
process.env.EMAIL_CREDENTIAL_KEY = 'lingxi-email-test-key-with-more-than-32-bytes'
|
process.env.EMAIL_CREDENTIAL_KEY = 'lingxi-email-test-key-with-more-than-32-bytes'
|
||||||
const encrypted = encryptEmailPassword('app-password-123')
|
const encrypted = encryptEmailPassword('app-password-123')
|
||||||
@@ -54,3 +71,41 @@ test('Email 渠道凭据使用认证加密且可正确解密', () => {
|
|||||||
tampered[2] = `${tampered[2][0] === 'A' ? 'B' : 'A'}${tampered[2].slice(1)}`
|
tampered[2] = `${tampered[2][0] === 'A' ? 'B' : 'A'}${tampered[2].slice(1)}`
|
||||||
assert.throws(() => decryptEmailPassword(tampered.join(':')))
|
assert.throws(() => decryptEmailPassword(tampered.join(':')))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('回复建议提示词突出最新消息、避免重复并遵循企业语气', () => {
|
||||||
|
const prompt = buildHumanReplyUserPrompt({
|
||||||
|
targetInstruction: '使用西班牙语',
|
||||||
|
tone: 'friendly',
|
||||||
|
latestCustomerMessage: '¿Cuándo pueden enviarlo?',
|
||||||
|
latestAgentMessage: 'I will check it.',
|
||||||
|
conversation: '客户: ¿Cuándo pueden enviarlo?',
|
||||||
|
conversationMemory: '客户关注交期',
|
||||||
|
knowledgeContext: [],
|
||||||
|
})
|
||||||
|
assert.match(HUMAN_REPLY_SYSTEM_PROMPT, /不是写客服范文/)
|
||||||
|
assert.match(HUMAN_REPLY_SYSTEM_PROMPT, /不要重新问候/)
|
||||||
|
assert.match(prompt, /¿Cuándo pueden enviarlo\?/)
|
||||||
|
assert.match(prompt, /坐席上一条回复(避免重复)/)
|
||||||
|
assert.match(prompt, /长期合作伙伴/)
|
||||||
|
assert.match(replyToneInstruction('concise'), /简洁直接/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('回复建议质量检查拦截模板话术和同质草稿', () => {
|
||||||
|
const valid = [
|
||||||
|
{ style: 'stable', content: 'I can confirm the dispatch date with our warehouse and update you once I have it.' },
|
||||||
|
{ style: 'active', content: "Let me check the warehouse schedule and come back to you with the exact date." },
|
||||||
|
{ style: 'brief', content: "I'll verify the dispatch date and get back to you." },
|
||||||
|
]
|
||||||
|
assert.deepEqual(replySuggestionIssues(valid), [])
|
||||||
|
const invalid = valid.map((item) => ({ ...item }))
|
||||||
|
invalid[0].content = 'Dear customer, please rest assured that we value your feedback.'
|
||||||
|
invalid[2] = { style: 'active', content: invalid[1].content }
|
||||||
|
const issues = replySuggestionIssues(invalid)
|
||||||
|
assert.ok(issues.some((item) => item.includes('模板化')))
|
||||||
|
assert.ok(issues.some((item) => item.includes('缺少 brief')))
|
||||||
|
assert.ok(issues.some((item) => item.includes('过于相似')))
|
||||||
|
assert.ok(replySuggestionIssues([
|
||||||
|
...valid.slice(0, 2),
|
||||||
|
{ style: 'brief', content: "I'll confirm it today." },
|
||||||
|
]).some((item) => item.includes('时间承诺')))
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import { isTimestampOlderThan, webhookEventClaimDecision } from '../src/reliability.js'
|
||||||
|
|
||||||
|
test('completed webhooks deduplicate while failed and expired processing events can resume', () => {
|
||||||
|
const now = Date.parse('2026-08-01T12:00:00.000Z')
|
||||||
|
assert.equal(webhookEventClaimDecision({ status: 'completed' }, now), 'duplicate')
|
||||||
|
assert.equal(webhookEventClaimDecision({ status: 'failed' }, now), 'claim')
|
||||||
|
assert.equal(webhookEventClaimDecision({ status: 'processing', processingAt: '2026-08-01T11:59:30.000Z' }, now), 'busy')
|
||||||
|
assert.equal(webhookEventClaimDecision({ status: 'processing', processingAt: '2026-08-01T11:57:00.000Z' }, now), 'claim')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('missing timestamps are due and recent maintenance timestamps are not', () => {
|
||||||
|
const now = Date.parse('2026-08-01T12:00:00.000Z')
|
||||||
|
assert.equal(isTimestampOlderThan(undefined, 60_000, now), true)
|
||||||
|
assert.equal(isTimestampOlderThan('2026-08-01T11:58:00.000Z', 60_000, now), true)
|
||||||
|
assert.equal(isTimestampOlderThan('2026-08-01T11:59:30.000Z', 60_000, now), false)
|
||||||
|
})
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import {
|
||||||
|
canonicalWhatsAppJid,
|
||||||
|
isWeakWhatsAppDisplayName,
|
||||||
|
resolvedWhatsAppJid,
|
||||||
|
reusableWhatsAppConversations,
|
||||||
|
whatsappCustomerStableId,
|
||||||
|
} from '../src/whatsapp-identity.js'
|
||||||
|
|
||||||
|
test('WhatsApp LID resolves to the same canonical phone identity', () => {
|
||||||
|
const phoneJid = '15551234567@s.whatsapp.net'
|
||||||
|
assert.equal(canonicalWhatsAppJid('987654321@lid', { phone: '+1 555 123 4567' }), phoneJid)
|
||||||
|
assert.equal(canonicalWhatsAppJid('987654321@lid', { canonicalJid: phoneJid }), phoneJid)
|
||||||
|
assert.equal(whatsappCustomerStableId(canonicalWhatsAppJid('987654321@lid', { phone: '+1 555 123 4567' })), whatsappCustomerStableId(phoneJid))
|
||||||
|
assert.equal(canonicalWhatsAppJid('15551234567:4@s.whatsapp.net'), phoneJid)
|
||||||
|
assert.equal(canonicalWhatsAppJid('987654321@lid', { canonicalJid: '15551234567:0@s.whatsapp.net' }), phoneJid)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a newly resolved phone identity overrides a stale cached LID', () => {
|
||||||
|
const lid = '987654321@lid'
|
||||||
|
const phoneJid = '15551234567@s.whatsapp.net'
|
||||||
|
assert.equal(resolvedWhatsAppJid(lid, {}, lid), lid)
|
||||||
|
assert.equal(resolvedWhatsAppJid(lid, { phone: '+1 555 123 4567' }, lid), phoneJid)
|
||||||
|
assert.equal(resolvedWhatsAppJid(lid, {}, phoneJid), phoneJid)
|
||||||
|
})
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -29,13 +29,13 @@ export function StatCard({
|
|||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
'group p-5 transition-all duration-200',
|
'group p-5 transition-all duration-200',
|
||||||
onClick && 'cursor-pointer hover:-translate-y-0.5 hover:border-primary-200 hover:shadow-[0_14px_35px_rgba(15,23,42,.07)]',
|
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 font-medium text-slate-500">{title}</span>
|
<span className="text-xs font-medium text-slate-500">{title}</span>
|
||||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary-50 transition-colors group-hover:bg-primary-100">
|
<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>
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import { toast, errorMessage } from '@/app/store/toast'
|
|||||||
import { languageName } from '@/config/languages'
|
import { languageName } from '@/config/languages'
|
||||||
|
|
||||||
const STYLE_LABELS = {
|
const STYLE_LABELS = {
|
||||||
stable: { title: '稳健回复', color: 'bg-blue-100 text-blue-700', icon: '🛡️' },
|
stable: { title: '清晰可靠', color: 'bg-blue-100 text-blue-700', icon: '🛡️' },
|
||||||
active: { title: '积极促单回复', color: 'bg-emerald-100 text-emerald-700', icon: '🔥' },
|
active: { title: '温暖主动', color: 'bg-violet-100 text-violet-700', icon: '💬' },
|
||||||
brief: { title: '简洁快速回复', color: 'bg-gray-100 text-gray-600', icon: '⚡' },
|
brief: { title: '简洁直接', color: 'bg-gray-100 text-gray-600', icon: '⚡' },
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AiReplies({
|
export function AiReplies({
|
||||||
@@ -20,17 +20,19 @@ export function AiReplies({
|
|||||||
onAdopt,
|
onAdopt,
|
||||||
onEdit,
|
onEdit,
|
||||||
onClose,
|
onClose,
|
||||||
|
targetLanguage = 'auto',
|
||||||
}: {
|
}: {
|
||||||
conversationId: string
|
conversationId: string
|
||||||
onAdopt: (content: string, translationCn: string) => void
|
onAdopt: (content: string, translationCn: string, suggestionId: string) => void
|
||||||
onEdit: (content: string, translationCn: 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('回复建议已生成', '已参考客户资料、知识库和历史回复')
|
toast.success('回复建议已生成', '已参考客户资料、知识库和历史回复')
|
||||||
@@ -98,7 +100,7 @@ export function AiReplies({
|
|||||||
<div>
|
<div>
|
||||||
<div className="mb-1 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
|
<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" />
|
<Globe className="h-3 w-3" />
|
||||||
发送给客户 · English
|
发送给客户 · {languageName(s.targetLang)}
|
||||||
</div>
|
</div>
|
||||||
<p className="whitespace-pre-wrap text-sm leading-relaxed text-slate-800">{s.content}</p>
|
<p className="whitespace-pre-wrap text-sm leading-relaxed text-slate-800">{s.content}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,7 +147,7 @@ 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, s.translationCn)}>
|
<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>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Shrink,
|
Shrink,
|
||||||
Expand,
|
Expand,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
Languages,
|
||||||
Reply,
|
Reply,
|
||||||
X,
|
X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
@@ -20,6 +21,7 @@ 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 type { ChannelType } from '@/types'
|
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.' },
|
||||||
@@ -48,8 +50,11 @@ export function Composer({
|
|||||||
replyingTo,
|
replyingTo,
|
||||||
onCancelReply,
|
onCancelReply,
|
||||||
channel = 'telegram',
|
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
|
||||||
@@ -57,6 +62,9 @@ export function Composer({
|
|||||||
replyingTo?: { id: string; text: string } | null
|
replyingTo?: { id: string; text: string } | null
|
||||||
onCancelReply?: () => void
|
onCancelReply?: () => void
|
||||||
channel?: ChannelType
|
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)
|
||||||
@@ -92,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)
|
||||||
@@ -116,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 {
|
||||||
@@ -238,11 +246,31 @@ export function Composer({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<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} />
|
||||||
中文自动译为英文{channel === 'email' ? '邮件' : '发送'}
|
双向翻译
|
||||||
</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…
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Brain, 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 { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
CustomerStageBadge,
|
CustomerStageBadge,
|
||||||
@@ -17,13 +17,15 @@ 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 || '收到更多客户消息后将补充客户画像'
|
const summary = customer.aiProfile?.summary || conversation.summary || '收到更多消息后将补充联系人画像'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -36,24 +38,44 @@ export function ConversationInsightBar({
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
<span className="hidden shrink-0 text-[10px] font-semibold uppercase tracking-[0.12em] text-violet-600 sm:inline">
|
<span className="hidden shrink-0 text-[10px] font-semibold uppercase tracking-[0.12em] text-violet-600 sm:inline">
|
||||||
客户洞察
|
对话洞察
|
||||||
</span>
|
</span>
|
||||||
<p className="truncate text-xs leading-5 text-slate-600">{summary}</p>
|
<p className="truncate text-xs leading-5 text-slate-600">{summary}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden shrink-0 items-center gap-2 md: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>
|
||||||
|
{customer.aiProfile?.relationshipLabel && (
|
||||||
|
<span className="rounded-full bg-indigo-50 px-2 py-1 text-[10px] font-medium text-indigo-700">
|
||||||
|
{customer.aiProfile.relationshipLabel}
|
||||||
|
</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'} />
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" onClick={onOpenProfile} title="打开客户画像">
|
<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-2 overflow-x-auto pb-0.5 md:hidden">
|
<div className="mt-1.5 flex items-center gap-2 overflow-x-auto pb-0.5 md:hidden">
|
||||||
|
{aiBackfill && ['queued', 'running'].includes(aiBackfill.status) && (
|
||||||
|
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-50 px-2 py-1 text-[10px] font-medium text-blue-700">
|
||||||
|
<LoaderCircle className="h-3 w-3 animate-spin" />
|
||||||
|
历史分析 {Math.round(Number(aiBackfill.progress || 0) * 100)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="flex shrink-0 items-center gap-1 text-[11px]">意向 <IntentScoreBadge score={Number(customer.intentScore || 0)} /></span>
|
<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} />
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ 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 { formatProductName } from '@/utils/format'
|
||||||
import { CustomerProfileRadar } from '@/components/customer/CustomerProfileRadar'
|
import { CustomerProfileRadar } from '@/components/customer/CustomerProfileRadar'
|
||||||
import { CustomerSemanticTag } from '@/components/customer/CustomerSemanticTag'
|
import { CustomerSemanticTag } from '@/components/customer/CustomerSemanticTag'
|
||||||
|
|
||||||
@@ -46,9 +47,17 @@ export function InsightPanel({
|
|||||||
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(
|
||||||
new Date().getTime() + new Date().getTimezoneOffset() * 60000 + c.tzOffset * 3600000,
|
new Date().getTime() + new Date().getTimezoneOffset() * 60000 + c.tzOffset * 3600000,
|
||||||
).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
).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),
|
||||||
@@ -114,11 +123,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" />
|
||||||
@@ -140,8 +152,8 @@ export function InsightPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
{customer.products?.map((p) => (
|
{customer.products?.map((p, index) => (
|
||||||
<Badge key={p} variant="primary">{p}</Badge>
|
<Badge key={`${formatProductName(p)}-${index}`} variant="primary">{formatProductName(p)}</Badge>
|
||||||
))}
|
))}
|
||||||
{customer.tags?.map((t) => (
|
{customer.tags?.map((t) => (
|
||||||
<CustomerSemanticTag key={t} tag={t} />
|
<CustomerSemanticTag key={t} tag={t} />
|
||||||
@@ -177,6 +189,51 @@ export function InsightPanel({
|
|||||||
{/* AI 分析 */}
|
{/* AI 分析 */}
|
||||||
<div className="space-y-4 border-b border-slate-100 p-3">
|
<div className="space-y-4 border-b border-slate-100 p-3">
|
||||||
<CustomerProfileRadar customer={customer} compact />
|
<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="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">
|
||||||
<div>
|
<div>
|
||||||
@@ -193,14 +250,15 @@ 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-3 text-xs leading-6 text-slate-600">{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-4 grid grid-cols-2 gap-2 border-t border-slate-100 pt-4">
|
<div className="mt-4 grid grid-cols-2 gap-2 border-t border-slate-100 pt-4">
|
||||||
{Object.entries({
|
{Object.entries({
|
||||||
|
关系类型: customer.aiProfile.relationshipLabel,
|
||||||
行业: customer.aiProfile.industry,
|
行业: customer.aiProfile.industry,
|
||||||
采购角色: customer.aiProfile.purchasingRole,
|
业务身份: customer.aiProfile.purchasingRole,
|
||||||
询价数量: customer.aiProfile.inquiryQuantity,
|
询价数量: customer.aiProfile.inquiryQuantity,
|
||||||
目标价格: customer.aiProfile.targetPrice,
|
目标价格: customer.aiProfile.targetPrice,
|
||||||
预算: customer.aiProfile.budget,
|
预算: customer.aiProfile.budget,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
SmilePlus,
|
SmilePlus,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Brain,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import type { ChannelType, Message } from '@/types'
|
import type { ChannelType, Message } from '@/types'
|
||||||
@@ -89,6 +90,7 @@ export function MessageBubble({
|
|||||||
customerName,
|
customerName,
|
||||||
customerAvatar,
|
customerAvatar,
|
||||||
channel,
|
channel,
|
||||||
|
showIntent = true,
|
||||||
}: {
|
}: {
|
||||||
msg: Message
|
msg: Message
|
||||||
viewMode: ViewMode
|
viewMode: ViewMode
|
||||||
@@ -102,6 +104,7 @@ export function MessageBubble({
|
|||||||
customerName?: string
|
customerName?: string
|
||||||
customerAvatar?: string
|
customerAvatar?: string
|
||||||
channel?: ChannelType
|
channel?: ChannelType
|
||||||
|
showIntent?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [showActions, setShowActions] = useState(false)
|
const [showActions, setShowActions] = useState(false)
|
||||||
const [translationExpanded, setTranslationExpanded] = useState(false)
|
const [translationExpanded, setTranslationExpanded] = useState(false)
|
||||||
@@ -124,7 +127,15 @@ export function MessageBubble({
|
|||||||
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 hasKnownLanguage = Boolean(msg.originalLang && !['und', 'auto', 'unknown'].includes(msg.originalLang))
|
||||||
const sourceLanguage = hasKnownLanguage ? languageName(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 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 isMediaMessage = ['image', 'audio', 'file'].includes(msg.type)
|
||||||
const hasProviderAttachment = Boolean(
|
const hasProviderAttachment = Boolean(
|
||||||
msg.telegramFileId
|
msg.telegramFileId
|
||||||
@@ -219,7 +230,7 @@ export function MessageBubble({
|
|||||||
<div>
|
<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">
|
<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" />
|
<Globe className="h-3 w-3" />
|
||||||
客户收到 · {translatedText ? '英文译文' : '原文'}
|
客户收到 · {translatedText ? targetLanguage : '原文'}
|
||||||
</span>
|
</span>
|
||||||
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{outgoingSentText}</p>
|
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{outgoingSentText}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -265,9 +276,12 @@ export function MessageBubble({
|
|||||||
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{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>
|
||||||
@@ -277,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 ? `${sourceLanguage} → 英文发送内容` : `${sourceLanguage} → 中文`}
|
{isMe ? `${sourceLanguage} → ${targetLanguage}` : `${sourceLanguage} → 中文`}
|
||||||
</span>
|
</span>
|
||||||
<div className="mt-1.5">{translationContent(false)}</div>
|
<div className="mt-1.5">{translationContent(false)}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -377,6 +394,31 @@ export function MessageBubble({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
|
{!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 && (
|
{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" /> 正在读取 {providerName} 文件…
|
<LoaderCircle className="h-3.5 w-3.5 animate-spin" /> 正在读取 {providerName} 文件…
|
||||||
|
|||||||
@@ -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 bg-white/35 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>
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -35,18 +35,16 @@ export function Sidebar() {
|
|||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative z-30 flex h-full flex-col border-e border-slate-300/80 bg-[#f8fafc] shadow-[4px_0_16px_-14px_rgba(15,23,42,.32)] 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-[68px]' : 'w-[228px]',
|
sidebarCollapsed ? 'w-[68px]' : 'w-[236px]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div
|
<div
|
||||||
className="flex h-16 cursor-pointer items-center gap-2.5 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-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-primary-600 to-primary-800 text-primary-foreground shadow-sm shadow-primary-900/10">
|
<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-[15px] font-semibold tracking-tight leading-tight">{t('app.name')}</p>
|
<p className="truncate text-[15px] font-semibold tracking-tight leading-tight">{t('app.name')}</p>
|
||||||
@@ -56,7 +54,7 @@ export function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 导航 */}
|
{/* 导航 */}
|
||||||
<nav className="flex-1 overflow-y-auto px-2.5 py-2 scrollbar-thin">
|
<nav className="flex-1 overflow-y-auto px-2.5 py-3 scrollbar-thin">
|
||||||
{groups.map((group) => {
|
{groups.map((group) => {
|
||||||
const groupItems = items.filter((item) => item.group === group.key)
|
const groupItems = items.filter((item) => item.group === group.key)
|
||||||
if (!groupItems.length) return null
|
if (!groupItems.length) return null
|
||||||
@@ -76,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-3 rounded-xl px-3 py-2.5 text-[13px] transition-all duration-150',
|
'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/90 font-semibold text-primary-800 shadow-[inset_0_0_0_1px_rgba(37,99,235,.08)]'
|
? 'bg-primary-50 font-semibold text-primary-800 shadow-[inset_0_0_0_1px_rgba(37,99,235,.1)] before:absolute before:inset-y-2 before:left-0 before:w-[3px] before:rounded-r-full before:bg-primary-600'
|
||||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900',
|
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-950',
|
||||||
sidebarCollapsed && 'justify-center px-0',
|
sidebarCollapsed && 'justify-center px-0',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -96,8 +94,8 @@ export function Sidebar() {
|
|||||||
|
|
||||||
{/* 底部:套餐与企业信息 */}
|
{/* 底部:套餐与企业信息 */}
|
||||||
{!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>
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ export function Topbar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-16 shrink-0 items-center gap-3 border-b border-slate-200/70 bg-white/90 px-3 backdrop-blur-xl md:px-5">
|
<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="打开导航"
|
||||||
>
|
>
|
||||||
@@ -75,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" />
|
||||||
@@ -96,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>
|
||||||
}
|
}
|
||||||
@@ -118,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')}
|
||||||
>
|
>
|
||||||
@@ -128,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">
|
||||||
@@ -172,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>
|
||||||
|
|||||||
@@ -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,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-2xl border border-slate-200/80 bg-card shadow-[0_1px_2px_rgba(15,23,42,0.035),0_10px_30px_rgba(15,23,42,0.025)]', 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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -21,7 +21,7 @@ export function CardHeader({
|
|||||||
return (
|
return (
|
||||||
<div className={cn('flex items-start justify-between gap-3 border-b border-slate-100 px-5 py-4', 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 tracking-tight 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>) {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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} />
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -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: '' }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 行业 */
|
/** 行业 */
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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: '#2563eb' }]} /></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>
|
|
||||||
<ChartCard title="产品关注度排行" height={260}><RankBars data={data.productRank.map((p) => ({ name: p.product, value: p.inquiries }))} /></ChartCard>
|
|
||||||
<ChartCard title="团队回复效率排行" height={240}><RankBars data={data.teamReplyRank.map((r) => ({ name: r.name, value: r.avgResponseMin }))} unit="分钟" max={30} /></ChartCard>
|
|
||||||
<ChartCard title="团队客户转化排行" height={240}><RankBars data={data.teamConversionRank.map((r) => ({ name: r.name, value: r.rate }))} unit="%" /></ChartCard>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-12">
|
||||||
|
<ChartCard className="xl:col-span-7" title="客户增长趋势" description="所选周期内的新增客户变化" height={290}>
|
||||||
|
<TrendChart data={data.customerGrowth} series={[{ key: 'count', name: '新增客户', color: '#2563eb' }]} />
|
||||||
|
</ChartCard>
|
||||||
|
<ChartCard className="xl:col-span-5" title="客户阶段转化漏斗" description="识别转化链路中的主要流失阶段" height={290}>
|
||||||
|
<FunnelChart data={data.funnel} />
|
||||||
|
</ChartCard>
|
||||||
|
<ChartCard className="xl:col-span-4" title="各渠道询盘数量" height={255}>
|
||||||
|
<SimpleBarChart data={data.channelInquiries} xKey="name" barKey="count" name="询盘数" />
|
||||||
|
</ChartCard>
|
||||||
|
<ChartCard className="xl:col-span-4" title="客户国家/地区分布" height={255}>
|
||||||
|
<DonutChart data={data.countryDistribution} />
|
||||||
|
</ChartCard>
|
||||||
|
<ChartCard className="xl:col-span-4" title="意向分数分布" height={255}>
|
||||||
|
<DonutChart data={data.intentDistribution} nameKey="range" />
|
||||||
|
</ChartCard>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* 业务概览 */}
|
<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>
|
|
||||||
<Card><CardHeader title="渠道转化" /><div className="p-4 text-center"><p className="text-2xl font-semibold text-primary-700">{data.bossBoard.bestChannel.name}</p><p className="text-sm text-muted-foreground">转化率 {data.bossBoard.bestChannel.conversion}%</p></div></Card>
|
|
||||||
<Card><CardHeader title="产品询盘" /><div className="space-y-2 p-4">{data.bossBoard.topProducts.map((p) => (<div key={p.product} className="flex items-center justify-between text-sm"><span>{p.product}</span><span className="text-muted-foreground">{p.inquiries} 次询盘</span></div>))}</div></Card>
|
|
||||||
<Card><CardHeader title="久未联系的高意向客户" /><div className="divide-y divide-border">{data.bossBoard.churningHighIntent.map((c) => (<div key={c.id} className="px-4 py-2.5 text-sm"><span>{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="text-xs text-muted-foreground ml-2">{c.daysSilent}天未联系 · 意向{c.intentScore}分</span></div>))}</div></Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
|
<ChartCard title="产品关注度排行" height={250}><RankBars data={data.productRank.map((p) => ({ name: p.product, value: p.inquiries }))} /></ChartCard>
|
||||||
|
<ChartCard title="团队回复效率" height={250}><RankBars data={data.teamReplyRank.map((r) => ({ name: r.name, value: r.avgResponseMin }))} unit="分钟" max={30} /></ChartCard>
|
||||||
|
<ChartCard title="团队客户转化" height={250}><RankBars data={data.teamConversionRank.map((r) => ({ name: r.name, value: r.rate }))} unit="%" /></ChartCard>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="page-section">
|
||||||
|
<div className="section-heading">
|
||||||
|
<div>
|
||||||
|
<h2 className="section-title">经营提醒</h2>
|
||||||
|
<p className="section-description">需要优先处理的客户、跟进和市场变化</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
|
<Card><CardHeader title="重点跟进客户" /><div className="divide-y divide-slate-100">{data.bossBoard.urgentHighValue.map((c) => (<div key={c.id} className="flex items-center justify-between px-5 py-3 text-sm"><span className="font-medium text-slate-900">{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="max-w-[55%] truncate text-xs text-slate-500">{c.reason}</span></div>))}</div></Card>
|
||||||
|
<Card><CardHeader title="逾期跟进" /><div className="divide-y divide-slate-100">{data.bossBoard.overdueMembers.map((m) => (<div key={m.id} className="flex items-center justify-between px-5 py-3 text-sm"><span className="font-medium text-slate-900">{m.name}</span><Badge variant="danger">{m.overdueCount} 项逾期</Badge></div>))}</div></Card>
|
||||||
|
<Card><CardHeader title="增长较快的市场" /><div className="divide-y divide-slate-100">{data.bossBoard.fastestCountries.map((c) => (<div key={c.country} className="flex items-center justify-between px-5 py-3 text-sm"><CountryFlag code={c.country} /><Badge variant="success">+{(c.growth * 100).toFixed(0)}%</Badge></div>))}</div></Card>
|
||||||
|
<Card><CardHeader title="渠道转化" /><div className="px-5 py-6"><p className="text-2xl font-semibold tracking-tight text-slate-950">{data.bossBoard.bestChannel.name}</p><p className="mt-1 text-sm text-slate-500">当前转化率 {data.bossBoard.bestChannel.conversion}%</p></div></Card>
|
||||||
|
<Card><CardHeader title="产品询盘" /><div className="divide-y divide-slate-100 px-5">{data.bossBoard.topProducts.map((p) => (<div key={p.product} className="flex items-center justify-between py-3 text-sm"><span className="font-medium text-slate-900">{p.product}</span><span className="text-slate-500">{p.inquiries} 次</span></div>))}</div></Card>
|
||||||
|
<Card><CardHeader title="久未联系的高意向客户" /><div className="divide-y divide-slate-100">{data.bossBoard.churningHighIntent.map((c) => (<div key={c.id} className="px-5 py-3 text-sm"><span className="font-medium text-slate-900">{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="ml-2 text-xs text-slate-500">{c.daysSilent} 天未联系 · 意向 {c.intentScore} 分</span></div>))}</div></Card>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+81
-148
@@ -3,7 +3,7 @@ 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,37 +11,36 @@ 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 [searchParams, setSearchParams] = useSearchParams()
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const setSession = useAuthStore((s) => s.setSession)
|
const setSession = useAuthStore((state) => state.setSession)
|
||||||
const [mode, setMode] = useState<'code' | 'password'>('password')
|
|
||||||
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: '' },
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searchParams.get('reason') !== 'session-expired') return
|
if (searchParams.get('reason') !== 'session-expired') return
|
||||||
@@ -49,179 +48,113 @@ export function LoginPage() {
|
|||||||
setSearchParams({}, { replace: true })
|
setSearchParams({}, { replace: true })
|
||||||
}, [searchParams, setSearchParams])
|
}, [searchParams, setSearchParams])
|
||||||
|
|
||||||
const doLogin = async (values: FormValues) => {
|
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)
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
setCodeSent((s) => {
|
|
||||||
if (s <= 1) clearInterval(timer)
|
|
||||||
return s - 1
|
|
||||||
})
|
|
||||||
}, 1000)
|
|
||||||
toast.success('验证码已发送')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-screen bg-background">
|
|
||||||
{/* 品牌区 */}
|
|
||||||
<div className="hidden w-[46%] flex-col justify-between bg-primary-900 p-10 text-primary-50 lg:flex">
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/10">
|
|
||||||
<Sparkles className="h-5 w-5" />
|
|
||||||
</span>
|
|
||||||
<div>
|
|
||||||
<p className="text-base font-semibold">{t('app.name')}</p>
|
|
||||||
<p className="text-xs text-primary-200">{t('app.slogan')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-8">
|
|
||||||
<h1 className="text-2xl font-semibold leading-snug">
|
|
||||||
集中处理客户消息,
|
|
||||||
<br />
|
|
||||||
更快回复和跟进。
|
|
||||||
</h1>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{[
|
|
||||||
{ icon: MessageCircle, text: '集中处理 WhatsApp、Telegram、邮件和独立站消息' },
|
|
||||||
{ icon: Languages, text: '消息翻译与双语对照,支持行业术语' },
|
|
||||||
{ icon: ShieldCheck, text: '识别客户情绪、沟通意图和风险信号' },
|
|
||||||
{ icon: UsersRound, text: '管理客户资料、意向评分和团队跟进' },
|
|
||||||
].map((f) => (
|
|
||||||
<div key={f.text} className="flex items-center gap-3 text-sm text-primary-100">
|
|
||||||
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-white/10">
|
|
||||||
<f.icon className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
{f.text}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-primary-300">© 2026 {t('app.company')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 表单区 */}
|
|
||||||
<div className="flex flex-1 items-center justify-center p-6">
|
|
||||||
<div className="w-full max-w-[400px]">
|
|
||||||
<div className="mb-8 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2 lg:hidden">
|
|
||||||
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-white">
|
|
||||||
<Sparkles className="h-4 w-4" />
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold">{t('app.name')}</span>
|
|
||||||
</div>
|
|
||||||
<Dropdown
|
<Dropdown
|
||||||
trigger={
|
trigger={(
|
||||||
<button className="flex items-center gap-1 rounded-md px-2 py-1 text-sm text-muted-foreground hover:bg-muted">
|
<button className="flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 text-xs font-medium text-slate-600 transition hover:border-slate-300 hover:bg-slate-50">
|
||||||
<Globe className="h-4 w-4" />
|
<Globe className="h-3.5 w-3.5" />
|
||||||
{UI_LOCALES.find((l) => l.code === locale)?.nativeName}
|
{UI_LOCALES.find((item) => item.code === locale)?.nativeName}
|
||||||
</button>
|
</button>
|
||||||
}
|
)}
|
||||||
items={UI_LOCALES.map((l) => ({
|
items={UI_LOCALES.map((item) => ({
|
||||||
key: l.code,
|
key: item.code,
|
||||||
label: l.nativeName,
|
label: item.nativeName,
|
||||||
onSelect: () => setLocale(l.code as UiLocale),
|
onSelect: () => setLocale(item.code as UiLocale),
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthShell topAction={localeMenu}>
|
||||||
|
<div className="mb-8">
|
||||||
|
<p className="text-xs font-semibold tracking-[0.12em] text-blue-600">欢迎回来</p>
|
||||||
|
<h2 className="mt-3 text-[28px] font-semibold tracking-[-0.035em] text-slate-950">登录工作台</h2>
|
||||||
|
<p className="mt-2 text-sm text-slate-500">继续处理客户消息与团队跟进。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 className="text-xl font-semibold">{t('login.title')}</h2>
|
<form className="space-y-5" onSubmit={handleSubmit(onSubmit)}>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">{t('login.subtitle')}</p>
|
<Field label="手机号或邮箱" error={errors.account?.message}>
|
||||||
|
<div className="relative">
|
||||||
<div className="mt-6">
|
<UserRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
<Tabs
|
<Input
|
||||||
value={mode}
|
className="h-11 rounded-xl border-slate-200 bg-white pl-10 focus-visible:ring-blue-100"
|
||||||
onChange={(v) => setMode(v as 'code' | 'password')}
|
placeholder="请输入手机号或邮箱"
|
||||||
items={[{ value: 'password', label: t('login.passwordTab') }]}
|
autoComplete="username"
|
||||||
|
{...register('account')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</Field>
|
||||||
{mode === 'code' ? (
|
<Field label="密码" error={errors.password?.message}>
|
||||||
<div className="flex gap-2">
|
<div className="relative">
|
||||||
<Input placeholder={t('login.codePlaceholder')} maxLength={6} {...register('code')} />
|
<LockKeyhole className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||||
<Button type="button" variant="outline" className="shrink-0" disabled={codeSent > 0} onClick={sendCode}>
|
<Input
|
||||||
{codeSent > 0 ? `${codeSent}s` : t('login.getCode')}
|
className="h-11 rounded-xl border-slate-200 bg-white pl-10 focus-visible:ring-blue-100"
|
||||||
</Button>
|
type="password"
|
||||||
|
placeholder="请输入登录密码"
|
||||||
|
autoComplete="current-password"
|
||||||
|
{...register('password')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<Field error={errors.password?.message}>
|
|
||||||
<Input type="password" placeholder={t('login.passwordPlaceholder')} autoComplete="current-password" {...register('password')} />
|
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between">
|
||||||
<Checkbox label={t('login.remember')} checked={remember} onCheckedChange={setRemember} />
|
<Checkbox label="保持登录" checked={remember} onCheckedChange={setRemember} />
|
||||||
|
<span className="text-xs text-slate-400">安全会话 · 12 小时</span>
|
||||||
</div>
|
</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}>
|
||||||
{t('login.submit')}
|
登录
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="text-center text-sm text-muted-foreground">
|
<p className="text-center text-sm text-slate-500">
|
||||||
还没有账号?{' '}
|
还没有账号?{' '}
|
||||||
<Link to="/register" className="font-medium text-primary hover:underline">
|
<Link to="/register" className="font-medium text-blue-600 hover:text-blue-700">
|
||||||
{t('login.register')}
|
创建企业
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="mt-6 text-center text-xs text-muted-foreground">
|
<div className="mt-8 border-t border-slate-100 pt-5">
|
||||||
<Checkbox
|
<label className="flex items-start gap-2 text-xs leading-5 text-slate-400">
|
||||||
checked={agreed}
|
<Checkbox checked={agreed} onCheckedChange={setAgreed} className="mt-0.5" />
|
||||||
onCheckedChange={setAgreed}
|
<span>
|
||||||
className="me-1 align-middle"
|
登录即表示同意
|
||||||
/>
|
<button type="button" className="mx-1 text-slate-600 hover:text-blue-600" onClick={() => setAgreementOpen('privacy')}>隐私政策</button>
|
||||||
{t('login.agreementPrefix')}
|
和
|
||||||
<button type="button" className="text-primary hover:underline" onClick={() => setAgreementOpen('privacy')}>
|
<button type="button" className="ml-1 text-slate-600 hover:text-blue-600" onClick={() => setAgreementOpen('terms')}>服务协议</button>
|
||||||
{t('login.privacy')}
|
</span>
|
||||||
</button>
|
</label>
|
||||||
{t('login.and')}
|
|
||||||
<button type="button" className="text-primary hover:underline" onClick={() => setAgreementOpen('terms')}>
|
|
||||||
{t('login.terms')}
|
|
||||||
</button>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 协议弹窗 */}
|
<Dialog
|
||||||
<Dialog open={!!agreementOpen} onOpenChange={() => setAgreementOpen(null)} title={agreementOpen === 'privacy' ? '隐私政策' : '服务协议'}>
|
open={Boolean(agreementOpen)}
|
||||||
<div className="space-y-3 text-sm leading-relaxed text-muted-foreground">
|
onOpenChange={() => setAgreementOpen(null)}
|
||||||
|
title={agreementOpen === 'privacy' ? '隐私政策' : '服务协议'}
|
||||||
|
>
|
||||||
|
<div className="space-y-3 text-sm leading-7 text-slate-600">
|
||||||
<p>使用本服务前,请阅读并确认以下数据处理与服务条款。</p>
|
<p>使用本服务前,请阅读并确认以下数据处理与服务条款。</p>
|
||||||
<p>1. 我们仅收集为您提供跨境沟通与客户管理服务所必需的信息,包括账号信息、企业信息与您主动录入的客户数据。</p>
|
<p>我们仅处理提供跨境沟通、客户管理和团队协作所必需的数据。</p>
|
||||||
<p>2. 我们按照已公布的安全措施处理会话数据;涉及第三方服务时,处理范围以隐私政策及相关服务条款为准。</p>
|
<p>使用翻译与回复建议时,相关内容会发送至企业已配置的模型服务商处理。</p>
|
||||||
<p>3. 数据导出、删除和账号注销申请按照正式发布的隐私政策处理。</p>
|
<p>数据导出、删除和账号注销按照正式发布的政策与协议执行。</p>
|
||||||
<p>4. 使用翻译和回复建议时,相关内容可能发送至已配置的模型服务商处理,具体范围以隐私政策为准。</p>
|
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
</AuthShell>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +10,7 @@ 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({
|
||||||
@@ -23,12 +23,12 @@ const schema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
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?setup=choose')
|
||||||
} catch (e) {
|
} catch (error) {
|
||||||
toast.error(errorMessage(e))
|
toast.error(errorMessage(error))
|
||||||
} 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">
|
||||||
|
<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>
|
</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="8—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>
|
||||||
<Field label="企业名称" required error={errors.company?.message}>
|
<Field label="企业名称" required error={errors.company?.message}>
|
||||||
<Input placeholder="如:徐州欧汇科技有限公司" {...register('company')} />
|
<Input className={fieldClass} placeholder="请输入企业名称" {...register('company')} />
|
||||||
</Field>
|
</Field>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<Field label="所属行业" required error={errors.industry?.message}>
|
<Field label="所属行业" required error={errors.industry?.message}>
|
||||||
<Select placeholder="请选择" options={INDUSTRIES.map((i) => ({ value: i, label: i }))} {...register('industry')} />
|
<Select placeholder="请选择" options={INDUSTRIES.map((item) => ({ value: item, label: item }))} {...register('industry')} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="团队规模" required error={errors.teamSize?.message}>
|
<Field label="团队规模" required error={errors.teamSize?.message}>
|
||||||
<Select placeholder="请选择" options={TEAM_SIZES.map((s) => ({ value: s, label: s }))} {...register('teamSize')} />
|
<Select placeholder="请选择" options={TEAM_SIZES.map((item) => ({ value: item, label: item }))} {...register('teamSize')} />
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</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">
|
注册后先选择需要接入的渠道,也可以暂时跳过。
|
||||||
已有账号?{' '}
|
|
||||||
<Link to="/login" className="font-medium text-primary hover:underline">
|
|
||||||
直接登录
|
|
||||||
</Link>
|
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</AuthShell>
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -233,19 +233,18 @@ export function ChannelsPage() {
|
|||||||
.at(-1)
|
.at(-1)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[1480px] p-4 md:p-7">
|
<div className="page-shell">
|
||||||
<section className="relative mb-6 overflow-hidden rounded-[28px] border border-slate-200/80 bg-gradient-to-br from-white via-sky-50/40 to-emerald-50/60 px-5 py-6 shadow-[0_18px_60px_rgba(15,23,42,.06)] md:px-7">
|
<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-sky-200/25 blur-3xl" />
|
<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 className="relative flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-primary-200/80 bg-white/85 px-3 py-1 text-[11px] font-semibold text-primary-700 shadow-sm">
|
<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 flex h-2 w-2">
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary-400 opacity-45" />
|
|
||||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary-500" />
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary-500" />
|
||||||
</span>
|
</span>
|
||||||
渠道连接
|
渠道连接
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-2xl font-bold tracking-[-0.035em] text-slate-950 md:text-3xl">渠道与账号</h1>
|
<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">
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
|
||||||
在一个工作台管理 WhatsApp、Telegram 与企业邮箱消息。
|
在一个工作台管理 WhatsApp、Telegram 与企业邮箱消息。
|
||||||
</p>
|
</p>
|
||||||
@@ -256,7 +255,7 @@ export function ChannelsPage() {
|
|||||||
{ label: '今日消息', value: String(todayMessages), icon: MessageSquareText },
|
{ label: '今日消息', value: String(todayMessages), icon: MessageSquareText },
|
||||||
{ label: '最近同步', value: latestSync ? formatRelative(latestSync) : '等待首次同步', icon: Activity },
|
{ label: '最近同步', value: latestSync ? formatRelative(latestSync) : '等待首次同步', icon: Activity },
|
||||||
].map((stat) => (
|
].map((stat) => (
|
||||||
<div key={stat.label} className="rounded-2xl border border-white/80 bg-white/75 p-3 shadow-sm backdrop-blur">
|
<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" />
|
<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="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>
|
<p className="mt-0.5 text-[10px] font-medium text-slate-400">{stat.label}</p>
|
||||||
@@ -312,7 +311,7 @@ export function ChannelsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white p-3 shadow-sm">
|
<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>
|
<div>
|
||||||
<p className="text-sm font-semibold text-slate-900">连接更多工作账号</p>
|
<p className="text-sm font-semibold text-slate-900">连接更多工作账号</p>
|
||||||
<p className="mt-0.5 text-xs text-slate-500">每种渠道可绑定多个账号,并指定一个默认账号用于发起新会话。</p>
|
<p className="mt-0.5 text-xs text-slate-500">每种渠道可绑定多个账号,并指定一个默认账号用于发起新会话。</p>
|
||||||
@@ -335,17 +334,17 @@ export function ChannelsPage() {
|
|||||||
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 data-testid={`channel-card-${ch.type}`} key={ch.id} className="group overflow-hidden border-slate-200/80 bg-white transition-all duration-300 hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-[0_18px_50px_rgba(15,23,42,.08)]">
|
<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={
|
<div className={
|
||||||
ch.type === 'telegram'
|
ch.type === 'telegram'
|
||||||
? 'h-1 bg-gradient-to-r from-sky-400 to-cyan-300'
|
? 'h-[3px] bg-primary-500'
|
||||||
: ch.type === 'whatsapp'
|
: ch.type === 'whatsapp'
|
||||||
? 'h-1 bg-gradient-to-r from-blue-500 via-cyan-400 to-violet-400'
|
? 'h-[3px] bg-sky-500'
|
||||||
: 'h-1 bg-gradient-to-r from-slate-500 to-slate-300'
|
: 'h-[3px] bg-slate-500'
|
||||||
} />
|
} />
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-50 ring-1 ring-slate-100 transition-transform duration-300 group-hover:scale-105">
|
<div className="flex h-12 w-12 items-center justify-center rounded-[12px] bg-slate-50 ring-1 ring-slate-100 transition-transform duration-200 group-hover:scale-[1.03]">
|
||||||
<ChannelIcon channel={ch.type} size="lg" />
|
<ChannelIcon channel={ch.type} size="lg" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap justify-end gap-1.5">
|
<div className="flex flex-wrap justify-end gap-1.5">
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
INTENT_LEVELS,
|
INTENT_LEVELS,
|
||||||
} from '@/config/constants'
|
} from '@/config/constants'
|
||||||
import { cn } from '@/utils/cn'
|
import { cn } from '@/utils/cn'
|
||||||
|
import { formatProductName } from '@/utils/format'
|
||||||
import type { Customer } from '@/types'
|
import type { Customer } from '@/types'
|
||||||
import type { ChannelType } from '@/types/messaging'
|
import type { ChannelType } from '@/types/messaging'
|
||||||
|
|
||||||
@@ -175,7 +176,7 @@ 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} 个客户`}
|
||||||
@@ -204,27 +205,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 +236,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 +247,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>
|
||||||
@@ -285,7 +286,7 @@ export function CustomersPage() {
|
|||||||
<Td onClick={() => navigate(`/customers/${customer.id}`)}><CountryFlag code={customer.country} /></Td>
|
<Td onClick={() => navigate(`/customers/${customer.id}`)}><CountryFlag code={customer.country} /></Td>
|
||||||
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{customer.email || customer.phone || '—'}</span></Td>
|
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{customer.email || customer.phone || '—'}</span></Td>
|
||||||
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="flex items-center gap-1"><ChannelIcon channel={customer.channel} size="xs" withBg={false} />{customer.channel}</span></Td>
|
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="flex items-center gap-1"><ChannelIcon channel={customer.channel} size="xs" withBg={false} />{customer.channel}</span></Td>
|
||||||
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{customer.products[0] || '—'}</span></Td>
|
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{formatProductName(customer.products?.[0]) || '—'}</span></Td>
|
||||||
<Td><IntentScoreBadge score={customer.intentScore} /></Td>
|
<Td><IntentScoreBadge score={customer.intentScore} /></Td>
|
||||||
<Td><CustomerStageBadge stage={customer.stage} /></Td>
|
<Td><CustomerStageBadge stage={customer.stage} /></Td>
|
||||||
<Td><SentimentBadge sentiment={customer.sentiment} emojiOnly /></Td>
|
<Td><SentimentBadge sentiment={customer.sentiment} emojiOnly /></Td>
|
||||||
@@ -333,8 +334,8 @@ export function CustomersPage() {
|
|||||||
<SentimentBadge sentiment={customer.sentiment} emojiOnly />
|
<SentimentBadge sentiment={customer.sentiment} emojiOnly />
|
||||||
{customer.riskLevel !== 'none' && <RiskLevelBadge level={customer.riskLevel} />}
|
{customer.riskLevel !== 'none' && <RiskLevelBadge level={customer.riskLevel} />}
|
||||||
</div>
|
</div>
|
||||||
{customer.products.length > 0 && (
|
{customer.products?.length > 0 && (
|
||||||
<p className="mt-2 text-xs text-muted-foreground truncate">{customer.products[0]}</p>
|
<p className="mt-2 text-xs text-muted-foreground truncate">{formatProductName(customer.products[0])}</p>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { useAuthStore } from '@/app/store/auth'
|
|||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { getPlan } from '@/config/pricingPlans'
|
import { getPlan } from '@/config/pricingPlans'
|
||||||
import { countryMeta, taskTypeLabel, CHANNEL_META, riskLevelMeta } from '@/config/constants'
|
import { countryMeta, taskTypeLabel, CHANNEL_META, riskLevelMeta } from '@/config/constants'
|
||||||
import { formatDateTime, formatRelative } from '@/utils/format'
|
import { formatDateTime, formatProductName, formatRelative } from '@/utils/format'
|
||||||
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'
|
||||||
import { Card, CardHeader } from '@/components/ui/card'
|
import { Card, CardHeader } from '@/components/ui/card'
|
||||||
@@ -92,17 +92,17 @@ export function DashboardPage() {
|
|||||||
const s = data.stats
|
const s = data.stats
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[1600px] p-4 md:p-7">
|
<div className="page-shell">
|
||||||
{/* 顶部欢迎区 */}
|
{/* 顶部欢迎区 */}
|
||||||
<div className="mb-7 overflow-hidden rounded-3xl border border-slate-200/80 bg-white px-5 py-5 shadow-[0_16px_50px_rgba(15,23,42,.035)] md:px-7 md:py-6">
|
<div className="mb-6 overflow-hidden rounded-[16px] border border-slate-200 bg-white px-5 py-5 shadow-[0_8px_28px_-24px_rgba(15,23,42,.55)] md:px-6">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-5">
|
<div className="flex flex-wrap items-start justify-between gap-5">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Business command center</p>
|
<p className="eyebrow">今日工作概览</p>
|
||||||
<h1 className="mt-2 text-2xl font-semibold tracking-[-0.035em] text-slate-950 md:text-[28px]">
|
<h1 className="mt-1.5 text-2xl font-semibold tracking-[-0.035em] text-slate-950 md:text-[28px]">
|
||||||
{greeting},{user?.name}
|
{greeting},{user?.name}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-1.5 text-sm text-slate-500">
|
<p className="mt-1.5 text-sm text-slate-500">
|
||||||
{dateStr} · {tenant?.name} · 数据更新于 {formatDateTime(data.updatedAt).slice(11)}
|
{dateStr} · 数据更新于 {formatDateTime(data.updatedAt).slice(11)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -114,9 +114,9 @@ export function DashboardPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex gap-2 overflow-x-auto border-t border-slate-100 pt-4 scrollbar-thin">
|
<div className="mt-5 flex flex-wrap gap-2 border-t border-slate-100 pt-4">
|
||||||
{quickActions.slice(0, 5).map((a) => (
|
{quickActions.slice(0, 5).map((a) => (
|
||||||
<Button key={a.label} variant="ghost" size="sm" className="shrink-0 rounded-xl bg-slate-50 text-slate-600 hover:bg-primary-50 hover:text-primary-800" onClick={a.onClick}>
|
<Button key={a.label} variant="ghost" size="sm" className="rounded-[9px] bg-slate-50 text-slate-600 hover:bg-primary-50 hover:text-primary-800" onClick={a.onClick}>
|
||||||
<a.icon className="h-3.5 w-3.5" />
|
<a.icon className="h-3.5 w-3.5" />
|
||||||
{a.label}
|
{a.label}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -125,10 +125,10 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 核心指标 */}
|
{/* 核心指标 */}
|
||||||
<div className="mb-3 flex items-end justify-between">
|
<div className="section-heading">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">工作台</p>
|
<h2 className="section-title">今日重点</h2>
|
||||||
<h2 className="mt-1 text-lg font-semibold tracking-tight">今日重点</h2>
|
<p className="section-description">优先处理待回复、跟进与高意向客户</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => navigate('/analytics')} className="text-xs font-medium text-primary hover:text-primary-700">查看完整分析 →</button>
|
<button onClick={() => navigate('/analytics')} className="text-xs font-medium text-primary hover:text-primary-700">查看完整分析 →</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,15 +138,41 @@ export function DashboardPage() {
|
|||||||
<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')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-4">
|
<Card className="mt-3 overflow-hidden">
|
||||||
<StatCard title="风险预警" value={s.openRisks.value} delta={s.openRisks.delta} icon={AlertTriangle} onClick={() => navigate('/risks')} />
|
<div className="grid grid-cols-2 divide-x divide-y divide-slate-100 md:grid-cols-4 md:divide-y-0">
|
||||||
<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} />
|
{ label: '风险预警', value: s.openRisks.value, icon: AlertTriangle, onClick: () => navigate('/risks') },
|
||||||
<StatCard title="AI回复采纳率" value={`${s.aiAdoptionRate.value}%`} delta={s.aiAdoptionRate.delta} icon={Zap} />
|
{ 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>
|
</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
|
||||||
@@ -209,7 +235,7 @@ export function DashboardPage() {
|
|||||||
<CountryFlag code={c.country} withName={false} />
|
<CountryFlag code={c.country} withName={false} />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||||
{c.products[0] ?? '—'} · 建议:{c.aiProfile.nextActions[0] ?? '跟进'}
|
{formatProductName(c.products?.[0]) || '—'} · 建议:{c.aiProfile?.nextActions?.[0] ?? '跟进'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 flex-col items-end gap-1">
|
<div className="flex shrink-0 flex-col items-end gap-1">
|
||||||
@@ -257,9 +283,17 @@ export function DashboardPage() {
|
|||||||
</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>
|
||||||
@@ -277,6 +311,7 @@ export function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* 最近风险 */}
|
{/* 最近风险 */}
|
||||||
<Card className="mt-4">
|
<Card className="mt-4">
|
||||||
|
|||||||
+136
-12
@@ -6,6 +6,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Ban,
|
Ban,
|
||||||
BellOff,
|
BellOff,
|
||||||
|
Brain,
|
||||||
Check,
|
Check,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
Eraser,
|
Eraser,
|
||||||
@@ -48,6 +49,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Dialog } from '@/components/ui/dialog'
|
import { Dialog } from '@/components/ui/dialog'
|
||||||
import { Dropdown } from '@/components/ui/dropdown'
|
import { Dropdown } from '@/components/ui/dropdown'
|
||||||
import { Segmented } from '@/components/ui/tabs'
|
import { Segmented } from '@/components/ui/tabs'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Avatar } from '@/components/ui/avatar'
|
import { Avatar } from '@/components/ui/avatar'
|
||||||
import { ChannelIcon } from '@/components/common/ChannelIcon'
|
import { ChannelIcon } from '@/components/common/ChannelIcon'
|
||||||
import { CountryFlag } from '@/components/common/CountryFlag'
|
import { CountryFlag } from '@/components/common/CountryFlag'
|
||||||
@@ -92,7 +94,7 @@ function updateRealtimeConversationList(
|
|||||||
conversationId: string,
|
conversationId: string,
|
||||||
patch: Partial<ConversationListItem>,
|
patch: Partial<ConversationListItem>,
|
||||||
) {
|
) {
|
||||||
if (!current) return current
|
if (!current || !Array.isArray(current.list)) return current
|
||||||
let matched = false
|
let matched = false
|
||||||
const list = current.list.map((item) => {
|
const list = current.list.map((item) => {
|
||||||
if (item.id !== conversationId) return item
|
if (item.id !== conversationId) return item
|
||||||
@@ -127,6 +129,7 @@ export function InboxPage() {
|
|||||||
const [mobilePanel, setMobilePanel] = useState<'list' | 'chat' | 'insight'>('list')
|
const [mobilePanel, setMobilePanel] = useState<'list' | 'chat' | 'insight'>('list')
|
||||||
const [showInsight, setShowInsight] = useState(false) // desktop right panel toggle
|
const [showInsight, setShowInsight] = useState(false) // desktop right panel toggle
|
||||||
const [showAiReplies, setShowAiReplies] = useState(false)
|
const [showAiReplies, setShowAiReplies] = useState(false)
|
||||||
|
const [intentSaving, setIntentSaving] = useState(false)
|
||||||
const [chatWallpaper, setChatWallpaper] = useState<ChatWallpaper>(() => {
|
const [chatWallpaper, setChatWallpaper] = useState<ChatWallpaper>(() => {
|
||||||
const saved = window.localStorage.getItem('lingxi-chat-wallpaper')
|
const saved = window.localStorage.getItem('lingxi-chat-wallpaper')
|
||||||
return CHAT_WALLPAPERS.some((item) => item.value === saved) ? saved as ChatWallpaper : 'ocean'
|
return CHAT_WALLPAPERS.some((item) => item.value === saved) ? saved as ChatWallpaper : 'ocean'
|
||||||
@@ -252,10 +255,14 @@ export function InboxPage() {
|
|||||||
queryClient.setQueryData(['conversation', convId], (current: any) => current
|
queryClient.setQueryData(['conversation', convId], (current: any) => current
|
||||||
? { ...current, conversation: { ...current.conversation, ...conversation } }
|
? { ...current, conversation: { ...current.conversation, ...conversation } }
|
||||||
: current)
|
: current)
|
||||||
queryClient.setQueriesData<any>({ queryKey: ['conversations'] }, (current: any) => current ? {
|
queryClient.setQueriesData<any>({ queryKey: ['conversations'] }, (current: any) => (
|
||||||
|
current && Array.isArray(current.list)
|
||||||
|
? {
|
||||||
...current,
|
...current,
|
||||||
list: current.list.map((item: any) => item.id === convId ? { ...item, unreadCount: 0 } : item),
|
list: current.list.map((item: any) => item.id === convId ? { ...item, unreadCount: 0 } : item),
|
||||||
} : current)
|
}
|
||||||
|
: current
|
||||||
|
))
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}, [convId, lastMessageId, queryClient])
|
}, [convId, lastMessageId, queryClient])
|
||||||
@@ -294,14 +301,37 @@ export function InboxPage() {
|
|||||||
message?: Message
|
message?: Message
|
||||||
customer?: Customer
|
customer?: Customer
|
||||||
conversation?: Conversation
|
conversation?: Conversation
|
||||||
|
backfill?: import('@/types').AiBackfillStatus
|
||||||
}
|
}
|
||||||
try { event = JSON.parse(String(raw.data)) } catch { return }
|
try { event = JSON.parse(String(raw.data)) } catch { return }
|
||||||
|
if (event.type === 'conversation.deleted' && event.conversationId) {
|
||||||
|
queryClient.removeQueries({ queryKey: ['conversation', event.conversationId] })
|
||||||
|
queryClient.removeQueries({ queryKey: ['messages', event.conversationId] })
|
||||||
|
queryClient.setQueriesData<{ list: ConversationListItem[]; total: number }>(
|
||||||
|
{ queryKey: ['conversations'] },
|
||||||
|
(current) => current && Array.isArray(current.list)
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
list: current.list.filter((item) => item.id !== event.conversationId),
|
||||||
|
total: Math.max(0, current.total - (current.list.some((item) => item.id === event.conversationId) ? 1 : 0)),
|
||||||
|
}
|
||||||
|
: current,
|
||||||
|
)
|
||||||
|
if (convId === event.conversationId) {
|
||||||
|
setSearchParams((prev) => {
|
||||||
|
prev.delete('c')
|
||||||
|
return prev
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
const activeConversationCache = queryClient.getQueryData<{ list: ConversationListItem[] }>(
|
const activeConversationCache = queryClient.getQueryData<{ list: ConversationListItem[] }>(
|
||||||
activeConversationQueryKeyRef.current,
|
activeConversationQueryKeyRef.current,
|
||||||
)
|
)
|
||||||
const conversationWasVisible = Boolean(
|
const conversationWasVisible = Boolean(
|
||||||
event.conversationId
|
event.conversationId
|
||||||
&& activeConversationCache?.list.some((item) => item.id === event.conversationId),
|
&& Array.isArray(activeConversationCache?.list)
|
||||||
|
&& activeConversationCache.list.some((item) => item.id === event.conversationId),
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
['message.created', 'message.received'].includes(event.type || '') &&
|
['message.created', 'message.received'].includes(event.type || '') &&
|
||||||
@@ -331,7 +361,9 @@ export function InboxPage() {
|
|||||||
{ queryKey: ['conversations'] },
|
{ queryKey: ['conversations'] },
|
||||||
(current) => {
|
(current) => {
|
||||||
const typed = current as { list: ConversationListItem[]; total: number } | undefined
|
const typed = current as { list: ConversationListItem[]; total: number } | undefined
|
||||||
const item = typed?.list.find((entry) => entry.id === event.conversationId)
|
const item = Array.isArray(typed?.list)
|
||||||
|
? typed.list.find((entry) => entry.id === event.conversationId)
|
||||||
|
: undefined
|
||||||
const isNewMessage = ['message.created', 'message.received'].includes(event.type || '')
|
const isNewMessage = ['message.created', 'message.received'].includes(event.type || '')
|
||||||
const isCurrentLastMessage =
|
const isCurrentLastMessage =
|
||||||
String(item?.lastMessage?.id || '') === String(event.message?.id || '')
|
String(item?.lastMessage?.id || '') === String(event.message?.id || '')
|
||||||
@@ -342,6 +374,15 @@ export function InboxPage() {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
event.type === 'conversation.ai-backfill.updated'
|
||||||
|
&& event.conversationId
|
||||||
|
&& event.backfill
|
||||||
|
) {
|
||||||
|
queryClient.setQueryData<any>(['conversation', event.conversationId], (current: any) => current
|
||||||
|
? { ...current, aiBackfill: event.backfill }
|
||||||
|
: current)
|
||||||
|
}
|
||||||
if (event.conversationId && event.conversation) {
|
if (event.conversationId && event.conversation) {
|
||||||
queryClient.setQueryData<any>(['conversation', event.conversationId], (current: any) => {
|
queryClient.setQueryData<any>(['conversation', event.conversationId], (current: any) => {
|
||||||
const customer = event.customer || current?.customer
|
const customer = event.customer || current?.customer
|
||||||
@@ -350,6 +391,7 @@ export function InboxPage() {
|
|||||||
// insight components require both the conversation and its customer.
|
// insight components require both the conversation and its customer.
|
||||||
if (!customer) return current
|
if (!customer) return current
|
||||||
return {
|
return {
|
||||||
|
...current,
|
||||||
conversation: event.conversation,
|
conversation: event.conversation,
|
||||||
customer,
|
customer,
|
||||||
}
|
}
|
||||||
@@ -382,7 +424,7 @@ export function InboxPage() {
|
|||||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||||
socket?.close()
|
socket?.close()
|
||||||
}
|
}
|
||||||
}, [token, queryClient])
|
}, [token, queryClient, convId, setSearchParams])
|
||||||
|
|
||||||
const setConv = useCallback(
|
const setConv = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
@@ -424,16 +466,19 @@ export function InboxPage() {
|
|||||||
text,
|
text,
|
||||||
convId: id,
|
convId: id,
|
||||||
autoTranslate,
|
autoTranslate,
|
||||||
|
targetLang,
|
||||||
translationCn,
|
translationCn,
|
||||||
}: {
|
}: {
|
||||||
text: string
|
text: string
|
||||||
convId: string
|
convId: string
|
||||||
autoTranslate?: boolean
|
autoTranslate?: boolean
|
||||||
|
targetLang?: string
|
||||||
translationCn?: string
|
translationCn?: string
|
||||||
}) => {
|
}) => {
|
||||||
return sendMessage(id, {
|
return sendMessage(id, {
|
||||||
text,
|
text,
|
||||||
autoTranslate,
|
autoTranslate,
|
||||||
|
targetLang,
|
||||||
translationCn,
|
translationCn,
|
||||||
replyToId: replyingTo?.id,
|
replyToId: replyingTo?.id,
|
||||||
replyPreview: replyingTo?.text,
|
replyPreview: replyingTo?.text,
|
||||||
@@ -447,12 +492,13 @@ export function InboxPage() {
|
|||||||
onError: (e) => toast.error(errorMessage(e)),
|
onError: (e) => toast.error(errorMessage(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSend = async (text: string, autoTranslate = true) => {
|
const handleSend = async (text: string, autoTranslate = true, targetLang = 'auto') => {
|
||||||
if (!convId) return
|
if (!convId) return
|
||||||
await sendMsg.mutateAsync({
|
await sendMsg.mutateAsync({
|
||||||
text,
|
text,
|
||||||
convId,
|
convId,
|
||||||
autoTranslate,
|
autoTranslate,
|
||||||
|
targetLang,
|
||||||
translationCn: draftTranslationCn || undefined,
|
translationCn: draftTranslationCn || undefined,
|
||||||
})
|
})
|
||||||
if (convDetail?.conversation.channel === 'whatsapp') {
|
if (convDetail?.conversation.channel === 'whatsapp') {
|
||||||
@@ -461,6 +507,22 @@ export function InboxPage() {
|
|||||||
setDraftTranslationCn('')
|
setDraftTranslationCn('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleTargetLanguageChange = async (targetLang: string) => {
|
||||||
|
if (!convId) return
|
||||||
|
queryClient.setQueryData(['conversation', convId], (current: any) => current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
conversation: { ...current.conversation, translationTargetLang: targetLang },
|
||||||
|
}
|
||||||
|
: current)
|
||||||
|
try {
|
||||||
|
await updateConversation(convId, { translationTargetLang: targetLang })
|
||||||
|
} catch (error) {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['conversation', convId] })
|
||||||
|
toast.error(errorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleDraftChange = useCallback((text: string) => {
|
const handleDraftChange = useCallback((text: string) => {
|
||||||
setDraftText(text)
|
setDraftText(text)
|
||||||
if (!convId || convDetail?.conversation.channel !== 'whatsapp') return
|
if (!convId || convDetail?.conversation.channel !== 'whatsapp') return
|
||||||
@@ -899,12 +961,12 @@ export function InboxPage() {
|
|||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleAdopt = (content: string, translationCn: string) => {
|
const handleAdopt = (content: string, translationCn: string, suggestionId: string) => {
|
||||||
setDraftText(content)
|
setDraftText(content)
|
||||||
setDraftTranslationCn(translationCn)
|
setDraftTranslationCn(translationCn)
|
||||||
setShowAiReplies(false)
|
setShowAiReplies(false)
|
||||||
if (convId) {
|
if (convId) {
|
||||||
void import('@/services/api/ai').then(({ recordAiAdoption }) => recordAiAdoption(convId, content))
|
void import('@/services/api/ai').then(({ recordAiAdoption }) => recordAiAdoption(convId, content, suggestionId))
|
||||||
}
|
}
|
||||||
toast.success('回复已采用,请在输入区编辑后发送')
|
toast.success('回复已采用,请在输入区编辑后发送')
|
||||||
}
|
}
|
||||||
@@ -921,6 +983,25 @@ export function InboxPage() {
|
|||||||
window.localStorage.setItem('lingxi-message-view-mode', nextMode)
|
window.localStorage.setItem('lingxi-message-view-mode', nextMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleIntentToggle = async (enabled: boolean) => {
|
||||||
|
if (!convId || intentSaving) return
|
||||||
|
setIntentSaving(true)
|
||||||
|
try {
|
||||||
|
const conversation = await updateConversation(convId, { messageIntentEnabled: enabled })
|
||||||
|
queryClient.setQueryData(['conversation', convId], (current: any) => current
|
||||||
|
? { ...current, conversation: { ...current.conversation, ...conversation } }
|
||||||
|
: current)
|
||||||
|
if (enabled) {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['messages', convId] })
|
||||||
|
}
|
||||||
|
toast.success(enabled ? '已开启逐句意图识别' : '已关闭逐句意图识别')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(errorMessage(error))
|
||||||
|
} finally {
|
||||||
|
setIntentSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const listItems = convData?.list ?? []
|
const listItems = convData?.list ?? []
|
||||||
const renderMessageTimeline = () => {
|
const renderMessageTimeline = () => {
|
||||||
let previousDay = ''
|
let previousDay = ''
|
||||||
@@ -958,6 +1039,7 @@ export function InboxPage() {
|
|||||||
customerName={convDetail?.customer?.name}
|
customerName={convDetail?.customer?.name}
|
||||||
customerAvatar={convDetail?.customer?.avatarUrl}
|
customerAvatar={convDetail?.customer?.avatarUrl}
|
||||||
channel={convDetail?.conversation.channel}
|
channel={convDetail?.conversation.channel}
|
||||||
|
showIntent={convDetail?.conversation.messageIntentEnabled !== false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -1006,6 +1088,9 @@ export function InboxPage() {
|
|||||||
loading={convDetailLoading}
|
loading={convDetailLoading}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={handleViewModeChange}
|
onViewModeChange={handleViewModeChange}
|
||||||
|
intentEnabled={convDetail?.conversation.messageIntentEnabled !== false}
|
||||||
|
intentSaving={intentSaving}
|
||||||
|
onIntentToggle={handleIntentToggle}
|
||||||
wallpaper={chatWallpaper}
|
wallpaper={chatWallpaper}
|
||||||
onWallpaperChange={setChatWallpaper}
|
onWallpaperChange={setChatWallpaper}
|
||||||
onTelegramAction={(action) => {
|
onTelegramAction={(action) => {
|
||||||
@@ -1027,6 +1112,7 @@ export function InboxPage() {
|
|||||||
customer={convDetail.customer}
|
customer={convDetail.customer}
|
||||||
conversation={convDetail.conversation}
|
conversation={convDetail.conversation}
|
||||||
onOpenProfile={() => setMobilePanel('insight')}
|
onOpenProfile={() => setMobilePanel('insight')}
|
||||||
|
aiBackfill={convDetail.aiBackfill}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div ref={messageListRef} data-testid="message-list" className={`chat-wallpaper chat-wallpaper-${chatWallpaper} min-h-0 flex-1 overflow-y-auto overscroll-contain scrollbar-thin`}>
|
<div ref={messageListRef} data-testid="message-list" className={`chat-wallpaper chat-wallpaper-${chatWallpaper} min-h-0 flex-1 overflow-y-auto overscroll-contain scrollbar-thin`}>
|
||||||
@@ -1044,6 +1130,7 @@ export function InboxPage() {
|
|||||||
{showAiReplies && convId ? (
|
{showAiReplies && convId ? (
|
||||||
<AiReplies
|
<AiReplies
|
||||||
conversationId={convId}
|
conversationId={convId}
|
||||||
|
targetLanguage={convDetail?.conversation.translationTargetLang || 'auto'}
|
||||||
onAdopt={handleAdopt}
|
onAdopt={handleAdopt}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onClose={() => setShowAiReplies(false)}
|
onClose={() => setShowAiReplies(false)}
|
||||||
@@ -1067,6 +1154,9 @@ export function InboxPage() {
|
|||||||
replyingTo={replyingTo}
|
replyingTo={replyingTo}
|
||||||
onCancelReply={() => setReplyingTo(null)}
|
onCancelReply={() => setReplyingTo(null)}
|
||||||
channel={convDetail?.conversation.channel}
|
channel={convDetail?.conversation.channel}
|
||||||
|
targetLanguage={convDetail?.conversation.translationTargetLang || 'auto'}
|
||||||
|
detectedLanguage={convDetail?.customer.language}
|
||||||
|
onTargetLanguageChange={handleTargetLanguageChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1087,7 +1177,7 @@ export function InboxPage() {
|
|||||||
|
|
||||||
// 桌面端三栏布局
|
// 桌面端三栏布局
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-64px)] overflow-hidden bg-white">
|
<div className="flex h-[calc(100vh-64px)] overflow-hidden bg-slate-100/70">
|
||||||
{noteDialog}
|
{noteDialog}
|
||||||
{whatsappGroupDialog}
|
{whatsappGroupDialog}
|
||||||
{telegramDialogs}
|
{telegramDialogs}
|
||||||
@@ -1104,7 +1194,7 @@ export function InboxPage() {
|
|||||||
{/* 左栏:会话列表 360px */}
|
{/* 左栏:会话列表 360px */}
|
||||||
<div
|
<div
|
||||||
data-testid="conversation-list-panel"
|
data-testid="conversation-list-panel"
|
||||||
className="relative z-20 w-80 shrink-0 border-e border-slate-300/90 bg-white lg:w-[360px]"
|
className="relative z-20 w-80 shrink-0 border-e border-slate-300/90 bg-white shadow-[8px_0_24px_-24px_rgba(15,23,42,.75)] lg:w-[360px]"
|
||||||
>
|
>
|
||||||
<ConversationList
|
<ConversationList
|
||||||
items={listItems}
|
items={listItems}
|
||||||
@@ -1126,7 +1216,7 @@ export function InboxPage() {
|
|||||||
{/* 中栏:聊天区 flex-1 */}
|
{/* 中栏:聊天区 flex-1 */}
|
||||||
<div
|
<div
|
||||||
data-testid="chat-panel"
|
data-testid="chat-panel"
|
||||||
className={`relative z-10 flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-[#eaf2f9] ${showInsight ? '' : 'border-e-0'}`}
|
className={`relative z-10 flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-[#eaf2f9] shadow-[inset_1px_0_0_rgba(255,255,255,.75)] ${showInsight ? '' : 'border-e-0'}`}
|
||||||
>
|
>
|
||||||
{convId ? (
|
{convId ? (
|
||||||
<>
|
<>
|
||||||
@@ -1136,6 +1226,9 @@ export function InboxPage() {
|
|||||||
loading={convDetailLoading}
|
loading={convDetailLoading}
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={handleViewModeChange}
|
onViewModeChange={handleViewModeChange}
|
||||||
|
intentEnabled={convDetail?.conversation.messageIntentEnabled !== false}
|
||||||
|
intentSaving={intentSaving}
|
||||||
|
onIntentToggle={handleIntentToggle}
|
||||||
wallpaper={chatWallpaper}
|
wallpaper={chatWallpaper}
|
||||||
onWallpaperChange={setChatWallpaper}
|
onWallpaperChange={setChatWallpaper}
|
||||||
onTelegramAction={(action) => {
|
onTelegramAction={(action) => {
|
||||||
@@ -1156,6 +1249,7 @@ export function InboxPage() {
|
|||||||
customer={convDetail.customer}
|
customer={convDetail.customer}
|
||||||
conversation={convDetail.conversation}
|
conversation={convDetail.conversation}
|
||||||
onOpenProfile={() => setShowInsight(true)}
|
onOpenProfile={() => setShowInsight(true)}
|
||||||
|
aiBackfill={convDetail.aiBackfill}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div ref={messageListRef} data-testid="message-list" className={`chat-wallpaper chat-wallpaper-${chatWallpaper} min-h-0 flex-1 overflow-y-auto overscroll-contain scrollbar-thin`}>
|
<div ref={messageListRef} data-testid="message-list" className={`chat-wallpaper chat-wallpaper-${chatWallpaper} min-h-0 flex-1 overflow-y-auto overscroll-contain scrollbar-thin`}>
|
||||||
@@ -1177,6 +1271,7 @@ export function InboxPage() {
|
|||||||
{showAiReplies ? (
|
{showAiReplies ? (
|
||||||
<AiReplies
|
<AiReplies
|
||||||
conversationId={convId}
|
conversationId={convId}
|
||||||
|
targetLanguage={convDetail?.conversation.translationTargetLang || 'auto'}
|
||||||
onAdopt={handleAdopt}
|
onAdopt={handleAdopt}
|
||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onClose={() => setShowAiReplies(false)}
|
onClose={() => setShowAiReplies(false)}
|
||||||
@@ -1200,6 +1295,9 @@ export function InboxPage() {
|
|||||||
replyingTo={replyingTo}
|
replyingTo={replyingTo}
|
||||||
onCancelReply={() => setReplyingTo(null)}
|
onCancelReply={() => setReplyingTo(null)}
|
||||||
channel={convDetail?.conversation.channel}
|
channel={convDetail?.conversation.channel}
|
||||||
|
targetLanguage={convDetail?.conversation.translationTargetLang || 'auto'}
|
||||||
|
detectedLanguage={convDetail?.customer.language}
|
||||||
|
onTargetLanguageChange={handleTargetLanguageChange}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -1395,6 +1493,9 @@ function ChatHeader({
|
|||||||
loading,
|
loading,
|
||||||
viewMode,
|
viewMode,
|
||||||
onViewModeChange,
|
onViewModeChange,
|
||||||
|
intentEnabled,
|
||||||
|
intentSaving,
|
||||||
|
onIntentToggle,
|
||||||
wallpaper,
|
wallpaper,
|
||||||
onWallpaperChange,
|
onWallpaperChange,
|
||||||
onTelegramAction,
|
onTelegramAction,
|
||||||
@@ -1408,6 +1509,9 @@ function ChatHeader({
|
|||||||
loading: boolean
|
loading: boolean
|
||||||
viewMode: ViewMode
|
viewMode: ViewMode
|
||||||
onViewModeChange: (v: ViewMode) => void
|
onViewModeChange: (v: ViewMode) => void
|
||||||
|
intentEnabled: boolean
|
||||||
|
intentSaving: boolean
|
||||||
|
onIntentToggle: (enabled: boolean) => void
|
||||||
wallpaper: ChatWallpaper
|
wallpaper: ChatWallpaper
|
||||||
onWallpaperChange: (wallpaper: ChatWallpaper) => void
|
onWallpaperChange: (wallpaper: ChatWallpaper) => void
|
||||||
onTelegramAction: (action: ConversationAction) => void
|
onTelegramAction: (action: ConversationAction) => void
|
||||||
@@ -1494,6 +1598,26 @@ function ChatHeader({
|
|||||||
]}
|
]}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
<div
|
||||||
|
data-testid="conversation-intent-toggle"
|
||||||
|
className={cn(
|
||||||
|
'flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-1 transition-colors',
|
||||||
|
intentEnabled
|
||||||
|
? 'border-violet-200 bg-violet-50 text-violet-700'
|
||||||
|
: 'border-slate-200 bg-slate-50 text-slate-500',
|
||||||
|
)}
|
||||||
|
title={intentEnabled ? '逐句意图识别已开启' : '逐句意图识别已关闭'}
|
||||||
|
>
|
||||||
|
<Brain className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden text-[11px] font-medium lg:inline">意图识别</span>
|
||||||
|
<Switch
|
||||||
|
ariaLabel="当前会话逐句意图识别"
|
||||||
|
checked={intentEnabled}
|
||||||
|
disabled={intentSaving}
|
||||||
|
onCheckedChange={onIntentToggle}
|
||||||
|
className="scale-90"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
trigger={(
|
trigger={(
|
||||||
<Button variant="ghost" size="icon-sm" title="切换聊天背景" aria-label="切换聊天背景">
|
<Button variant="ghost" size="icon-sm" title="切换聊天背景" aria-label="切换聊天背景">
|
||||||
|
|||||||
@@ -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} 条知识 · 用于回复建议与翻译参考`}
|
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 })} />
|
||||||
@@ -148,8 +174,9 @@ export function KnowledgePage() {
|
|||||||
<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,18 +202,19 @@ 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 () => {
|
||||||
|
setUploadLoading(true)
|
||||||
|
try {
|
||||||
const payload = await Promise.all(uploadFiles.map(async (file) => ({
|
const payload = await Promise.all(uploadFiles.map(async (file) => ({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
type: file.type,
|
type: file.type,
|
||||||
@@ -197,12 +225,18 @@ export function KnowledgePage() {
|
|||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file)
|
||||||
}),
|
}),
|
||||||
})))
|
})))
|
||||||
await uploadKnowledgeFiles(payload)
|
const result = await uploadKnowledgeFiles(payload)
|
||||||
toast.success(`${payload.length} 个知识文件解析完成`)
|
toast.success(`${result.items.length} 个文件解析并建立检索索引`)
|
||||||
queryClient.invalidateQueries({ queryKey: ['knowledge'] })
|
queryClient.invalidateQueries({ queryKey: ['knowledge'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['knowledge-stats'] })
|
||||||
setUploadFiles([])
|
setUploadFiles([])
|
||||||
setUploadDialog(false)
|
setUploadDialog(false)
|
||||||
}}>上传并解析</Button>
|
} catch (error) {
|
||||||
|
toast.error(errorMessage(error))
|
||||||
|
} finally {
|
||||||
|
setUploadLoading(false)
|
||||||
|
}
|
||||||
|
}}>上传、解析并索引</Button>
|
||||||
}>
|
}>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="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>
|
||||||
|
|||||||
@@ -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" />修改密码后,其他已登录设备需要重新登录。</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,8 +165,8 @@ 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">密钥只在创建时完整显示一次,请立即复制并妥善保存。</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="密钥名称" />
|
||||||
@@ -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>
|
||||||
@@ -196,5 +215,6 @@ export function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ export function analyzeConversation(conversationId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 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 */
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|
||||||
@@ -26,7 +26,20 @@ 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 */
|
||||||
@@ -80,6 +93,7 @@ export function sendMessage(
|
|||||||
payload: SendMessagePayload & {
|
payload: SendMessagePayload & {
|
||||||
translatedText?: string
|
translatedText?: string
|
||||||
autoTranslate?: boolean
|
autoTranslate?: boolean
|
||||||
|
targetLang?: string
|
||||||
idempotencyKey?: string
|
idempotencyKey?: string
|
||||||
clientMessageId?: string
|
clientMessageId?: string
|
||||||
},
|
},
|
||||||
@@ -136,7 +150,16 @@ export function runWhatsAppMessageAction(id: string, payload: WhatsAppMessageAct
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateConversation(id: string, payload: { assigneeId?: string | null; markRead?: boolean; status?: string }) {
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+72
-5
@@ -42,8 +42,8 @@
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
letter-spacing: -0.006em;
|
letter-spacing: -0.006em;
|
||||||
background-image:
|
background-image:
|
||||||
radial-gradient(circle at 75% -10%, rgba(37, 99, 235, 0.05), transparent 26rem),
|
radial-gradient(circle at 76% -12%, rgba(37, 99, 235, 0.055), transparent 28rem),
|
||||||
linear-gradient(180deg, #ffffff 0, hsl(var(--background)) 22rem);
|
linear-gradient(180deg, #ffffff 0, #f7f9fc 23rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
@@ -70,10 +70,34 @@
|
|||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
.surface-panel {
|
.surface-panel {
|
||||||
@apply rounded-2xl border border-slate-200/80 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.03),0_10px_30px_rgba(15,23,42,0.025)];
|
@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 {
|
.eyebrow {
|
||||||
@apply text-[11px] font-semibold uppercase tracking-[0.14em] text-primary-700;
|
@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)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,11 +255,54 @@
|
|||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.conversation-selected,
|
.conversation-selected,
|
||||||
.conversation-selected::after {
|
.conversation-selected::after,
|
||||||
|
.auth-message,
|
||||||
|
.auth-translation-dot,
|
||||||
|
.auth-floating-chip {
|
||||||
animation: none;
|
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);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface AiReplySuggestion {
|
|||||||
targetLang: string
|
targetLang: string
|
||||||
reason: string
|
reason: string
|
||||||
knowledgeRefs: string[]
|
knowledgeRefs: string[]
|
||||||
|
knowledgeRefIds?: string[]
|
||||||
expectedEffect: string
|
expectedEffect: string
|
||||||
cultureTip?: string
|
cultureTip?: string
|
||||||
}
|
}
|
||||||
@@ -42,6 +43,13 @@ export interface TranslateResult {
|
|||||||
detectedLang: string
|
detectedLang: string
|
||||||
terms: { source: string; target: string }[]
|
terms: { source: string; target: string }[]
|
||||||
historyItem?: TranslationHistoryItem
|
historyItem?: TranslationHistoryItem
|
||||||
|
meta?: {
|
||||||
|
method: 'ai-contextual' | 'ai-direct'
|
||||||
|
contextApplied: boolean
|
||||||
|
contextMessageCount: number
|
||||||
|
conversationSummaryApplied?: boolean
|
||||||
|
model: string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TranslationHistoryItem {
|
export interface TranslationHistoryItem {
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ export type CustomerStage =
|
|||||||
|
|
||||||
/** AI 自动提取的客户画像 */
|
/** AI 自动提取的客户画像 */
|
||||||
export interface AiCustomerProfile {
|
export interface AiCustomerProfile {
|
||||||
|
relationshipType?: 'buyer' | 'supplier' | 'partner' | 'distributor' | 'procurement_agent' | 'logistics' | 'service_provider' | 'internal' | 'other' | 'unknown'
|
||||||
|
relationshipLabel?: string
|
||||||
|
relationshipConfidence?: number
|
||||||
|
relationshipEvidence?: string[]
|
||||||
inquiryQuantity?: string
|
inquiryQuantity?: string
|
||||||
targetPrice?: string
|
targetPrice?: string
|
||||||
moqRequirement?: string
|
moqRequirement?: string
|
||||||
@@ -46,6 +50,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 +70,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
|
||||||
@@ -73,6 +92,7 @@ export interface Customer {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
isKey: boolean
|
isKey: boolean
|
||||||
aiProfile: AiCustomerProfile
|
aiProfile: AiCustomerProfile
|
||||||
|
relationshipType?: AiCustomerProfile['relationshipType']
|
||||||
telegramUsername?: string
|
telegramUsername?: string
|
||||||
telegramUserId?: string
|
telegramUserId?: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -32,7 +32,24 @@ export interface Message {
|
|||||||
translationCn?: 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
|
||||||
@@ -52,6 +69,9 @@ export interface Message {
|
|||||||
}>
|
}>
|
||||||
telegramFileId?: number
|
telegramFileId?: number
|
||||||
whatsappMessageId?: string
|
whatsappMessageId?: string
|
||||||
|
gatewayEventAt?: string
|
||||||
|
ingestedAt?: string
|
||||||
|
realtimeLatencyMs?: number
|
||||||
whatsappContentType?: string
|
whatsappContentType?: string
|
||||||
attachmentMimeType?: string
|
attachmentMimeType?: string
|
||||||
attachmentSize?: number
|
attachmentSize?: number
|
||||||
@@ -96,6 +116,37 @@ export interface Conversation {
|
|||||||
presence?: 'available' | 'unavailable' | 'composing' | 'recording' | 'paused'
|
presence?: 'available' | 'unavailable' | 'composing' | 'recording' | 'paused'
|
||||||
presenceUpdatedAt?: string
|
presenceUpdatedAt?: string
|
||||||
lastSeenAt?: 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'
|
||||||
@@ -142,6 +193,8 @@ export interface SendMessagePayload {
|
|||||||
text: string
|
text: string
|
||||||
/** 已确认的中文参考译文,仅在内部消息记录中保存 */
|
/** 已确认的中文参考译文,仅在内部消息记录中保存 */
|
||||||
translationCn?: string
|
translationCn?: string
|
||||||
|
/** auto 表示根据客户最近消息或客户画像自动选择目标语言 */
|
||||||
|
targetLang?: string
|
||||||
replyToId?: string
|
replyToId?: string
|
||||||
replyPreview?: string
|
replyPreview?: string
|
||||||
type?: MessageType
|
type?: MessageType
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ export interface AiSettings {
|
|||||||
autoGenerate: boolean
|
autoGenerate: boolean
|
||||||
requireConfirm: boolean
|
requireConfirm: boolean
|
||||||
sentimentAnalysis: boolean
|
sentimentAnalysis: boolean
|
||||||
|
messageIntentAnalysis: boolean
|
||||||
|
contextualTranslation: boolean
|
||||||
riskAlert: boolean
|
riskAlert: boolean
|
||||||
cultureCheck: boolean
|
cultureCheck: boolean
|
||||||
allowHistoryRef: boolean
|
allowHistoryRef: boolean
|
||||||
|
|||||||
@@ -71,3 +71,15 @@ export function localTimeByOffset(offsetHours: number): string {
|
|||||||
const target = new Date(utc + offsetHours * 3600000)
|
const target = new Date(utc + offsetHours * 3600000)
|
||||||
return `${pad(target.getHours())}:${pad(target.getMinutes())}`
|
return `${pad(target.getHours())}:${pad(target.getMinutes())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatProductName(value: unknown): string {
|
||||||
|
if (typeof value === 'string' || typeof value === 'number') return String(value).trim()
|
||||||
|
if (!value || typeof value !== 'object') return ''
|
||||||
|
const product = value as Record<string, unknown>
|
||||||
|
for (const key of ['name', 'product', 'category']) {
|
||||||
|
if (typeof product[key] === 'string' || typeof product[key] === 'number') {
|
||||||
|
return String(product[key]).trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
tdlib_data
|
||||||
|
uploads
|
||||||
|
data
|
||||||
|
test
|
||||||
|
*.log
|
||||||
|
.git
|
||||||
|
.claude
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Application
|
||||||
|
NODE_ENV=development
|
||||||
|
# Optional image build mirrors for networks where deb.debian.org is unavailable.
|
||||||
|
DEBIAN_MIRROR=https://deb.debian.org/debian
|
||||||
|
DEBIAN_SECURITY_MIRROR=https://deb.debian.org/debian-security
|
||||||
|
PORT=3000
|
||||||
|
APP_NAME=TelegramUserGateway
|
||||||
|
CORS_ORIGINS=
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
|
DB_HOST=localhost
|
||||||
|
DB_PORT=5432
|
||||||
|
DB_USERNAME=postgres
|
||||||
|
DB_PASSWORD=postgres
|
||||||
|
DB_DATABASE=telegram_gateway
|
||||||
|
DB_SYNCHRONIZE=false
|
||||||
|
DB_MIGRATIONS_RUN=true
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
REDIS_DB=0
|
||||||
|
REDIS_ENABLED=true
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_SECRET=change-me-to-a-secure-random-string
|
||||||
|
JWT_EXPIRATION=24h
|
||||||
|
|
||||||
|
# TDLib
|
||||||
|
TDLIB_PATH=/usr/local/lib/libtdjson.so
|
||||||
|
TG_API_ID=0
|
||||||
|
TG_API_HASH=your_api_hash_here
|
||||||
|
|
||||||
|
# 可选默认代理。账号未绑定专属代理时使用;只影响 Telegram TDLib 出站。
|
||||||
|
TG_DEFAULT_PROXY_TYPE=
|
||||||
|
TG_DEFAULT_PROXY_HOST=
|
||||||
|
TG_DEFAULT_PROXY_PORT=
|
||||||
|
TG_DEFAULT_PROXY_USERNAME=
|
||||||
|
TG_DEFAULT_PROXY_PASSWORD=
|
||||||
|
TG_DEFAULT_PROXY_SECRET=
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_DEFAULT_TTL=60000
|
||||||
|
RATE_LIMIT_DEFAULT_LIMIT=30
|
||||||
|
|
||||||
|
# Webhook
|
||||||
|
WEBHOOK_MAX_RETRIES=8
|
||||||
|
WEBHOOK_RETRY_BACKOFF_BASE=5000
|
||||||
|
WEBHOOK_TIMEOUT=10000
|
||||||
|
|
||||||
|
# File Storage
|
||||||
|
FILE_STORAGE_PATH=./uploads
|
||||||
|
FILE_MAX_SIZE=209715200
|
||||||
|
|
||||||
|
# Admin
|
||||||
|
ADMIN_API_KEY=change-me-admin-key
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.local
|
||||||
|
|
||||||
|
tdlib_data/
|
||||||
|
uploads/
|
||||||
|
data/
|
||||||
|
backups/
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
*.rdb
|
||||||
|
*.binlog
|
||||||
|
*.dump
|
||||||
|
*.tar.gz
|
||||||
|
*.log
|
||||||
|
|
||||||
|
.claude/
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
test-report.html
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Production deployment
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker Desktop with the WSL 2 engine
|
||||||
|
- Telegram `TG_API_ID` and `TG_API_HASH`
|
||||||
|
- Strong, unique `DB_PASSWORD` and `JWT_SECRET`
|
||||||
|
|
||||||
|
Never commit `.env` or `tdlib_data/`. Existing Telegram API credentials that
|
||||||
|
have previously appeared in scripts should be rotated in Telegram's developer
|
||||||
|
portal before exposing this service publicly.
|
||||||
|
|
||||||
|
## Windows setup
|
||||||
|
|
||||||
|
Run the following once in an **Administrator PowerShell**, then restart Windows:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
wsl --install --no-distribution
|
||||||
|
```
|
||||||
|
|
||||||
|
After restart, open Docker Desktop and wait until the Linux engine reports that
|
||||||
|
it is running.
|
||||||
|
|
||||||
|
## Configure
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and set at least:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
NODE_ENV=production
|
||||||
|
DB_USERNAME=postgres
|
||||||
|
DB_PASSWORD=<strong-random-password>
|
||||||
|
DB_DATABASE=telegram_gateway
|
||||||
|
JWT_SECRET=<at-least-32-random-bytes>
|
||||||
|
TG_API_ID=<telegram-api-id>
|
||||||
|
TG_API_HASH=<telegram-api-hash>
|
||||||
|
CORS_ORIGINS=https://your-control-plane.example
|
||||||
|
```
|
||||||
|
|
||||||
|
Production uses migrations and keeps TypeORM synchronization disabled.
|
||||||
|
|
||||||
|
If Docker build containers cannot reach the default Debian repositories, set
|
||||||
|
`DEBIAN_MIRROR` and `DEBIAN_SECURITY_MIRROR` to reachable mirrors. HTTP mirrors
|
||||||
|
can bootstrap older base images that do not yet contain CA certificates; keep
|
||||||
|
all application and credential traffic on HTTPS.
|
||||||
|
|
||||||
|
## Start
|
||||||
|
|
||||||
|
From the project root:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
docker compose --env-file .env -f docker/docker-compose.yml up -d --build
|
||||||
|
docker compose --env-file .env -f docker/docker-compose.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Readiness and API documentation:
|
||||||
|
|
||||||
|
- `GET http://127.0.0.1:3000/api/admin/ready`
|
||||||
|
- `GET http://127.0.0.1:3000/api/admin/health`
|
||||||
|
- `http://127.0.0.1:3000/api/docs`
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run build
|
||||||
|
npm test -- --runInBand
|
||||||
|
npm run test:e2e -- --runInBand
|
||||||
|
node test/real-readonly-smoke.js
|
||||||
|
```
|
||||||
|
|
||||||
|
The real smoke test uses the session selected by `TG_SESSION_DIR` and performs
|
||||||
|
read-only calls only: authorization state, connection, current identity, public
|
||||||
|
chat lookup, and chat listing.
|
||||||
|
|
||||||
|
## Persistent data
|
||||||
|
|
||||||
|
Docker volumes preserve:
|
||||||
|
|
||||||
|
- PostgreSQL data
|
||||||
|
- Redis data
|
||||||
|
- uploaded files
|
||||||
|
- TDLib authorization/session databases
|
||||||
|
|
||||||
|
Back up PostgreSQL and the `tdlibdata` volume together. Losing `tdlibdata`
|
||||||
|
requires Telegram account authorization again.
|
||||||
|
|
||||||
|
## Operational behavior
|
||||||
|
|
||||||
|
- `/health` is a liveness check.
|
||||||
|
- `/ready` returns failure if PostgreSQL or Redis is unavailable.
|
||||||
|
- Telegram workers wait for native TDLib initialization before an account is
|
||||||
|
marked started.
|
||||||
|
- Active account sessions restart automatically after application restart.
|
||||||
|
- Incoming TDLib updates are stored, mapped to webhook events, and delivered
|
||||||
|
through the retry queue.
|
||||||
|
- Webhook, proxy, password, audit, message-job, and admin data are tenant scoped.
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Telegram User Account Gateway
|
||||||
|
|
||||||
|
A production-grade REST API + Webhook gateway for managing Telegram user accounts via TDLib (not Bot API). Supports multi-tenant, multi-account management with independent proxies, message queue, rate limiting, and audit logging.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Runtime**: Node.js 20+ with TypeScript
|
||||||
|
- **Framework**: NestJS 11.x
|
||||||
|
- **Database**: PostgreSQL 16 (TypeORM)
|
||||||
|
- **Cache/Queue**: Redis 7 + BullMQ
|
||||||
|
- **TDLib**: tdjson via worker_threads
|
||||||
|
- **Auth**: JWT (Bearer tokens)
|
||||||
|
- **API Docs**: Swagger at `/api/docs`
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Copy environment config
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# Start infrastructure
|
||||||
|
docker-compose -f docker/docker-compose.yml up -d postgres redis
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Run migrations
|
||||||
|
npm run migration:run
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
npm run start:dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Compose
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start all services
|
||||||
|
docker compose --env-file .env -f docker/docker-compose.yml up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
See [DEPLOYMENT.md](./DEPLOYMENT.md) for production setup, readiness checks,
|
||||||
|
persistent data, and Windows WSL requirements.
|
||||||
|
|
||||||
|
When used by 灵犀跨境, also read the repository-level
|
||||||
|
[integration guide](../docs/TELEGRAM-GATEWAY.md).
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Admin
|
||||||
|
- `GET /api/admin/health` - Health check (public)
|
||||||
|
- `GET /api/admin/metrics` - Application metrics
|
||||||
|
- `GET /api/admin/status` - TDLib client status
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
- `POST /api/auth/register` - Register user (public)
|
||||||
|
- `POST /api/auth/login` - Login (public)
|
||||||
|
|
||||||
|
### Tenants
|
||||||
|
- `POST /api/tenants` - Create tenant
|
||||||
|
- `GET /api/tenants` - List tenants
|
||||||
|
- `GET /api/tenants/:id` - Get tenant
|
||||||
|
- `PUT /api/tenants/:id` - Update tenant
|
||||||
|
- `DELETE /api/tenants/:id` - Delete tenant
|
||||||
|
|
||||||
|
### TG Accounts
|
||||||
|
- `POST /api/tg-accounts` - Create Telegram account
|
||||||
|
- `GET /api/tg-accounts` - List accounts
|
||||||
|
- `GET /api/tg-accounts/:id` - Get account
|
||||||
|
- `DELETE /api/tg-accounts/:id` - Remove account
|
||||||
|
|
||||||
|
### TG Account Authorization
|
||||||
|
- `POST /api/tg-accounts/:id/auth/phone` - Set phone number
|
||||||
|
- `POST /api/tg-accounts/:id/auth/code` - Submit verification code
|
||||||
|
- `POST /api/tg-accounts/:id/auth/password` - Submit 2FA password
|
||||||
|
- `POST /api/tg-accounts/:id/auth/logout` - Logout
|
||||||
|
|
||||||
|
### Chats & Messages
|
||||||
|
- `GET /api/tg-accounts/:id/chats` - List chats
|
||||||
|
- `GET /api/tg-accounts/:id/chats/:chatId/history` - Chat history
|
||||||
|
- `POST /api/tg-accounts/:id/messages/send` - Send message (queued)
|
||||||
|
- `POST /api/tg-accounts/:id/messages/send-direct` - Send message (direct)
|
||||||
|
- `POST /api/tg-accounts/:id/messages/pin` - Pin a message
|
||||||
|
- `POST /api/tg-accounts/:id/messages/unpin` - Unpin a message
|
||||||
|
- `POST /api/tg-accounts/:id/messages/action` - Send typing/chat action
|
||||||
|
- `GET /api/tg-accounts/:id/messages/:chatId/:messageId/link` - Get message link
|
||||||
|
|
||||||
|
### Contacts, Sessions & Groups
|
||||||
|
- `GET /api/tg-accounts/:id/contacts/search?q=...` - Search by phone, username, or general query
|
||||||
|
- `GET /api/tg-accounts/:id/dialogs/sessions` - List active Telegram sessions
|
||||||
|
- `POST /api/tg-accounts/:id/dialogs/sessions/:sessionId/terminate` - Terminate session
|
||||||
|
- `POST /api/tg-accounts/:id/dialogs/users/:userId/block` - Block user
|
||||||
|
- `POST /api/tg-accounts/:id/dialogs/users/:userId/unblock` - Unblock user
|
||||||
|
- `POST /api/tg-accounts/:id/groups/join-by-invite-link` - Join via invite link
|
||||||
|
- `PUT /api/tg-accounts/:id/groups/:chatId/title` - Change group title
|
||||||
|
- `PUT /api/tg-accounts/:id/groups/:chatId/photo` - Change/remove group photo
|
||||||
|
|
||||||
|
### Webhooks
|
||||||
|
- `POST /api/tg-accounts/:id/webhooks` - Create webhook
|
||||||
|
- `GET /api/tg-accounts/:id/webhooks` - List webhooks
|
||||||
|
|
||||||
|
### Operations
|
||||||
|
- `GET /api/admin/audit-logs` - Query tenant-scoped audit logs
|
||||||
|
- `GET /api/admin/audit-logs/accounts/:accountId` - Query account audit logs
|
||||||
|
- `GET /api/admin/rate-limits/:accountId` - Get account rate-limit statistics
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
See `.env.example` for all available configuration options.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
FROM node:20-bookworm-slim AS builder
|
||||||
|
|
||||||
|
ARG DEBIAN_MIRROR=https://deb.debian.org/debian
|
||||||
|
ARG DEBIAN_SECURITY_MIRROR=https://deb.debian.org/debian-security
|
||||||
|
RUN sed -i "s|http://deb.debian.org/debian-security|${DEBIAN_SECURITY_MIRROR}|g; s|http://deb.debian.org/debian|${DEBIAN_MIRROR}|g" /etc/apt/sources.list.d/debian.sources
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends g++ make python3 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
|
npm ci \
|
||||||
|
--prefer-offline \
|
||||||
|
--fetch-retries=8 \
|
||||||
|
--fetch-retry-mintimeout=2000 \
|
||||||
|
--fetch-retry-maxtimeout=30000 \
|
||||||
|
--fetch-timeout=600000
|
||||||
|
|
||||||
|
COPY tsconfig.json nest-cli.json ./
|
||||||
|
COPY src/ ./src/
|
||||||
|
RUN npm run build
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
|
FROM node:20-bookworm-slim
|
||||||
|
|
||||||
|
ARG DEBIAN_MIRROR=https://deb.debian.org/debian
|
||||||
|
ARG DEBIAN_SECURITY_MIRROR=https://deb.debian.org/debian-security
|
||||||
|
RUN sed -i "s|http://deb.debian.org/debian-security|${DEBIAN_SECURITY_MIRROR}|g; s|http://deb.debian.org/debian|${DEBIAN_MIRROR}|g" /etc/apt/sources.list.d/debian.sources
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends tini ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --chown=node:node --from=builder /app/dist ./dist
|
||||||
|
COPY --chown=node:node --from=builder /app/node_modules ./node_modules
|
||||||
|
COPY --chown=node:node package.json ./
|
||||||
|
|
||||||
|
RUN mkdir -p /app/uploads /app/tdlib_data \
|
||||||
|
&& chown node:node /app/uploads /app/tdlib_data
|
||||||
|
|
||||||
|
USER node
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||||
|
CMD ["node", "dist/main.js"]
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: tgw-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${DB_USERNAME:-postgres}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||||
|
POSTGRES_DB: ${DB_DATABASE:-telegram_gateway}
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
- ./scripts/init-db.sql:/docker-entrypoint-initdb.d/init.sql
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD-SHELL', 'pg_isready -U ${DB_USERNAME:-postgres} -d ${DB_DATABASE:-telegram_gateway}']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: tgw-redis
|
||||||
|
volumes:
|
||||||
|
- redisdata:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ['CMD', 'redis-cli', 'ping']
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: docker/Dockerfile
|
||||||
|
args:
|
||||||
|
DEBIAN_MIRROR: ${DEBIAN_MIRROR:-https://deb.debian.org/debian}
|
||||||
|
DEBIAN_SECURITY_MIRROR: ${DEBIAN_SECURITY_MIRROR:-https://deb.debian.org/debian-security}
|
||||||
|
container_name: tgw-app
|
||||||
|
ports:
|
||||||
|
- '127.0.0.1:3010:3000'
|
||||||
|
env_file:
|
||||||
|
- ../.env
|
||||||
|
environment:
|
||||||
|
NODE_ENV: production
|
||||||
|
DB_TYPE: postgres
|
||||||
|
DB_SYNCHRONIZE: 'false'
|
||||||
|
DB_MIGRATIONS_RUN: 'true'
|
||||||
|
DB_HOST: postgres
|
||||||
|
DB_PORT: 5432
|
||||||
|
DB_USERNAME: ${DB_USERNAME:-postgres}
|
||||||
|
DB_PASSWORD: ${DB_PASSWORD}
|
||||||
|
DB_DATABASE: ${DB_DATABASE:-telegram_gateway}
|
||||||
|
REDIS_HOST: redis
|
||||||
|
REDIS_PORT: 6379
|
||||||
|
REDIS_ENABLED: 'true'
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
TDLIB_PATH: /app/node_modules/@prebuilt-tdlib/linux-x64-glibc/libtdjson.so
|
||||||
|
TG_API_ID: ${TG_API_ID}
|
||||||
|
TG_API_HASH: ${TG_API_HASH}
|
||||||
|
TG_DEFAULT_PROXY_TYPE: ${TG_DEFAULT_PROXY_TYPE:-}
|
||||||
|
TG_DEFAULT_PROXY_HOST: ${TG_DEFAULT_PROXY_HOST:-}
|
||||||
|
TG_DEFAULT_PROXY_PORT: ${TG_DEFAULT_PROXY_PORT:-}
|
||||||
|
TG_DEFAULT_PROXY_USERNAME: ${TG_DEFAULT_PROXY_USERNAME:-}
|
||||||
|
TG_DEFAULT_PROXY_PASSWORD: ${TG_DEFAULT_PROXY_PASSWORD:-}
|
||||||
|
TG_DEFAULT_PROXY_SECRET: ${TG_DEFAULT_PROXY_SECRET:-}
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- uploads:/app/uploads
|
||||||
|
- tdlibdata:/app/tdlib_data
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
'CMD',
|
||||||
|
'node',
|
||||||
|
'-e',
|
||||||
|
"fetch('http://127.0.0.1:3000/api/admin/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))",
|
||||||
|
]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 20s
|
||||||
|
|
||||||
|
webhook-receiver:
|
||||||
|
profiles: ['test']
|
||||||
|
image: node:20-bookworm-slim
|
||||||
|
container_name: tgw-webhook-receiver
|
||||||
|
command: ['node', '/receiver/webhook-receiver.js']
|
||||||
|
environment:
|
||||||
|
WEBHOOK_TEST_SECRET: ${WEBHOOK_TEST_SECRET:-gateway-test-secret}
|
||||||
|
WEBHOOK_FAIL_COUNT: ${WEBHOOK_FAIL_COUNT:-3}
|
||||||
|
volumes:
|
||||||
|
- ./scripts/webhook-receiver.js:/receiver/webhook-receiver.js:ro
|
||||||
|
ports:
|
||||||
|
- '18080:8080'
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
socks5-proxy:
|
||||||
|
profiles: ['test']
|
||||||
|
image: node:20-bookworm-slim
|
||||||
|
container_name: tgw-socks5-proxy
|
||||||
|
command: ['node', '/proxy/socks5-proxy.js']
|
||||||
|
volumes:
|
||||||
|
- ./scripts/socks5-proxy.js:/proxy/socks5-proxy.js:ro
|
||||||
|
ports:
|
||||||
|
- '11080:1080'
|
||||||
|
- '11081:1081'
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
redisdata:
|
||||||
|
uploads:
|
||||||
|
tdlibdata:
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$Destination
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$resolved = [System.IO.Path]::GetFullPath($Destination)
|
||||||
|
New-Item -ItemType Directory -Force -Path $resolved | Out-Null
|
||||||
|
|
||||||
|
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||||
|
$backupDir = Join-Path $resolved "telegram-gateway-$timestamp"
|
||||||
|
New-Item -ItemType Directory -Force -Path $backupDir | Out-Null
|
||||||
|
|
||||||
|
docker exec tgw-postgres pg_dump -U postgres -d telegram_gateway -Fc -f /tmp/gateway.dump
|
||||||
|
docker cp tgw-postgres:/tmp/gateway.dump (Join-Path $backupDir 'postgres.dump')
|
||||||
|
docker exec tgw-redis redis-cli SAVE | Out-Null
|
||||||
|
docker cp tgw-redis:/data/dump.rdb (Join-Path $backupDir 'redis.rdb')
|
||||||
|
docker run --rm --volumes-from tgw-app -v "${backupDir}:/backup" `
|
||||||
|
node:20-bookworm-slim `
|
||||||
|
tar -czf /backup/tdlib-data.tar.gz -C /app tdlib_data uploads
|
||||||
|
|
||||||
|
$publicTables = [int](docker exec tgw-postgres psql -U postgres -d telegram_gateway -Atc `
|
||||||
|
"select count(*) from information_schema.tables where table_schema='public';")
|
||||||
|
$tenantRows = [int](docker exec tgw-postgres psql -U postgres -d telegram_gateway -Atc `
|
||||||
|
'select count(*) from tenants;')
|
||||||
|
$accountRows = [int](docker exec tgw-postgres psql -U postgres -d telegram_gateway -Atc `
|
||||||
|
'select count(*) from tg_accounts;')
|
||||||
|
$redisDbSize = [int](docker exec tgw-redis redis-cli DBSIZE)
|
||||||
|
$tdlibFileCount = [int](docker exec tgw-app sh -c `
|
||||||
|
"find /app/tdlib_data /app/uploads -type f 2>/dev/null | wc -l")
|
||||||
|
|
||||||
|
$manifest = @{
|
||||||
|
createdAt = (Get-Date).ToUniversalTime().ToString('o')
|
||||||
|
postgres = 'postgres.dump'
|
||||||
|
redis = 'redis.rdb'
|
||||||
|
tdlib = 'tdlib-data.tar.gz'
|
||||||
|
sha256 = @{}
|
||||||
|
expected = @{
|
||||||
|
publicTables = $publicTables
|
||||||
|
tenantRows = $tenantRows
|
||||||
|
accountRows = $accountRows
|
||||||
|
redisDbSize = $redisDbSize
|
||||||
|
tdlibFileCount = $tdlibFileCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($file in @('postgres.dump', 'redis.rdb', 'tdlib-data.tar.gz')) {
|
||||||
|
$manifest.sha256[$file] = (Get-FileHash -Algorithm SHA256 (Join-Path $backupDir $file)).Hash
|
||||||
|
}
|
||||||
|
$manifest | ConvertTo-Json -Depth 4 | Set-Content -Encoding utf8 (Join-Path $backupDir 'manifest.json')
|
||||||
|
Write-Output $backupDir
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Telegram User Gateway - Database Initialization
|
||||||
|
-- This script runs on first PostgreSQL startup
|
||||||
|
|
||||||
|
-- Enable UUID extension
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- Create indexes will be handled by TypeORM synchronize in dev
|
||||||
|
-- or by migrations in production
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Install TDLib shared library for the gateway
|
||||||
|
# This script downloads and installs pre-built tdlib
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
TDLIB_VERSION="1.8.0"
|
||||||
|
TDLIB_URL="https://github.com/tdlib/td/archive/refs/tags/v${TDLIB_VERSION}.tar.gz"
|
||||||
|
|
||||||
|
echo "Installing TDLib v${TDLIB_VERSION}..."
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
apk add --no-cache \
|
||||||
|
g++ \
|
||||||
|
make \
|
||||||
|
cmake \
|
||||||
|
openssl-dev \
|
||||||
|
zlib-dev \
|
||||||
|
linux-headers \
|
||||||
|
git
|
||||||
|
|
||||||
|
# Build TDLib
|
||||||
|
cd /tmp
|
||||||
|
wget -q "${TDLIB_URL}" -O tdlib.tar.gz
|
||||||
|
tar xzf tdlib.tar.gz
|
||||||
|
cd "td-${TDLIB_VERSION}"
|
||||||
|
mkdir build && cd build
|
||||||
|
cmake -DCMAKE_BUILD_TYPE=Release ..
|
||||||
|
cmake --build . --target tdjson -j$(nproc)
|
||||||
|
|
||||||
|
# Install
|
||||||
|
cp libtdjson.so /usr/local/lib/
|
||||||
|
cp libtdjson.so.${TDLIB_VERSION} /usr/local/lib/
|
||||||
|
|
||||||
|
echo "TDLib installed successfully to /usr/local/lib/libtdjson.so"
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$BackupDirectory
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$source = [System.IO.Path]::GetFullPath($BackupDirectory)
|
||||||
|
$manifest = Get-Content (Join-Path $source 'manifest.json') | ConvertFrom-Json
|
||||||
|
foreach ($file in @('postgres.dump', 'redis.rdb', 'tdlib-data.tar.gz')) {
|
||||||
|
$actual = (Get-FileHash -Algorithm SHA256 (Join-Path $source $file)).Hash
|
||||||
|
if ($actual -ne $manifest.sha256.$file) {
|
||||||
|
throw "Checksum mismatch: $file"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$postgresContainer = 'tgw-restore-postgres'
|
||||||
|
$redisContainer = 'tgw-restore-redis'
|
||||||
|
$filesVolume = 'tgw_restore_files'
|
||||||
|
|
||||||
|
function Remove-TestResources {
|
||||||
|
$containerNames = @(docker ps -a --format '{{.Names}}')
|
||||||
|
if ($containerNames -contains $postgresContainer) {
|
||||||
|
docker rm -f $postgresContainer | Out-Null
|
||||||
|
}
|
||||||
|
if ($containerNames -contains $redisContainer) {
|
||||||
|
docker rm -f $redisContainer | Out-Null
|
||||||
|
}
|
||||||
|
$volumeNames = @(docker volume ls --format '{{.Name}}')
|
||||||
|
if ($volumeNames -contains $filesVolume) {
|
||||||
|
docker volume rm $filesVolume | Out-Null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Remove-TestResources
|
||||||
|
|
||||||
|
docker run -d --name $postgresContainer -e POSTGRES_PASSWORD=restore-test `
|
||||||
|
-e POSTGRES_DB=telegram_gateway_restore postgres:16-alpine | Out-Null
|
||||||
|
$postgresReady = $false
|
||||||
|
for ($attempt = 0; $attempt -lt 30; $attempt++) {
|
||||||
|
docker exec $postgresContainer pg_isready -U postgres -d telegram_gateway_restore 2>$null | Out-Null
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
$postgresReady = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
}
|
||||||
|
if (-not $postgresReady) {
|
||||||
|
throw 'Isolated PostgreSQL restore container did not become ready'
|
||||||
|
}
|
||||||
|
|
||||||
|
docker cp (Join-Path $source 'postgres.dump') "${postgresContainer}:/tmp/gateway.dump"
|
||||||
|
docker exec $postgresContainer pg_restore -U postgres -d telegram_gateway_restore /tmp/gateway.dump
|
||||||
|
$tables = [int](docker exec $postgresContainer psql -U postgres -d telegram_gateway_restore -Atc `
|
||||||
|
"select count(*) from information_schema.tables where table_schema='public';")
|
||||||
|
$tenants = [int](docker exec $postgresContainer psql -U postgres -d telegram_gateway_restore -Atc `
|
||||||
|
'select count(*) from tenants;')
|
||||||
|
$accounts = [int](docker exec $postgresContainer psql -U postgres -d telegram_gateway_restore -Atc `
|
||||||
|
'select count(*) from tg_accounts;')
|
||||||
|
if ($tables -ne [int]$manifest.expected.publicTables -or
|
||||||
|
$tenants -ne [int]$manifest.expected.tenantRows -or
|
||||||
|
$accounts -ne [int]$manifest.expected.accountRows) {
|
||||||
|
throw "PostgreSQL restore count mismatch"
|
||||||
|
}
|
||||||
|
|
||||||
|
docker create --name $redisContainer redis:7-alpine | Out-Null
|
||||||
|
docker cp (Join-Path $source 'redis.rdb') "${redisContainer}:/data/dump.rdb"
|
||||||
|
docker run --rm -v "${source}:/backup:ro" redis:7-alpine `
|
||||||
|
redis-check-rdb /backup/redis.rdb | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw 'Redis RDB integrity check failed'
|
||||||
|
}
|
||||||
|
docker start $redisContainer | Out-Null
|
||||||
|
$redisReady = $false
|
||||||
|
for ($attempt = 0; $attempt -lt 20; $attempt++) {
|
||||||
|
$ping = docker exec $redisContainer redis-cli PING 2>$null
|
||||||
|
if ($ping -eq 'PONG') {
|
||||||
|
$redisReady = $true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
Start-Sleep -Milliseconds 500
|
||||||
|
}
|
||||||
|
if (-not $redisReady) {
|
||||||
|
throw 'Isolated Redis restore container did not become ready'
|
||||||
|
}
|
||||||
|
$redisSize = [int](docker exec $redisContainer redis-cli DBSIZE)
|
||||||
|
# BullMQ keys have TTLs and may legitimately expire between SAVE and this
|
||||||
|
# isolated restore. The RDB checksum/load must succeed and the restored key
|
||||||
|
# count must never exceed the snapshot-time upper bound.
|
||||||
|
if ($redisSize -gt [int]$manifest.expected.redisDbSize) {
|
||||||
|
throw "Redis restore produced more keys than the snapshot"
|
||||||
|
}
|
||||||
|
|
||||||
|
docker volume create $filesVolume | Out-Null
|
||||||
|
docker run --rm -v "${filesVolume}:/restore" -v "${source}:/backup:ro" `
|
||||||
|
node:20-bookworm-slim `
|
||||||
|
tar -xzf /backup/tdlib-data.tar.gz -C /restore
|
||||||
|
$fileCount = [int](docker run --rm -v "${filesVolume}:/restore:ro" `
|
||||||
|
node:20-bookworm-slim sh -c `
|
||||||
|
"find /restore/tdlib_data /restore/uploads -type f 2>/dev/null | wc -l")
|
||||||
|
if ($fileCount -ne [int]$manifest.expected.tdlibFileCount) {
|
||||||
|
throw "TDLib/uploads restore file count mismatch"
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Output (
|
||||||
|
"restore_verified tables=$tables tenants=$tenants accounts=$accounts " +
|
||||||
|
"redis_keys=$redisSize redis_snapshot_max=$($manifest.expected.redisDbSize) " +
|
||||||
|
"files=$fileCount checksums=ok"
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
Remove-TestResources
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
param(
|
||||||
|
[Parameter(Mandatory = $true)]
|
||||||
|
[string]$EnvironmentFile
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$envPath = [System.IO.Path]::GetFullPath($EnvironmentFile)
|
||||||
|
if (-not (Test-Path -LiteralPath $envPath -PathType Leaf)) {
|
||||||
|
throw "Environment file not found"
|
||||||
|
}
|
||||||
|
|
||||||
|
$bytes = New-Object byte[] 48
|
||||||
|
$generator = [System.Security.Cryptography.RandomNumberGenerator]::Create()
|
||||||
|
try {
|
||||||
|
$generator.GetBytes($bytes)
|
||||||
|
} finally {
|
||||||
|
$generator.Dispose()
|
||||||
|
}
|
||||||
|
$newSecret = [Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
|
||||||
|
|
||||||
|
$found = $false
|
||||||
|
$updated = foreach ($line in [System.IO.File]::ReadAllLines($envPath)) {
|
||||||
|
if ($line -match '^JWT_SECRET=') {
|
||||||
|
$found = $true
|
||||||
|
"JWT_SECRET=$newSecret"
|
||||||
|
} else {
|
||||||
|
$line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $found) {
|
||||||
|
$updated += "JWT_SECRET=$newSecret"
|
||||||
|
}
|
||||||
|
|
||||||
|
$temporaryPath = "$envPath.rotate.tmp"
|
||||||
|
$utf8WithoutBom = New-Object System.Text.UTF8Encoding($false)
|
||||||
|
[System.IO.File]::WriteAllLines($temporaryPath, $updated, $utf8WithoutBom)
|
||||||
|
Move-Item -LiteralPath $temporaryPath -Destination $envPath -Force
|
||||||
|
Write-Output 'JWT_SECRET rotated'
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
const net = require('net');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
const listenPort = Number(process.env.SOCKS5_PORT || 1080);
|
||||||
|
const metricsPort = Number(process.env.SOCKS5_METRICS_PORT || 1081);
|
||||||
|
const stats = { accepted: 0, connected: 0, failed: 0, active: 0 };
|
||||||
|
|
||||||
|
function reply(socket, code, addressType = 1) {
|
||||||
|
const address = addressType === 4 ? Buffer.alloc(16) : Buffer.alloc(4);
|
||||||
|
socket.write(Buffer.concat([
|
||||||
|
Buffer.from([5, code, 0, addressType]),
|
||||||
|
address,
|
||||||
|
Buffer.from([0, 0]),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumeRequest(socket, state) {
|
||||||
|
if (state.phase === 'greeting') {
|
||||||
|
if (state.buffer.length < 2) return;
|
||||||
|
const methodsLength = state.buffer[1];
|
||||||
|
if (state.buffer.length < 2 + methodsLength) return;
|
||||||
|
const methods = state.buffer.subarray(2, 2 + methodsLength);
|
||||||
|
state.buffer = state.buffer.subarray(2 + methodsLength);
|
||||||
|
if (state.buffer[0] !== undefined && state.buffer[0] !== 5) {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!methods.includes(0)) {
|
||||||
|
socket.end(Buffer.from([5, 255]));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
socket.write(Buffer.from([5, 0]));
|
||||||
|
state.phase = 'request';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.phase !== 'request' || state.buffer.length < 4) return;
|
||||||
|
const [version, command, , addressType] = state.buffer;
|
||||||
|
if (version !== 5 || command !== 1) {
|
||||||
|
reply(socket, command === 1 ? 1 : 7);
|
||||||
|
socket.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = 4;
|
||||||
|
let host;
|
||||||
|
if (addressType === 1) {
|
||||||
|
if (state.buffer.length < offset + 4 + 2) return;
|
||||||
|
host = Array.from(state.buffer.subarray(offset, offset + 4)).join('.');
|
||||||
|
offset += 4;
|
||||||
|
} else if (addressType === 3) {
|
||||||
|
if (state.buffer.length < offset + 1) return;
|
||||||
|
const length = state.buffer[offset++];
|
||||||
|
if (state.buffer.length < offset + length + 2) return;
|
||||||
|
host = state.buffer.subarray(offset, offset + length).toString('utf8');
|
||||||
|
offset += length;
|
||||||
|
} else if (addressType === 4) {
|
||||||
|
if (state.buffer.length < offset + 16 + 2) return;
|
||||||
|
const words = [];
|
||||||
|
for (let index = 0; index < 16; index += 2) {
|
||||||
|
words.push(state.buffer.readUInt16BE(offset + index).toString(16));
|
||||||
|
}
|
||||||
|
host = words.join(':');
|
||||||
|
offset += 16;
|
||||||
|
} else {
|
||||||
|
reply(socket, 8);
|
||||||
|
socket.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = state.buffer.readUInt16BE(offset);
|
||||||
|
state.buffer = state.buffer.subarray(offset + 2);
|
||||||
|
state.phase = 'connecting';
|
||||||
|
|
||||||
|
const upstream = net.createConnection({ host, port });
|
||||||
|
upstream.setTimeout(30000);
|
||||||
|
upstream.once('connect', () => {
|
||||||
|
stats.connected += 1;
|
||||||
|
stats.active += 1;
|
||||||
|
console.log(JSON.stringify({ event: 'connect', host, port, addressType }));
|
||||||
|
reply(socket, 0, addressType === 4 ? 4 : 1);
|
||||||
|
if (state.buffer.length) upstream.write(state.buffer);
|
||||||
|
socket.pipe(upstream);
|
||||||
|
upstream.pipe(socket);
|
||||||
|
});
|
||||||
|
upstream.once('error', (error) => {
|
||||||
|
stats.failed += 1;
|
||||||
|
console.error(JSON.stringify({ event: 'connect_error', host, port, code: error.code }));
|
||||||
|
if (!socket.destroyed) {
|
||||||
|
reply(socket, error.code === 'ECONNREFUSED' ? 5 : 4);
|
||||||
|
socket.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
upstream.once('close', () => {
|
||||||
|
if (stats.active > 0) stats.active -= 1;
|
||||||
|
});
|
||||||
|
socket.once('close', () => upstream.destroy());
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = net.createServer((socket) => {
|
||||||
|
stats.accepted += 1;
|
||||||
|
const state = { phase: 'greeting', buffer: Buffer.alloc(0) };
|
||||||
|
socket.setTimeout(30000);
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
if (state.phase === 'connecting') return;
|
||||||
|
state.buffer = Buffer.concat([state.buffer, chunk]);
|
||||||
|
consumeRequest(socket, state);
|
||||||
|
});
|
||||||
|
socket.on('timeout', () => socket.destroy());
|
||||||
|
socket.on('error', () => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(listenPort, '0.0.0.0', () => {
|
||||||
|
console.log(`SOCKS5 test proxy listening on ${listenPort}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
http.createServer((request, response) => {
|
||||||
|
if (request.url !== '/stats') {
|
||||||
|
response.writeHead(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
response.end(JSON.stringify(stats));
|
||||||
|
}).listen(metricsPort, '0.0.0.0');
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
const http = require('http');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
const secret = process.env.WEBHOOK_TEST_SECRET || '';
|
||||||
|
const received = [];
|
||||||
|
let failuresRemaining = Number(process.env.WEBHOOK_FAIL_COUNT || 0);
|
||||||
|
|
||||||
|
http.createServer((req, res) => {
|
||||||
|
if (req.method === 'GET' && req.url === '/received') {
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
return res.end(JSON.stringify(received));
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && (req.url === '/events' || req.url === '/fail')) {
|
||||||
|
const chunks = [];
|
||||||
|
req.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
req.on('end', () => {
|
||||||
|
const body = Buffer.concat(chunks).toString();
|
||||||
|
const expected = `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`;
|
||||||
|
const signatureValid = !secret || req.headers['x-webhook-signature'] === expected;
|
||||||
|
received.push({
|
||||||
|
path: req.url,
|
||||||
|
signatureValid,
|
||||||
|
body: JSON.parse(body),
|
||||||
|
receivedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
if (!signatureValid) {
|
||||||
|
res.writeHead(401);
|
||||||
|
return res.end('invalid signature');
|
||||||
|
}
|
||||||
|
if (req.url === '/fail' && failuresRemaining-- > 0) {
|
||||||
|
res.writeHead(503);
|
||||||
|
return res.end('intentional failure');
|
||||||
|
}
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ accepted: true }));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end('not found');
|
||||||
|
}).listen(8080, '0.0.0.0');
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/nest-cli",
|
||||||
|
"collection": "@nestjs/schematics",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"compilerOptions": {
|
||||||
|
"deleteOutDir": true,
|
||||||
|
"assets": ["**/*.js"],
|
||||||
|
"watchAssets": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+11937
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
|||||||
|
{
|
||||||
|
"name": "telegram-user-gateway",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Production-grade Telegram User Account Gateway - REST API + Webhook system using TDLib",
|
||||||
|
"private": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"scripts": {
|
||||||
|
"build": "nest build",
|
||||||
|
"start": "nest start",
|
||||||
|
"start:dev": "nest start --watch",
|
||||||
|
"start:debug": "nest start --debug --watch",
|
||||||
|
"start:prod": "node dist/main",
|
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"test:cov": "jest --coverage",
|
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||||
|
"typeorm": "typeorm-ts-node-commonjs",
|
||||||
|
"migration:generate": "npm run typeorm -- migration:generate -d src/database/data-source.ts",
|
||||||
|
"migration:run": "npm run typeorm -- migration:run -d src/database/data-source.ts",
|
||||||
|
"migration:revert": "npm run typeorm -- migration:revert -d src/database/data-source.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nestjs/bull": "^11.0.4",
|
||||||
|
"@nestjs/common": "^11.1.28",
|
||||||
|
"@nestjs/config": "^4.0.4",
|
||||||
|
"@nestjs/core": "^11.1.28",
|
||||||
|
"@nestjs/jwt": "^11.0.2",
|
||||||
|
"@nestjs/passport": "^11.0.5",
|
||||||
|
"@nestjs/platform-express": "^11.1.28",
|
||||||
|
"@nestjs/swagger": "^11.4.5",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
|
"@nestjs/typeorm": "^11.0.3",
|
||||||
|
"@types/multer": "^2.1.0",
|
||||||
|
"@types/supertest": "^7.2.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"bullmq": "^5.1.0",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"helmet": "^8.3.0",
|
||||||
|
"ioredis": "^5.3.2",
|
||||||
|
"koffi": "^3.0.1",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-jwt": "^4.0.1",
|
||||||
|
"pg": "^8.11.3",
|
||||||
|
"pg-mem": "^3.0.14",
|
||||||
|
"prebuilt-tdlib": "^0.1008064.0",
|
||||||
|
"qs": "^6.15.3",
|
||||||
|
"reflect-metadata": "^0.2.1",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"sql.js": "^1.14.1",
|
||||||
|
"supertest": "^7.2.2",
|
||||||
|
"swagger-ui-express": "^5.0.0",
|
||||||
|
"typeorm": "^0.3.31",
|
||||||
|
"uuid": "^11.1.1",
|
||||||
|
"winston": "^3.11.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nestjs/cli": "^11.0.0",
|
||||||
|
"@nestjs/schematics": "^11.0.0",
|
||||||
|
"@nestjs/testing": "^11.1.28",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/jest": "^29.5.11",
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"@types/passport-jwt": "^4.0.0",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.15.0",
|
||||||
|
"@typescript-eslint/parser": "^6.15.0",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-prettier": "^5.1.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"prettier": "^3.1.1",
|
||||||
|
"source-map-support": "^0.5.21",
|
||||||
|
"ts-jest": "^29.1.1",
|
||||||
|
"ts-loader": "^9.5.1",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"tsconfig-paths": "^4.2.0",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"jest": {
|
||||||
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
|
"rootDir": ".",
|
||||||
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"collectCoverageFrom": [
|
||||||
|
"src/**/*.(t|j)s"
|
||||||
|
],
|
||||||
|
"coverageDirectory": "./coverage",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^@/(.*)$": "<rootDir>/src/$1"
|
||||||
|
},
|
||||||
|
"testPathIgnorePatterns": [
|
||||||
|
"<rootDir>/test/endpoints.spec.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,907 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Telegram User Gateway - 项目架构文档</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0d1117;
|
||||||
|
--card: #161b22;
|
||||||
|
--border: #30363d;
|
||||||
|
--text: #c9d1d9;
|
||||||
|
--heading: #f0f6fc;
|
||||||
|
--accent: #58a6ff;
|
||||||
|
--accent2: #3fb950;
|
||||||
|
--accent3: #d2a8ff;
|
||||||
|
--accent4: #f78166;
|
||||||
|
--accent5: #79c0ff;
|
||||||
|
--code-bg: #0d1117;
|
||||||
|
--tag-bg: #1f6feb33;
|
||||||
|
}
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', Helvetica, Arial, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.7;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.hero {
|
||||||
|
background: linear-gradient(135deg, #1a1e2e 0%, #0d1117 50%, #161b22 100%);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 60px 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.hero h1 {
|
||||||
|
font-size: 2.8em;
|
||||||
|
color: var(--heading);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
}
|
||||||
|
.hero h1 span { color: var(--accent); }
|
||||||
|
.hero p {
|
||||||
|
font-size: 1.15em;
|
||||||
|
color: #8b949e;
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.badge-row {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.82em;
|
||||||
|
font-weight: 600;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--accent5);
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 24px;
|
||||||
|
}
|
||||||
|
.toc {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 28px 32px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
.toc h2 {
|
||||||
|
color: var(--heading);
|
||||||
|
font-size: 1.3em;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.toc-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.toc a {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.95em;
|
||||||
|
padding: 4px 0;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.toc a:hover { color: var(--accent3); text-decoration: underline; }
|
||||||
|
.section {
|
||||||
|
background: var(--card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px;
|
||||||
|
margin-bottom: 28px;
|
||||||
|
}
|
||||||
|
.section h2 {
|
||||||
|
color: var(--heading);
|
||||||
|
font-size: 1.6em;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.section h2 .num {
|
||||||
|
background: var(--tag-bg);
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 2px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.7em;
|
||||||
|
}
|
||||||
|
.section h3 {
|
||||||
|
color: var(--accent3);
|
||||||
|
font-size: 1.15em;
|
||||||
|
margin: 24px 0 12px;
|
||||||
|
}
|
||||||
|
.section p, .section li {
|
||||||
|
color: var(--text);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.section ul, .section ol {
|
||||||
|
padding-left: 24px;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 16px 0;
|
||||||
|
font-size: 0.92em;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: #21262d;
|
||||||
|
color: var(--accent);
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 9px 14px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
tr:nth-child(even) td { background: #161b22; }
|
||||||
|
tr:nth-child(odd) td { background: #0d1117; }
|
||||||
|
code {
|
||||||
|
background: #21262d;
|
||||||
|
color: var(--accent5);
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.88em;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
background: var(--code-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 16px 0;
|
||||||
|
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.arch-box {
|
||||||
|
background: var(--code-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px;
|
||||||
|
margin: 16px 0;
|
||||||
|
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.82em;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--accent2);
|
||||||
|
white-space: pre;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.highlight { color: var(--accent); font-weight: 600; }
|
||||||
|
.highlight2 { color: var(--accent3); font-weight: 600; }
|
||||||
|
.highlight3 { color: var(--accent4); font-weight: 600; }
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.78em;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 2px;
|
||||||
|
}
|
||||||
|
.tag-green { background: #238636; color: #fff; }
|
||||||
|
.tag-blue { background: #1f6feb; color: #fff; }
|
||||||
|
.tag-purple { background: #8957e5; color: #fff; }
|
||||||
|
.tag-orange { background: #9e6a03; color: #fff; }
|
||||||
|
.tag-red { background: #da3633; color: #fff; }
|
||||||
|
.flow-card {
|
||||||
|
background: #21262d;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin: 8px 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.flow-card .step {
|
||||||
|
background: var(--tag-bg);
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.8em;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 50px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.flow-card .content { flex: 1; }
|
||||||
|
.flow-card .content strong { color: var(--heading); }
|
||||||
|
.flow-arrow {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--border);
|
||||||
|
font-size: 1.2em;
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
.two-col {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.two-col { grid-template-columns: 1fr; }
|
||||||
|
.hero h1 { font-size: 1.8em; }
|
||||||
|
.container { padding: 20px 12px; }
|
||||||
|
.section { padding: 20px; }
|
||||||
|
}
|
||||||
|
.file-tree {
|
||||||
|
background: var(--code-bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||||||
|
font-size: 0.82em;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: var(--text);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.file-tree .dir { color: var(--accent); font-weight: 600; }
|
||||||
|
.file-tree .file { color: var(--text); }
|
||||||
|
.file-tree .comment { color: #8b949e; }
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #484f58;
|
||||||
|
font-size: 0.85em;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
margin-top: 40px;
|
||||||
|
}
|
||||||
|
.state-machine {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin: 16px 0;
|
||||||
|
font-size: 0.88em;
|
||||||
|
}
|
||||||
|
.state-node {
|
||||||
|
background: #21262d;
|
||||||
|
border: 2px solid var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.state-arrow { color: var(--accent4); font-weight: bold; font-size: 1.2em; }
|
||||||
|
.info-box {
|
||||||
|
background: #161b22;
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin: 16px 0;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
}
|
||||||
|
.info-box.warning { border-left-color: var(--accent4); }
|
||||||
|
.info-box.success { border-left-color: var(--accent2); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<h1>Telegram User <span>Gateway</span></h1>
|
||||||
|
<p>基于 TDLib 的生产级 Telegram 用户账号网关,通过 REST API + Webhook 方式管理多租户多账号的 Telegram 操作</p>
|
||||||
|
<div class="badge-row">
|
||||||
|
<span class="badge">Node.js 20+</span>
|
||||||
|
<span class="badge">NestJS 10</span>
|
||||||
|
<span class="badge">TypeScript 5.3</span>
|
||||||
|
<span class="badge">TDLib 1.8.64+</span>
|
||||||
|
<span class="badge">PostgreSQL / SQLite</span>
|
||||||
|
<span class="badge">Redis + BullMQ</span>
|
||||||
|
<span class="badge">Docker</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<!-- TOC -->
|
||||||
|
<div class="toc">
|
||||||
|
<h2>目录</h2>
|
||||||
|
<div class="toc-grid">
|
||||||
|
<a href="#overview">1. 项目概述</a>
|
||||||
|
<a href="#tech-stack">2. 技术栈</a>
|
||||||
|
<a href="#architecture">3. 核心架构</a>
|
||||||
|
<a href="#structure">4. 目录结构</a>
|
||||||
|
<a href="#modules">5. 核心模块</a>
|
||||||
|
<a href="#entities">6. 数据库实体</a>
|
||||||
|
<a href="#state-machine">7. 账号状态机</a>
|
||||||
|
<a href="#api">8. API 端点</a>
|
||||||
|
<a href="#security">9. 安全与多租户</a>
|
||||||
|
<a href="#config">10. 配置模式</a>
|
||||||
|
<a href="#workflow">11. 完整调用流程</a>
|
||||||
|
<a href="#deploy">12. 构建与部署</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 1. 项目概述 -->
|
||||||
|
<div class="section" id="overview">
|
||||||
|
<h2><span class="num">01</span> 项目概述</h2>
|
||||||
|
<p>这是一个 <strong>生产级 Telegram 用户账号网关</strong>。它不是 Telegram Bot API 的封装,而是通过 <strong>TDLib</strong>(Telegram 的底层 C 语言库)以<strong>用户账号</strong>身份操作 Telegram。</p>
|
||||||
|
<p>对外暴露标准 REST API,让上层业务系统可以通过 HTTP 请求操控 Telegram 账号,支持多租户、多账号管理。</p>
|
||||||
|
|
||||||
|
<h3>典型使用场景</h3>
|
||||||
|
<div class="two-col">
|
||||||
|
<div>
|
||||||
|
<ul>
|
||||||
|
<li>自动化消息发送与群发</li>
|
||||||
|
<li>群组/频道管理自动化</li>
|
||||||
|
<li>用户数据采集与分析</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ul>
|
||||||
|
<li>消息监控与实时推送</li>
|
||||||
|
<li>客户服务自动化</li>
|
||||||
|
<li>跨平台消息桥接</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. 技术栈 -->
|
||||||
|
<div class="section" id="tech-stack">
|
||||||
|
<h2><span class="num">02</span> 技术栈</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr><th>层次</th><th>技术选型</th><th>说明</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>运行时</td><td><code>Node.js 20+</code> / <code>TypeScript 5.3</code></td><td>高性能异步 I/O</td></tr>
|
||||||
|
<tr><td>Web 框架</td><td><code>NestJS 10.x</code></td><td>模块化架构 + 依赖注入</td></tr>
|
||||||
|
<tr><td>数据库</td><td><code>PostgreSQL 16</code> / <code>SQLite (sql.js)</code></td><td>生产用 PG,开发用 SQLite</td></tr>
|
||||||
|
<tr><td>队列</td><td><code>Redis 7</code> + <code>BullMQ</code></td><td>可选,通过 .env 关闭</td></tr>
|
||||||
|
<tr><td>TDLib 绑定</td><td><code>prebuilt-tdlib</code> + <code>koffi</code></td><td>FFI 调用原生 DLL/SO</td></tr>
|
||||||
|
<tr><td>线程模型</td><td><code>worker_threads</code></td><td>每账号一个独立线程</td></tr>
|
||||||
|
<tr><td>认证</td><td><code>JWT</code> + <code>Passport</code></td><td>Bearer Token 认证</td></tr>
|
||||||
|
<tr><td>API 文档</td><td><code>Swagger</code></td><td>自动生成 /api/docs</td></tr>
|
||||||
|
<tr><td>日志</td><td><code>Winston</code></td><td>结构化日志</td></tr>
|
||||||
|
<tr><td>容器化</td><td><code>Docker</code> + <code>Docker Compose</code></td><td>多阶段构建</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. 核心架构 -->
|
||||||
|
<div class="section" id="architecture">
|
||||||
|
<h2><span class="num">03</span> 核心架构</h2>
|
||||||
|
<p>系统采用 <strong>三层架构</strong>:应用层(NestJS 主线程)、Worker 层(每账号独立线程)、TDLib 层(C 原生库)。</p>
|
||||||
|
|
||||||
|
<div class="arch-box"><span class="highlight">┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 外部业务系统 │
|
||||||
|
│ (通过 REST API 调用) │
|
||||||
|
└──────────────────────┬──────────────────────────────────┘
|
||||||
|
│ HTTP
|
||||||
|
<span class="highlight2">┌──────────────────────▼──────────────────────────────────┐
|
||||||
|
│ NestJS 应用层 (主线程) │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │
|
||||||
|
│ │ 认证模块 │ │ 账号模块 │ │ 消息模块 │ │Webhook │ │
|
||||||
|
│ │ Auth │ │ Accounts │ │ Messages │ │ 模块 │ │
|
||||||
|
│ └──────────┘ └──────────┘ └──────────┘ └─────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ TdlibClientManagerService │
|
||||||
|
│ (Worker 线程管理器) │
|
||||||
|
└────────────────┬─────────────────────────────────────────┘
|
||||||
|
│ worker_threads (postMessage)
|
||||||
|
<span class="highlight3">┌────────────────▼─────────────────────────────────────────┐
|
||||||
|
│ Worker 线程层 (每 TG 账号一个) │
|
||||||
|
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
|
||||||
|
│ │ Worker #1 │ │ Worker #2 │ │ Worker #3 │ │
|
||||||
|
│ │ (tdjson │ │ (tdjson │ │ (tdjson │ │
|
||||||
|
│ │ FFI 调用) │ │ FFI 调用) │ │ FFI 调用) │ │
|
||||||
|
│ └─────────────┘ └─────────────┘ └─────────────┘ │
|
||||||
|
└────────────────┬─────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌────────────────▼─────────────────────────────────────────┐
|
||||||
|
│ TDLib (C 原生库) │
|
||||||
|
│ tdjson.dll / libtdjson.so │
|
||||||
|
└──────────────────────────────────────────────────────────┘</span></span></div>
|
||||||
|
|
||||||
|
<h3>请求-响应匹配机制</h3>
|
||||||
|
<div class="info-box">
|
||||||
|
<p>每个请求通过 <code>UUID @extra</code> 标记,存入 <code>pendingRequests</code> Map。Worker 处理完成后通过相同的 <code>@extra</code> 回传,主线程匹配后 resolve/reject 对应的 Promise。超时 30 秒自动 reject。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 4. 目录结构 -->
|
||||||
|
<div class="section" id="structure">
|
||||||
|
<h2><span class="num">04</span> 目录结构</h2>
|
||||||
|
|
||||||
|
<h3>项目根目录</h3>
|
||||||
|
<div class="file-tree">
|
||||||
|
<span class="dir">telegram-getway/</span>
|
||||||
|
├── <span class="file">.env</span> <span class="comment"># 运行时配置</span>
|
||||||
|
├── <span class="file">.env.example</span> <span class="comment"># 配置模板</span>
|
||||||
|
├── <span class="file">package.json</span> <span class="comment"># 依赖与脚本</span>
|
||||||
|
├── <span class="file">tsconfig.json</span> <span class="comment"># TypeScript 配置</span>
|
||||||
|
├── <span class="file">nest-cli.json</span> <span class="comment"># NestJS CLI 配置</span>
|
||||||
|
├── <span class="dir">src/</span> <span class="comment"># 源代码</span>
|
||||||
|
├── <span class="dir">data/</span> <span class="comment"># SQLite 数据库</span>
|
||||||
|
├── <span class="dir">tdlib_data/</span> <span class="comment"># TDLib 数据(每账号子目录)</span>
|
||||||
|
├── <span class="dir">uploads/</span> <span class="comment"># 文件上传存储</span>
|
||||||
|
├── <span class="dir">docker/</span> <span class="comment"># Docker 配置</span>
|
||||||
|
│ ├── <span class="file">Dockerfile</span> <span class="comment"># 多阶段构建</span>
|
||||||
|
│ └── <span class="file">docker-compose.yml</span> <span class="comment"># PG + Redis + App</span>
|
||||||
|
└── <span class="dir">test/</span> <span class="comment"># 测试文件</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>源代码目录 (src/)</h3>
|
||||||
|
<div class="file-tree">
|
||||||
|
<span class="dir">src/</span>
|
||||||
|
├── <span class="file">main.ts</span> <span class="comment"># 应用入口</span>
|
||||||
|
├── <span class="file">app.module.ts</span> <span class="comment"># 根模块(注册 15 个子模块)</span>
|
||||||
|
├── <span class="dir">config/</span> <span class="comment"># 配置模块</span>
|
||||||
|
│ ├── <span class="file">app.config.ts</span> <span class="comment"># 应用配置</span>
|
||||||
|
│ ├── <span class="file">database.config.ts</span> <span class="comment"># 数据库配置(PG/SQLite)</span>
|
||||||
|
│ ├── <span class="file">redis.config.ts</span> <span class="comment"># Redis 配置</span>
|
||||||
|
│ └── <span class="file">rate-limit.config.ts</span> <span class="comment"># 速率限制配置</span>
|
||||||
|
├── <span class="dir">common/</span> <span class="comment"># 通用组件</span>
|
||||||
|
│ ├── <span class="dir">decorators/</span> <span class="comment"># 装饰器(@CurrentUser, @Tenant)</span>
|
||||||
|
│ ├── <span class="dir">errors/</span> <span class="comment"># 异常类 + 60+ 错误码</span>
|
||||||
|
│ ├── <span class="dir">filters/</span> <span class="comment"># 全局异常过滤器</span>
|
||||||
|
│ ├── <span class="dir">guards/</span> <span class="comment"># JWT 守卫 + 租户守卫</span>
|
||||||
|
│ ├── <span class="dir">interceptors/</span> <span class="comment"># 响应格式 + 审计日志</span>
|
||||||
|
│ └── <span class="dir">pipes/</span> <span class="comment"># 参数验证管道</span>
|
||||||
|
├── <span class="dir">database/</span> <span class="comment"># 数据库层</span>
|
||||||
|
│ ├── <span class="dir">entities/</span> <span class="comment"># 16 个 TypeORM 实体</span>
|
||||||
|
│ └── <span class="dir">migrations/</span> <span class="comment"># 数据库迁移</span>
|
||||||
|
├── <span class="dir">modules/</span> <span class="comment"># 15 个业务模块</span>
|
||||||
|
│ ├── <span class="dir">auth/</span> <span class="comment"># 认证(注册/登录/JWT)</span>
|
||||||
|
│ ├── <span class="dir">tenants/</span> <span class="comment"># 租户管理</span>
|
||||||
|
│ ├── <span class="dir">tg-accounts/</span> <span class="comment"># TG 账号 CRUD</span>
|
||||||
|
│ ├── <span class="dir">tg-auth/</span> <span class="comment"># TG 授权流程</span>
|
||||||
|
│ ├── <span class="dir">tdlib/</span> <span class="comment"># TDLib 核心管理器</span>
|
||||||
|
│ ├── <span class="dir">tg-proxies/</span> <span class="comment"># 代理管理</span>
|
||||||
|
│ ├── <span class="dir">tg-chats/</span> <span class="comment"># 聊天模块</span>
|
||||||
|
│ ├── <span class="dir">tg-dialogs/</span> <span class="comment"># 对话模块</span>
|
||||||
|
│ ├── <span class="dir">tg-contacts/</span> <span class="comment"># 联系人模块</span>
|
||||||
|
│ ├── <span class="dir">tg-messages/</span> <span class="comment"># 消息发送/编辑/搜索</span>
|
||||||
|
│ ├── <span class="dir">tg-groups/</span> <span class="comment"># 群组管理</span>
|
||||||
|
│ ├── <span class="dir">tg-channels/</span> <span class="comment"># 频道管理</span>
|
||||||
|
│ ├── <span class="dir">tg-files/</span> <span class="comment"># 文件上传/下载</span>
|
||||||
|
│ ├── <span class="dir">tg-webhooks/</span> <span class="comment"># Webhook 订阅与投递</span>
|
||||||
|
│ ├── <span class="dir">tg-rate-limit/</span> <span class="comment"># 速率限制</span>
|
||||||
|
│ ├── <span class="dir">tg-audit/</span> <span class="comment"># 审计日志</span>
|
||||||
|
│ └── <span class="dir">admin/</span> <span class="comment"># 管理(健康检查/指标)</span>
|
||||||
|
└── <span class="dir">workers/</span> <span class="comment"># Worker 线程</span>
|
||||||
|
├── <span class="file">tdlib-client.worker.ts</span> <span class="comment"># TDLib 客户端(核心)</span>
|
||||||
|
├── <span class="file">send-message.worker.ts</span> <span class="comment"># 消息发送 BullMQ</span>
|
||||||
|
├── <span class="file">file-download.worker.ts</span><span class="comment"># 文件下载</span>
|
||||||
|
└── <span class="file">webhook-delivery.worker.ts</span> <span class="comment"># Webhook 投递</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 5. 核心模块 -->
|
||||||
|
<div class="section" id="modules">
|
||||||
|
<h2><span class="num">05</span> 核心模块详解</h2>
|
||||||
|
|
||||||
|
<h3>5.1 TDLib 客户端管理器</h3>
|
||||||
|
<p><code>src/modules/tdlib/tdlib-client-manager.service.ts</code> — 整个系统的<strong>心脏</strong></p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>属性</th><th>类型</th><th>作用</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>workers</code></td><td><code>Map<string, Worker></code></td><td>accountId → Worker 线程映射</td></tr>
|
||||||
|
<tr><td><code>pendingRequests</code></td><td><code>Map<string, PendingRequest></code></td><td>UUID → Promise 映射(请求-响应匹配)</td></tr>
|
||||||
|
<tr><td><code>accountAuthState</code></td><td><code>Map<string, string></code></td><td>追踪每个账号的授权状态</td></tr>
|
||||||
|
<tr><td><code>clientIdToAccountId</code></td><td><code>Map<number, string></code></td><td>clientId → accountId 反向查找</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p><strong>核心方法:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><code>startClient(accountId)</code> — 创建 Worker 线程,启动 TDLib 实例</li>
|
||||||
|
<li><code>stopClient(accountId)</code> — 发送 stop 消息,清理 Worker</li>
|
||||||
|
<li><code>sendRequest(accountId, request)</code> — 发送请求并返回 Promise(30s 超时)</li>
|
||||||
|
<li><code>handleTdlibResponse(msg)</code> — 处理 Worker 回传的响应,匹配 pending 请求</li>
|
||||||
|
<li><code>handleUnsolicitedUpdate(msg)</code> — 处理非请求触发的 TG 更新(如状态变化)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>5.2 TDLib Worker</h3>
|
||||||
|
<p><code>src/workers/tdlib-client.worker.ts</code> — 每个 TG 账号的独立线程</p>
|
||||||
|
|
||||||
|
<div class="info-box warning">
|
||||||
|
<p><strong>关键兼容性</strong>:TDLib 1.8.64+ 使用 <strong>FLAT 格式</strong>,<code>setTdlibParameters</code> 参数不再包裹在 <code>parameters</code> 对象里。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p><strong>启动流程:</strong></p>
|
||||||
|
<div class="flow-card">
|
||||||
|
<span class="step">1</span>
|
||||||
|
<div class="content"><strong>loadTdlib()</strong> — 通过 koffi 加载原生 tdjson DLL/SO(支持自动路径检测)</div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-arrow">↓</div>
|
||||||
|
<div class="flow-card">
|
||||||
|
<span class="step">2</span>
|
||||||
|
<div class="content"><strong>td_json_client_create()</strong> — 创建 TDLib 客户端实例</div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-arrow">↓</div>
|
||||||
|
<div class="flow-card">
|
||||||
|
<span class="step">3</span>
|
||||||
|
<div class="content"><strong>配置代理</strong> — 支持 SOCKS5 / HTTP / MTProto 代理</div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-arrow">↓</div>
|
||||||
|
<div class="flow-card">
|
||||||
|
<span class="step">4</span>
|
||||||
|
<div class="content"><strong>mainLoop()</strong> — setImmediate 递归事件循环,非阻塞轮询 TDLib</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p><strong>事件循环逻辑:</strong></p>
|
||||||
|
<ol>
|
||||||
|
<li>处理主线程消息队列(发送请求到 TDLib)</li>
|
||||||
|
<li><code>td_json_client_receive(timeout=0.1)</code> — 非阻塞轮询响应</li>
|
||||||
|
<li><code>processResponse()</code> — 处理 TDLib 响应,自动处理授权状态机</li>
|
||||||
|
<li><code>setImmediate(mainLoop)</code> — 让出事件循环,继续下一轮</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3>5.3 账号授权模块</h3>
|
||||||
|
<p><code>src/modules/tg-auth/tg-auth.service.ts</code> — 处理 Telegram 完整授权流程</p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>API 端点</th><th>方法</th><th>TDLib 调用</th><th>状态转换</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>POST /auth/phone</code></td><td>setPhoneNumber()</td><td>setAuthenticationPhoneNumber</td><td>→ WAITING_CODE</td></tr>
|
||||||
|
<tr><td><code>POST /auth/code</code></td><td>submitCode()</td><td>checkAuthenticationCode</td><td>→ READY / WAITING_PASSWORD</td></tr>
|
||||||
|
<tr><td><code>POST /auth/password</code></td><td>submitPassword()</td><td>checkAuthenticationPassword</td><td>→ READY</td></tr>
|
||||||
|
<tr><td><code>POST /auth/logout</code></td><td>logout()</td><td>logOut</td><td>→ CREATED</td></tr>
|
||||||
|
<tr><td><code>POST /auth/resend</code></td><td>resendCode()</td><td>resendAuthenticationCode</td><td>—</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>5.4 消息模块</h3>
|
||||||
|
<p><code>src/modules/tg-messages/tg-messages.service.ts</code> — 双模式消息发送</p>
|
||||||
|
|
||||||
|
<div class="two-col">
|
||||||
|
<div class="section" style="margin-bottom:0">
|
||||||
|
<h3 style="margin-top:0">队列模式(Redis 启用)</h3>
|
||||||
|
<ul>
|
||||||
|
<li>BullMQ 异步发送</li>
|
||||||
|
<li>3 次指数退避重试</li>
|
||||||
|
<li>TgSendJob 持久化追踪</li>
|
||||||
|
<li>失败回退到直接模式</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="section" style="margin-bottom:0">
|
||||||
|
<h3 style="margin-top:0">直接模式(Redis 禁用)</h3>
|
||||||
|
<ul>
|
||||||
|
<li>同步调用 TDLib</li>
|
||||||
|
<li>无重试机制</li>
|
||||||
|
<li>简单直接</li>
|
||||||
|
<li>适合开发/轻量场景</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p><strong>支持的操作:</strong></p>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>操作</th><th>TDLib 方法</th><th>说明</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>sendMessage</td><td>sendMessage</td><td>发送文本/媒体消息</td></tr>
|
||||||
|
<tr><td>editMessage</td><td>editMessageText</td><td>编辑已发消息</td></tr>
|
||||||
|
<tr><td>forwardMessage</td><td>forwardMessages</td><td>转发消息</td></tr>
|
||||||
|
<tr><td>deleteMessage</td><td>deleteMessages</td><td>删除消息(可选 revoke)</td></tr>
|
||||||
|
<tr><td>searchMessages</td><td>searchChatMessages</td><td>搜索聊天记录</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h3>5.5 Webhook 模块</h3>
|
||||||
|
<p><code>src/modules/tg-webhooks/tg-webhooks.service.ts</code> — 事件推送机制</p>
|
||||||
|
<ul>
|
||||||
|
<li>为每个 TG 账号创建 Webhook 订阅,指定 URL 和事件类型</li>
|
||||||
|
<li>支持通配符 <code>*</code> 匹配所有事件</li>
|
||||||
|
<li><strong>HMAC-SHA256</strong> 签名验证(<code>X-Webhook-Signature</code> header)</li>
|
||||||
|
<li>指数退避重试(默认最多 8 次)</li>
|
||||||
|
<li>投递记录持久化到数据库</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 6. 数据库实体 -->
|
||||||
|
<div class="section" id="entities">
|
||||||
|
<h2><span class="num">06</span> 数据库实体关系</h2>
|
||||||
|
<p>共 <strong>16 个实体</strong>,覆盖 Telegram 操作的所有数据面。</p>
|
||||||
|
|
||||||
|
<div class="arch-box"><span class="highlight">Tenant (租户)</span>
|
||||||
|
├── 1:N → <span class="highlight2">User</span> (系统用户: email + password)
|
||||||
|
└── 1:N → <span class="highlight2">TgAccount</span> (TG账号)
|
||||||
|
├── 1:N → <span class="highlight3">TgAccountProxyBinding</span> → <span class="highlight3">TgProxy</span> (代理绑定)
|
||||||
|
├── 1:N → <span class="highlight3">TgChat</span> (聊天)
|
||||||
|
├── 1:N → <span class="highlight3">TgMessage</span> (消息)
|
||||||
|
├── 1:N → <span class="highlight3">TgContact</span> (联系人)
|
||||||
|
├── 1:N → <span class="highlight3">TgGroup</span> (群组)
|
||||||
|
├── 1:N → <span class="highlight3">TgSendJob</span> (发送任务)
|
||||||
|
├── 1:N → <span class="highlight3">TgFile</span> (文件)
|
||||||
|
├── 1:N → <span class="highlight3">TgWebhookSubscription</span> (Webhook订阅)
|
||||||
|
│ └── 1:N → <span class="highlight3">TgWebhookDelivery</span> (投递记录)
|
||||||
|
└── 1:N → <span class="highlight3">TgAuditLog</span> (审计日志)
|
||||||
|
|
||||||
|
独立实体: <span class="highlight">TgRawUpdate</span> (原始更新), <span class="highlight">TgRateLimitEvent</span> (速率事件)</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>实体</th><th>表名</th><th>关键字段</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>Tenant</code></td><td>tenants</td><td>name, maxAccounts, isActive</td></tr>
|
||||||
|
<tr><td><code>User</code></td><td>users</td><td>email, passwordHash, tenantId</td></tr>
|
||||||
|
<tr><td><code>TgAccount</code></td><td>tg_accounts</td><td>phoneNumber, status, tenantId, tdlibClientId</td></tr>
|
||||||
|
<tr><td><code>TgProxy</code></td><td>tg_proxies</td><td>type, host, port, username, password</td></tr>
|
||||||
|
<tr><td><code>TgChat</code></td><td>tg_chats</td><td>tgChatId, title, type, accountId</td></tr>
|
||||||
|
<tr><td><code>TgMessage</code></td><td>tg_messages</td><td>tgMessageId, content, chatId, accountId</td></tr>
|
||||||
|
<tr><td><code>TgSendJob</code></td><td>tg_send_jobs</td><td>bullJobId, status, payload, accountId</td></tr>
|
||||||
|
<tr><td><code>TgWebhookSubscription</code></td><td>tg_webhook_subscriptions</td><td>url, events, secret, isActive</td></tr>
|
||||||
|
<tr><td><code>TgWebhookDelivery</code></td><td>tg_webhook_deliveries</td><td>url, payload, status, attempt</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 7. 状态机 -->
|
||||||
|
<div class="section" id="state-machine">
|
||||||
|
<h2><span class="num">07</span> 账号状态机</h2>
|
||||||
|
<p>TG 账号的生命周期通过状态机管理:</p>
|
||||||
|
|
||||||
|
<div class="state-machine">
|
||||||
|
<span class="state-node">CREATED</span>
|
||||||
|
<span class="state-arrow">→</span>
|
||||||
|
<span class="state-node">WAITING_TDLIB</span>
|
||||||
|
<span class="state-arrow">→</span>
|
||||||
|
<span class="state-node">WAITING_PHONE</span>
|
||||||
|
<span class="state-arrow">→</span>
|
||||||
|
<span class="state-node">WAITING_CODE</span>
|
||||||
|
<span class="state-arrow">→</span>
|
||||||
|
<span class="state-node">WAITING_PASSWORD</span>
|
||||||
|
<span class="state-arrow">→</span>
|
||||||
|
<span class="state-node" style="border-color: var(--accent2); color: var(--accent2);">READY</span>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center; margin-top:8px;">
|
||||||
|
<span class="state-arrow">↓ 异常</span>
|
||||||
|
</div>
|
||||||
|
<div class="state-machine" style="justify-content:center;">
|
||||||
|
<span class="state-node" style="border-color: var(--accent4); color: var(--accent4);">ERROR</span>
|
||||||
|
<span style="margin: 0 20px; color: #8b949e;">/</span>
|
||||||
|
<span class="state-node" style="border-color: var(--accent4); color: var(--accent4);">DISABLED</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>状态</th><th>含义</th><th>触发条件</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>CREATED</code></td><td>已创建,未启动</td><td>创建 TG 账号记录</td></tr>
|
||||||
|
<tr><td><code>WAITING_TDLIB</code></td><td>TDLib 初始化中</td><td>调用 startClient()</td></tr>
|
||||||
|
<tr><td><code>WAITING_PHONE</code></td><td>等待提交手机号</td><td>TDLib 就绪</td></tr>
|
||||||
|
<tr><td><code>WAITING_CODE</code></td><td>等待验证码</td><td>提交手机号成功</td></tr>
|
||||||
|
<tr><td><code>WAITING_PASSWORD</code></td><td>等待 2FA 密码</td><td>提交验证码后检测到 2FA</td></tr>
|
||||||
|
<tr><td><code>READY</code></td><td>已授权,可用</td><td>授权流程完成</td></tr>
|
||||||
|
<tr><td><code>ERROR</code></td><td>出错</td><td>任何步骤失败</td></tr>
|
||||||
|
<tr><td><code>DISABLED</code></td><td>已禁用</td><td>手动禁用或登出</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 8. API 端点 -->
|
||||||
|
<div class="section" id="api">
|
||||||
|
<h2><span class="num">08</span> API 端点总览</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>路径</th><th>方法</th><th>功能</th><th>认证</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>/api/auth/register</code></td><td>POST</td><td>用户注册</td><td><span class="tag tag-green">公开</span></td></tr>
|
||||||
|
<tr><td><code>/api/auth/login</code></td><td>POST</td><td>用户登录(返回 JWT)</td><td><span class="tag tag-green">公开</span></td></tr>
|
||||||
|
<tr><td><code>/api/admin/health</code></td><td>GET</td><td>健康检查</td><td><span class="tag tag-green">公开</span></td></tr>
|
||||||
|
<tr><td><code>/api/admin/metrics</code></td><td>GET</td><td>应用指标</td><td><span class="tag tag-blue">JWT</span></td></tr>
|
||||||
|
<tr><td><code>/api/admin/status</code></td><td>GET</td><td>TDLib 状态</td><td><span class="tag tag-blue">JWT</span></td></tr>
|
||||||
|
<tr><td><code>/api/tenants</code></td><td>CRUD</td><td>租户管理</td><td><span class="tag tag-blue">JWT</span></td></tr>
|
||||||
|
<tr><td><code>/api/tg-accounts</code></td><td>CRUD</td><td>TG 账号管理</td><td><span class="tag tag-purple">JWT + 租户</span></td></tr>
|
||||||
|
<tr><td><code>/api/tg-accounts/:id/start</code></td><td>POST</td><td>启动 TDLib 客户端</td><td><span class="tag tag-purple">JWT + 租户</span></td></tr>
|
||||||
|
<tr><td><code>/api/tg-accounts/:id/stop</code></td><td>POST</td><td>停止 TDLib 客户端</td><td><span class="tag tag-purple">JWT + 租户</span></td></tr>
|
||||||
|
<tr><td><code>/api/tg-accounts/:id/auth/phone</code></td><td>POST</td><td>提交手机号</td><td><span class="tag tag-purple">JWT + 租户</span></td></tr>
|
||||||
|
<tr><td><code>/api/tg-accounts/:id/auth/code</code></td><td>POST</td><td>提交验证码</td><td><span class="tag tag-purple">JWT + 租户</span></td></tr>
|
||||||
|
<tr><td><code>/api/tg-accounts/:id/auth/password</code></td><td>POST</td><td>提交 2FA 密码</td><td><span class="tag tag-purple">JWT + 租户</span></td></tr>
|
||||||
|
<tr><td><code>/api/tg-accounts/:id/chats</code></td><td>GET</td><td>获取聊天列表</td><td><span class="tag tag-purple">JWT + 租户</span></td></tr>
|
||||||
|
<tr><td><code>/api/tg-accounts/:id/messages</code></td><td>POST</td><td>发送消息</td><td><span class="tag tag-purple">JWT + 租户</span></td></tr>
|
||||||
|
<tr><td><code>/api/tg-accounts/:id/messages/search</code></td><td>POST</td><td>搜索消息</td><td><span class="tag tag-purple">JWT + 租户</span></td></tr>
|
||||||
|
<tr><td><code>/api/tg-accounts/:id/webhooks</code></td><td>CRUD</td><td>Webhook 订阅管理</td><td><span class="tag tag-purple">JWT + 租户</span></td></tr>
|
||||||
|
<tr><td><code>/api/docs</code></td><td>GET</td><td>Swagger API 文档</td><td><span class="tag tag-green">公开</span></td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 9. 安全 -->
|
||||||
|
<div class="section" id="security">
|
||||||
|
<h2><span class="num">09</span> 安全与多租户</h2>
|
||||||
|
|
||||||
|
<div class="two-col">
|
||||||
|
<div>
|
||||||
|
<h3>认证机制</h3>
|
||||||
|
<ul>
|
||||||
|
<li><strong>JWT 全局守卫</strong>:<code>APP_GUARD</code> 注册 <code>JwtAuthGuard</code></li>
|
||||||
|
<li>所有路由默认需要认证</li>
|
||||||
|
<li>公开路由通过 <code>@Public()</code> 装饰器标记</li>
|
||||||
|
<li>密码使用 <strong>bcrypt</strong> 哈希存储</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3>租户隔离</h3>
|
||||||
|
<ul>
|
||||||
|
<li><code>x-tenant-id</code> header + <code>TenantGuard</code></li>
|
||||||
|
<li>每个请求都绑定到特定租户</li>
|
||||||
|
<li>租户有 <code>maxAccounts</code> 限制(默认 5)</li>
|
||||||
|
<li>数据完全隔离,不可跨租户访问</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>审计日志</h3>
|
||||||
|
<ul>
|
||||||
|
<li><code>AuditLogInterceptor</code> 自动记录关键操作</li>
|
||||||
|
<li>记录操作人、操作类型、请求参数、响应结果</li>
|
||||||
|
<li>持久化到 <code>tg_audit_logs</code> 表</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3>错误码体系</h3>
|
||||||
|
<p>定义了 <strong>60+ 错误码</strong>,覆盖所有异常场景:</p>
|
||||||
|
<div class="two-col">
|
||||||
|
<div>
|
||||||
|
<ul>
|
||||||
|
<li><code>GENERAL</code>: INTERNAL_ERROR, VALIDATION_ERROR, NOT_FOUND...</li>
|
||||||
|
<li><code>TENANT</code>: TENANT_NOT_FOUND, TENANT_DISABLED</li>
|
||||||
|
<li><code>AUTH</code>: INVALID_CREDENTIALS, TOKEN_EXPIRED</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<ul>
|
||||||
|
<li><code>TG_ACCOUNT</code>: TG_ACCOUNT_NOT_FOUND, TG_ACCOUNT_LIMIT_REACHED</li>
|
||||||
|
<li><code>TDLib</code>: TDLIB_CLIENT_NOT_FOUND, TDLIB_ERROR</li>
|
||||||
|
<li><code>WEBHOOK</code>: WEBHOOK_NOT_FOUND, WEBHOOK_DELIVERY_FAILED</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 10. 配置 -->
|
||||||
|
<div class="section" id="config">
|
||||||
|
<h2><span class="num">10</span> 配置模式</h2>
|
||||||
|
|
||||||
|
<div class="two-col">
|
||||||
|
<div class="section" style="margin-bottom:0">
|
||||||
|
<h3 style="margin-top:0">开发模式(当前)</h3>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>DB_TYPE</code></td><td>sqlite</td></tr>
|
||||||
|
<tr><td><code>REDIS_ENABLED</code></td><td>false</td></tr>
|
||||||
|
<tr><td><code>TDLIB_PATH</code></td><td>自动检测</td></tr>
|
||||||
|
<tr><td>消息发送</td><td>直接模式</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="info-box success">
|
||||||
|
<p>无需外部依赖,开箱即用</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section" style="margin-bottom:0">
|
||||||
|
<h3 style="margin-top:0">生产模式</h3>
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>DB_TYPE</code></td><td>postgres</td></tr>
|
||||||
|
<tr><td><code>REDIS_ENABLED</code></td><td>true</td></tr>
|
||||||
|
<tr><td><code>TDLIB_PATH</code></td><td>指定路径</td></tr>
|
||||||
|
<tr><td>消息发送</td><td>队列模式</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="info-box warning">
|
||||||
|
<p>需要 PostgreSQL + Redis 服务</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>关键环境变量</h3>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>变量</th><th>说明</th><th>默认值</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td><code>PORT</code></td><td>服务端口</td><td>3000</td></tr>
|
||||||
|
<tr><td><code>DB_TYPE</code></td><td>数据库类型 (postgres/sqlite)</td><td>postgres</td></tr>
|
||||||
|
<tr><td><code>TG_API_ID</code></td><td>Telegram API ID</td><td>—</td></tr>
|
||||||
|
<tr><td><code>TG_API_HASH</code></td><td>Telegram API Hash</td><td>—</td></tr>
|
||||||
|
<tr><td><code>JWT_SECRET</code></td><td>JWT 签名密钥</td><td>—</td></tr>
|
||||||
|
<tr><td><code>REDIS_ENABLED</code></td><td>是否启用 Redis</td><td>true</td></tr>
|
||||||
|
<tr><td><code>TDLIB_PATH</code></td><td>TDLib 库路径</td><td>自动检测</td></tr>
|
||||||
|
<tr><td><code>MAX_FILE_SIZE</code></td><td>最大上传文件大小</td><td>200MB</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 11. 完整流程 -->
|
||||||
|
<div class="section" id="workflow">
|
||||||
|
<h2><span class="num">11</span> 完整调用流程示例</h2>
|
||||||
|
|
||||||
|
<div class="flow-card">
|
||||||
|
<span class="step">1</span>
|
||||||
|
<div class="content"><strong>POST /api/auth/register</strong> — 注册系统用户<br><code>{"email": "admin@example.com", "password": "***"}</code></div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-arrow">↓</div>
|
||||||
|
<div class="flow-card">
|
||||||
|
<span class="step">2</span>
|
||||||
|
<div class="content"><strong>POST /api/auth/login</strong> — 登录获取 JWT Token<br><code>{"token": "eyJhbGciOiJIUzI1NiIs..."}</code></div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-arrow">↓</div>
|
||||||
|
<div class="flow-card">
|
||||||
|
<span class="step">3</span>
|
||||||
|
<div class="content"><strong>POST /api/tenants</strong> — 创建租户<br>Header: <code>x-tenant-id: xxx</code></div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-arrow">↓</div>
|
||||||
|
<div class="flow-card">
|
||||||
|
<span class="step">4</span>
|
||||||
|
<div class="content"><strong>POST /api/tg-accounts</strong> — 创建 TG 账号(自动启动 TDLib Worker)</div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-arrow">↓</div>
|
||||||
|
<div class="flow-card">
|
||||||
|
<span class="step">5</span>
|
||||||
|
<div class="content"><strong>POST /api/tg-accounts/:id/auth/phone</strong> — 提交手机号<br><code>{"phoneNumber": "+8613800138000"}</code></div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-arrow">↓</div>
|
||||||
|
<div class="flow-card">
|
||||||
|
<span class="step">6</span>
|
||||||
|
<div class="content"><strong>POST /api/tg-accounts/:id/auth/code</strong> — 提交验证码<br><code>{"code": "12345"}</code></div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-arrow">↓</div>
|
||||||
|
<div class="flow-card">
|
||||||
|
<span class="step">7</span>
|
||||||
|
<div class="content"><strong>POST /api/tg-accounts/:id/messages</strong> — 发送消息<br><code>{"chatId": 123456, "text": "Hello!"}</code></div>
|
||||||
|
</div>
|
||||||
|
<div class="flow-arrow">↓</div>
|
||||||
|
<div class="flow-card">
|
||||||
|
<span class="step">8</span>
|
||||||
|
<div class="content"><strong>POST /api/tg-accounts/:id/webhooks</strong> — 创建 Webhook 订阅<br><code>{"url": "https://example.com/hook", "events": ["newMessage"]}</code></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 12. 构建部署 -->
|
||||||
|
<div class="section" id="deploy">
|
||||||
|
<h2><span class="num">12</span> 构建与部署</h2>
|
||||||
|
|
||||||
|
<h3>本地开发</h3>
|
||||||
|
<pre><span class="highlight"># 安装依赖</span>
|
||||||
|
npm install
|
||||||
|
|
||||||
|
<span class="highlight"># 开发模式(热重载)</span>
|
||||||
|
npm run start:dev
|
||||||
|
|
||||||
|
<span class="highlight"># 访问 Swagger 文档</span>
|
||||||
|
http://localhost:3000/api/docs</pre>
|
||||||
|
|
||||||
|
<h3>生产构建</h3>
|
||||||
|
<pre><span class="highlight"># 编译 TypeScript</span>
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
<span class="highlight"># 启动生产服务</span>
|
||||||
|
npm run start:prod
|
||||||
|
|
||||||
|
<span class="highlight"># 运行数据库迁移</span>
|
||||||
|
npm run migration:run</pre>
|
||||||
|
|
||||||
|
<h3>Docker 部署</h3>
|
||||||
|
<pre><span class="highlight"># 一键启动(PostgreSQL + Redis + App)</span>
|
||||||
|
docker-compose -f docker/docker-compose.yml up -d
|
||||||
|
|
||||||
|
<span class="highlight"># 查看日志</span>
|
||||||
|
docker-compose -f docker/docker-compose.yml logs -f app</pre>
|
||||||
|
|
||||||
|
<h3>测试</h3>
|
||||||
|
<pre><span class="highlight"># 单元测试</span>
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
<span class="highlight"># 端到端测试</span>
|
||||||
|
npm run test:e2e</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Telegram User Gateway v1.0.0 | 生成于 2026-06-24 | 基于源码自动分析</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// Preload .env before any module imports
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
dotenv.config({ path: ['.env', '.env.local'] });
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
||||||
|
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
|
|
||||||
|
import { appConfig } from './config/app.config';
|
||||||
|
import { databaseConfig } from './config/database.config';
|
||||||
|
import { redisConfig } from './config/redis.config';
|
||||||
|
import { rateLimitConfig } from './config/rate-limit.config';
|
||||||
|
|
||||||
|
import { JwtAuthGuard } from './common/guards/jwt-auth.guard';
|
||||||
|
|
||||||
|
import { TenantsModule } from './modules/tenants/tenants.module';
|
||||||
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
|
import { TgAccountsModule } from './modules/tg-accounts/tg-accounts.module';
|
||||||
|
import { TgAuthModule } from './modules/tg-auth/tg-auth.module';
|
||||||
|
import { TgProxiesModule } from './modules/tg-proxies/tg-proxies.module';
|
||||||
|
import { TdlibModule } from './modules/tdlib/tdlib.module';
|
||||||
|
import { TgChatsModule } from './modules/tg-chats/tg-chats.module';
|
||||||
|
import { TgDialogsModule } from './modules/tg-dialogs/tg-dialogs.module';
|
||||||
|
import { TgContactsModule } from './modules/tg-contacts/tg-contacts.module';
|
||||||
|
import { TgMessagesModule } from './modules/tg-messages/tg-messages.module';
|
||||||
|
import { TgGroupsModule } from './modules/tg-groups/tg-groups.module';
|
||||||
|
import { TgChannelsModule } from './modules/tg-channels/tg-channels.module';
|
||||||
|
import { TgFilesModule } from './modules/tg-files/tg-files.module';
|
||||||
|
import { TgWebhooksModule } from './modules/tg-webhooks/tg-webhooks.module';
|
||||||
|
import { TgRateLimitModule } from './modules/tg-rate-limit/tg-rate-limit.module';
|
||||||
|
import { TgAuditModule } from './modules/tg-audit/tg-audit.module';
|
||||||
|
import { AdminModule } from './modules/admin/admin.module';
|
||||||
|
import { TgUpdatesModule } from './modules/tg-updates/tg-updates.module';
|
||||||
|
import { AuditLogInterceptor } from './common/interceptors/audit-log.interceptor';
|
||||||
|
|
||||||
|
// Conditionally import BullMQ
|
||||||
|
const redisEnabled = process.env.REDIS_ENABLED !== 'false';
|
||||||
|
const bullImports: any[] = [];
|
||||||
|
const bullProviders: any[] = [];
|
||||||
|
|
||||||
|
if (redisEnabled) {
|
||||||
|
const { BullModule } = require('@nestjs/bull');
|
||||||
|
bullImports.push(
|
||||||
|
BullModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
redis: {
|
||||||
|
host: config.get('redis.host'),
|
||||||
|
port: config.get('redis.port'),
|
||||||
|
password: config.get('redis.password'),
|
||||||
|
db: config.get('redis.db'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Provide mock Bull queue tokens so injectors don't crash
|
||||||
|
const { getQueueToken } = require('@nestjs/bull');
|
||||||
|
const queueNames = ['send-message', 'file-download', 'webhook-delivery'];
|
||||||
|
for (const name of queueNames) {
|
||||||
|
bullProviders.push({
|
||||||
|
provide: getQueueToken(name),
|
||||||
|
useValue: { add: () => Promise.reject(new Error('Redis/BullMQ not available')), process: () => {} },
|
||||||
|
});
|
||||||
|
bullProviders.push({
|
||||||
|
provide: 'BullQueue_' + name,
|
||||||
|
useValue: { add: () => Promise.reject(new Error('Redis/BullMQ not available')), process: () => {} },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const imports: any[] = [
|
||||||
|
ConfigModule.forRoot({
|
||||||
|
isGlobal: true,
|
||||||
|
load: [appConfig, databaseConfig, redisConfig, rateLimitConfig],
|
||||||
|
envFilePath: ['.env'],
|
||||||
|
}),
|
||||||
|
TypeOrmModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => {
|
||||||
|
const dbType = config.get('database.type') || 'postgres';
|
||||||
|
if (dbType === 'sqlite') {
|
||||||
|
const sqlitePath = config.get('database.sqlitePath') || 'data/gateway.sqlite';
|
||||||
|
const inMemory = sqlitePath === ':memory:';
|
||||||
|
return {
|
||||||
|
type: 'sqljs',
|
||||||
|
...(inMemory ? {} : { location: sqlitePath }),
|
||||||
|
autoSave: !inMemory,
|
||||||
|
entities: [__dirname + '/database/entities/**/*.entity.{ts,js}'],
|
||||||
|
synchronize: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'postgres',
|
||||||
|
host: config.get('database.host'),
|
||||||
|
port: config.get('database.port'),
|
||||||
|
username: config.get('database.username'),
|
||||||
|
password: config.get('database.password'),
|
||||||
|
database: config.get('database.database'),
|
||||||
|
entities: [__dirname + '/database/entities/**/*.entity.{ts,js}'],
|
||||||
|
synchronize:
|
||||||
|
config.get('database.synchronize') ||
|
||||||
|
config.get('app.nodeEnv') === 'development',
|
||||||
|
migrationsRun: config.get('database.migrationsRun') || false,
|
||||||
|
migrations: [__dirname + '/database/migrations/*.{ts,js}'],
|
||||||
|
logging: config.get('app.nodeEnv') === 'development',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...bullImports,
|
||||||
|
ThrottlerModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => ({
|
||||||
|
throttlers: [
|
||||||
|
{
|
||||||
|
ttl: config.get('rateLimit.defaultTtl') || 60000,
|
||||||
|
limit: config.get('rateLimit.defaultLimit') || 30,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
TenantsModule,
|
||||||
|
AuthModule,
|
||||||
|
TgAccountsModule,
|
||||||
|
TgAuthModule,
|
||||||
|
TgProxiesModule,
|
||||||
|
TdlibModule,
|
||||||
|
TgChatsModule,
|
||||||
|
TgDialogsModule,
|
||||||
|
TgContactsModule,
|
||||||
|
TgMessagesModule,
|
||||||
|
TgGroupsModule,
|
||||||
|
TgChannelsModule,
|
||||||
|
TgFilesModule,
|
||||||
|
TgWebhooksModule,
|
||||||
|
TgRateLimitModule,
|
||||||
|
TgAuditModule,
|
||||||
|
AdminModule,
|
||||||
|
TgUpdatesModule,
|
||||||
|
];
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports,
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: JwtAuthGuard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_GUARD,
|
||||||
|
useClass: ThrottlerGuard,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_INTERCEPTOR,
|
||||||
|
useClass: AuditLogInterceptor,
|
||||||
|
},
|
||||||
|
...bullProviders,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { applyDecorators } from '@nestjs/common';
|
||||||
|
import { ApiProperty, ApiResponse } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class OkResponse<T> {
|
||||||
|
@ApiProperty()
|
||||||
|
success: boolean;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
data: T;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ApiOkResponse(description: string) {
|
||||||
|
return applyDecorators(ApiResponse({ status: 200, description, type: OkResponse }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||||
|
|
||||||
|
export const CurrentUser = createParamDecorator(
|
||||||
|
(data: string | undefined, ctx: ExecutionContext) => {
|
||||||
|
const request = ctx.switchToHttp().getRequest();
|
||||||
|
const user = request.user;
|
||||||
|
return data ? user?.[data] : user;
|
||||||
|
},
|
||||||
|
);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user