Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06112602ec | |||
| af49d22ca7 | |||
| 9804341463 | |||
| 7f09132fad | |||
| cd06d049fe |
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
server/node_modules
|
||||
dist
|
||||
.git
|
||||
.env
|
||||
*.log
|
||||
+45
-6
@@ -1,7 +1,46 @@
|
||||
# ===== 灵犀跨境 前端环境变量 =====
|
||||
# 是否使用内置 Mock 服务:true = 本地模拟数据(默认);false = 请求真实后端
|
||||
VITE_USE_MOCK=true
|
||||
# 必须生成新值,禁止提交真实密钥
|
||||
LX_DB_PASSWORD=replace-with-strong-database-password
|
||||
LX_JWT_SECRET=replace-with-at-least-32-random-characters
|
||||
LX_WEB_PORT=8088
|
||||
|
||||
# 真实后端 API 基础地址(VITE_USE_MOCK=false 时生效)
|
||||
# 注意:请通过服务端环境变量管理密钥,前端代码中禁止放置任何真实 API Key
|
||||
VITE_API_BASE_URL=http://localhost:8080/api
|
||||
# DeepSeek Anthropic-compatible API
|
||||
DEEPSEEK_API_KEY=replace-with-rotated-deepseek-key
|
||||
DEEPSEEK_MODEL=deepseek-v4-flash
|
||||
|
||||
# 已部署的 Telegram Gateway 服务凭据
|
||||
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-gateway-service-email
|
||||
TELEGRAM_GATEWAY_SERVICE_PASSWORD=replace-with-random-service-password
|
||||
TELEGRAM_GATEWAY_ACCOUNT_ID=replace-with-default-account-id
|
||||
TELEGRAM_WEBHOOK_SECRET=replace-with-random-webhook-secret
|
||||
|
||||
# 普通 WhatsApp 账户通过“关联设备”接入。两个值均至少 32 位且必须彼此不同。
|
||||
WHATSAPP_GATEWAY_TOKEN=replace-with-at-least-32-random-characters
|
||||
WHATSAPP_WEBHOOK_SECRET=replace-with-another-32-random-secret
|
||||
WHATSAPP_LOG_LEVEL=info
|
||||
|
||||
PUBLIC_WEBHOOK_BASE_URL=http://host.docker.internal:8088
|
||||
APP_PUBLIC_URL=http://127.0.0.1:8088
|
||||
|
||||
# 真实团队邀请邮件
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=
|
||||
|
||||
# 企业邮箱渠道(IMAP 收信 + SMTP 发信)
|
||||
# 建议使用 32 字节以上独立随机值;未配置时回退到 LX_JWT_SECRET 派生密钥。
|
||||
EMAIL_CREDENTIAL_KEY=
|
||||
EMAIL_POLL_MS=30000
|
||||
|
||||
# 真实 Stripe Checkout
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_PRICE_LITE=
|
||||
STRIPE_PRICE_STANDARD=
|
||||
STRIPE_PRICE_PRO=
|
||||
STRIPE_PRICE_GROWTH=
|
||||
|
||||
@@ -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
-7
@@ -1,11 +1,56 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
# Dependencies and build output
|
||||
node_modules/
|
||||
**/node_modules/
|
||||
dist/
|
||||
**/dist/
|
||||
dist-ssr/
|
||||
coverage/
|
||||
.vite/
|
||||
|
||||
# Local configuration and secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.local
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.pem
|
||||
*.key
|
||||
id_rsa*
|
||||
id_ed25519*
|
||||
|
||||
# Runtime data
|
||||
.smoke-data/
|
||||
**/.smoke-data/
|
||||
data/
|
||||
uploads/
|
||||
storage/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.dump
|
||||
*.sql
|
||||
*.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
|
||||
.idea
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
npm-debug.log*
|
||||
.claude/
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# 版本迭代记录
|
||||
|
||||
本项目遵循语义化版本。早期版本由经过脱敏的本地源码快照恢复,因此使用带日期的 `snapshot-*` 标签;自 `v1.0.0` 起按正式发布流程维护。
|
||||
|
||||
## [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. 推送分支和标签,随后按部署文档发布。
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
FROM node:20-alpine AS web-build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=web-build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=15s --timeout=3s --retries=5 CMD wget -qO- http://127.0.0.1/health || exit 1
|
||||
@@ -1,156 +1,86 @@
|
||||
# 灵犀跨境 · 跨境 AI 经营服务平台(前端)
|
||||
# 灵犀跨境
|
||||
|
||||
> 让跨境沟通更简单 — 徐州欧汇科技有限公司
|
||||
灵犀跨境是一套面向跨境业务团队的多渠道客户经营 SaaS。系统将 Telegram、普通 WhatsApp 账号和企业邮箱接入统一消息中心,并提供上下文翻译、逐句意图识别、情绪与风险分析、客户画像、商机、任务、知识库和经营分析能力。
|
||||
|
||||
本仓库为「灵犀跨境」SaaS 平台的前端部分,面向中小外贸企业、跨境卖家和产业带企业,将 WhatsApp / Telegram / 邮件 / 独立站表单等渠道的客户咨询统一接入,通过 AI 翻译、意图识别、情感分析、风险预警与客户建档,将聊天记录沉淀为可追踪、可成交的客户资产。
|
||||
当前仓库包含 Web 前端、业务 API、Telegram Gateway 和 WhatsApp Gateway。Telegram Gateway 源码位于 `telegram-gateway/`,运行时仍作为独立服务部署。开放功能均连接真实数据库和真实网关,不使用前端 Mock 作为生产数据源。
|
||||
|
||||
## 技术栈
|
||||
## 核心能力
|
||||
|
||||
- **React 18** + **TypeScript 5**
|
||||
- **Vite 5** — 开发与构建
|
||||
- **Tailwind CSS 3.4** — 原子化样式
|
||||
- **shadcn/ui 风格** — 手写轻量企业 UI 组件
|
||||
- **React Router 6** — 路由
|
||||
- **Zustand 4** — 全局状态(认证/UI/Toast/确认弹窗)
|
||||
- **TanStack Query 5** — 数据请求与缓存
|
||||
- **Recharts 2** — 图表
|
||||
- **React Hook Form 7** + **Zod 3** — 表单与校验
|
||||
- **Lucide React** — 图标
|
||||
- 多租户注册、登录、RBAC、API Key、密码修改和会话撤销。
|
||||
- 多 Telegram、WhatsApp、企业邮箱账号绑定、切换和状态管理。
|
||||
- 实时会话、历史分页、已读、引用、媒体、消息状态和 WebSocket 推送。
|
||||
- 自动识别目标语言的双向翻译,逐会话上下文压缩与历史后台补齐。
|
||||
- 情感、意图、风险、摘要、智能回复和知识库辅助。
|
||||
- 客户、标签、画像、商机、任务、团队、通知、审计和经营分析。
|
||||
- WhatsApp 群组、联系人、代理、设备、重试、死信重放与备份接口。
|
||||
|
||||
## 快速开始
|
||||
## 快速启动
|
||||
|
||||
```bash
|
||||
# 1. 安装依赖
|
||||
npm install
|
||||
要求:Docker Engine 24+、Docker Compose v2。首次部署应先按 [Telegram Gateway 接入指南](docs/TELEGRAM-GATEWAY.md) 启动网关,再启动平台。
|
||||
|
||||
# 2. 启动开发服务器(默认 Mock 模式)
|
||||
npm run dev
|
||||
|
||||
# 3. 浏览器打开 http://localhost:5173
|
||||
```powershell
|
||||
Copy-Item .env.example .env
|
||||
# 编辑 .env,替换所有 replace-with-* 配置
|
||||
docker compose --env-file .env up -d --build
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
登录页点击下方**演示角色一键登录**即可进入系统:
|
||||
- 企业管理员 / 业务主管 / 外贸业务员 / 客服人员 / 只读成员
|
||||
默认入口为 `http://127.0.0.1:8088`。存活与就绪检查:
|
||||
|
||||
也可以使用验证码模式:任意手机号 / 邮箱 + 任意 6 位验证码即可登录。
|
||||
|
||||
## 构建生产包
|
||||
|
||||
```bash
|
||||
npm run build # TypeScript 检查 + Vite 构建
|
||||
npm run preview # 预览构建结果
|
||||
```powershell
|
||||
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)})"
|
||||
```
|
||||
|
||||
## 环境变量与模式切换
|
||||
完整部署步骤、生产加固和回滚方法见 [部署文档](docs/DEPLOYMENT.md)。
|
||||
|
||||
项目通过 `.env` 文件控制数据来源:
|
||||
## 本地开发与测试
|
||||
|
||||
```env
|
||||
# 使用内置 Mock 服务(默认,无需后端)
|
||||
VITE_USE_MOCK=true
|
||||
```powershell
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm run build
|
||||
|
||||
# 真实后端 API 地址(VITE_USE_MOCK=false 时生效)
|
||||
VITE_API_BASE_URL=http://localhost:8080/api
|
||||
Push-Location server
|
||||
npm ci
|
||||
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
|
||||
```
|
||||
|
||||
- **Mock 模式**(`VITE_USE_MOCK=true`):前端内置完整演示数据集(30 个客户、20 条会话、100+ 条多语种消息、20 个跟进任务、15 条风险预警、30 条企业知识、8 名团队成员、13 个渠道),所有 API 请求在前端通过 Mock 路由处理,无需任何后端服务。
|
||||
- **真实模式**(`VITE_USE_MOCK=false`):所有 API 请求转发到 `VITE_API_BASE_URL`,自动携带 Bearer Token。
|
||||
E2E 默认访问 `http://127.0.0.1:8088`,可通过 `E2E_BASE_URL` 修改。
|
||||
|
||||
> ⚠️ **安全提示**:请勿在前端代码中存放任何真实 API 密钥。所有密钥应由服务端环境变量管理。
|
||||
## 文档
|
||||
|
||||
## 项目结构
|
||||
- [系统架构](docs/ARCHITECTURE.md)
|
||||
- [技术选型](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)
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/
|
||||
│ ├── router/index.tsx # React Router 路由配置
|
||||
│ ├── providers/index.tsx # QueryClient + i18n + Toast + Confirm
|
||||
│ └── store/ # Zustand stores (auth / ui / toast / confirm)
|
||||
├── components/
|
||||
│ ├── layout/ # AppLayout / Sidebar / Topbar / GlobalSearch
|
||||
│ ├── common/ # PageHeader / StatCard / ChannelIcon / CountryFlag / StatusBadges / PermissionGate / DemoNote
|
||||
│ ├── ui/ # UI 基础组件(button / input / select / dialog / drawer / tabs / table / toast / pagination 等)
|
||||
│ ├── inbox/ # 统一消息专属组件(ConversationList / MessageBubble / Composer / InsightPanel / AiReplies)
|
||||
│ ├── customer/ # 客户表单弹窗(CustomerFormDialog)
|
||||
│ ├── crm/ # 商机与任务表单(TaskFormDialog)
|
||||
│ └── charts/ # 图表封装(TrendChart / DonutChart / FunnelChart / RankBars)
|
||||
├── pages/
|
||||
│ ├── auth/ # 登录 / 注册
|
||||
│ ├── onboarding/ # 8 步企业初始化向导
|
||||
│ ├── dashboard/ # 今日经营工作台
|
||||
│ ├── inbox/ # 统一消息中心(三栏布局 + 移动适配)
|
||||
│ ├── customers/ # 客户管理(表格/卡片/看板) + 客户详情
|
||||
│ ├── opportunities/ # 商机看板 + 跟进任务 + 日历
|
||||
│ ├── translate/ # AI 翻译中心
|
||||
│ ├── interpret/ # 同声传译(模拟实时双向字幕)
|
||||
│ ├── risks/ # 风险预警中心
|
||||
│ ├── knowledge/ # 企业知识库 + 检索测试 + 使用统计
|
||||
│ ├── analytics/ # 数据分析 + 老板看板
|
||||
│ ├── channels/ # 渠道中心 + 连接向导
|
||||
│ ├── team/ # 团队协作 + 角色权限矩阵
|
||||
│ ├── billing/ # 套餐与账单
|
||||
│ ├── settings/ # 系统设置(13 组)
|
||||
│ └── help/ # 帮助与培训 + 交付进度
|
||||
├── services/
|
||||
│ ├── api/ # 领域 API 服务(auth / dashboard / channels / conversations / ai / customers / crm / knowledge / org / search)
|
||||
│ └── mock/ # Mock 路由与处理器(按 HTTP 方法 + 路径匹配,支持 :param 参数)
|
||||
├── mocks/
|
||||
│ ├── db.ts # 内存数据库(增删改持久到刷新)
|
||||
│ └── seed/ # 种子数据(customers / conversations / messages / tasks / opportunities / risks / knowledge / team / channels / analytics / misc)
|
||||
├── types/ # TypeScript 类型定义
|
||||
├── config/ # 全局配置(套餐价格 / 支持语言数 / 角色权限 / 导航 / 业务常量 / 环境变量)
|
||||
├── i18n/ # 国际化(zh / en / ar + RTL)
|
||||
├── hooks/ # 自定义 Hooks(usePermission / useDebounce / useMediaQuery)
|
||||
├── utils/ # 工具函数(cn / format / misc)
|
||||
└── styles/ # 全局样式与 CSS 变量
|
||||
```
|
||||
## 版本
|
||||
|
||||
## 接入真实后端
|
||||
`main` 保存当前版本。历史源码以 `snapshot-YYYY.MM.DD-*` 标签归档,当前稳定版本使用语义化标签。任何历史标签都可通过 `git checkout <tag>` 独立检出。
|
||||
|
||||
项目中预留了完整的 API 接口层,对应后端路由如下:
|
||||
## 安全
|
||||
|
||||
| 领域 | 核心接口 |
|
||||
|------|---------|
|
||||
| 认证 | `POST /api/auth/login`, `POST /api/auth/register`, `GET /api/auth/me` |
|
||||
| 工作台 | `GET /api/dashboard/overview` |
|
||||
| 渠道 | `GET /api/channels`, `POST /api/channels/connect`, `POST /api/channels/test`, `PATCH /api/channels/:id`, `DELETE /api/channels/:id` |
|
||||
| 会话 | `GET /api/conversations`, `GET /api/conversations/:id`, `GET /api/conversations/:id/messages`, `POST /api/conversations/:id/messages`, `PATCH /api/conversations/:id` |
|
||||
| AI | `POST /api/ai/translate`, `POST /api/ai/analyze`, `POST /api/ai/replies`, `POST /api/ai/summarize`, `POST /api/ai/interpretation`, `POST /api/ai/text-action` |
|
||||
| 客户 | `GET/POST /api/customers`, `GET/PATCH/DELETE /api/customers/:id`, `POST /api/customers/merge`, `POST /api/customers/import`, `GET /api/customers/export`, `GET /api/customers/duplicates` |
|
||||
| 商机 | `GET/POST /api/opportunities`, `PATCH /api/opportunities/:id` |
|
||||
| 任务 | `GET/POST /api/tasks`, `PATCH /api/tasks/:id` |
|
||||
| 风险 | `GET /api/risks`, `PATCH /api/risks/:id` |
|
||||
| 知识库 | `GET/POST /api/knowledge`, `PATCH/DELETE /api/knowledge/:id`, `POST /api/knowledge/upload`, `POST /api/knowledge/search-test`, `GET /api/knowledge/stats` |
|
||||
| 分析 | `GET /api/analytics` |
|
||||
| 团队 | `GET /api/team`, `POST /api/team/invite`, `PATCH/DELETE /api/team/:id` |
|
||||
| 账单 | `GET /api/billing`, `POST /api/billing/upgrade` |
|
||||
| 设置 | `GET/PATCH /api/settings` |
|
||||
| 通知 | `GET /api/notifications`, `POST /api/notifications/read-all` |
|
||||
| 搜索 | `GET /api/search?q=` |
|
||||
|
||||
后端可使用 Java / Node.js / Python 等任意技术栈实现。参考 Mock 服务中 `src/services/mock/handlers/` 目录下的处理器即可了解各接口的请求/响应 Shape。
|
||||
|
||||
## 套餐与价格配置
|
||||
|
||||
所有套餐价格集中维护在 `src/config/pricingPlans.ts` 中,包含四档套餐(轻助手版 / 标准工作台版 / 专业版 / 增长版/定制版),一处修改全局生效。
|
||||
|
||||
## 语言支持
|
||||
|
||||
平台 UI 支持中文 / English / العربية(RTL),RTL 布局通过 `document.documentElement.dir = 'rtl'` 原生实现。
|
||||
|
||||
翻译引擎支持语言数配置在 `src/config/languages.ts` 的 `supportedLanguageCount` 字段,当前默认值为 96。
|
||||
|
||||
## 演示数据说明
|
||||
|
||||
所有图表、经营指标与客户数据均为**模拟演示数据**(数据来源:`src/mocks/seed/` 目录),不代表真实企业经营状况。每个图表卡片均标注「演示数据」提示。
|
||||
|
||||
客户与消息覆盖以下真实语言场景:
|
||||
- 🇵🇰 乌尔都语(巴基斯坦 · WhatsApp)
|
||||
- 🇦🇪 / 🇸🇦 阿拉伯语(阿联酋 / 沙特 · 邮件 / Telegram)
|
||||
- 🇧🇩 孟加拉语(孟加拉国 · WhatsApp)
|
||||
- 🇪🇸 西班牙语(西班牙 · 独立站表单 / WhatsApp)
|
||||
- 🇬🇧 / 🇺🇸 / 🇩🇪 英语(英国 / 美国 / 德国 · 邮件 / Messenger)
|
||||
|
||||
## License
|
||||
|
||||
本项目为徐州欧汇科技有限公司内部产品,未开源授权。仅供演示评估使用。
|
||||
禁止提交 `.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-*` 仅用于审计与回溯,不提供安全维护。
|
||||
@@ -0,0 +1,14 @@
|
||||
name: lingxi-email-protocol
|
||||
|
||||
services:
|
||||
greenmail:
|
||||
image: lingxi-greenmail-test:2.1.8-runtime
|
||||
ports:
|
||||
- "127.0.0.1:3025:3025"
|
||||
- "127.0.0.1:3143:3143"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "nc -z 127.0.0.1 3025"]
|
||||
interval: 3s
|
||||
timeout: 2s
|
||||
retries: 20
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,101 @@
|
||||
name: lingxi-crossborder
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: ${LX_DB_USER:-lingxi}
|
||||
POSTGRES_PASSWORD: ${LX_DB_PASSWORD:?LX_DB_PASSWORD is required}
|
||||
POSTGRES_DB: ${LX_DB_NAME:-lingxi_crossborder}
|
||||
volumes:
|
||||
- lingxi_pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${LX_DB_USER:-lingxi} -d ${LX_DB_NAME:-lingxi_crossborder}"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
whatsapp-gateway:
|
||||
build:
|
||||
context: ./whatsapp-gateway
|
||||
environment:
|
||||
PORT: 3200
|
||||
DATA_DIR: /data
|
||||
WHATSAPP_GATEWAY_TOKEN: ${WHATSAPP_GATEWAY_TOKEN:?WHATSAPP_GATEWAY_TOKEN is required}
|
||||
LOG_LEVEL: ${WHATSAPP_LOG_LEVEL:-info}
|
||||
volumes:
|
||||
- lingxi_whatsapp_data:/data
|
||||
dns:
|
||||
- 1.1.1.1
|
||||
- 8.8.8.8
|
||||
restart: unless-stopped
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ./server
|
||||
environment:
|
||||
PORT: 3100
|
||||
DATABASE_URL: postgresql://${LX_DB_USER:-lingxi}:${LX_DB_PASSWORD}@postgres:5432/${LX_DB_NAME:-lingxi_crossborder}
|
||||
JWT_SECRET: ${LX_JWT_SECRET:?LX_JWT_SECRET is required}
|
||||
DEEPSEEK_BASE_URL: https://api.deepseek.com/anthropic
|
||||
DEEPSEEK_MODEL: ${DEEPSEEK_MODEL:-deepseek-v4-flash}
|
||||
DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:?DEEPSEEK_API_KEY is required}
|
||||
TELEGRAM_GATEWAY_URL: ${TELEGRAM_GATEWAY_URL:-http://host.docker.internal:3000/api}
|
||||
TELEGRAM_GATEWAY_TOKEN: ${TELEGRAM_GATEWAY_TOKEN:?TELEGRAM_GATEWAY_TOKEN is required}
|
||||
TELEGRAM_GATEWAY_TENANT_ID: ${TELEGRAM_GATEWAY_TENANT_ID:?TELEGRAM_GATEWAY_TENANT_ID is required}
|
||||
TELEGRAM_GATEWAY_SERVICE_EMAIL: ${TELEGRAM_GATEWAY_SERVICE_EMAIL:?TELEGRAM_GATEWAY_SERVICE_EMAIL is required}
|
||||
TELEGRAM_GATEWAY_SERVICE_PASSWORD: ${TELEGRAM_GATEWAY_SERVICE_PASSWORD:?TELEGRAM_GATEWAY_SERVICE_PASSWORD is required}
|
||||
TELEGRAM_GATEWAY_ACCOUNT_ID: ${TELEGRAM_GATEWAY_ACCOUNT_ID:-}
|
||||
TELEGRAM_WEBHOOK_SECRET: ${TELEGRAM_WEBHOOK_SECRET:?TELEGRAM_WEBHOOK_SECRET is required}
|
||||
WHATSAPP_GATEWAY_URL: http://whatsapp-gateway:3200
|
||||
WHATSAPP_GATEWAY_TOKEN: ${WHATSAPP_GATEWAY_TOKEN:?WHATSAPP_GATEWAY_TOKEN is required}
|
||||
WHATSAPP_WEBHOOK_SECRET: ${WHATSAPP_WEBHOOK_SECRET:?WHATSAPP_WEBHOOK_SECRET is required}
|
||||
WHATSAPP_WEBHOOK_BASE_URL: ${WHATSAPP_WEBHOOK_BASE_URL:-http://api:3100}
|
||||
PUBLIC_WEBHOOK_BASE_URL: ${PUBLIC_WEBHOOK_BASE_URL:-http://host.docker.internal:8088}
|
||||
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://127.0.0.1:8088}
|
||||
SMTP_HOST: ${SMTP_HOST:-}
|
||||
SMTP_PORT: ${SMTP_PORT:-587}
|
||||
SMTP_SECURE: ${SMTP_SECURE:-false}
|
||||
SMTP_USER: ${SMTP_USER:-}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||
SMTP_FROM: ${SMTP_FROM:-}
|
||||
EMAIL_CREDENTIAL_KEY: ${EMAIL_CREDENTIAL_KEY:-}
|
||||
EMAIL_POLL_MS: ${EMAIL_POLL_MS:-30000}
|
||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
||||
STRIPE_PRICE_LITE: ${STRIPE_PRICE_LITE:-}
|
||||
STRIPE_PRICE_STANDARD: ${STRIPE_PRICE_STANDARD:-}
|
||||
STRIPE_PRICE_PRO: ${STRIPE_PRICE_PRO:-}
|
||||
STRIPE_PRICE_GROWTH: ${STRIPE_PRICE_GROWTH:-}
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
dns:
|
||||
- 1.1.1.1
|
||||
- 8.8.8.8
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
whatsapp-gateway:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3100/health/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "${LX_WEB_PORT:-8088}:80"
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
lingxi_pgdata:
|
||||
lingxi_whatsapp_data:
|
||||
@@ -0,0 +1,4 @@
|
||||
FROM lingxi-greenmail-test:2.1.8
|
||||
|
||||
ENTRYPOINT ["java"]
|
||||
CMD ["-Dgreenmail.setup.test.smtp", "-Dgreenmail.setup.test.imap", "-Dgreenmail.hostname=0.0.0.0", "-Dgreenmail.users.login=email", "-Dgreenmail.users=sender:pwd@local.test,receiver:pwd@local.test", "-jar", "/tmp/greenmail.jar"]
|
||||
@@ -0,0 +1,34 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
client_max_body_size 50m;
|
||||
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 'ok';
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:3100;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Request-Id $request_id;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
location /internal/ {
|
||||
proxy_pass http://api:3100;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Request-Id $request_id;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
+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,83 @@
|
||||
# 部署与回滚
|
||||
|
||||
## 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)。
|
||||
|
||||
## 3. 生产入口
|
||||
|
||||
在 Compose 外层部署 Caddy、Nginx 或云负载均衡:
|
||||
|
||||
- 只对公网开放 80/443。
|
||||
- 将 HTTPS 请求转发到 `127.0.0.1:8088`。
|
||||
- 保留 `Host`、`X-Forwarded-For`、`X-Forwarded-Proto`。
|
||||
- WebSocket 路径启用 Upgrade/Connection 头并提高空闲超时。
|
||||
- PostgreSQL、API、WhatsApp Gateway 不映射公网端口。
|
||||
|
||||
将 `APP_PUBLIC_URL` 和 `PUBLIC_WEBHOOK_BASE_URL` 改为正式 HTTPS 地址,随后重建 API。
|
||||
|
||||
## 4. 更新
|
||||
|
||||
```bash
|
||||
git fetch --tags
|
||||
git checkout main
|
||||
git pull --ff-only
|
||||
docker compose --env-file .env build
|
||||
docker compose --env-file .env up -d
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
更新前先执行数据库和 WhatsApp 登录态备份。更新后验证登录、渠道状态、收发、WebSocket、AI、媒体和健康检查。
|
||||
|
||||
## 5. 回滚
|
||||
|
||||
1. 记录当前版本:`git describe --tags --always`。
|
||||
2. 恢复与目标版本兼容的数据库备份。
|
||||
3. 检出稳定标签,例如 `git checkout v1.0.0`。
|
||||
4. 重新构建并启动:`docker compose up -d --build`。
|
||||
5. 执行健康和关键业务检查。
|
||||
|
||||
`snapshot-*` 是历史源码归档,不保证数据库向后兼容,不应直接作为生产回滚点。
|
||||
|
||||
## 6. 零停机演进
|
||||
|
||||
当前 Compose 是单实例,会有短暂重启窗口。需要零停机时应:
|
||||
|
||||
- 将 PostgreSQL 迁移到托管或高可用实例。
|
||||
- 将 Web/API 部署为至少两个实例并使用外部负载均衡。
|
||||
- WebSocket 使用共享事件总线或粘性会话。
|
||||
- 将持久任务改用共享队列并确保幂等。
|
||||
- 数据库变更采用“先兼容、再切换、后清理”的多阶段迁移。
|
||||
@@ -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,72 @@
|
||||
# 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`:仅填写受信任的管理入口;平台服务端调用不依赖浏览器跨域。
|
||||
|
||||
生产环境不要使用示例中的 `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 name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="灵犀跨境 - 让跨境沟通更简单。面向中小外贸企业的跨境AI经营服务平台。" />
|
||||
<link rel="icon" type="image/svg+xml" href="/brand-mark.svg" />
|
||||
<meta name="theme-color" content="#2563EB" />
|
||||
<title>灵犀跨境 · 跨境AI经营服务平台</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
+297
-23
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "lingxi-crossborder-saas",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lingxi-crossborder-saas",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
@@ -16,18 +16,20 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.54.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"recharts": "^2.15.0",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"zod": "^3.24.1",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^18.3.14",
|
||||
"@types/react-dom": "^18.3.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "^5.7.2",
|
||||
@@ -338,6 +340,22 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@derhuerst/http-basic": {
|
||||
"version": "8.2.4",
|
||||
"resolved": "https://registry.npmmirror.com/@derhuerst/http-basic/-/http-basic-8.2.4.tgz",
|
||||
"integrity": "sha512-F9rL9k9Xjf5blCz8HsJRO4diy111cayL2vkY2XE4r4t3n0yPXVYy3KD3nJ1qbrSn9743UWSXH4IwuCa/HWlGFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"caseless": "^0.12.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"http-response-object": "^3.0.1",
|
||||
"parse-cache-control": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
@@ -826,13 +844,20 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.3",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
|
||||
"integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
|
||||
"license": "MIT",
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmmirror.com/@playwright/test/-/test-1.62.0.tgz",
|
||||
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
@@ -1431,6 +1456,19 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
|
||||
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/any-promise": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
|
||||
@@ -1569,6 +1607,13 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/camelcase-css": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
@@ -1600,6 +1645,13 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/caseless": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmmirror.com/caseless/-/caseless-0.12.0.tgz",
|
||||
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -1669,6 +1721,22 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"dev": true,
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -1676,6 +1744,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -1871,6 +1952,16 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/env-paths": {
|
||||
"version": "2.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/env-paths/-/env-paths-2.2.1.tgz",
|
||||
"integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
@@ -1985,6 +2076,23 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ffmpeg-static": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/ffmpeg-static/-/ffmpeg-static-5.3.0.tgz",
|
||||
"integrity": "sha512-H+K6sW6TiIX6VGend0KQwthe+kaceeH/luE8dIZyOP35ik7ahYojDuqlTV1bOrtEwl01sy2HFNGQfi5IDJvotg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@derhuerst/http-basic": "^8.2.0",
|
||||
"env-paths": "^2.2.0",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"progress": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -2073,6 +2181,44 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http-response-object": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/http-response-object/-/http-response-object-3.0.2.tgz",
|
||||
"integrity": "sha512-bqX0XTF6fnXSQcEJ2Iuyr75yVakyjIDCqroJQ/aHfSdlM743Cwqoi2nDYMzLGWUcuTWGWy8AAvOKXTfiv6q9RA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^10.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/http-response-object/node_modules/@types/node": {
|
||||
"version": "10.17.60",
|
||||
"resolved": "https://registry.npmmirror.com/@types/node/-/node-10.17.60.tgz",
|
||||
"integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "6",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
@@ -2344,6 +2490,12 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-cache-control": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/parse-cache-control/-/parse-cache-control-1.0.1.tgz",
|
||||
"integrity": "sha512-60zvsJReQPX5/QP0Kzfd/VrpjScIQ7SHBW6bFCYfEP+fp0Eppr1SHhIO5nd1PjZtvclzSzES9D/p5nFJurwfWg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
@@ -2391,6 +2543,53 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmmirror.com/playwright/-/playwright-1.62.0.tgz",
|
||||
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.62.0.tgz",
|
||||
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.21",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.21.tgz",
|
||||
@@ -2554,6 +2753,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/progress": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/progress/-/progress-2.0.3.tgz",
|
||||
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
@@ -2650,35 +2859,41 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
|
||||
"integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
|
||||
"version": "7.18.1",
|
||||
"resolved": "https://registry.npmmirror.com/react-router/-/react-router-7.18.1.tgz",
|
||||
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.3"
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8"
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.4",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
|
||||
"integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
|
||||
"version": "7.18.1",
|
||||
"resolved": "https://registry.npmmirror.com/react-router-dom/-/react-router-dom-7.18.1.tgz",
|
||||
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.3",
|
||||
"react-router": "6.30.4"
|
||||
"react-router": "7.18.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8",
|
||||
"react-dom": ">=16.8"
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-smooth": {
|
||||
@@ -2722,6 +2937,21 @@
|
||||
"pify": "^2.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||
@@ -2870,6 +3100,27 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.23.2",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
|
||||
@@ -2889,6 +3140,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmmirror.com/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -2899,6 +3156,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
@@ -3080,6 +3347,13 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
||||
+8
-4
@@ -1,14 +1,16 @@
|
||||
{
|
||||
"name": "lingxi-crossborder-saas",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"type": "module",
|
||||
"description": "灵犀跨境 - 跨境AI经营服务平台前端(演示环境,Mock数据)",
|
||||
"description": "灵犀跨境 - Telegram、WhatsApp 与企业邮箱 AI 客户经营平台",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:update": "playwright test --update-snapshots"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
@@ -19,18 +21,20 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.54.0",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"recharts": "^2.15.0",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"zod": "^3.24.1",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^18.3.14",
|
||||
"@types/react-dom": "^18.3.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "^5.7.2",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/e2e',
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 10_000 },
|
||||
fullyParallel: false,
|
||||
// E2E projects share one real tenant and gateway; serialize mutations to avoid
|
||||
// desktop/mobile runs racing on conversation ordering and channel state.
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
|
||||
use: {
|
||||
baseURL: process.env.E2E_BASE_URL || 'http://127.0.0.1:8088',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{ name: 'desktop-chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
{ name: 'mobile-chromium', use: { ...devices['Pixel 7'] } },
|
||||
],
|
||||
outputDir: 'test-results',
|
||||
})
|
||||
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 |
@@ -0,0 +1,22 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320" viewBox="0 0 320 320">
|
||||
<g fill="none" stroke="#6f96bd" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" opacity=".13">
|
||||
<path d="M25 31h39a9 9 0 0 1 9 9v19a9 9 0 0 1-9 9H47l-11 9 2-9H25a9 9 0 0 1-9-9V40a9 9 0 0 1 9-9Z"/>
|
||||
<path d="M29 45h31M29 54h20"/>
|
||||
<path d="m221 26 36 15-36 15 7-12 16-3-16-3-7-12Z"/>
|
||||
<circle cx="132" cy="91" r="18"/>
|
||||
<path d="M114 91h36M132 73c6 6 9 12 9 18s-3 12-9 18c-6-6-9-12-9-18s3-12 9-18Z"/>
|
||||
<path d="m278 105 3 8 8 3-8 3-3 8-3-8-8-3 8-3 3-8Z"/>
|
||||
<rect x="25" y="174" width="46" height="32" rx="7"/>
|
||||
<path d="m29 180 19 14 19-14"/>
|
||||
<path d="M168 195a20 20 0 0 1 40 0v18M168 195v12a7 7 0 0 0 7 7h5v-19h-5a7 7 0 0 0-7 7M208 195v19h5a7 7 0 0 0 7-7v-5a7 7 0 0 0-7-7h-5Z"/>
|
||||
<path d="M179 226h17a8 8 0 0 0 8-8"/>
|
||||
<path d="M270 228h27a8 8 0 0 1 8 8v16a8 8 0 0 1-8 8h-8l-8 7 1-7h-12a8 8 0 0 1-8-8v-16a8 8 0 0 1 8-8Z"/>
|
||||
<path d="M274 239h19M274 247h13"/>
|
||||
<circle cx="93" cy="267" r="12"/>
|
||||
<path d="M88 267h10M93 262v10"/>
|
||||
<path d="m31 278 2 5 5 2-5 2-2 5-2-5-5-2 5-2 2-5Z"/>
|
||||
<circle cx="252" cy="164" r="3"/>
|
||||
<circle cx="102" cy="155" r="2"/>
|
||||
<circle cx="292" cy="58" r="2"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,8 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY src ./src
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3100
|
||||
CMD ["npm", "start"]
|
||||
Generated
+1971
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "lingxi-crossborder-api",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"test": "node --test test/business.test.js",
|
||||
"test:email-protocol": "node --test test/email-protocol.test.js",
|
||||
"test:email-api": "node --test test/email-api-e2e.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^5.1.0",
|
||||
"helmet": "^8.1.0",
|
||||
"imapflow": "^1.6.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"libphonenumber-js": "^1.11.20",
|
||||
"mailparser": "^3.9.14",
|
||||
"mammoth": "^1.9.0",
|
||||
"nodemailer": "^7.0.5",
|
||||
"pdf-parse": "^1.1.1",
|
||||
"pg": "^8.13.1",
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
function contentText(payload) {
|
||||
return payload?.content?.map((part) => part.text || '').join('') || ''
|
||||
}
|
||||
|
||||
function parseJson(text) {
|
||||
const cleaned = text.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim()
|
||||
return JSON.parse(cleaned)
|
||||
}
|
||||
|
||||
export async function deepseek(system, user, jsonOrOptions = false) {
|
||||
const options = typeof jsonOrOptions === 'boolean'
|
||||
? { json: jsonOrOptions }
|
||||
: { ...(jsonOrOptions || {}) }
|
||||
const json = Boolean(options.json)
|
||||
const apiKey = process.env.DEEPSEEK_API_KEY
|
||||
if (!apiKey) throw Object.assign(new Error('DEEPSEEK_API_KEY 尚未配置'), { status: 503 })
|
||||
const endpoint = `${(process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/anthropic').replace(/\/$/, '')}/v1/messages`
|
||||
const attempts = json ? 2 : 1
|
||||
let prompt = user
|
||||
let lastError
|
||||
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
Number(options.timeoutMs || process.env.AI_TIMEOUT_MS || 30000),
|
||||
)
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash',
|
||||
max_tokens: Number(options.maxTokens || process.env.AI_MAX_TOKENS || 4096),
|
||||
temperature: options.temperature ?? (json ? 0 : 0.3),
|
||||
thinking: options.thinking === true
|
||||
? { type: 'enabled' }
|
||||
: { type: 'disabled' },
|
||||
system,
|
||||
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
||||
}),
|
||||
})
|
||||
const body = await response.json()
|
||||
if (!response.ok) {
|
||||
throw Object.assign(new Error(body?.error?.message || `AI 请求失败 (${response.status})`), { status: 502 })
|
||||
}
|
||||
const text = contentText(body)
|
||||
if (!json) return text
|
||||
try {
|
||||
return parseJson(text)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
prompt = `${user}\n\n上一次响应不是合法 JSON。请重新生成完整结果:只允许输出一个可被 JSON.parse 直接解析的 JSON 值;不得使用 Markdown;字符串中的双引号、换行和反斜杠必须正确转义。`
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw Object.assign(new Error('AI 响应超时,请稍后重试'), { status: 504 })
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
throw Object.assign(new Error('AI 返回格式异常,自动重试后仍无法解析'), { status: 502, cause: lastError })
|
||||
}
|
||||
|
||||
export function conversationContext(messages, limit = 30) {
|
||||
return messages.slice(-limit).map((m) => {
|
||||
const role = m.sender === 'agent' ? '坐席' : m.sender === 'system' ? '系统' : '客户'
|
||||
const content = m.originalText || m.attachmentName || `[${m.type || '消息'}]`
|
||||
const translation = m.translatedText && m.translatedText !== content ? `(译文:${m.translatedText})` : ''
|
||||
return `${m.createdAt || ''} ${role}: ${content}${translation}`
|
||||
}).join('\n')
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export function telegramAttachment(content = {}) {
|
||||
const type = content['@type']
|
||||
if (type === 'messagePhoto') {
|
||||
const sizes = Array.isArray(content.photo?.sizes) ? content.photo.sizes : []
|
||||
const file = sizes.at(-1)?.photo
|
||||
return file?.id ? {
|
||||
telegramFileId: Number(file.id),
|
||||
attachmentName: `telegram-photo-${file.id}.jpg`,
|
||||
attachmentMimeType: 'image/jpeg',
|
||||
attachmentSize: Number(file.size || file.expected_size || 0),
|
||||
} : {}
|
||||
}
|
||||
if (type === 'messageDocument') {
|
||||
const document = content.document || {}
|
||||
const file = document.document
|
||||
return file?.id ? {
|
||||
telegramFileId: Number(file.id),
|
||||
attachmentName: document.file_name || `document-${file.id}`,
|
||||
attachmentMimeType: document.mime_type || 'application/octet-stream',
|
||||
attachmentSize: Number(file.size || file.expected_size || 0),
|
||||
} : {}
|
||||
}
|
||||
if (type === 'messageVoiceNote') {
|
||||
const file = content.voice_note?.voice
|
||||
return file?.id ? {
|
||||
telegramFileId: Number(file.id),
|
||||
attachmentName: `voice-${file.id}.ogg`,
|
||||
attachmentMimeType: content.voice_note?.mime_type || 'audio/ogg',
|
||||
attachmentSize: Number(file.size || file.expected_size || 0),
|
||||
duration: Number(content.voice_note?.duration || 0),
|
||||
} : {}
|
||||
}
|
||||
if (type === 'messageAudio') {
|
||||
const audio = content.audio || {}
|
||||
const file = audio.audio
|
||||
return file?.id ? {
|
||||
telegramFileId: Number(file.id),
|
||||
attachmentName: audio.file_name || audio.title || `audio-${file.id}`,
|
||||
attachmentMimeType: audio.mime_type || 'audio/mpeg',
|
||||
attachmentSize: Number(file.size || file.expected_size || 0),
|
||||
duration: Number(audio.duration || 0),
|
||||
} : {}
|
||||
}
|
||||
if (type === 'messageSticker') {
|
||||
const sticker = content.sticker || {}
|
||||
const file = sticker.sticker
|
||||
const format = sticker.format?.['@type']
|
||||
const extension = format === 'stickerFormatTgs' ? 'tgs' : format === 'stickerFormatWebm' ? 'webm' : 'webp'
|
||||
return file?.id ? {
|
||||
telegramFileId: Number(file.id),
|
||||
attachmentName: `sticker-${file.id}.${extension}`,
|
||||
attachmentMimeType: extension === 'webp' ? 'image/webp' : extension === 'webm' ? 'video/webm' : 'application/x-tgsticker',
|
||||
attachmentSize: Number(file.size || file.expected_size || 0),
|
||||
stickerEmoji: sticker.emoji || '',
|
||||
} : {}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
export const dateKey = (value = Date.now()) => {
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export const percent = (numerator, denominator) =>
|
||||
denominator ? Math.round((numerator / denominator) * 1000) / 10 : 0
|
||||
|
||||
export const countBy = (list, key) => {
|
||||
const counts = new Map()
|
||||
for (const item of list) {
|
||||
const value = typeof key === 'function' ? key(item) : item[key]
|
||||
if (!value) continue
|
||||
counts.set(String(value), (counts.get(String(value)) || 0) + 1)
|
||||
}
|
||||
return counts
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import pg from 'pg'
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
const { Pool } = pg
|
||||
export const pool = new Pool({ connectionString: process.env.DATABASE_URL })
|
||||
|
||||
export async function migrate() {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS tenants (
|
||||
id uuid PRIMARY KEY, name text NOT NULL, industry text NOT NULL DEFAULT '',
|
||||
size text NOT NULL DEFAULT '', country text NOT NULL DEFAULT 'CN',
|
||||
plan text NOT NULL DEFAULT 'pro', created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id uuid PRIMARY KEY, tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
name text NOT NULL, account text NOT NULL UNIQUE, password_hash text NOT NULL,
|
||||
phone text NOT NULL DEFAULT '', role text NOT NULL DEFAULT 'admin',
|
||||
department text NOT NULL DEFAULT '', title text NOT NULL DEFAULT '',
|
||||
status text NOT NULL DEFAULT 'active', last_login_at timestamptz, created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS resources (
|
||||
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
kind text NOT NULL, id text NOT NULL, data jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (tenant_id, kind, id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS resources_kind_idx ON resources(tenant_id, kind, updated_at DESC);
|
||||
CREATE TABLE IF NOT EXISTS telegram_bindings (
|
||||
id uuid PRIMARY KEY, tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
gateway_account_id text, phone text, display_name text,
|
||||
status text NOT NULL DEFAULT 'unconnected', auth_state text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id uuid PRIMARY KEY, tenant_id uuid, user_id uuid, action text NOT NULL,
|
||||
target text NOT NULL DEFAULT '', detail jsonb NOT NULL DEFAULT '{}',
|
||||
ip text NOT NULL DEFAULT '', created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS login_records (
|
||||
id uuid PRIMARY KEY, tenant_id uuid REFERENCES tenants(id) ON DELETE CASCADE,
|
||||
user_id uuid REFERENCES users(id) ON DELETE SET NULL, account text NOT NULL DEFAULT '',
|
||||
device text NOT NULL DEFAULT '', ip text NOT NULL DEFAULT '',
|
||||
status text NOT NULL, created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS login_records_tenant_idx ON login_records(tenant_id, created_at DESC);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS token_version integer NOT NULL DEFAULT 0;
|
||||
`)
|
||||
}
|
||||
|
||||
export const uid = () => crypto.randomUUID()
|
||||
|
||||
export async function listResources(tenantId, kind) {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT data FROM resources WHERE tenant_id=$1 AND kind=$2 ORDER BY updated_at DESC',
|
||||
[tenantId, kind],
|
||||
)
|
||||
return rows.map((row) => row.data)
|
||||
}
|
||||
|
||||
export async function getResource(tenantId, kind, id) {
|
||||
const { rows } = await pool.query(
|
||||
'SELECT data FROM resources WHERE tenant_id=$1 AND kind=$2 AND id=$3',
|
||||
[tenantId, kind, id],
|
||||
)
|
||||
return rows[0]?.data ?? null
|
||||
}
|
||||
|
||||
export async function putResource(tenantId, kind, value) {
|
||||
const data = { ...value, id: value.id || uid(), updatedAt: new Date().toISOString() }
|
||||
await pool.query(
|
||||
`INSERT INTO resources(tenant_id,kind,id,data) VALUES($1,$2,$3,$4)
|
||||
ON CONFLICT(tenant_id,kind,id) DO UPDATE SET data=$4,updated_at=now()`,
|
||||
[tenantId, kind, data.id, data],
|
||||
)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function patchResource(tenantId, kind, id, patch) {
|
||||
const current = await getResource(tenantId, kind, id)
|
||||
if (!current) return null
|
||||
return putResource(tenantId, kind, { ...current, ...patch, id })
|
||||
}
|
||||
|
||||
export async function deleteResource(tenantId, kind, id) {
|
||||
const result = await pool.query(
|
||||
'DELETE FROM resources WHERE tenant_id=$1 AND kind=$2 AND id=$3',
|
||||
[tenantId, kind, id],
|
||||
)
|
||||
return result.rowCount > 0
|
||||
}
|
||||
|
||||
export async function audit(req, action, target, detail = {}) {
|
||||
// pg 会把顶层 JavaScript 数组编码成 PostgreSQL 数组文本(如 {ai}),
|
||||
// jsonb 无法解析该格式。显式 JSON 序列化可同时兼容对象、数组和标量。
|
||||
const serializedDetail = JSON.stringify(detail ?? {})
|
||||
await pool.query(
|
||||
'INSERT INTO audit_logs(id,tenant_id,user_id,action,target,detail,ip) VALUES($1,$2,$3,$4,$5,$6,$7)',
|
||||
[uid(), req.auth?.tenantId ?? null, req.auth?.userId ?? null, action, target, serializedDetail, req.ip],
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import crypto from 'node:crypto'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { ImapFlow } from 'imapflow'
|
||||
import { simpleParser } from 'mailparser'
|
||||
|
||||
function encryptionKey() {
|
||||
const secret = process.env.EMAIL_CREDENTIAL_KEY || process.env.JWT_SECRET
|
||||
if (!secret) throw Object.assign(new Error('EMAIL_CREDENTIAL_KEY 或 JWT_SECRET 尚未配置'), { status: 503 })
|
||||
return crypto.createHash('sha256').update(secret).digest()
|
||||
}
|
||||
|
||||
export function encryptEmailPassword(password) {
|
||||
const iv = crypto.randomBytes(12)
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey(), iv)
|
||||
const encrypted = Buffer.concat([cipher.update(String(password), 'utf8'), cipher.final()])
|
||||
return `v1:${iv.toString('base64url')}:${cipher.getAuthTag().toString('base64url')}:${encrypted.toString('base64url')}`
|
||||
}
|
||||
|
||||
export function decryptEmailPassword(value) {
|
||||
const [version, ivValue, tagValue, encryptedValue] = String(value || '').split(':')
|
||||
if (version !== 'v1' || !ivValue || !tagValue || !encryptedValue) {
|
||||
throw Object.assign(new Error('Email 凭据无效,请重新连接邮箱'), { status: 409 })
|
||||
}
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', encryptionKey(), Buffer.from(ivValue, 'base64url'))
|
||||
decipher.setAuthTag(Buffer.from(tagValue, 'base64url'))
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(encryptedValue, 'base64url')),
|
||||
decipher.final(),
|
||||
]).toString('utf8')
|
||||
}
|
||||
|
||||
function imapClient(channel, password) {
|
||||
return new ImapFlow({
|
||||
host: channel.imapHost,
|
||||
port: Number(channel.imapPort || 993),
|
||||
secure: channel.imapSecure !== false,
|
||||
auth: { user: channel.account, pass: password },
|
||||
logger: false,
|
||||
tls: { rejectUnauthorized: channel.allowInvalidCertificate !== true },
|
||||
})
|
||||
}
|
||||
|
||||
function smtpTransport(channel, password) {
|
||||
return nodemailer.createTransport({
|
||||
host: channel.smtpHost,
|
||||
port: Number(channel.smtpPort || 465),
|
||||
secure: channel.smtpSecure !== false,
|
||||
auth: { user: channel.account, pass: password },
|
||||
tls: { rejectUnauthorized: channel.allowInvalidCertificate !== true },
|
||||
})
|
||||
}
|
||||
|
||||
export async function verifyEmailConnection(channel, password) {
|
||||
const startedAt = Date.now()
|
||||
const imap = imapClient(channel, password)
|
||||
try {
|
||||
await imap.connect()
|
||||
await imap.mailboxOpen(channel.imapFolder || 'INBOX', { readOnly: true })
|
||||
} finally {
|
||||
if (imap.usable) await imap.logout().catch(() => undefined)
|
||||
}
|
||||
const smtp = smtpTransport(channel, password)
|
||||
await smtp.verify()
|
||||
smtp.close()
|
||||
return { latencyMs: Date.now() - startedAt }
|
||||
}
|
||||
|
||||
function address(value) {
|
||||
const item = value?.value?.[0]
|
||||
return {
|
||||
name: String(item?.name || item?.address || '').trim(),
|
||||
email: String(item?.address || '').trim().toLowerCase(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchEmailMessages(channel, { full = false } = {}) {
|
||||
const password = decryptEmailPassword(channel.encryptedPassword)
|
||||
const client = imapClient(channel, password)
|
||||
const messages = []
|
||||
try {
|
||||
await client.connect()
|
||||
await client.mailboxOpen(channel.imapFolder || 'INBOX')
|
||||
let uids
|
||||
if (!full && Number(channel.lastUid || 0) > 0) {
|
||||
uids = await client.search({ uid: `${Number(channel.lastUid) + 1}:*` }, { uid: true })
|
||||
} else {
|
||||
const days = Math.max(1, Math.min(365, Number(channel.syncDays || 30)))
|
||||
uids = await client.search({ since: new Date(Date.now() - days * 86400000) }, { uid: true })
|
||||
}
|
||||
uids = (uids || []).slice(-500)
|
||||
if (!uids.length) return { messages, lastUid: Number(channel.lastUid || 0) }
|
||||
|
||||
for await (const row of client.fetch(uids, {
|
||||
uid: true,
|
||||
envelope: true,
|
||||
flags: true,
|
||||
internalDate: true,
|
||||
source: true,
|
||||
}, { uid: true })) {
|
||||
const parsed = await simpleParser(row.source)
|
||||
const sender = address(parsed.from)
|
||||
const recipients = parsed.to?.value || []
|
||||
const attachments = (parsed.attachments || []).map((attachment, index) => ({
|
||||
id: `${row.uid}-${index + 1}`,
|
||||
name: attachment.filename || `attachment-${index + 1}`,
|
||||
mimeType: attachment.contentType || 'application/octet-stream',
|
||||
size: attachment.size || attachment.content?.length || 0,
|
||||
contentId: attachment.contentId,
|
||||
base64: attachment.content?.length <= 20 * 1024 * 1024
|
||||
? attachment.content.toString('base64')
|
||||
: undefined,
|
||||
}))
|
||||
messages.push({
|
||||
uid: Number(row.uid),
|
||||
messageId: String(parsed.messageId || `imap-${row.uid}`),
|
||||
inReplyTo: parsed.inReplyTo || undefined,
|
||||
references: Array.isArray(parsed.references) ? parsed.references : parsed.references ? [parsed.references] : [],
|
||||
subject: String(parsed.subject || '(无主题)').trim(),
|
||||
from: sender,
|
||||
to: recipients.map((item) => ({ name: item.name || item.address || '', email: String(item.address || '').toLowerCase() })),
|
||||
text: String(parsed.text || '').trim() || String(parsed.html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(),
|
||||
html: typeof parsed.html === 'string' ? parsed.html : undefined,
|
||||
date: (parsed.date || row.internalDate || new Date()).toISOString(),
|
||||
seen: row.flags?.has('\\Seen') || false,
|
||||
attachments,
|
||||
})
|
||||
}
|
||||
return {
|
||||
messages: messages.sort((a, b) => Date.parse(a.date) - Date.parse(b.date)),
|
||||
lastUid: Math.max(Number(channel.lastUid || 0), ...messages.map((message) => message.uid)),
|
||||
}
|
||||
} finally {
|
||||
if (client.usable) await client.logout().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendEmailMessage(channel, {
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
inReplyTo,
|
||||
references,
|
||||
attachments,
|
||||
}) {
|
||||
const password = decryptEmailPassword(channel.encryptedPassword)
|
||||
const transport = smtpTransport(channel, password)
|
||||
try {
|
||||
return await transport.sendMail({
|
||||
from: channel.displayName ? { name: channel.displayName, address: channel.account } : channel.account,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
inReplyTo,
|
||||
references,
|
||||
attachments,
|
||||
})
|
||||
} finally {
|
||||
transport.close()
|
||||
}
|
||||
}
|
||||
|
||||
export async function markEmailUidsSeen(channel, uids) {
|
||||
const values = [...new Set(uids.map(Number).filter(Number.isFinite))]
|
||||
if (!values.length) return
|
||||
const password = decryptEmailPassword(channel.encryptedPassword)
|
||||
const client = imapClient(channel, password)
|
||||
try {
|
||||
await client.connect()
|
||||
await client.mailboxOpen(channel.imapFolder || 'INBOX')
|
||||
await client.messageFlagsAdd(values, ['\\Seen'], { uid: true })
|
||||
} finally {
|
||||
if (client.usable) await client.logout().catch(() => undefined)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
const base = () => (process.env.TELEGRAM_GATEWAY_URL || 'http://host.docker.internal:3000/api').replace(/\/$/, '')
|
||||
|
||||
let gatewayToken = process.env.TELEGRAM_GATEWAY_TOKEN || ''
|
||||
let refreshPromise
|
||||
|
||||
function tokenExpiresSoon(token) {
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8'))
|
||||
return !payload.exp || payload.exp * 1000 < Date.now() + 5 * 60 * 1000
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshGatewayToken() {
|
||||
if (refreshPromise) return refreshPromise
|
||||
refreshPromise = (async () => {
|
||||
const email = process.env.TELEGRAM_GATEWAY_SERVICE_EMAIL
|
||||
const password = process.env.TELEGRAM_GATEWAY_SERVICE_PASSWORD
|
||||
const tenantId = process.env.TELEGRAM_GATEWAY_TENANT_ID
|
||||
if (!email || !password || !tenantId) {
|
||||
throw Object.assign(new Error('Telegram 网关服务账号凭据尚未配置'), { status: 503 })
|
||||
}
|
||||
const response = await fetch(`${base()}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, tenantId }),
|
||||
})
|
||||
const raw = await response.text()
|
||||
let result
|
||||
try { result = raw ? JSON.parse(raw) : null } catch { result = raw }
|
||||
const token = result?.data?.token || result?.token
|
||||
if (!response.ok || !token) {
|
||||
throw Object.assign(new Error(result?.message || 'Telegram 网关服务账号续签失败'), { status: response.status || 503 })
|
||||
}
|
||||
gatewayToken = token
|
||||
return token
|
||||
})().finally(() => { refreshPromise = undefined })
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
export async function gatewayRequest(path, options = {}) {
|
||||
const tenantId = process.env.TELEGRAM_GATEWAY_TENANT_ID
|
||||
if (!tenantId) throw Object.assign(new Error('Telegram 网关租户尚未配置'), { status: 503 })
|
||||
if (!gatewayToken || tokenExpiresSoon(gatewayToken)) await refreshGatewayToken()
|
||||
const send = () => fetch(`${base()}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }),
|
||||
authorization: `Bearer ${gatewayToken}`,
|
||||
'x-tenant-id': tenantId,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
})
|
||||
let response = await send()
|
||||
if (response.status === 401) {
|
||||
await refreshGatewayToken()
|
||||
response = await send()
|
||||
}
|
||||
const raw = await response.text()
|
||||
let result
|
||||
try { result = raw ? JSON.parse(raw) : null } catch { result = raw }
|
||||
if (!response.ok) {
|
||||
throw Object.assign(new Error(result?.message || `Telegram 网关请求失败 (${response.status})`), { status: response.status })
|
||||
}
|
||||
return result?.data ?? result
|
||||
}
|
||||
|
||||
export function postGateway(path, body) {
|
||||
return gatewayRequest(path, { method: 'POST', body: JSON.stringify(body) })
|
||||
}
|
||||
|
||||
export function putGateway(path, body) {
|
||||
return gatewayRequest(path, { method: 'PUT', body: JSON.stringify(body) })
|
||||
}
|
||||
|
||||
export function uploadGateway(path, formData) {
|
||||
return gatewayRequest(path, { method: 'POST', body: formData })
|
||||
}
|
||||
+4845
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,38 @@
|
||||
const base = () => (process.env.WHATSAPP_GATEWAY_URL || 'http://whatsapp-gateway:3200').replace(/\/$/, '')
|
||||
|
||||
export async function whatsappRequest(path, options = {}) {
|
||||
const token = String(process.env.WHATSAPP_GATEWAY_TOKEN || '')
|
||||
if (token.length < 24) {
|
||||
throw Object.assign(new Error('WhatsApp 网关令牌尚未配置'), { status: 503 })
|
||||
}
|
||||
const response = await fetch(`${base()}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }),
|
||||
authorization: `Bearer ${token}`,
|
||||
...(options.headers || {}),
|
||||
},
|
||||
})
|
||||
const raw = await response.text()
|
||||
let result
|
||||
try { result = raw ? JSON.parse(raw) : null } catch { result = raw }
|
||||
if (!response.ok) {
|
||||
throw Object.assign(
|
||||
new Error(result?.message || `WhatsApp 网关请求失败 (${response.status})`),
|
||||
{ status: response.status, data: result?.data },
|
||||
)
|
||||
}
|
||||
return result?.data ?? result
|
||||
}
|
||||
|
||||
export function postWhatsApp(path, body) {
|
||||
return whatsappRequest(path, { method: 'POST', body: JSON.stringify(body) })
|
||||
}
|
||||
|
||||
export function putWhatsApp(path, body) {
|
||||
return whatsappRequest(path, { method: 'PUT', body: JSON.stringify(body) })
|
||||
}
|
||||
|
||||
export function uploadWhatsApp(path, formData) {
|
||||
return whatsappRequest(path, { method: 'POST', body: formData })
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { countBy, dateKey, percent, telegramAttachment } from '../src/business.js'
|
||||
import { decryptEmailPassword, encryptEmailPassword } from '../src/email.js'
|
||||
|
||||
test('Telegram 图片、文档、语音和贴纸映射为可下载文件', () => {
|
||||
assert.deepEqual(
|
||||
telegramAttachment({
|
||||
'@type': 'messagePhoto',
|
||||
photo: { sizes: [{ photo: { id: 11, size: 2048 } }] },
|
||||
}),
|
||||
{
|
||||
telegramFileId: 11,
|
||||
attachmentName: 'telegram-photo-11.jpg',
|
||||
attachmentMimeType: 'image/jpeg',
|
||||
attachmentSize: 2048,
|
||||
},
|
||||
)
|
||||
|
||||
assert.equal(telegramAttachment({
|
||||
'@type': 'messageDocument',
|
||||
document: { file_name: 'quote.pdf', mime_type: 'application/pdf', document: { id: 12, size: 4096 } },
|
||||
}).attachmentMimeType, 'application/pdf')
|
||||
|
||||
assert.equal(telegramAttachment({
|
||||
'@type': 'messageVoiceNote',
|
||||
voice_note: { duration: 8, voice: { id: 13, expected_size: 1024 } },
|
||||
}).duration, 8)
|
||||
|
||||
assert.equal(telegramAttachment({
|
||||
'@type': 'messageSticker',
|
||||
sticker: { emoji: '👍', format: { '@type': 'stickerFormatWebp' }, sticker: { id: 14, size: 512 } },
|
||||
}).attachmentMimeType, 'image/webp')
|
||||
})
|
||||
|
||||
test('业务指标工具处理百分比、分组和无效日期', () => {
|
||||
assert.equal(percent(3, 8), 37.5)
|
||||
assert.equal(percent(1, 0), 0)
|
||||
assert.equal(dateKey('invalid'), '')
|
||||
assert.equal(dateKey('2026-07-27T12:30:00Z'), '2026-07-27')
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(countBy([{ stage: 'new' }, { stage: 'new' }, { stage: 'won' }], 'stage')),
|
||||
{ new: 2, won: 1 },
|
||||
)
|
||||
})
|
||||
|
||||
test('Email 渠道凭据使用认证加密且可正确解密', () => {
|
||||
process.env.EMAIL_CREDENTIAL_KEY = 'lingxi-email-test-key-with-more-than-32-bytes'
|
||||
const encrypted = encryptEmailPassword('app-password-123')
|
||||
assert.notEqual(encrypted, 'app-password-123')
|
||||
assert.match(encrypted, /^v1:/)
|
||||
assert.equal(decryptEmailPassword(encrypted), 'app-password-123')
|
||||
const tampered = encrypted.split(':')
|
||||
tampered[2] = `${tampered[2][0] === 'A' ? 'B' : 'A'}${tampered[2].slice(1)}`
|
||||
assert.throws(() => decryptEmailPassword(tampered.join(':')))
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
encryptEmailPassword,
|
||||
fetchEmailMessages,
|
||||
sendEmailMessage,
|
||||
} from '../src/email.js'
|
||||
|
||||
const enabled = process.env.RUN_EMAIL_API_E2E === '1'
|
||||
const baseUrl = process.env.EMAIL_API_E2E_BASE_URL || 'http://127.0.0.1:8088'
|
||||
|
||||
function localChannel(account) {
|
||||
return {
|
||||
account,
|
||||
encryptedPassword: encryptEmailPassword('pwd'),
|
||||
imapHost: '127.0.0.1',
|
||||
imapPort: 3143,
|
||||
imapSecure: false,
|
||||
imapFolder: 'INBOX',
|
||||
smtpHost: '127.0.0.1',
|
||||
smtpPort: 3025,
|
||||
smtpSecure: false,
|
||||
syncDays: 7,
|
||||
}
|
||||
}
|
||||
|
||||
function unwrap(payload) {
|
||||
return payload && typeof payload === 'object' && 'data' in payload ? payload.data : payload
|
||||
}
|
||||
|
||||
async function request(path, { token, method = 'GET', body } = {}) {
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
})
|
||||
const text = await response.text()
|
||||
const payload = text ? JSON.parse(text) : null
|
||||
if (!response.ok) throw new Error(`${method} ${path} -> ${response.status}: ${payload?.message || text}`)
|
||||
return unwrap(payload)
|
||||
}
|
||||
|
||||
async function waitForSenderMessage(predicate) {
|
||||
const sender = localChannel('sender@local.test')
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const result = await fetchEmailMessages(sender, { full: true })
|
||||
const message = result.messages.find(predicate)
|
||||
if (message) return message
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
}
|
||||
throw new Error('等待网关发出的邮件进入 sender IMAP 超时')
|
||||
}
|
||||
|
||||
test('真实 HTTP 网关完成 Email 连接、全量同步、附件读取、回复与已读', { skip: !enabled }, async () => {
|
||||
process.env.JWT_SECRET ||= 'lingxi-email-api-e2e-secret'
|
||||
const suffix = Date.now()
|
||||
const senderChannel = localChannel('sender@local.test')
|
||||
const olderSubject = `Lingxi sorting older ${suffix}`
|
||||
const newerSubject = `Lingxi sorting newer ${suffix}`
|
||||
await sendEmailMessage(senderChannel, {
|
||||
to: 'receiver@local.test',
|
||||
subject: olderSubject,
|
||||
text: 'Older conversation used for stable ordering',
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 30))
|
||||
await sendEmailMessage(senderChannel, {
|
||||
to: 'receiver@local.test',
|
||||
subject: newerSubject,
|
||||
text: 'Newer conversation must remain above the opened older conversation',
|
||||
})
|
||||
|
||||
const session = await request('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: 'Email API E2E',
|
||||
account: `email-api-${suffix}@local.test`,
|
||||
password: 'Lingxi-Email-Api-2026!',
|
||||
company: 'Lingxi Protocol Lab',
|
||||
industry: '跨境电商',
|
||||
teamSize: '1-10人',
|
||||
},
|
||||
})
|
||||
assert.ok(session.token)
|
||||
|
||||
const connected = await request('/api/channels/email/connect', {
|
||||
token: session.token,
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: '本地协议邮箱',
|
||||
displayName: 'Lingxi Receiver',
|
||||
account: 'receiver@local.test',
|
||||
password: 'pwd',
|
||||
imapHost: 'host.docker.internal',
|
||||
imapPort: 3143,
|
||||
imapSecure: false,
|
||||
imapFolder: 'INBOX',
|
||||
smtpHost: 'host.docker.internal',
|
||||
smtpPort: 3025,
|
||||
smtpSecure: false,
|
||||
syncDays: 7,
|
||||
},
|
||||
})
|
||||
assert.equal(connected.status, 'connected')
|
||||
assert.equal(connected.encryptedPassword, undefined)
|
||||
|
||||
const conversations = await request('/api/conversations?channel=email', { token: session.token })
|
||||
assert.ok(conversations.list.length >= 2)
|
||||
const newerConversation = conversations.list.find((item) => item.emailSubject === newerSubject)
|
||||
const olderConversation = conversations.list.find((item) => item.emailSubject === olderSubject)
|
||||
assert.ok(newerConversation)
|
||||
assert.ok(olderConversation)
|
||||
assert.ok(conversations.list.indexOf(newerConversation) < conversations.list.indexOf(olderConversation))
|
||||
|
||||
const olderMessages = await request(`/api/conversations/${olderConversation.id}/messages`, { token: session.token })
|
||||
await request(`/api/conversations/${olderConversation.id}/read`, {
|
||||
token: session.token,
|
||||
method: 'POST',
|
||||
body: { messageIds: olderMessages.map((message) => message.id) },
|
||||
})
|
||||
const afterRead = await request('/api/conversations?channel=email', { token: session.token })
|
||||
const relevantOrder = afterRead.list
|
||||
.filter((item) => [newerConversation.id, olderConversation.id].includes(item.id))
|
||||
.map((item) => item.id)
|
||||
assert.deepEqual(relevantOrder, [newerConversation.id, olderConversation.id])
|
||||
|
||||
let conversation
|
||||
let incoming
|
||||
for (const candidate of conversations.list.filter((item) => item.customer?.email === 'sender@local.test')) {
|
||||
const candidateMessages = await request(`/api/conversations/${candidate.id}/messages`, { token: session.token })
|
||||
const candidateIncoming = candidateMessages.find((message) => message.sender === 'customer' && message.emailAttachments?.length)
|
||||
if (candidateIncoming) {
|
||||
conversation = candidate
|
||||
incoming = candidateIncoming
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.ok(conversation)
|
||||
assert.ok(incoming)
|
||||
const attachment = await request(
|
||||
`/api/conversations/${conversation.id}/messages/${incoming.id}/attachment?attachmentId=${encodeURIComponent(incoming.emailAttachments[0].id)}`,
|
||||
{ token: session.token },
|
||||
)
|
||||
assert.equal(attachment.name, 'lingxi-proof.txt')
|
||||
assert.ok(attachment.dataUrl.startsWith('data:text/plain;base64,'))
|
||||
|
||||
const replyText = `Lingxi gateway API reply ${suffix}`
|
||||
const sent = await request(`/api/conversations/${conversation.id}/messages`, {
|
||||
token: session.token,
|
||||
method: 'POST',
|
||||
body: { text: replyText, autoTranslate: false },
|
||||
})
|
||||
assert.equal(sent.status, 'sent')
|
||||
assert.ok(sent.emailMessageId)
|
||||
|
||||
const receivedBySender = await waitForSenderMessage((message) => message.messageId === sent.emailMessageId)
|
||||
assert.equal(receivedBySender.text, replyText)
|
||||
assert.ok(receivedBySender.inReplyTo)
|
||||
|
||||
await request(`/api/conversations/${conversation.id}/read`, {
|
||||
token: session.token,
|
||||
method: 'POST',
|
||||
body: { messageIds: [incoming.id] },
|
||||
})
|
||||
const receiver = await fetchEmailMessages(localChannel('receiver@local.test'), { full: true })
|
||||
assert.equal(receiver.messages.find((message) => message.uid === incoming.emailUid)?.seen, true)
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
encryptEmailPassword,
|
||||
fetchEmailMessages,
|
||||
markEmailUidsSeen,
|
||||
sendEmailMessage,
|
||||
verifyEmailConnection,
|
||||
} from '../src/email.js'
|
||||
|
||||
const enabled = process.env.RUN_EMAIL_PROTOCOL_TEST === '1'
|
||||
|
||||
function channel(account) {
|
||||
return {
|
||||
id: `protocol-${account}`,
|
||||
type: 'email',
|
||||
account,
|
||||
displayName: account.startsWith('sender') ? 'Lingxi Sender' : 'Lingxi Receiver',
|
||||
encryptedPassword: encryptEmailPassword('pwd'),
|
||||
imapHost: process.env.EMAIL_TEST_HOST || '127.0.0.1',
|
||||
imapPort: Number(process.env.EMAIL_TEST_IMAP_PORT || 3143),
|
||||
imapSecure: false,
|
||||
imapFolder: 'INBOX',
|
||||
smtpHost: process.env.EMAIL_TEST_HOST || '127.0.0.1',
|
||||
smtpPort: Number(process.env.EMAIL_TEST_SMTP_PORT || 3025),
|
||||
smtpSecure: false,
|
||||
syncDays: 7,
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForMessage(target, predicate) {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const result = await fetchEmailMessages(target, { full: true })
|
||||
const message = result.messages.find(predicate)
|
||||
if (message) return { message, result }
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
}
|
||||
throw new Error('等待邮件进入 IMAP INBOX 超时')
|
||||
}
|
||||
|
||||
test('真实 SMTP 发件、IMAP 收件、附件解析、已读与回复头', { skip: !enabled }, async () => {
|
||||
process.env.JWT_SECRET ||= 'lingxi-email-protocol-test-secret'
|
||||
const sender = channel('sender@local.test')
|
||||
const receiver = channel('receiver@local.test')
|
||||
const subject = `Lingxi protocol ${Date.now()}`
|
||||
|
||||
const senderCheck = await verifyEmailConnection(sender, 'pwd')
|
||||
const receiverCheck = await verifyEmailConnection(receiver, 'pwd')
|
||||
assert.ok(senderCheck.latencyMs >= 0)
|
||||
assert.ok(receiverCheck.latencyMs >= 0)
|
||||
assert.ok(!sender.encryptedPassword.includes('pwd'))
|
||||
|
||||
const sent = await sendEmailMessage(sender, {
|
||||
to: receiver.account,
|
||||
subject,
|
||||
text: 'Lingxi IMAP/SMTP real protocol message',
|
||||
attachments: [{
|
||||
filename: 'lingxi-proof.txt',
|
||||
content: Buffer.from('real email attachment'),
|
||||
contentType: 'text/plain',
|
||||
}],
|
||||
})
|
||||
assert.ok(sent.messageId)
|
||||
|
||||
const received = await waitForMessage(receiver, (message) => message.subject === subject)
|
||||
assert.equal(received.message.text, 'Lingxi IMAP/SMTP real protocol message')
|
||||
assert.equal(received.message.from.email, sender.account)
|
||||
assert.equal(received.message.attachments.length, 1)
|
||||
assert.equal(received.message.attachments[0].name, 'lingxi-proof.txt')
|
||||
assert.equal(Buffer.from(received.message.attachments[0].base64, 'base64').toString(), 'real email attachment')
|
||||
|
||||
await markEmailUidsSeen(receiver, [received.message.uid])
|
||||
const marked = await fetchEmailMessages(receiver, { full: true })
|
||||
assert.equal(marked.messages.find((message) => message.uid === received.message.uid)?.seen, true)
|
||||
|
||||
const reply = await sendEmailMessage(receiver, {
|
||||
to: sender.account,
|
||||
subject: `Re: ${subject}`,
|
||||
text: 'Reply received by Lingxi',
|
||||
inReplyTo: received.message.messageId,
|
||||
references: [received.message.messageId],
|
||||
})
|
||||
const replyReceived = await waitForMessage(sender, (message) => message.messageId === reply.messageId)
|
||||
assert.equal(replyReceived.message.inReplyTo, received.message.messageId)
|
||||
assert.ok(replyReceived.message.references.includes(received.message.messageId))
|
||||
})
|
||||
+70
-48
@@ -1,53 +1,76 @@
|
||||
import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom'
|
||||
import { useAuthStore } from '@/app/store/auth'
|
||||
import { createBrowserRouter, Navigate, RouterProvider, useRouteError } from 'react-router-dom'
|
||||
import { isSessionTokenExpired, useAuthStore } from '@/app/store/auth'
|
||||
import { AppLayout } from '@/components/layout/AppLayout'
|
||||
import type { ReactNode } from 'react'
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
|
||||
import { LoginPage } from '@/pages/auth/LoginPage'
|
||||
import { RegisterPage } from '@/pages/auth/RegisterPage'
|
||||
import { OnboardingPage } from '@/pages/onboarding/OnboardingPage'
|
||||
import { DashboardPage } from '@/pages/dashboard/DashboardPage'
|
||||
import { InboxPage } from '@/pages/inbox/InboxPage'
|
||||
import { CustomersPage } from '@/pages/customers/CustomersPage'
|
||||
import { CustomerDetailPage } from '@/pages/customers/CustomerDetailPage'
|
||||
import { OpportunitiesPage } from '@/pages/opportunities/OpportunitiesPage'
|
||||
import { TranslatePage } from '@/pages/translate/TranslatePage'
|
||||
import { InterpretPage } from '@/pages/interpret/InterpretPage'
|
||||
import { RisksPage } from '@/pages/risks/RisksPage'
|
||||
import { KnowledgePage } from '@/pages/knowledge/KnowledgePage'
|
||||
import { AnalyticsPage } from '@/pages/analytics/AnalyticsPage'
|
||||
import { ChannelsPage } from '@/pages/channels/ChannelsPage'
|
||||
import { TeamPage } from '@/pages/team/TeamPage'
|
||||
import { BillingPage } from '@/pages/billing/BillingPage'
|
||||
import { SettingsPage } from '@/pages/settings/SettingsPage'
|
||||
import { HelpPage } from '@/pages/help/HelpPage'
|
||||
import { NotFoundPage } from '@/pages/NotFoundPage'
|
||||
|
||||
function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token)
|
||||
if (!token) return <Navigate to="/login" replace />
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const expired = isSessionTokenExpired(token)
|
||||
useEffect(() => {
|
||||
if (token && expired) logout()
|
||||
}, [expired, logout, token])
|
||||
if (expired) return <Navigate to="/login?reason=session-expired" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function GuestOnly({ children }: { children: ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token)
|
||||
if (token) return <Navigate to="/dashboard" replace />
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
const expired = Boolean(token) && isSessionTokenExpired(token)
|
||||
useEffect(() => {
|
||||
if (expired) logout()
|
||||
}, [expired, logout])
|
||||
if (token && !expired) return <Navigate to="/dashboard" replace />
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
function RouteErrorFallback() {
|
||||
const error = useRouteError()
|
||||
|
||||
useEffect(() => {
|
||||
console.error('Route render failed', error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-slate-50 px-6">
|
||||
<section className="w-full max-w-md rounded-3xl border border-slate-200 bg-white p-8 text-center shadow-xl shadow-slate-200/60">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-blue-50 text-xl text-blue-600">
|
||||
!
|
||||
</div>
|
||||
<h1 className="mt-5 text-xl font-semibold tracking-tight text-slate-950">页面暂时无法显示</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
当前页面遇到异常。你可以重新加载;如果问题仍然存在,请返回工作台后重试。
|
||||
</p>
|
||||
<div className="mt-6 flex justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => window.location.reload()}
|
||||
className="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-blue-700"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
<a
|
||||
href="/dashboard"
|
||||
className="rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
返回工作台
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{ path: '/login', element: <GuestOnly><LoginPage /></GuestOnly> },
|
||||
{ path: '/register', element: <GuestOnly><RegisterPage /></GuestOnly> },
|
||||
{
|
||||
path: '/onboarding',
|
||||
element: (
|
||||
<RequireAuth>
|
||||
<OnboardingPage />
|
||||
</RequireAuth>
|
||||
),
|
||||
},
|
||||
{ path: '/login', element: <GuestOnly><LoginPage /></GuestOnly>, errorElement: <RouteErrorFallback /> },
|
||||
{ path: '/register', element: <GuestOnly><RegisterPage /></GuestOnly>, errorElement: <RouteErrorFallback /> },
|
||||
{
|
||||
path: '/',
|
||||
errorElement: <RouteErrorFallback />,
|
||||
element: (
|
||||
<RequireAuth>
|
||||
<AppLayout />
|
||||
@@ -55,22 +78,21 @@ const router = createBrowserRouter([
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: 'dashboard', element: <DashboardPage /> },
|
||||
{ path: 'inbox', element: <InboxPage /> },
|
||||
{ path: 'customers', element: <CustomersPage /> },
|
||||
{ path: 'customers/:id', element: <CustomerDetailPage /> },
|
||||
{ path: 'opportunities', element: <OpportunitiesPage /> },
|
||||
{ path: 'translate', element: <TranslatePage /> },
|
||||
{ path: 'interpret', element: <InterpretPage /> },
|
||||
{ path: 'risks', element: <RisksPage /> },
|
||||
{ path: 'knowledge', element: <KnowledgePage /> },
|
||||
{ path: 'analytics', element: <AnalyticsPage /> },
|
||||
{ path: 'channels', element: <ChannelsPage /> },
|
||||
{ path: 'team', element: <TeamPage /> },
|
||||
{ path: 'billing', element: <BillingPage /> },
|
||||
{ path: 'settings', element: <SettingsPage /> },
|
||||
{ path: 'help', element: <HelpPage /> },
|
||||
{ path: '*', element: <NotFoundPage /> },
|
||||
{ path: 'dashboard', lazy: async () => ({ Component: (await import('@/pages/dashboard/DashboardPage')).DashboardPage }) },
|
||||
{ path: 'inbox', lazy: async () => ({ Component: (await import('@/pages/inbox/InboxPage')).InboxPage }) },
|
||||
{ path: 'customers', lazy: async () => ({ Component: (await import('@/pages/customers/CustomersPage')).CustomersPage }) },
|
||||
{ path: 'customers/:id', lazy: async () => ({ Component: (await import('@/pages/customers/CustomerDetailPage')).CustomerDetailPage }) },
|
||||
{ path: 'opportunities', lazy: async () => ({ Component: (await import('@/pages/opportunities/OpportunitiesPage')).OpportunitiesPage }) },
|
||||
{ path: 'translate', lazy: async () => ({ Component: (await import('@/pages/translate/TranslatePage')).TranslatePage }) },
|
||||
{ path: 'risks', lazy: async () => ({ Component: (await import('@/pages/risks/RisksPage')).RisksPage }) },
|
||||
{ path: 'knowledge', lazy: async () => ({ Component: (await import('@/pages/knowledge/KnowledgePage')).KnowledgePage }) },
|
||||
{ path: 'analytics', lazy: async () => ({ Component: (await import('@/pages/analytics/AnalyticsPage')).AnalyticsPage }) },
|
||||
{ path: 'channels', lazy: async () => ({ Component: (await import('@/pages/channels/ChannelsPage')).ChannelsPage }) },
|
||||
{ path: 'team', lazy: async () => ({ Component: (await import('@/pages/team/TeamPage')).TeamPage }) },
|
||||
{ path: 'billing', lazy: async () => ({ Component: (await import('@/pages/billing/BillingPage')).BillingPage }) },
|
||||
{ path: 'settings', lazy: async () => ({ Component: (await import('@/pages/settings/SettingsPage')).SettingsPage }) },
|
||||
{ path: 'help', lazy: async () => ({ Component: (await import('@/pages/help/HelpPage')).HelpPage }) },
|
||||
{ path: '*', lazy: async () => ({ Component: (await import('@/pages/NotFoundPage')).NotFoundPage }) },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
+21
-6
@@ -2,8 +2,7 @@ import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import type { AuthSession, Tenant, User } from '@/types'
|
||||
import type { RoleKey } from '@/config/roles'
|
||||
import { ROLE_META } from '@/config/roles'
|
||||
import { setTokenGetter } from '@/services/api/client'
|
||||
import { setTokenGetter, setUnauthorizedHandler } from '@/services/api/client'
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
@@ -12,8 +11,6 @@ interface AuthState {
|
||||
onboarded: boolean
|
||||
setSession: (session: AuthSession) => void
|
||||
logout: () => void
|
||||
/** 演示用:切换当前用户角色以预览不同权限 */
|
||||
switchRole: (role: RoleKey) => void
|
||||
setOnboarded: (v: boolean) => void
|
||||
}
|
||||
|
||||
@@ -27,8 +24,6 @@ export const useAuthStore = create<AuthState>()(
|
||||
setSession: (session) =>
|
||||
set({ token: session.token, user: session.user, tenant: session.tenant, onboarded: session.onboarded }),
|
||||
logout: () => set({ token: null, user: null, tenant: null }),
|
||||
switchRole: (role) =>
|
||||
set((s) => (s.user ? { user: { ...s.user, role, title: ROLE_META[role].label } } : s)),
|
||||
setOnboarded: (v) => set({ onboarded: v }),
|
||||
}),
|
||||
{ name: 'lx-auth' },
|
||||
@@ -37,6 +32,26 @@ export const useAuthStore = create<AuthState>()(
|
||||
|
||||
// 让 API client 能读取 token(真实后端模式使用)
|
||||
setTokenGetter(() => useAuthStore.getState().token)
|
||||
setUnauthorizedHandler(() => {
|
||||
useAuthStore.getState().logout()
|
||||
if (!window.location.pathname.startsWith('/login')) {
|
||||
window.location.replace('/login?reason=session-expired')
|
||||
}
|
||||
})
|
||||
|
||||
export function isSessionTokenExpired(token: string | null): boolean {
|
||||
if (!token) return true
|
||||
try {
|
||||
const encoded = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = encoded.padEnd(Math.ceil(encoded.length / 4) * 4, '=')
|
||||
const payload = JSON.parse(window.atob(padded)) as {
|
||||
exp?: number
|
||||
}
|
||||
return !payload.exp || payload.exp * 1_000 <= Date.now()
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function useCurrentRole(): RoleKey {
|
||||
return useAuthStore((s) => s.user?.role ?? 'viewer')
|
||||
|
||||
@@ -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,10 +15,10 @@ import {
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
import { Card, CardHeader } from '@/components/ui/card'
|
||||
import { DemoNote } from '@/components/common/DemoNote'
|
||||
import { LiveDataBadge } from '@/components/common/LiveDataBadge'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export const CHART_COLORS = ['#247b71', '#32998c', '#52b3a5', '#83cfc2', '#f59e0b', '#3b82f6', '#8b5cf6', '#94a3b8']
|
||||
export const CHART_COLORS = ['#2563eb', '#3b82f6', '#60a5fa', '#8b5cf6', '#06b6d4', '#10b981', '#f59e0b', '#94a3b8']
|
||||
|
||||
export function ChartCard({
|
||||
title,
|
||||
@@ -43,7 +43,7 @@ export function ChartCard({
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
{description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
{action ?? <DemoNote />}
|
||||
{action ?? <LiveDataBadge />}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div className="p-3" style={{ height }}>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Info } from 'lucide-react'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
/** “演示数据”标注 */
|
||||
export function DemoNote({ className, text = '演示数据' }: { className?: string; text?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded bg-amber-50 px-1.5 py-0.5 text-[11px] text-amber-600',
|
||||
className,
|
||||
)}
|
||||
title="当前环境使用模拟数据演示,接入真实后端后显示真实经营数据"
|
||||
>
|
||||
<Info className="h-3 w-3" />
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Radio } from 'lucide-react'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export function LiveDataBadge({ className, text = '实时数据' }: { className?: string; text?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700',
|
||||
className,
|
||||
)}
|
||||
title="指标实时更新"
|
||||
>
|
||||
<Radio className="h-3 w-3" />
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -15,11 +15,11 @@ export function PageHeader({
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('mb-4', className)}>
|
||||
<div className={cn('mb-5', className)}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg font-semibold text-foreground">{title}</h1>
|
||||
{description && <p className="mt-0.5 text-sm text-muted-foreground">{description}</p>}
|
||||
<h1 className="text-[22px] font-semibold tracking-[-0.035em] text-slate-950 md:text-2xl">{title}</h1>
|
||||
{description && <p className="mt-1 text-sm text-slate-500">{description}</p>}
|
||||
</div>
|
||||
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
|
||||
</div>
|
||||
|
||||
@@ -27,17 +27,20 @@ export function StatCard({
|
||||
const DeltaIcon = neutral ? Minus : (delta ?? 0) > 0 ? TrendingUp : TrendingDown
|
||||
return (
|
||||
<Card
|
||||
className={cn('p-4', onClick && 'cursor-pointer transition-shadow hover:shadow-md')}
|
||||
className={cn(
|
||||
'group p-5 transition-all duration-200',
|
||||
onClick && 'cursor-pointer hover:-translate-y-0.5 hover:border-primary-200 hover:shadow-[0_12px_28px_-16px_rgba(37,99,235,.48)]',
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">{title}</span>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-primary-50">
|
||||
<span className="text-xs font-medium text-slate-500">{title}</span>
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-[10px] border border-primary-100 bg-primary-50 transition-colors group-hover:bg-primary-100">
|
||||
<Icon className="h-4 w-4 text-primary-600" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex items-baseline gap-1">
|
||||
<span className="text-2xl font-semibold tracking-tight text-foreground">{value}</span>
|
||||
<div className="mt-3 flex items-baseline gap-1">
|
||||
<span className="text-[28px] font-semibold tracking-[-0.04em] text-slate-950">{value}</span>
|
||||
{suffix && <span className="text-xs text-muted-foreground">{suffix}</span>}
|
||||
</div>
|
||||
{showDelta && (
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useId } from 'react'
|
||||
import {
|
||||
PolarAngleAxis,
|
||||
PolarGrid,
|
||||
PolarRadiusAxis,
|
||||
Radar,
|
||||
RadarChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
} from 'recharts'
|
||||
import { Sparkles, TrendingUp } from 'lucide-react'
|
||||
import type { Customer } from '@/types'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
function qualitativeScore(value?: string, fallback = 50) {
|
||||
const text = String(value || '').toLowerCase()
|
||||
if (!text) return fallback
|
||||
if (/高|强|紧急|决策|老板|owner|director|high|urgent/.test(text)) return 86
|
||||
if (/中|一般|参与|medium|normal/.test(text)) return 62
|
||||
if (/低|弱|未知|助理|low|unknown/.test(text)) return 36
|
||||
return fallback
|
||||
}
|
||||
|
||||
export function profileRadarData(customer: Customer) {
|
||||
const profile = customer.aiProfile || {}
|
||||
const extracted = [
|
||||
profile.companyName,
|
||||
profile.industry,
|
||||
profile.businessModel,
|
||||
profile.inquiryQuantity,
|
||||
profile.targetPrice,
|
||||
profile.budget,
|
||||
profile.purchaseTimeline,
|
||||
profile.payment,
|
||||
].filter(Boolean).length
|
||||
const fitScore = Math.min(96, 42 + extracted * 6 + (profile.keyNeeds?.length || 0) * 4)
|
||||
|
||||
return [
|
||||
{ dimension: '购买意向', score: Math.max(0, Math.min(100, Number(customer.intentScore || 0))), hint: '基于询盘强度与购买信号' },
|
||||
{ dimension: '成交可能', score: Number(profile.dealProbability || 0), hint: '根据当前客户资料和会话信号估算' },
|
||||
{ dimension: '决策能力', score: qualitativeScore(profile.decisionPower), hint: profile.decisionPower || '尚待确认采购决策权' },
|
||||
{ dimension: '需求紧迫', score: qualitativeScore(profile.urgency), hint: profile.urgency || '尚待确认采购时间表' },
|
||||
{ dimension: '客户匹配', score: fitScore, hint: '画像字段、需求与产品匹配度' },
|
||||
{ dimension: '关系健康', score: Math.max(0, 100 - Number(profile.churnProbability || 0)), hint: '流失风险的反向健康度' },
|
||||
]
|
||||
}
|
||||
|
||||
export function CustomerProfileRadar({
|
||||
customer,
|
||||
compact = false,
|
||||
className,
|
||||
}: {
|
||||
customer: Customer
|
||||
compact?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const data = profileRadarData(customer)
|
||||
const average = Math.round(data.reduce((sum, item) => sum + item.score, 0) / data.length)
|
||||
const chartId = useId().replace(/:/g, '')
|
||||
const gradientId = `radar-fill-${chartId}`
|
||||
const glowId = `radar-glow-${chartId}`
|
||||
const level = average >= 75 ? '高潜客户' : average >= 55 ? '重点培育' : '持续观察'
|
||||
const levelTone = average >= 75
|
||||
? 'text-blue-700 bg-blue-50 border-blue-100'
|
||||
: average >= 55
|
||||
? 'text-violet-700 bg-violet-50 border-violet-100'
|
||||
: 'text-amber-700 bg-amber-50 border-amber-100'
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'group relative isolate overflow-hidden rounded-2xl border border-blue-100/90 bg-white shadow-[0_18px_55px_-32px_rgba(37,99,235,.32)]',
|
||||
className,
|
||||
)}>
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-40 bg-[radial-gradient(circle_at_75%_0%,rgba(96,165,250,.16),transparent_58%)]" />
|
||||
<div className="flex items-start justify-between gap-3 px-5 pt-5">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-violet-600 text-white shadow-[0_8px_20px_-8px_rgba(37,99,235,.7)]">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold tracking-tight text-slate-950">客户价值雷达</p>
|
||||
<p className="mt-0.5 text-[11px] text-slate-500">基于当前客户资料和会话信号</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={cn('mt-3 inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-[11px] font-semibold', levelTone)}>
|
||||
<TrendingUp className="h-3 w-3" />
|
||||
{level}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="relative flex h-[70px] w-[70px] shrink-0 items-center justify-center rounded-full p-[5px] shadow-[0_10px_30px_-16px_rgba(37,99,235,.7)]"
|
||||
style={{ background: `conic-gradient(#2563eb ${average * 3.6}deg, #dbeafe 0deg)` }}
|
||||
aria-label={`综合指数 ${average} 分`}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center rounded-full border border-white bg-white shadow-inner">
|
||||
<p className="text-[9px] font-semibold tracking-wider text-blue-600">综合指数</p>
|
||||
<p className="text-2xl font-bold leading-6 tracking-tight text-slate-950">{average}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn(
|
||||
'customer-radar-stage relative mx-3 mt-1 overflow-hidden rounded-2xl',
|
||||
'bg-[radial-gradient(circle_at_center,rgba(239,246,255,.96)_0%,rgba(255,255,255,.45)_62%,transparent_72%)]',
|
||||
compact ? 'h-[240px]' : 'h-[310px]',
|
||||
)}>
|
||||
<div className="pointer-events-none absolute left-1/2 top-1/2 h-44 w-44 -translate-x-1/2 -translate-y-1/2 rounded-full border border-blue-100/80 shadow-[0_0_60px_rgba(37,99,235,.10)]" />
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart data={data} cx="50%" cy="52%" outerRadius={compact ? '66%' : '72%'}>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stopColor="#60a5fa" stopOpacity="0.58" />
|
||||
<stop offset="55%" stopColor="#3b82f6" stopOpacity="0.34" />
|
||||
<stop offset="100%" stopColor="#8b5cf6" stopOpacity="0.22" />
|
||||
</linearGradient>
|
||||
<filter id={glowId} x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feGaussianBlur stdDeviation="4" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<PolarGrid stroke="#dbeafe" strokeOpacity={0.95} gridType="polygon" />
|
||||
<PolarAngleAxis dataKey="dimension" tick={{ fill: '#334155', fontSize: compact ? 10 : 12, fontWeight: 650 }} />
|
||||
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={false} axisLine={false} />
|
||||
<Tooltip
|
||||
formatter={(value: number) => [`${value} / 100`, '画像指数']}
|
||||
labelFormatter={(label) => `${label} · 参考值`}
|
||||
contentStyle={{
|
||||
borderRadius: 14,
|
||||
border: '1px solid #dbeafe',
|
||||
boxShadow: '0 18px 40px rgba(37,99,235,.14)',
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
<Radar
|
||||
name="客户画像"
|
||||
dataKey="score"
|
||||
isAnimationActive={false}
|
||||
stroke="#60a5fa"
|
||||
strokeWidth={8}
|
||||
strokeOpacity={0.16}
|
||||
fill="none"
|
||||
dot={false}
|
||||
filter={`url(#${glowId})`}
|
||||
/>
|
||||
<Radar
|
||||
name="客户画像"
|
||||
dataKey="score"
|
||||
isAnimationActive={false}
|
||||
stroke="#2563eb"
|
||||
strokeWidth={3}
|
||||
fill={`url(#${gradientId})`}
|
||||
fillOpacity={1}
|
||||
dot={{ r: compact ? 3.5 : 4.5, fill: '#2563eb', strokeWidth: 3, stroke: '#fff' }}
|
||||
/>
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{!compact && (
|
||||
<div className="grid grid-cols-2 gap-2 border-t border-slate-100 bg-slate-50/40 px-4 py-4 sm:grid-cols-3">
|
||||
{data.map((item) => (
|
||||
<div key={item.dimension} className="min-w-0 rounded-xl border border-slate-100 bg-white px-3 py-2.5 shadow-sm">
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<span className="text-[11px] font-medium text-slate-500">{item.dimension}</span>
|
||||
<span className="text-base font-bold leading-none text-slate-900">{item.score}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-blue-400 via-blue-600 to-violet-500 shadow-[0_0_8px_rgba(37,99,235,.28)]"
|
||||
style={{ width: `${item.score}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Tag } from 'lucide-react'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
type TagCategory = 'positive' | 'emotion' | 'risk' | 'aftersales' | 'quality' | 'service' | 'profile'
|
||||
|
||||
const categoryConfig: Record<TagCategory, { label: string; className: string; dot: string }> = {
|
||||
positive: {
|
||||
label: '积极信号',
|
||||
className: 'border-emerald-200/90 bg-emerald-50 text-emerald-700',
|
||||
dot: 'bg-emerald-500 shadow-[0_0_0_2px_rgba(16,185,129,.12)]',
|
||||
},
|
||||
emotion: {
|
||||
label: '客户情绪',
|
||||
className: 'border-amber-200/90 bg-amber-50 text-amber-700',
|
||||
dot: 'bg-amber-500 shadow-[0_0_0_2px_rgba(245,158,11,.12)]',
|
||||
},
|
||||
risk: {
|
||||
label: '投诉风险',
|
||||
className: 'border-rose-200/90 bg-rose-50 text-rose-700',
|
||||
dot: 'bg-rose-500 shadow-[0_0_0_2px_rgba(244,63,94,.12)]',
|
||||
},
|
||||
aftersales: {
|
||||
label: '售后处理',
|
||||
className: 'border-orange-200/90 bg-orange-50 text-orange-700',
|
||||
dot: 'bg-orange-500 shadow-[0_0_0_2px_rgba(249,115,22,.12)]',
|
||||
},
|
||||
quality: {
|
||||
label: '产品质量',
|
||||
className: 'border-sky-200/90 bg-sky-50 text-sky-700',
|
||||
dot: 'bg-sky-500 shadow-[0_0_0_2px_rgba(14,165,233,.12)]',
|
||||
},
|
||||
service: {
|
||||
label: '服务沟通',
|
||||
className: 'border-violet-200/90 bg-violet-50 text-violet-700',
|
||||
dot: 'bg-violet-500 shadow-[0_0_0_2px_rgba(139,92,246,.12)]',
|
||||
},
|
||||
profile: {
|
||||
label: '客户属性',
|
||||
className: 'border-slate-200 bg-slate-50 text-slate-600',
|
||||
dot: 'bg-slate-400 shadow-[0_0_0_2px_rgba(100,116,139,.1)]',
|
||||
},
|
||||
}
|
||||
|
||||
export function customerTagCategory(tag: string): TagCategory {
|
||||
const value = tag.toLowerCase().replace(/[_-]+/g, ' ')
|
||||
|
||||
// Positive outcomes take priority for phrases such as “投诉处理成功”.
|
||||
if (/success|satisfaction|repurchase|positive|resolved|回升|缓和|修复|改进|成功|复购|满意|转正/.test(value)) return 'positive'
|
||||
if (/emotion|anger|angry|insult|情绪|愤怒|高情绪|冲突/.test(value)) return 'emotion'
|
||||
if (/urgent|escalation|crisis|complaint|投诉|危机|辱骂|欺诈|风险/.test(value)) return 'risk'
|
||||
if (/after.?sales|refund|return|replace|售后|退款|退货|补偿|换货|更换/.test(value)) return 'aftersales'
|
||||
if (/quality|defect|product|holes?|smell|品质|质量|瑕疵|破损|异味|制造|尺寸|材质/.test(value)) return 'quality'
|
||||
if (/agent|service|communication|feedback|客服|坐席|沟通|销售反馈/.test(value)) return 'service'
|
||||
return 'profile'
|
||||
}
|
||||
|
||||
export function CustomerSemanticTag({
|
||||
tag,
|
||||
className,
|
||||
}: {
|
||||
tag: string
|
||||
className?: string
|
||||
}) {
|
||||
const category = customerTagCategory(tag)
|
||||
const config = categoryConfig[category]
|
||||
|
||||
return (
|
||||
<span
|
||||
data-tag-category={category}
|
||||
title={`${config.label} · ${tag}`}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-medium leading-none transition-transform hover:-translate-y-px',
|
||||
config.className,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', config.dot)} />
|
||||
{tag}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function CustomerTagLegend({ tags }: { tags: string[] }) {
|
||||
const counts = tags.reduce<Partial<Record<TagCategory, number>>>((result, tag) => {
|
||||
const category = customerTagCategory(tag)
|
||||
result[category] = (result[category] || 0) + 1
|
||||
return result
|
||||
}, {})
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[10px] text-slate-500">
|
||||
<span className="inline-flex items-center gap-1 font-semibold uppercase tracking-[0.12em] text-slate-400">
|
||||
<Tag className="h-3 w-3" />
|
||||
标签画像
|
||||
</span>
|
||||
{(Object.keys(categoryConfig) as TagCategory[])
|
||||
.filter((category) => counts[category])
|
||||
.map((category) => (
|
||||
<span key={category} className="inline-flex items-center gap-1">
|
||||
<span className={cn('h-1.5 w-1.5 rounded-full', categoryConfig[category].dot)} />
|
||||
{categoryConfig[category].label}
|
||||
<span className="font-semibold text-slate-700">{counts[category]}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Sparkles, Copy, Edit, Check, Lightbulb, Globe, AlertTriangle, BookOpen } from 'lucide-react'
|
||||
import { Sparkles, Copy, Edit, Check, Lightbulb, Globe, AlertTriangle, BookOpen, Languages, X } from 'lucide-react'
|
||||
import { generateReplies } from '@/services/api/ai'
|
||||
import type { AiReplySuggestion } from '@/types/ai'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -19,44 +19,54 @@ export function AiReplies({
|
||||
conversationId,
|
||||
onAdopt,
|
||||
onEdit,
|
||||
onClose,
|
||||
targetLanguage = 'auto',
|
||||
}: {
|
||||
conversationId: string
|
||||
onAdopt: (content: string) => void
|
||||
onEdit: (content: string) => void
|
||||
onAdopt: (content: string, translationCn: string, suggestionId: string) => void
|
||||
onEdit: (content: string, translationCn: string) => void
|
||||
onClose: () => void
|
||||
targetLanguage?: string
|
||||
}) {
|
||||
const [items, setItems] = useState<AiReplySuggestion[] | null>(null)
|
||||
useEffect(() => setItems(null), [conversationId])
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => generateReplies(conversationId),
|
||||
mutationFn: () => generateReplies(conversationId, targetLanguage),
|
||||
onSuccess: (data) => {
|
||||
setItems(Array.isArray(data) ? data : [])
|
||||
toast.success('AI 回复建议已生成', '根据客户画像、知识库和历史成功回复综合生成')
|
||||
toast.success('回复建议已生成', '已参考客户资料、知识库和历史回复')
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="border-t border-border p-4">
|
||||
<div className="border-t border-slate-100 bg-white px-4 py-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
|
||||
<Sparkles className="h-3.5 w-3.5 text-primary" />
|
||||
AI 智能回复
|
||||
<h4 className="flex items-center gap-2 text-xs font-semibold text-slate-700">
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary-50"><Sparkles className="h-3.5 w-3.5 text-primary" /></span>
|
||||
回复建议
|
||||
</h4>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<>
|
||||
<Spinner className="h-3 w-3" />
|
||||
正在生成…
|
||||
</>
|
||||
) : (
|
||||
'生成回复建议'
|
||||
)}
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<>
|
||||
<Spinner className="h-3 w-3" />
|
||||
正在生成…
|
||||
</>
|
||||
) : (
|
||||
'生成回复建议'
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" onClick={onClose} title="关闭回复建议">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mutation.isPending && (
|
||||
@@ -76,7 +86,7 @@ export function AiReplies({
|
||||
{items && items.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{items.map((s) => {
|
||||
const style = STYLE_LABELS[s.style]
|
||||
const style = STYLE_LABELS[s.style] || STYLE_LABELS.stable
|
||||
return (
|
||||
<div key={s.id} className="rounded-lg border border-border bg-card p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
@@ -86,10 +96,32 @@ export function AiReplies({
|
||||
{languageName(s.targetLang)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">{s.content}</p>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
|
||||
<Globe className="h-3 w-3" />
|
||||
发送给客户 · {languageName(s.targetLang)}
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-sm leading-relaxed text-slate-800">{s.content}</p>
|
||||
</div>
|
||||
{s.translationCn && (
|
||||
<div
|
||||
data-testid="ai-reply-translation"
|
||||
className="rounded-lg border border-primary-100 bg-primary-50/60 px-3 py-2"
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-1.5 text-[10px] font-semibold text-primary-700">
|
||||
<Languages className="h-3 w-3" />
|
||||
中文参考 · 不会发送
|
||||
</div>
|
||||
<p className="whitespace-pre-wrap text-xs leading-relaxed text-slate-600">
|
||||
{s.translationCn}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{s.knowledgeRefs.slice(0, 2).map((ref) => (
|
||||
{(s.knowledgeRefs || []).slice(0, 2).map((ref) => (
|
||||
<span key={ref} className="flex items-center gap-1 rounded bg-muted px-1.5 py-0.5">
|
||||
<BookOpen className="h-3 w-3" />
|
||||
{ref}
|
||||
@@ -103,7 +135,7 @@ export function AiReplies({
|
||||
<span className="text-muted-foreground">{s.reason}</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-1">
|
||||
<Globe className="mt-0.5 h-3 w-3 shrink-0 text-blue-500" />
|
||||
<Sparkles className="mt-0.5 h-3 w-3 shrink-0 text-blue-500" />
|
||||
<span className="text-muted-foreground">{s.expectedEffect}</span>
|
||||
</div>
|
||||
{s.cultureTip && (
|
||||
@@ -115,11 +147,11 @@ export function AiReplies({
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<Button size="sm" variant="default" onClick={() => onAdopt(s.content)}>
|
||||
<Button size="sm" variant="default" onClick={() => onAdopt(s.content, s.translationCn, s.id)}>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
采用
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => onEdit(s.content)}>
|
||||
<Button size="sm" variant="outline" onClick={() => onEdit(s.content, s.translationCn)}>
|
||||
<Edit className="h-3.5 w-3.5" />
|
||||
编辑后采用
|
||||
</Button>
|
||||
@@ -138,9 +170,10 @@ export function AiReplies({
|
||||
)}
|
||||
|
||||
{!items && !mutation.isPending && (
|
||||
<p className="py-3 text-center text-xs text-muted-foreground">
|
||||
点击「生成回复建议」让 AI 根据客户上下文、知识库与历史成功回复为您生成回复
|
||||
</p>
|
||||
<div className="flex items-center justify-between rounded-xl bg-slate-50 px-3 py-2.5">
|
||||
<p className="text-xs text-slate-500">生成三种回复建议</p>
|
||||
<span className="hidden text-[10px] font-medium text-primary sm:inline">不会自动发送</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
Shrink,
|
||||
Expand,
|
||||
ChevronDown,
|
||||
Languages,
|
||||
Reply,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { Segmented } from '@/components/ui/tabs'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -17,7 +20,8 @@ import { Dropdown } from '@/components/ui/dropdown'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { toast } from '@/app/store/toast'
|
||||
import { cn } from '@/utils/cn'
|
||||
import type { ChannelType } from '@/types'
|
||||
import { LANGUAGES, languageName } from '@/config/languages'
|
||||
|
||||
const QUICK_PHRASES = [
|
||||
{ label: '报价催促', text: 'Friendly reminder: the quotation is valid until this Friday. Please confirm at your earliest convenience.' },
|
||||
@@ -39,27 +43,65 @@ const TONES = [
|
||||
|
||||
export function Composer({
|
||||
onSend,
|
||||
onMedia,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
replyingTo,
|
||||
onCancelReply,
|
||||
channel = 'telegram',
|
||||
targetLanguage = 'auto',
|
||||
detectedLanguage,
|
||||
onTargetLanguageChange,
|
||||
}: {
|
||||
onSend: (text: string, translatedText?: string) => Promise<void>
|
||||
onSend: (text: string, autoTranslate?: boolean, targetLang?: string) => Promise<void>
|
||||
onMedia?: (files: File[], kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker') => Promise<void>
|
||||
value?: string
|
||||
onChange?: (text: string) => void
|
||||
disabled?: boolean
|
||||
replyingTo?: { id: string; text: string } | null
|
||||
onCancelReply?: () => void
|
||||
channel?: ChannelType
|
||||
targetLanguage?: string
|
||||
detectedLanguage?: string
|
||||
onTargetLanguageChange?: (language: string) => void
|
||||
}) {
|
||||
const [text, setText] = useState('')
|
||||
const [internalText, setInternalText] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
const [mode, setMode] = useState<'text' | 'preview'>('text')
|
||||
const [previewText, setPreviewText] = useState('')
|
||||
const [confirmBeforeSend, setConfirmBeforeSend] = useState(true)
|
||||
const [autoTranslate, setAutoTranslate] = useState(true)
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
const text = value ?? internalText
|
||||
const setText = onChange ?? setInternalText
|
||||
const textarea = useRef<HTMLTextAreaElement>(null)
|
||||
const documentInput = useRef<HTMLInputElement>(null)
|
||||
const imageInput = useRef<HTMLInputElement>(null)
|
||||
const audioInput = useRef<HTMLInputElement>(null)
|
||||
const stickerInput = useRef<HTMLInputElement>(null)
|
||||
|
||||
const send = async () => {
|
||||
const body = confirmBeforeSend ? previewText || text : text
|
||||
if (!body.trim()) return
|
||||
const uploadFiles = async (files: FileList | null, kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker') => {
|
||||
if (!files?.length || !onMedia) return
|
||||
setSending(true)
|
||||
try {
|
||||
await onSend(body, previewText || undefined)
|
||||
await onMedia(Array.from(files), kind)
|
||||
const destination = channel === 'email'
|
||||
? '客户邮箱'
|
||||
: channel === 'whatsapp'
|
||||
? ' WhatsApp'
|
||||
: ' Telegram'
|
||||
toast.success(`${files.length} 个文件已发送到${destination}`)
|
||||
} catch {
|
||||
toast.error('媒体发送失败')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
if (!text.trim()) return
|
||||
setSending(true)
|
||||
try {
|
||||
await onSend(text, autoTranslate, targetLanguage)
|
||||
setText('')
|
||||
setPreviewText('')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
@@ -73,7 +115,7 @@ export function Composer({
|
||||
}
|
||||
|
||||
const insert = (txt: string) => {
|
||||
setText((prev) => (prev ? `${prev}\n${txt}` : txt))
|
||||
setText(text ? `${text}\n${txt}` : txt)
|
||||
textarea.current?.focus()
|
||||
}
|
||||
|
||||
@@ -82,7 +124,7 @@ export function Composer({
|
||||
setAiLoading(true)
|
||||
try {
|
||||
const { textAction } = await import('@/services/api/ai')
|
||||
const res = await textAction({ text, action: action as any, tone, targetLang: 'en' })
|
||||
const res = await textAction({ text, action: action as any, tone, targetLang: targetLanguage })
|
||||
setText(res.result)
|
||||
toast.success('AI 处理完成')
|
||||
} catch {
|
||||
@@ -93,11 +135,11 @@ export function Composer({
|
||||
}
|
||||
|
||||
const toolbar = (
|
||||
<div className="flex flex-wrap items-center gap-0.5 border-b border-border px-2 py-1.5">
|
||||
<div className="flex items-center gap-1 overflow-x-auto border-b border-slate-100 px-3 py-2 scrollbar-thin">
|
||||
{/* 话术 */}
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="flex items-center gap-0.5 rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground">
|
||||
<button className="flex shrink-0 items-center gap-1 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50 hover:text-slate-900">
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
常用话术
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
@@ -107,29 +149,45 @@ export function Composer({
|
||||
/>
|
||||
<span className="mx-1 h-4 w-px bg-border" />
|
||||
<button
|
||||
className="rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onClick={() => toast.info('文件上传功能已就绪(演示)')}
|
||||
className="shrink-0 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50 hover:text-slate-900"
|
||||
onClick={() => documentInput.current?.click()}
|
||||
title="上传文件"
|
||||
type="button"
|
||||
>
|
||||
<Paperclip className="inline h-3.5 w-3.5" /> 文件
|
||||
</button>
|
||||
<button
|
||||
className="rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onClick={() => toast.info('图片上传功能已就绪(演示)')}
|
||||
className="shrink-0 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50 hover:text-slate-900"
|
||||
onClick={() => imageInput.current?.click()}
|
||||
title="上传图片"
|
||||
type="button"
|
||||
>
|
||||
<Image className="inline h-3.5 w-3.5" /> 图片
|
||||
</button>
|
||||
<button
|
||||
className="rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
onClick={() => toast.info('语音录制功能已就绪(演示)')}
|
||||
title="语音输入"
|
||||
type="button"
|
||||
>
|
||||
<Mic className="inline h-3.5 w-3.5" /> 语音
|
||||
</button>
|
||||
{channel !== 'email' && (
|
||||
<>
|
||||
<button
|
||||
className="shrink-0 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50 hover:text-slate-900"
|
||||
onClick={() => audioInput.current?.click()}
|
||||
title="语音输入"
|
||||
type="button"
|
||||
>
|
||||
<Mic className="inline h-3.5 w-3.5" /> 语音
|
||||
</button>
|
||||
<button
|
||||
className="shrink-0 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50 hover:text-slate-900"
|
||||
onClick={() => stickerInput.current?.click()}
|
||||
title="发送贴纸"
|
||||
type="button"
|
||||
>
|
||||
贴纸
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<input ref={documentInput} type="file" className="hidden" onChange={(e) => uploadFiles(e.target.files, 'document')} />
|
||||
<input ref={imageInput} type="file" accept="image/*" multiple className="hidden" onChange={(e) => uploadFiles(e.target.files, 'photo')} />
|
||||
<input ref={audioInput} type="file" accept="audio/*" className="hidden" onChange={(e) => uploadFiles(e.target.files, 'voice')} />
|
||||
<input ref={stickerInput} type="file" accept=".webp,.tgs,.webm" className="hidden" onChange={(e) => uploadFiles(e.target.files, 'sticker')} />
|
||||
<span className="mx-1 h-4 w-px bg-border" />
|
||||
{/* AI 工具 */}
|
||||
{[
|
||||
@@ -143,7 +201,7 @@ export function Composer({
|
||||
type="button"
|
||||
disabled={aiLoading || !text.trim()}
|
||||
onClick={() => aiAction(t.action)}
|
||||
className="rounded px-1.5 py-1 text-xs text-teal-600 hover:bg-teal-50 disabled:opacity-50"
|
||||
className="shrink-0 rounded-lg px-2 py-1.5 text-xs font-medium text-primary-700 hover:bg-primary-50 disabled:opacity-50"
|
||||
>
|
||||
<t.icon className="inline h-3.5 w-3.5" /> {t.label}
|
||||
</button>
|
||||
@@ -151,7 +209,7 @@ export function Composer({
|
||||
<span className="mx-1 h-4 w-px bg-border" />
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="flex items-center gap-0.5 rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted">
|
||||
<button className="flex shrink-0 items-center gap-0.5 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50">
|
||||
修改语气 <ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
}
|
||||
@@ -161,29 +219,58 @@ export function Composer({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="border-t border-border bg-card">
|
||||
<div className="border-t border-slate-100 bg-white">
|
||||
{toolbar}
|
||||
<div className="p-3">
|
||||
<div className="p-3.5">
|
||||
{replyingTo && (
|
||||
<div className="mb-2 flex items-center gap-2 rounded-lg border-s-2 border-primary bg-primary-50/70 px-3 py-2">
|
||||
<Reply className="h-3.5 w-3.5 shrink-0 text-primary" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[11px] font-medium text-primary">引用回复</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{replyingTo.text}</p>
|
||||
</div>
|
||||
<button type="button" onClick={onCancelReply} className="rounded p-1 hover:bg-primary-100" aria-label="取消引用">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<textarea
|
||||
ref={textarea}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value)
|
||||
setPreviewText(e.target.value)
|
||||
}}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="输入回复内容,Enter 发送,Shift+Enter 换行"
|
||||
placeholder={channel === 'email' ? '撰写邮件回复,Enter 发送,Shift+Enter 换行' : '输入回复内容,Enter 发送,Shift+Enter 换行'}
|
||||
rows={3}
|
||||
className="w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground outline-none focus:border-primary focus:ring-1 focus:ring-ring scrollbar-thin"
|
||||
className="min-h-[86px] w-full resize-none rounded-2xl border border-slate-200 bg-slate-50/60 px-4 py-3.5 text-sm leading-6 placeholder:text-slate-400 outline-none transition-all focus:border-primary-300 focus:bg-white focus:ring-4 focus:ring-primary-50 scrollbar-thin"
|
||||
disabled={sending || disabled}
|
||||
/>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Switch checked={confirmBeforeSend} onCheckedChange={setConfirmBeforeSend} />
|
||||
发送前预览
|
||||
<Switch checked={autoTranslate} onCheckedChange={setAutoTranslate} />
|
||||
双向翻译
|
||||
</label>
|
||||
{autoTranslate && (
|
||||
<label className="relative flex items-center">
|
||||
<Languages className="pointer-events-none absolute left-2.5 h-3.5 w-3.5 text-primary" />
|
||||
<select
|
||||
value={targetLanguage}
|
||||
onChange={(event) => onTargetLanguageChange?.(event.target.value)}
|
||||
aria-label="客户目标语言"
|
||||
className="h-8 max-w-[190px] appearance-none rounded-lg border border-primary-100 bg-primary-50/70 py-1 pl-8 pr-7 text-xs font-medium text-slate-700 outline-none transition hover:border-primary-200 focus:border-primary-300 focus:ring-2 focus:ring-primary-100"
|
||||
>
|
||||
{LANGUAGES.map((language) => (
|
||||
<option key={language.code} value={language.code}>
|
||||
{language.code === 'auto' && detectedLanguage
|
||||
? `自动 · ${languageName(detectedLanguage)}`
|
||||
: `${language.name} · ${language.nativeName}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="pointer-events-none absolute right-2 h-3 w-3 text-slate-400" />
|
||||
</label>
|
||||
)}
|
||||
{aiLoading && (
|
||||
<Badge variant="primary" className="animate-pulse">
|
||||
正在调用 AI…
|
||||
@@ -192,7 +279,7 @@ export function Composer({
|
||||
</div>
|
||||
<Button onClick={send} size="sm" disabled={!text.trim() || sending || disabled} loading={sending}>
|
||||
<Send className="h-4 w-4" />
|
||||
发 送 (↵)
|
||||
{channel === 'email' ? '发送邮件' : '发 送'} (↵)
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Brain, ChevronRight, LoaderCircle } from 'lucide-react'
|
||||
import type { AiBackfillStatus, Conversation, Customer, CustomerStage } from '@/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
CustomerStageBadge,
|
||||
IntentScoreBadge,
|
||||
RiskLevelBadge,
|
||||
SentimentBadge,
|
||||
} from '@/components/common/StatusBadges'
|
||||
|
||||
const validStages = new Set([
|
||||
'initial', 'comparing', 'sample', 'custom', 'closing',
|
||||
'fulfillment', 'aftersales', 'repurchase',
|
||||
])
|
||||
|
||||
export function ConversationInsightBar({
|
||||
customer,
|
||||
conversation,
|
||||
onOpenProfile,
|
||||
aiBackfill,
|
||||
}: {
|
||||
customer: Customer
|
||||
conversation: Conversation
|
||||
onOpenProfile: () => void
|
||||
aiBackfill?: AiBackfillStatus
|
||||
}) {
|
||||
const stage = validStages.has(customer.stage) ? customer.stage : 'initial'
|
||||
const summary = customer.aiProfile?.summary || conversation.summary || '收到更多客户消息后将补充客户画像'
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="conversation-insight-bar"
|
||||
className="shrink-0 border-b border-slate-100 bg-white px-3 py-2 shadow-[0_1px_0_rgba(15,23,42,.02)] sm:px-4"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border border-violet-100 bg-violet-50 text-violet-600">
|
||||
<Brain className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<span className="hidden shrink-0 text-[10px] font-semibold uppercase tracking-[0.12em] text-violet-600 sm:inline">
|
||||
客户洞察
|
||||
</span>
|
||||
<p className="truncate text-xs leading-5 text-slate-600">{summary}</p>
|
||||
</div>
|
||||
<div className="hidden shrink-0 items-center gap-2 md:flex">
|
||||
{aiBackfill && ['queued', 'running'].includes(aiBackfill.status) && (
|
||||
<span
|
||||
title={`历史消息后台分析:${aiBackfill.completedCount}/${aiBackfill.totalCount}`}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2 py-1 text-[10px] font-medium text-blue-700"
|
||||
>
|
||||
<LoaderCircle className="h-3 w-3 animate-spin" />
|
||||
历史分析 {Math.round(Number(aiBackfill.progress || 0) * 100)}%
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
意向 <IntentScoreBadge score={Number(customer.intentScore || 0)} />
|
||||
</span>
|
||||
<SentimentBadge sentiment={customer.sentiment || 'neutral'} />
|
||||
<CustomerStageBadge stage={stage as CustomerStage} />
|
||||
<RiskLevelBadge level={customer.riskLevel || 'none'} />
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={onOpenProfile} title="打开客户画像">
|
||||
<span className="hidden lg:inline">客户画像</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center gap-2 overflow-x-auto pb-0.5 md:hidden">
|
||||
{aiBackfill && ['queued', 'running'].includes(aiBackfill.status) && (
|
||||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-blue-50 px-2 py-1 text-[10px] font-medium text-blue-700">
|
||||
<LoaderCircle className="h-3 w-3 animate-spin" />
|
||||
历史分析 {Math.round(Number(aiBackfill.progress || 0) * 100)}%
|
||||
</span>
|
||||
)}
|
||||
<span className="flex shrink-0 items-center gap-1 text-[11px]">意向 <IntentScoreBadge score={Number(customer.intentScore || 0)} /></span>
|
||||
<SentimentBadge sentiment={customer.sentiment || 'neutral'} />
|
||||
<CustomerStageBadge stage={stage as CustomerStage} />
|
||||
<RiskLevelBadge level={customer.riskLevel || 'none'} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Search, ShieldAlert } from 'lucide-react'
|
||||
import { useRef, useState } from 'react'
|
||||
import { Eraser, Mail, MessageCircle, Pin, Plus, Search, Send, ShieldAlert, Trash2 } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getTeam } from '@/services/api/org'
|
||||
import type { ConversationListItem } from '@/services/api/conversations'
|
||||
@@ -21,10 +22,11 @@ const FILTERS = [
|
||||
{ value: 'risk', label: '有风险' },
|
||||
{ value: 'unassigned', label: '无负责人' },
|
||||
{ value: 'mine', label: '我的客户' },
|
||||
{ value: 'archived', label: '已归档' },
|
||||
]
|
||||
|
||||
export function ConversationList({
|
||||
items,
|
||||
items = [],
|
||||
loading,
|
||||
activeId,
|
||||
onSelect,
|
||||
@@ -32,6 +34,11 @@ export function ConversationList({
|
||||
onFilterChange,
|
||||
keyword,
|
||||
onKeywordChange,
|
||||
channel,
|
||||
onChannelChange,
|
||||
onAdd,
|
||||
onClear,
|
||||
onDelete,
|
||||
}: {
|
||||
items: ConversationListItem[]
|
||||
loading: boolean
|
||||
@@ -41,34 +48,83 @@ export function ConversationList({
|
||||
onFilterChange: (f: string) => void
|
||||
keyword: string
|
||||
onKeywordChange: (k: string) => void
|
||||
channel: string
|
||||
onChannelChange: (channel: string) => void
|
||||
onAdd: () => void
|
||||
onClear: (item: ConversationListItem) => void
|
||||
onDelete: (item: ConversationListItem) => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const { data: team } = useQuery({ queryKey: ['team'], queryFn: getTeam, staleTime: 300_000 })
|
||||
const memberName = (id: string | null) => (id ? (team?.find((m) => m.id === id)?.name ?? '—') : null)
|
||||
const [swipedId, setSwipedId] = useState<string | null>(null)
|
||||
const [dragOffset, setDragOffset] = useState(0)
|
||||
const touchStart = useRef<{ x: number; y: number; id: string } | null>(null)
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 搜索 */}
|
||||
<div className="border-b border-border p-3">
|
||||
<div className="border-b border-slate-200 bg-white px-3 pb-3 pt-3">
|
||||
<div className="mb-3 flex items-center justify-between px-1">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold tracking-[-0.03em] text-slate-950">消息</h1>
|
||||
<p className="mt-0.5 text-[11px] text-slate-400">{items.length} 个活跃会话</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="rounded-full bg-emerald-50 px-2.5 py-1 text-[10px] font-semibold text-emerald-700">
|
||||
实时连接
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
title="添加联系人或群组"
|
||||
aria-label="添加联系人或群组"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-white shadow-sm shadow-primary-200 transition hover:-translate-y-0.5 hover:bg-primary-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground rtl:left-auto rtl:right-2.5" />
|
||||
<input
|
||||
value={keyword}
|
||||
onChange={(e) => onKeywordChange(e.target.value)}
|
||||
placeholder="搜索客户/国家/产品/消息内容"
|
||||
className="h-9 w-full rounded-md border border-input bg-muted/40 pl-8 pr-2 text-sm placeholder:text-muted-foreground focus:border-primary focus:bg-card focus:outline-none rtl:pl-2 rtl:pr-8"
|
||||
className="h-10 w-full rounded-full border border-transparent bg-slate-100/80 pl-9 pr-3 text-sm placeholder:text-slate-400 focus:border-sky-200 focus:bg-white focus:outline-none focus:ring-4 focus:ring-sky-50 rtl:pl-2 rtl:pr-8"
|
||||
/>
|
||||
</div>
|
||||
{/* 筛选 */}
|
||||
<div className="mt-2.5 flex flex-wrap gap-1">
|
||||
<div className="mt-2.5 flex items-center gap-1 rounded-xl bg-slate-50 p-1">
|
||||
{[
|
||||
{ value: '', label: '全部', icon: null },
|
||||
{ value: 'telegram', label: 'Telegram', icon: Send },
|
||||
{ value: 'whatsapp', label: 'WhatsApp', icon: MessageCircle },
|
||||
{ value: 'email', label: 'Email', icon: Mail },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => onChannelChange(item.value)}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center gap-1 rounded-lg px-2 py-1.5 text-[11px] font-semibold transition-all',
|
||||
channel === item.value
|
||||
? 'bg-white text-slate-900 shadow-sm ring-1 ring-slate-200/70'
|
||||
: 'text-slate-400 hover:text-slate-700',
|
||||
)}
|
||||
>
|
||||
{item.icon && <item.icon className="h-3 w-3" />}
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex gap-1 overflow-x-auto pb-0.5 scrollbar-none">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => onFilterChange(f.value)}
|
||||
className={cn(
|
||||
'rounded-full px-2.5 py-1 text-xs transition-colors',
|
||||
'shrink-0 rounded-full px-3 py-1.5 text-[11px] font-medium transition-colors',
|
||||
filter === f.value
|
||||
? 'bg-primary font-medium text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-border',
|
||||
? 'bg-primary font-semibold text-white shadow-sm shadow-primary-200'
|
||||
: 'text-slate-500 hover:bg-slate-100 hover:text-slate-800',
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
@@ -78,10 +134,10 @@ export function ConversationList({
|
||||
</div>
|
||||
|
||||
{/* 列表 */}
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin">
|
||||
<div className="flex-1 overflow-y-auto bg-white px-1.5 py-1.5 scrollbar-thin">
|
||||
{loading &&
|
||||
Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex gap-3 border-b border-border px-3 py-3">
|
||||
<div key={i} className="flex gap-3 rounded-xl px-3 py-3">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-3.5 w-2/3" />
|
||||
@@ -97,50 +153,127 @@ export function ConversationList({
|
||||
const c = item.customer
|
||||
const active = item.id === activeId
|
||||
const intent = intentLevelMeta(item.intentLevel)
|
||||
const isTelegramChannel = String(item.telegramChatType || '').toLowerCase() === 'channel'
|
||||
const supportsSwipeActions = item.channel === 'telegram' || item.channel === 'whatsapp'
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onSelect(item.id)}
|
||||
className={cn(
|
||||
'flex w-full gap-2.5 border-b border-border px-3 py-3 text-start transition-colors',
|
||||
active ? 'bg-primary-50/70' : 'hover:bg-muted/50',
|
||||
<div key={item.id} className="relative mb-0.5 overflow-hidden rounded-xl">
|
||||
{supportsSwipeActions && (
|
||||
<div
|
||||
data-testid="conversation-swipe-actions"
|
||||
aria-hidden={swipedId !== item.id && touchStart.current?.id !== item.id}
|
||||
className={cn(
|
||||
'absolute inset-y-0 right-0 flex w-[144px] overflow-hidden rounded-xl transition-opacity duration-150',
|
||||
swipedId === item.id || touchStart.current?.id === item.id
|
||||
? 'opacity-100'
|
||||
: 'pointer-events-none opacity-0',
|
||||
)}
|
||||
>
|
||||
{!isTelegramChannel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClear(item)}
|
||||
className="flex w-[72px] flex-col items-center justify-center gap-1 bg-amber-500 text-[11px] font-semibold text-white"
|
||||
>
|
||||
<Eraser className="h-4 w-4" />清空
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(item)}
|
||||
className="flex flex-1 flex-col items-center justify-center gap-1 bg-red-500 text-[11px] font-semibold text-white"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />{isTelegramChannel ? '退出并删除' : '删除'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<button
|
||||
data-testid="conversation-item"
|
||||
data-conversation-id={item.id}
|
||||
data-channel={item.channel}
|
||||
data-telegram-chat-type={item.telegramChatType || ''}
|
||||
data-last-sender={item.lastMessage?.sender || ''}
|
||||
onTouchStart={(event) => {
|
||||
if (!supportsSwipeActions) return
|
||||
const touch = event.touches[0]
|
||||
touchStart.current = { x: touch.clientX, y: touch.clientY, id: item.id }
|
||||
if (swipedId !== item.id) setDragOffset(0)
|
||||
}}
|
||||
onTouchMove={(event) => {
|
||||
const start = touchStart.current
|
||||
if (!start || start.id !== item.id) return
|
||||
const touch = event.touches[0]
|
||||
const dx = touch.clientX - start.x
|
||||
const dy = touch.clientY - start.y
|
||||
if (Math.abs(dx) <= Math.abs(dy)) return
|
||||
setDragOffset(Math.max(-144, Math.min(0, (swipedId === item.id ? -144 : 0) + dx)))
|
||||
}}
|
||||
onTouchEnd={() => {
|
||||
setSwipedId(dragOffset < -54 ? item.id : null)
|
||||
setDragOffset(0)
|
||||
touchStart.current = null
|
||||
}}
|
||||
onClick={() => {
|
||||
if (swipedId === item.id) {
|
||||
setSwipedId(null)
|
||||
return
|
||||
}
|
||||
onSelect(item.id)
|
||||
}}
|
||||
style={{
|
||||
transform: `translateX(${swipedId === item.id ? -144 : touchStart.current?.id === item.id ? dragOffset : 0}px)`,
|
||||
}}
|
||||
className={cn(
|
||||
'relative flex w-full gap-3 overflow-hidden rounded-xl px-3 py-3 text-start transition-[transform,background-color,box-shadow] duration-200',
|
||||
active
|
||||
? 'conversation-selected bg-gradient-to-br from-primary-200/80 via-primary-100/90 to-white text-foreground ring-1 ring-inset ring-primary-300'
|
||||
: 'bg-white hover:bg-slate-50',
|
||||
)}
|
||||
>
|
||||
<div className="relative shrink-0">
|
||||
<Avatar name={c.name} size="md" />
|
||||
<Avatar name={c.name} src={c.avatarUrl} size="md" />
|
||||
<span className="absolute -bottom-1 -right-1 rounded-full border border-card bg-card">
|
||||
<ChannelIcon channel={item.channel} size="xs" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-sm font-medium">{c.name}</span>
|
||||
<span className={cn('truncate text-sm font-semibold', active && 'text-primary-900')}>{c.name}</span>
|
||||
{item.isPinned && <Pin className="h-3 w-3 shrink-0 fill-primary-100 text-primary" />}
|
||||
{c.telegramUsername && (
|
||||
<span className="hidden max-w-24 truncate text-[10px] text-muted-foreground xl:inline">
|
||||
@{c.telegramUsername}
|
||||
</span>
|
||||
)}
|
||||
<CountryFlag code={c.country} withName={false} />
|
||||
{item.hasRisk && <ShieldAlert className="h-3.5 w-3.5 shrink-0 text-red-500" />}
|
||||
<span className="ms-auto shrink-0 text-[11px] text-muted-foreground">
|
||||
{formatRelative(item.updatedAt)}
|
||||
<span className={cn('ms-auto shrink-0 text-[11px] text-muted-foreground', active && 'text-primary-600')}>
|
||||
{formatRelative(item.lastMessage?.createdAt || item.updatedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
<p className={cn('mt-0.5 truncate text-xs text-muted-foreground', active && 'text-slate-700')}>
|
||||
{item.lastMessage?.sender === 'agent' ? '我:' : ''}
|
||||
{item.lastMessage?.translatedText ?? item.lastMessage?.originalText ?? item.summary}
|
||||
</p>
|
||||
<div className="mt-1.5 flex items-center gap-1.5">
|
||||
<Badge className={cn('text-[10px] px-1 py-0', intent.color)}>{intent.label}</Badge>
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
{!active && <Badge className={cn('rounded-md px-1.5 py-0 text-[10px]', intent.color)}>{intent.label}</Badge>}
|
||||
<SentimentBadge sentiment={item.sentiment} emojiOnly />
|
||||
{item.assigneeId ? (
|
||||
<span className="text-[10px] text-muted-foreground">负责人:{memberName(item.assigneeId)}</span>
|
||||
<span className={cn('text-[10px] text-muted-foreground', active && 'text-slate-500')}>负责人:{memberName(item.assigneeId)}</span>
|
||||
) : (
|
||||
<span className="text-[10px] text-amber-600">未分配</span>
|
||||
<span className="text-[10px] text-amber-700">未分配</span>
|
||||
)}
|
||||
{item.unreadCount > 0 && (
|
||||
<span className="ms-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-white">
|
||||
<span className={cn(
|
||||
'ms-auto flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-semibold',
|
||||
'bg-primary text-white',
|
||||
)}>
|
||||
{item.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,9 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { updateCustomer } from '@/services/api/customers'
|
||||
import { createTask } from '@/services/api/crm'
|
||||
import { analyzeConversation } from '@/services/api/ai'
|
||||
import { useAuthStore } from '@/app/store/auth'
|
||||
import type { Customer, Conversation } from '@/types'
|
||||
import { Avatar } from '@/components/ui/avatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -29,6 +31,8 @@ import { IntentScoreBadge, CustomerStageBadge, SentimentBadge, RiskLevelBadge }
|
||||
import { CUSTOMER_STAGES, NEXT_ACTIONS, countryMeta, INTENT_LEVELS } from '@/config/constants'
|
||||
import { toast, errorMessage } from '@/app/store/toast'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { CustomerProfileRadar } from '@/components/customer/CustomerProfileRadar'
|
||||
import { CustomerSemanticTag } from '@/components/customer/CustomerSemanticTag'
|
||||
|
||||
export function InsightPanel({
|
||||
customer,
|
||||
@@ -38,12 +42,21 @@ export function InsightPanel({
|
||||
conversation: Conversation
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const user = useAuthStore((state) => state.user)
|
||||
const [analyzing, setAnalyzing] = useState(false)
|
||||
const [analysis, setAnalysis] = useState(customer.aiProfile.summary)
|
||||
const c = countryMeta(customer.country)
|
||||
const localTime = new Date(
|
||||
new Date().getTime() + new Date().getTimezoneOffset() * 60000 + c.tzOffset * 3600000,
|
||||
).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
const localTime = Number.isFinite(c.tzOffset)
|
||||
? new Date(
|
||||
new Date().getTime() + new Date().getTimezoneOffset() * 60000 + c.tzOffset * 3600000,
|
||||
).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
: ''
|
||||
const countrySourceLabel = {
|
||||
manual: '人工确认',
|
||||
'account-profile': '账户资料',
|
||||
'phone-prefix': '手机号区号',
|
||||
unknown: '待确认',
|
||||
}[customer.countrySource || 'unknown']
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: (data: Partial<Customer>) => updateCustomer(customer.id, data),
|
||||
@@ -54,26 +67,48 @@ export function InsightPanel({
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
})
|
||||
const taskMut = useMutation({
|
||||
mutationFn: (title: string) => createTask({
|
||||
title,
|
||||
type: 'revisit',
|
||||
priority: customer.riskLevel === 'urgent' || customer.riskLevel === 'high' ? 'high' : 'medium',
|
||||
status: 'pending',
|
||||
customerId: customer.id,
|
||||
customerName: customer.name,
|
||||
conversationId: conversation.id,
|
||||
ownerId: user?.id || '',
|
||||
dueAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||
note: `由客户洞察建议创建:${title}`,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||
toast.success('已创建跟进任务', '默认明天到期')
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
})
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
setAnalyzing(true)
|
||||
try {
|
||||
const result = await analyzeConversation(conversation.id)
|
||||
setAnalysis(result.summary)
|
||||
toast.success('AI 分析完成', `意向评分 ${result.intentScore} · ${INTENT_LEVELS.find(l => l.value === result.intentLevel)?.label}`)
|
||||
toast.success('会话分析完成', `意向评分 ${result.intentScore} · ${INTENT_LEVELS.find(l => l.value === result.intentLevel)?.label}`)
|
||||
} catch {
|
||||
toast.error('分析失败,请稍后重试')
|
||||
} finally { setAnalyzing(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto scrollbar-thin">
|
||||
<div className="flex h-full flex-col overflow-y-auto bg-slate-50/40 scrollbar-thin">
|
||||
{/* 客户基本信息 */}
|
||||
<div className="border-b border-border p-4">
|
||||
<div className="border-b border-slate-100 bg-white p-5">
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<Avatar name={customer.name} size="lg" />
|
||||
<Avatar name={customer.name} src={customer.avatarUrl} size="lg" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold">{customer.name}</p>
|
||||
{customer.telegramUsername && (
|
||||
<p className="truncate text-xs text-primary">@{customer.telegramUsername}</p>
|
||||
)}
|
||||
{customer.company && (
|
||||
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Building2 className="h-3 w-3" />
|
||||
@@ -87,11 +122,14 @@ export function InsightPanel({
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<CountryFlag code={customer.country} />
|
||||
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[10px] text-slate-500">
|
||||
{countrySourceLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{localTime && <div className="flex items-center gap-2">
|
||||
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">当地时间:{localTime}</span>
|
||||
</div>
|
||||
</div>}
|
||||
{customer.email && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
@@ -117,7 +155,7 @@ export function InsightPanel({
|
||||
<Badge key={p} variant="primary">{p}</Badge>
|
||||
))}
|
||||
{customer.tags?.map((t) => (
|
||||
<Badge key={t} variant="outline">{t}</Badge>
|
||||
<CustomerSemanticTag key={t} tag={t} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -148,9 +186,59 @@ export function InsightPanel({
|
||||
</div>
|
||||
|
||||
{/* AI 分析 */}
|
||||
<div className="border-b border-border p-4">
|
||||
<div className="space-y-4 border-b border-slate-100 p-3">
|
||||
<CustomerProfileRadar customer={customer} compact />
|
||||
<div className="rounded-2xl border border-blue-100 bg-gradient-to-br from-white to-blue-50/60 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">会话长期记忆</p>
|
||||
<p className="mt-0.5 text-[11px] text-slate-400">
|
||||
{conversation.aiContextStatus === 'compressing'
|
||||
? '正在压缩较早的会话内容…'
|
||||
: conversation.aiContextMemory
|
||||
? `已压缩 ${conversation.aiContextSourceMessageCount || conversation.aiContextMemory.sourceMessageCount || 0} 条较早消息`
|
||||
: '会话达到长度阈值后自动生成'}
|
||||
</p>
|
||||
</div>
|
||||
<span className={cn(
|
||||
'rounded-full px-2 py-1 text-[10px] font-medium',
|
||||
conversation.aiContextStatus === 'failed'
|
||||
? 'bg-red-50 text-red-600'
|
||||
: conversation.aiContextMemory
|
||||
? 'bg-blue-100 text-blue-700'
|
||||
: 'bg-slate-100 text-slate-500',
|
||||
)}>
|
||||
{conversation.aiContextStatus === 'compressing'
|
||||
? '压缩中'
|
||||
: conversation.aiContextMemory
|
||||
? '已启用'
|
||||
: '等待触发'}
|
||||
</span>
|
||||
</div>
|
||||
{conversation.aiContextMemory?.summary && (
|
||||
<p className="mt-3 text-xs leading-5 text-slate-600">
|
||||
{conversation.aiContextMemory.summary}
|
||||
</p>
|
||||
)}
|
||||
{conversation.aiContextMemory?.unresolved?.length ? (
|
||||
<div className="mt-3 border-t border-blue-100 pt-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wide text-blue-600">未决事项</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
{conversation.aiContextMemory.unresolved.slice(0, 4).map((item) => (
|
||||
<span key={item} className="rounded-full bg-white px-2 py-1 text-[10px] text-slate-600 shadow-sm">
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/80 bg-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground">AI 分析摘要</h4>
|
||||
<div>
|
||||
<p className="text-sm font-semibold tracking-tight text-slate-900">会话摘要</p>
|
||||
<p className="text-[11px] text-slate-400">根据已同步的会话内容生成</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={handleAnalyze} disabled={analyzing}>
|
||||
{analyzing ? <Spinner className="h-3 w-3" /> : '重新分析'}
|
||||
</Button>
|
||||
@@ -161,40 +249,61 @@ export function InsightPanel({
|
||||
<span className="ml-2 text-sm text-muted-foreground">正在分析会话上下文…</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">{analysis}</p>
|
||||
<p className="mt-3 text-xs leading-6 text-slate-600">{analysis || '收到更多客户消息后将补充客户画像。'}</p>
|
||||
)}
|
||||
{/* AI 提取关键信息 */}
|
||||
{customer.aiProfile.inquiryQuantity && (
|
||||
<div className="mt-3 space-y-1 border-t border-border pt-3">
|
||||
{Object.values(customer.aiProfile || {}).some(Boolean) && (
|
||||
<div className="mt-4 grid grid-cols-2 gap-2 border-t border-slate-100 pt-4">
|
||||
{Object.entries({
|
||||
行业: customer.aiProfile.industry,
|
||||
采购角色: customer.aiProfile.purchasingRole,
|
||||
询价数量: customer.aiProfile.inquiryQuantity,
|
||||
目标价格: customer.aiProfile.targetPrice,
|
||||
预算: customer.aiProfile.budget,
|
||||
MOQ: customer.aiProfile.moqRequirement,
|
||||
样品: customer.aiProfile.sampleNeed,
|
||||
定制: customer.aiProfile.customRequirement,
|
||||
交付: customer.aiProfile.deliveryTime,
|
||||
物流: customer.aiProfile.logistics,
|
||||
付款: customer.aiProfile.payment,
|
||||
顾虑: customer.aiProfile.concerns?.join('、'),
|
||||
}).filter(([, v]) => v).map(([k, v]) => (
|
||||
<div key={k} className="flex gap-2 text-xs">
|
||||
<span className="shrink-0 text-muted-foreground">{k}</span>
|
||||
<span className="text-foreground">{v as string}</span>
|
||||
<div key={k} className="min-w-0 rounded-xl bg-slate-50 px-3 py-2">
|
||||
<p className="text-[10px] font-medium text-slate-400">{k}</p>
|
||||
<p className="mt-0.5 truncate text-xs font-medium text-slate-700">{v as string}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||
{[
|
||||
['成交概率', customer.aiProfile.dealProbability],
|
||||
['流失概率', customer.aiProfile.churnProbability],
|
||||
['分析可信度', customer.aiProfile.confidenceScore],
|
||||
].map(([label, value]) => (
|
||||
<div key={String(label)} className="rounded-xl bg-slate-50 px-2 py-2 text-center">
|
||||
<p className="text-[10px] text-muted-foreground">{label}</p>
|
||||
<p className="text-sm font-semibold">{Number(value || 0)}%</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{customer.aiProfile.evidence?.length ? (
|
||||
<div className="mt-3 border-t border-border pt-3">
|
||||
<p className="text-[11px] font-medium text-muted-foreground">判断依据</p>
|
||||
<ul className="mt-1 space-y-1 text-xs text-muted-foreground">
|
||||
{customer.aiProfile.evidence.map((item) => <li key={item}>• {item}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 推荐动作 */}
|
||||
<div className="p-4">
|
||||
<h4 className="text-xs font-semibold text-muted-foreground">AI 推荐下一步</h4>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
<h4 className="text-xs font-semibold text-slate-500">下一步建议</h4>
|
||||
<div className="mt-2 space-y-2">
|
||||
{customer.aiProfile.nextActions.map((a) => (
|
||||
<button
|
||||
key={a}
|
||||
onClick={() => toast.info(`建议操作:${a}`)}
|
||||
className="rounded-full border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs text-primary-700 hover:bg-primary-100"
|
||||
onClick={() => taskMut.mutate(a)}
|
||||
disabled={taskMut.isPending}
|
||||
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-start text-xs text-slate-700 hover:border-primary-200 hover:bg-primary-50"
|
||||
>
|
||||
{a}
|
||||
</button>
|
||||
|
||||
@@ -12,17 +12,71 @@ import {
|
||||
ListTodo,
|
||||
BookOpen,
|
||||
MoreHorizontal,
|
||||
Languages,
|
||||
Globe,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Download,
|
||||
FileText,
|
||||
LoaderCircle,
|
||||
SmilePlus,
|
||||
Trash2,
|
||||
Brain,
|
||||
} from 'lucide-react'
|
||||
import type { Message } from '@/types'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { ChannelType, Message } from '@/types'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { toast } from '@/app/store/toast'
|
||||
import { formatTime } from '@/utils/format'
|
||||
import { languageName } from '@/config/languages'
|
||||
import { Dropdown } from '@/components/ui/dropdown'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Avatar } from '@/components/ui/avatar'
|
||||
import { getMessageAttachment } from '@/services/api/conversations'
|
||||
|
||||
export type ViewMode = 'original' | 'translated' | 'bilingual'
|
||||
|
||||
function EmailAttachmentLink({
|
||||
conversationId,
|
||||
messageId,
|
||||
attachment,
|
||||
}: {
|
||||
conversationId: string
|
||||
messageId: string
|
||||
attachment: NonNullable<Message['emailAttachments']>[number]
|
||||
}) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['email-attachment', conversationId, messageId, attachment.id],
|
||||
queryFn: () => getMessageAttachment(conversationId, messageId, attachment.id),
|
||||
enabled: attachment.available,
|
||||
staleTime: Infinity,
|
||||
retry: 1,
|
||||
})
|
||||
const size = attachment.size >= 1024 * 1024
|
||||
? `${(attachment.size / 1024 / 1024).toFixed(1)} MB`
|
||||
: `${Math.max(1, Math.round(attachment.size / 1024))} KB`
|
||||
|
||||
return (
|
||||
<a
|
||||
href={data?.dataUrl}
|
||||
download={data?.name || attachment.name}
|
||||
onClick={(event) => {
|
||||
if (!data?.dataUrl) event.preventDefault()
|
||||
}}
|
||||
className={cn(
|
||||
'mt-2 flex items-center gap-2 rounded-xl border border-slate-200/80 bg-white/75 px-3 py-2 transition-colors',
|
||||
data?.dataUrl && 'hover:border-sky-200 hover:bg-sky-50',
|
||||
)}
|
||||
>
|
||||
{isLoading ? <LoaderCircle className="h-5 w-5 animate-spin text-sky-500" /> : <FileText className="h-5 w-5 text-sky-500" />}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-medium text-slate-700">{attachment.name}</span>
|
||||
<span className="text-[10px] text-slate-400">{error ? '附件暂不可用' : size}</span>
|
||||
</span>
|
||||
{data?.dataUrl && <Download className="h-4 w-4 text-slate-400" />}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export function MessageBubble({
|
||||
msg,
|
||||
viewMode,
|
||||
@@ -31,6 +85,12 @@ export function MessageBubble({
|
||||
onConvertToTask,
|
||||
onAddToKnowledge,
|
||||
onAddNote,
|
||||
onReact,
|
||||
onDelete,
|
||||
customerName,
|
||||
customerAvatar,
|
||||
channel,
|
||||
showIntent = true,
|
||||
}: {
|
||||
msg: Message
|
||||
viewMode: ViewMode
|
||||
@@ -39,47 +99,219 @@ export function MessageBubble({
|
||||
onConvertToTask: () => void
|
||||
onAddToKnowledge: () => void
|
||||
onAddNote: () => void
|
||||
onReact?: (emoji: string) => void
|
||||
onDelete?: () => void
|
||||
customerName?: string
|
||||
customerAvatar?: string
|
||||
channel?: ChannelType
|
||||
showIntent?: boolean
|
||||
}) {
|
||||
const [showActions, setShowActions] = useState(false)
|
||||
const [translationExpanded, setTranslationExpanded] = useState(false)
|
||||
const isMe = msg.sender === 'agent'
|
||||
const isSystem = msg.sender === 'system'
|
||||
const hasTranslation = msg.translatedText && msg.translatedText !== msg.originalText
|
||||
const originalText = String(msg.originalText || '')
|
||||
const rawTranslation = String(msg.translatedText || '')
|
||||
const translationCorrupted =
|
||||
/(?:^|\n)\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:[\d.]+Z\s+(?:客户|坐席):/m.test(rawTranslation) ||
|
||||
rawTranslation.length > (originalText.length <= 20 ? 160 : Math.max(500, originalText.length * 8))
|
||||
const translatedText = translationCorrupted ? '' : rawTranslation
|
||||
const hasTranslation = translatedText && translatedText !== originalText
|
||||
const outgoingChineseNote = isMe
|
||||
? String(
|
||||
msg.translationCn ||
|
||||
(hasTranslation && /[\u3400-\u9fff]/u.test(originalText) ? originalText : ''),
|
||||
).trim()
|
||||
: ''
|
||||
const outgoingSentText = isMe && translatedText ? translatedText : originalText
|
||||
const longTranslation = translatedText.length > 260 || translatedText.split('\n').length > 6
|
||||
const hasKnownLanguage = Boolean(msg.originalLang && !['und', 'auto', 'unknown'].includes(msg.originalLang))
|
||||
const sourceLanguage = hasKnownLanguage ? languageName(msg.originalLang) : '原文'
|
||||
const targetLanguage = msg.targetLang && !['und', 'auto', 'unknown'].includes(msg.targetLang)
|
||||
? languageName(msg.targetLang)
|
||||
: '客户语言'
|
||||
const providerName = channel === 'whatsapp' ? 'WhatsApp' : channel === 'email' ? 'Email' : 'Telegram'
|
||||
const contextualTranslation = msg.translationMethod === 'ai-contextual'
|
||||
&& Number(msg.translationContextMessages || 0) > 0
|
||||
const translationTrace = contextualTranslation
|
||||
? `AI 结合最近 ${msg.translationContextMessages} 条会话消息${msg.translationConversationSummaryApplied ? '及客户画像摘要' : ''}完成翻译`
|
||||
: 'AI 直接翻译当前消息'
|
||||
const isMediaMessage = ['image', 'audio', 'file'].includes(msg.type)
|
||||
const hasProviderAttachment = Boolean(
|
||||
msg.telegramFileId
|
||||
|| (msg.whatsappMessageId && isMediaMessage),
|
||||
)
|
||||
const { data: attachment, isLoading: attachmentLoading, error: attachmentError } = useQuery({
|
||||
queryKey: ['message-attachment', msg.conversationId, msg.id, msg.telegramFileId, msg.whatsappMessageId],
|
||||
queryFn: () => getMessageAttachment(msg.conversationId, msg.id),
|
||||
enabled: hasProviderAttachment,
|
||||
staleTime: Infinity,
|
||||
retry: 1,
|
||||
})
|
||||
|
||||
if (isSystem) {
|
||||
return (
|
||||
<div className="flex justify-center py-2">
|
||||
<span className="rounded-full bg-amber-50 px-3 py-1 text-xs text-amber-700">{msg.originalText}</span>
|
||||
<span className="rounded-full bg-amber-50 px-3 py-1 text-xs text-amber-700">{originalText}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const translationContent = (muted = true) => (
|
||||
<div>
|
||||
<p
|
||||
className={cn(
|
||||
'whitespace-pre-wrap break-words text-sm leading-6',
|
||||
muted && 'text-foreground/75',
|
||||
longTranslation && !translationExpanded && 'max-h-28 overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{translatedText}
|
||||
</p>
|
||||
{longTranslation && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTranslationExpanded((value) => !value)}
|
||||
className="mt-1.5 inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-primary hover:bg-primary-50"
|
||||
>
|
||||
{translationExpanded ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||
{translationExpanded ? '收起译文' : '展开完整译文'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const renderContent = () => {
|
||||
if (viewMode === 'bilingual' && hasTranslation) {
|
||||
if (msg.isDeleted) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<span className="text-[10px] text-muted-foreground">{languageName(msg.originalLang)}</span>
|
||||
<p className="mt-0.5 whitespace-pre-wrap break-words text-sm">{msg.originalText}</p>
|
||||
</div>
|
||||
<div className="border-t border-border/40 pt-2">
|
||||
<span className="text-[10px] text-muted-foreground">中文释义</span>
|
||||
<p className="mt-0.5 whitespace-pre-wrap break-words text-sm text-muted-foreground">{msg.translatedText}</p>
|
||||
<p className="flex items-center gap-1.5 text-xs italic text-slate-400">
|
||||
<AlertCircle className="h-3.5 w-3.5" />
|
||||
该消息已撤回
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (msg.type !== 'text') {
|
||||
const labels = { image: '图片', audio: '语音', file: '文件', product: '产品卡片' }
|
||||
return originalText ? (
|
||||
<p className="mb-2 whitespace-pre-wrap break-words text-sm leading-6 text-slate-700">{originalText}</p>
|
||||
) : (
|
||||
<div className="mb-1 flex items-center gap-2 text-xs font-medium text-slate-500">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-primary-400" />
|
||||
{labels[msg.type]}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (!originalText.trim()) {
|
||||
return <p className="text-xs text-slate-500">{providerName} 服务通知</p>
|
||||
}
|
||||
if (
|
||||
(viewMode === 'bilingual' || viewMode === 'translated') &&
|
||||
msg.sender === 'customer' &&
|
||||
msg.type === 'text' &&
|
||||
(!translatedText || translationCorrupted)
|
||||
) {
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<p className="whitespace-pre-wrap break-words text-sm leading-6">{originalText}</p>
|
||||
<div className="flex items-center gap-1.5 border-t border-border/60 pt-2 text-xs text-muted-foreground">
|
||||
<Languages className="h-3.5 w-3.5 text-primary" />
|
||||
{translationCorrupted
|
||||
? '检测到异常译文,系统正在自动修复'
|
||||
: msg.translationStatus === 'failed'
|
||||
? 'AI 翻译暂时失败,将自动重试'
|
||||
: 'AI 正在翻译…'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (viewMode === 'translated' && msg.translatedText) {
|
||||
if (isMe && viewMode === 'bilingual') {
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<div>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100/80 px-2 py-0.5 text-[10px] font-medium text-blue-700">
|
||||
<Globe className="h-3 w-3" />
|
||||
客户收到 · {translatedText ? targetLanguage : '原文'}
|
||||
</span>
|
||||
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{outgoingSentText}</p>
|
||||
</div>
|
||||
{outgoingChineseNote ? (
|
||||
<div data-testid="outgoing-chinese-note" className="border-t border-blue-200/60 pt-2">
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-white/65 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
||||
<Languages className="h-3 w-3 text-primary" />
|
||||
中文备注 · 仅内部可见
|
||||
</span>
|
||||
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6 text-slate-600">
|
||||
{outgoingChineseNote}
|
||||
</p>
|
||||
</div>
|
||||
) : msg.translationStatus === 'pending' || msg.translationStatus === 'failed' ? (
|
||||
<div className="flex items-center gap-1.5 border-t border-blue-200/60 pt-2 text-xs text-slate-500">
|
||||
<Languages className="h-3.5 w-3.5 text-primary" />
|
||||
{msg.translationStatus === 'failed' ? '中文备注生成失败,将自动重试' : 'AI 正在生成中文备注…'}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isMe && viewMode === 'translated' && !translatedText && outgoingChineseNote) {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-[10px] text-muted-foreground">{languageName(msg.originalLang)} → 中文</span>
|
||||
<p className="mt-0.5 whitespace-pre-wrap break-words text-sm">{msg.translatedText}</p>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-white/65 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
||||
<Languages className="h-3 w-3 text-primary" />
|
||||
中文备注 · 仅内部可见
|
||||
</span>
|
||||
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6 text-slate-700">
|
||||
{outgoingChineseNote}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (viewMode === 'bilingual' && hasTranslation) {
|
||||
return (
|
||||
<div className="space-y-2.5">
|
||||
<div>
|
||||
<span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{hasKnownLanguage ? `原文 · ${sourceLanguage}` : '原文'}
|
||||
</span>
|
||||
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{originalText}</p>
|
||||
</div>
|
||||
<div className="border-t border-border/60 pt-2">
|
||||
<span
|
||||
title={translationTrace}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-0.5 text-[10px] font-medium text-primary"
|
||||
>
|
||||
<Languages className="h-3 w-3" />
|
||||
{isMe ? `发送内容 · ${targetLanguage}` : contextualTranslation ? 'AI 语境译文 · 中文' : 'AI 译文 · 中文'}
|
||||
</span>
|
||||
<div className="mt-1.5">{translationContent()}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (viewMode === 'translated' && translatedText) {
|
||||
return (
|
||||
<div>
|
||||
<span
|
||||
title={translationTrace}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-0.5 text-[10px] font-medium text-primary"
|
||||
>
|
||||
<Languages className="h-3 w-3" />
|
||||
{isMe ? `${sourceLanguage} → ${targetLanguage}` : `${sourceLanguage} → 中文`}
|
||||
</span>
|
||||
<div className="mt-1.5">{translationContent(false)}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<span className="text-[10px] text-muted-foreground">{languageName(msg.originalLang)}</span>
|
||||
<p className="mt-0.5 whitespace-pre-wrap break-words text-sm">{msg.originalText}</p>
|
||||
{hasKnownLanguage && (
|
||||
<span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||
{sourceLanguage}
|
||||
</span>
|
||||
)}
|
||||
<p className={cn('whitespace-pre-wrap break-words text-sm leading-6', hasKnownLanguage && 'mt-1.5')}>
|
||||
{originalText}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -102,39 +334,143 @@ export function MessageBubble({
|
||||
}
|
||||
|
||||
const moreActions = [
|
||||
{ key: 'copy', label: '复制', icon: Copy, onSelect: () => { navigator.clipboard.writeText(msg.originalText); toast.success('已复制') } },
|
||||
{ key: 'copy', label: '复制', icon: Copy, onSelect: () => { void navigator.clipboard.writeText(originalText); toast.success('已复制') } },
|
||||
{ key: 'reply', label: '引用回复', icon: Reply, onSelect: onQuote },
|
||||
{ key: 'forward', label: '转发', icon: Forward, onSelect: () => toast.info('转发功能:选择目标会话(演示)') },
|
||||
...(channel === 'whatsapp' && onReact
|
||||
? [{ key: 'react', label: '👍 回应', icon: SmilePlus, onSelect: () => onReact('👍') }]
|
||||
: []),
|
||||
{ key: 'note', label: '添加内部备注', icon: StickyNote, onSelect: onAddNote },
|
||||
{ key: 'important', label: msg.isImportant ? '取消重要' : '标记重要', icon: Star, onSelect: onMarkImportant },
|
||||
{ key: 'task', label: '转为待办', icon: ListTodo, onSelect: onConvertToTask },
|
||||
{ key: 'knowledge', label: '添加到知识库', icon: BookOpen, onSelect: onAddToKnowledge },
|
||||
...(channel === 'whatsapp' && isMe && onDelete
|
||||
? [{ key: 'delete', label: '撤回消息', icon: Trash2, danger: true, onSelect: onDelete }]
|
||||
: []),
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('group flex gap-2 px-4 py-2.5', isMe ? 'justify-end' : 'justify-start')}
|
||||
data-testid="message-bubble"
|
||||
data-sender={msg.sender}
|
||||
className={cn(
|
||||
'group flex gap-2 px-3 py-2 will-change-transform sm:px-4 sm:py-2.5',
|
||||
isMe ? 'justify-end motion-safe:animate-message-in-right' : 'justify-start motion-safe:animate-message-in-left',
|
||||
)}
|
||||
onMouseEnter={() => setShowActions(true)}
|
||||
onMouseLeave={() => setShowActions(false)}
|
||||
>
|
||||
<div className={cn('max-w-[70%] min-w-0', isMe ? 'order-2' : 'order-1')}>
|
||||
{!isMe && (
|
||||
<Avatar
|
||||
name={customerName || msg.senderName || `${providerName} 用户`}
|
||||
src={customerAvatar}
|
||||
size="xs"
|
||||
className="order-1 mt-1"
|
||||
/>
|
||||
)}
|
||||
<div className={cn('min-w-0 max-w-[min(84%,760px)] sm:max-w-[min(74%,760px)]', isMe ? 'order-2' : 'order-1')}>
|
||||
{!isMe && (
|
||||
<p className="mb-1 px-1 text-[11px] font-medium text-muted-foreground">
|
||||
{msg.senderName || customerName}
|
||||
</p>
|
||||
)}
|
||||
{/* 消息气泡 */}
|
||||
<div
|
||||
data-testid="message-surface"
|
||||
data-direction={isMe ? 'outgoing' : 'incoming'}
|
||||
className={cn(
|
||||
'relative rounded-lg px-3 py-2.5',
|
||||
isMe ? 'bg-primary-50' : 'bg-card border border-border',
|
||||
'relative isolate rounded-2xl px-3.5 py-2.5 transition-shadow',
|
||||
isMe
|
||||
? 'telegram-bubble-out rounded-br-[5px]'
|
||||
: 'telegram-bubble-in rounded-bl-[5px]',
|
||||
msg.isImportant && 'ring-1 ring-amber-400',
|
||||
)}
|
||||
>
|
||||
{renderContent()}
|
||||
{msg.attachmentName && msg.type === 'image' && (
|
||||
<div className="mt-2 flex h-24 items-center justify-center rounded bg-muted text-xs text-muted-foreground">
|
||||
📷 {msg.attachmentName}
|
||||
{msg.replyToId && (
|
||||
<div className="mb-2 rounded-lg border-s-2 border-primary/70 bg-muted/60 px-2.5 py-1.5">
|
||||
<p className="text-[10px] font-medium text-primary">回复消息</p>
|
||||
<p className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">
|
||||
{msg.replyPreview || `消息 #${msg.replyToId}`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{msg.attachmentName && msg.type === 'file' && (
|
||||
<div className="mt-1.5 rounded bg-muted px-2 py-1 text-xs text-muted-foreground">
|
||||
📎 {msg.attachmentName}
|
||||
{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 && (
|
||||
<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} 文件…
|
||||
</div>
|
||||
)}
|
||||
{attachment && msg.type === 'image' && (
|
||||
<a href={attachment.dataUrl} download={attachment.name} className="mt-2 block overflow-hidden rounded-xl">
|
||||
<img src={attachment.dataUrl} alt={attachment.name} className="max-h-80 w-auto max-w-full object-contain" />
|
||||
</a>
|
||||
)}
|
||||
{attachment && msg.type === 'audio' && (
|
||||
<audio controls preload="metadata" src={attachment.dataUrl} className="mt-2 h-10 max-w-full" />
|
||||
)}
|
||||
{attachment && msg.type === 'file' && attachment.mimeType === 'image/webp' && (
|
||||
<img src={attachment.dataUrl} alt={msg.stickerEmoji || attachment.name} className="mt-2 h-32 w-32 object-contain" />
|
||||
)}
|
||||
{attachment && msg.type === 'file' && attachment.mimeType === 'video/webm' && (
|
||||
<video src={attachment.dataUrl} autoPlay loop muted playsInline className="mt-2 h-32 w-32 object-contain" />
|
||||
)}
|
||||
{attachment && msg.type === 'file' && !['image/webp', 'video/webm'].includes(attachment.mimeType) && (
|
||||
<a
|
||||
href={attachment.dataUrl}
|
||||
download={attachment.name}
|
||||
className="mt-2 flex items-center gap-2 rounded-xl border border-border bg-muted/50 px-3 py-2 hover:bg-muted"
|
||||
>
|
||||
<FileText className="h-5 w-5 text-primary" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs">{attachment.name}</span>
|
||||
<Download className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
)}
|
||||
{hasProviderAttachment && attachmentError && (
|
||||
<p className="mt-2 rounded-lg bg-red-50 px-2.5 py-1.5 text-xs text-red-600">文件暂时无法下载,请稍后重试</p>
|
||||
)}
|
||||
{msg.emailAttachments?.map((emailAttachment) => (
|
||||
<EmailAttachmentLink
|
||||
key={emailAttachment.id}
|
||||
conversationId={msg.conversationId}
|
||||
messageId={msg.id}
|
||||
attachment={emailAttachment}
|
||||
/>
|
||||
))}
|
||||
{Boolean(msg.reactions?.length) && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{msg.reactions?.map((reaction, index) => (
|
||||
<span
|
||||
key={`${reaction.actor}-${reaction.emoji}-${index}`}
|
||||
title={reaction.fromMe ? '我' : reaction.actor}
|
||||
className="inline-flex min-w-7 items-center justify-center rounded-full border border-blue-200 bg-blue-50/90 px-2 py-0.5 text-sm shadow-sm"
|
||||
>
|
||||
{reaction.emoji}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -142,6 +478,7 @@ export function MessageBubble({
|
||||
{/* 底栏:时间 + 状态 */}
|
||||
<div className={cn('mt-0.5 flex items-center gap-1.5 text-[10px] text-muted-foreground', isMe ? 'justify-end' : 'justify-start')}>
|
||||
<span>{formatTime(msg.createdAt)}</span>
|
||||
{msg.editedAt && !msg.isDeleted && <span>已编辑</span>}
|
||||
{isMe && statusIcon()}
|
||||
{msg.isImportant && <Star className="h-2.5 w-2.5 fill-amber-400 text-amber-400" />}
|
||||
{msg.internalNote && <StickyNote className="h-2.5 w-2.5 text-blue-500" />}
|
||||
|
||||
@@ -3,12 +3,14 @@ import { Sidebar } from './Sidebar'
|
||||
import { Topbar } from './Topbar'
|
||||
import { Drawer } from '@/components/ui/drawer'
|
||||
import { useUiStore } from '@/app/store/ui'
|
||||
import { GlobalRealtime } from './GlobalRealtime'
|
||||
|
||||
export function AppLayout() {
|
||||
const { mobileNavOpen, setMobileNavOpen } = useUiStore()
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
<div className="flex h-screen overflow-hidden bg-[#f7f9fc]">
|
||||
<GlobalRealtime />
|
||||
{/* 桌面端固定侧边栏 */}
|
||||
<div className="hidden shrink-0 lg:block">
|
||||
<Sidebar />
|
||||
@@ -23,7 +25,7 @@ export function AppLayout() {
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<Topbar />
|
||||
<main className="flex-1 overflow-y-auto scrollbar-thin" id="main-scroll">
|
||||
<main className="flex-1 overflow-y-auto bg-[#f7f9fc] scrollbar-thin" id="main-scroll">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useAuthStore } from '@/app/store/auth'
|
||||
import { toast } from '@/app/store/toast'
|
||||
import type { Customer, Message } from '@/types'
|
||||
import { getWebSocketTicket } from '@/services/api/auth'
|
||||
|
||||
export function GlobalRealtime() {
|
||||
const token = useAuthStore((state) => state.token)
|
||||
const location = useLocation()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
useEffect(() => {
|
||||
// Inbox owns the richer message socket so it can update message and
|
||||
// conversation caches atomically. Keep only one live socket per page.
|
||||
if (!token || location.pathname.startsWith('/inbox')) return
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
let socket: WebSocket | undefined
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let stopped = false
|
||||
let retryDelay = 500
|
||||
|
||||
const connect = async () => {
|
||||
if (stopped) return
|
||||
try {
|
||||
const { ticket } = await getWebSocketTicket()
|
||||
if (stopped) return
|
||||
socket = new WebSocket(`${protocol}//${window.location.host}/api/ws?ticket=${encodeURIComponent(ticket)}`)
|
||||
} catch {
|
||||
reconnectTimer = setTimeout(() => { void connect() }, retryDelay)
|
||||
retryDelay = Math.min(retryDelay * 2, 10_000)
|
||||
return
|
||||
}
|
||||
socket.onopen = () => { retryDelay = 500 }
|
||||
socket.onmessage = (raw) => {
|
||||
let event: { type?: string; message?: Message; customer?: Customer; conversationId?: string }
|
||||
try { event = JSON.parse(String(raw.data)) } catch { return }
|
||||
if (!['message.created', 'message.received'].includes(event.type || '') || event.message?.sender !== 'customer') return
|
||||
void queryClient.invalidateQueries({ queryKey: ['notifications'] })
|
||||
void queryClient.invalidateQueries({ queryKey: ['conversations'] })
|
||||
if (!location.pathname.startsWith('/inbox')) {
|
||||
const preview = event.message.translatedText || event.message.originalText || `[${event.message.type}]`
|
||||
toast.info(`${event.customer?.name || event.message.senderName || '客户'} 发来新消息`, preview.slice(0, 120))
|
||||
}
|
||||
}
|
||||
socket.onerror = () => socket?.close()
|
||||
socket.onclose = () => {
|
||||
if (stopped) return
|
||||
reconnectTimer = setTimeout(() => { void connect() }, retryDelay)
|
||||
retryDelay = Math.min(retryDelay * 2, 10_000)
|
||||
}
|
||||
}
|
||||
void connect()
|
||||
return () => {
|
||||
stopped = true
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer)
|
||||
socket?.close()
|
||||
}
|
||||
}, [token, location.pathname, queryClient])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import { PanelLeftClose, PanelLeftOpen, Sparkles } from 'lucide-react'
|
||||
import { PanelLeftClose, PanelLeftOpen } from 'lucide-react'
|
||||
import { NAV_ITEMS } from '@/config/navigation'
|
||||
import { useUiStore } from '@/app/store/ui'
|
||||
import { useAuthStore } from '@/app/store/auth'
|
||||
@@ -26,33 +26,47 @@ export function Sidebar() {
|
||||
const usagePct = msgUsage ? Math.round((msgUsage.used / msgUsage.total) * 100) : 0
|
||||
|
||||
const items = NAV_ITEMS.filter((item) => !item.permission || can(item.permission))
|
||||
const groups = [
|
||||
{ key: 'core', label: '核心业务' },
|
||||
{ key: 'intelligence', label: '知识与洞察' },
|
||||
{ key: 'admin', label: '企业管理' },
|
||||
] as const
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'flex h-full flex-col border-e border-border bg-card transition-[width] duration-200',
|
||||
sidebarCollapsed ? 'w-16' : 'w-60',
|
||||
'relative z-30 flex h-full flex-col border-e border-slate-200 bg-white shadow-[6px_0_24px_-22px_rgba(15,23,42,.45)] transition-[width] duration-200',
|
||||
sidebarCollapsed ? 'w-[68px]' : 'w-[236px]',
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div
|
||||
className="flex h-14 cursor-pointer items-center gap-2.5 border-b border-border px-4"
|
||||
className="flex h-16 cursor-pointer items-center gap-2.5 border-b border-slate-100 px-4"
|
||||
onClick={() => navigate('/dashboard')}
|
||||
>
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<Sparkles className="h-4.5 w-4.5 h-[18px] w-[18px]" />
|
||||
</span>
|
||||
<img src="/brand-mark.svg" alt="" className="h-9 w-9 shrink-0 rounded-xl shadow-sm shadow-primary-900/10" />
|
||||
{!sidebarCollapsed && (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold leading-tight">{t('app.name')}</p>
|
||||
<p className="truncate text-[15px] font-semibold tracking-tight leading-tight">{t('app.name')}</p>
|
||||
<p className="truncate text-[11px] text-muted-foreground leading-tight">{t('app.slogan')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 导航 */}
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 py-3 scrollbar-thin">
|
||||
{items.map((item) => (
|
||||
<nav className="flex-1 overflow-y-auto px-2.5 py-3 scrollbar-thin">
|
||||
{groups.map((group) => {
|
||||
const groupItems = items.filter((item) => item.group === group.key)
|
||||
if (!groupItems.length) return null
|
||||
return (
|
||||
<div key={group.key} className="mb-3">
|
||||
{!sidebarCollapsed && (
|
||||
<p className="mb-1.5 px-3 pt-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
|
||||
{group.label}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{groupItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.key}
|
||||
to={item.path}
|
||||
@@ -60,10 +74,10 @@ export function Sidebar() {
|
||||
title={sidebarCollapsed ? t(item.labelKey as never) : undefined}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm transition-colors',
|
||||
'group relative flex items-center gap-3 rounded-[10px] px-3 py-2.5 text-[13px] transition-all duration-150',
|
||||
isActive
|
||||
? 'bg-primary-50 font-medium text-primary-700'
|
||||
: 'text-secondary-foreground hover:bg-muted',
|
||||
? 'bg-primary-50 font-semibold text-primary-800 shadow-[inset_0_0_0_1px_rgba(37,99,235,.1)] before:absolute before:inset-y-2 before:left-0 before:w-[3px] before:rounded-r-full before:bg-primary-600'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-950',
|
||||
sidebarCollapsed && 'justify-center px-0',
|
||||
)
|
||||
}
|
||||
@@ -71,13 +85,17 @@ export function Sidebar() {
|
||||
<item.icon className="h-[18px] w-[18px] shrink-0" />
|
||||
{!sidebarCollapsed && <span className="truncate">{t(item.labelKey as never)}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* 底部:套餐与企业信息 */}
|
||||
{!sidebarCollapsed && (
|
||||
<div className="border-t border-border p-3">
|
||||
<div className="rounded-md bg-muted/60 p-3">
|
||||
<div className="border-t border-slate-100 p-3">
|
||||
<div className="rounded-[12px] border border-slate-200 bg-slate-50/80 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-muted-foreground">{t('sidebar.currentPlan')}</span>
|
||||
<span className="text-xs font-medium text-primary-700">{plan.name}</span>
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useAuthStore } from '@/app/store/auth'
|
||||
import { useUiStore } from '@/app/store/ui'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { UI_LOCALES, type UiLocale } from '@/config/languages'
|
||||
import { ROLE_META, type RoleKey } from '@/config/roles'
|
||||
import { ROLE_META } from '@/config/roles'
|
||||
import { toast } from '@/app/store/toast'
|
||||
import { confirmDialog } from '@/app/store/confirm'
|
||||
import { Avatar } from '@/components/ui/avatar'
|
||||
@@ -29,6 +29,7 @@ import { cn } from '@/utils/cn'
|
||||
|
||||
const NOTIF_TYPE_COLOR: Record<string, string> = {
|
||||
inquiry: 'bg-emerald-100 text-emerald-700',
|
||||
message: 'bg-sky-100 text-sky-700',
|
||||
risk: 'bg-red-100 text-red-700',
|
||||
task: 'bg-amber-100 text-amber-700',
|
||||
channel: 'bg-blue-100 text-blue-700',
|
||||
@@ -39,7 +40,7 @@ export function Topbar() {
|
||||
const { t, locale, setLocale } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { user, tenant, switchRole, logout } = useAuthStore()
|
||||
const { user, tenant, logout } = useAuthStore()
|
||||
const setMobileNavOpen = useUiStore((s) => s.setMobileNavOpen)
|
||||
|
||||
const { data: notifications } = useQuery({
|
||||
@@ -61,10 +62,10 @@ export function Topbar() {
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-border bg-card px-3 md:px-4">
|
||||
<header className="relative z-20 flex h-16 shrink-0 items-center gap-3 border-b border-slate-200 bg-white/95 px-3 shadow-[0_6px_24px_-24px_rgba(15,23,42,.7)] backdrop-blur-xl md:px-5">
|
||||
{/* 移动端菜单 */}
|
||||
<button
|
||||
className="rounded-md p-2 text-muted-foreground hover:bg-muted lg:hidden"
|
||||
className="rounded-[10px] p-2 text-muted-foreground hover:bg-slate-100 lg:hidden"
|
||||
onClick={() => setMobileNavOpen(true)}
|
||||
aria-label="打开导航"
|
||||
>
|
||||
@@ -74,14 +75,14 @@ export function Topbar() {
|
||||
{/* 企业切换 */}
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="hidden items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium hover:bg-muted md:flex">
|
||||
<button className="hidden items-center gap-1.5 rounded-[10px] border border-transparent px-2.5 py-1.5 text-sm font-medium hover:border-slate-200 hover:bg-slate-50 md:flex">
|
||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="max-w-[180px] truncate">{tenant?.name ?? '—'}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
}
|
||||
items={[
|
||||
{ key: 'current', label: `${tenant?.name ?? ''}(当前企业)`, icon: Building2, onSelect: () => toast.info('当前演示环境仅一个企业') },
|
||||
{ key: 'current', label: `${tenant?.name ?? ''}(当前企业)`, icon: Building2, onSelect: () => {} },
|
||||
'separator',
|
||||
{ key: 'switch', label: '创建/加入新企业', icon: UsersRound, onSelect: () => navigate('/register') },
|
||||
]}
|
||||
@@ -95,7 +96,7 @@ export function Topbar() {
|
||||
{/* 语言切换 */}
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="flex items-center gap-1 rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground" title={t('lang.name')}>
|
||||
<button className="flex items-center gap-1 rounded-[10px] p-2 text-muted-foreground hover:bg-slate-100 hover:text-foreground" title={t('lang.name')}>
|
||||
<Globe className="h-[18px] w-[18px]" />
|
||||
</button>
|
||||
}
|
||||
@@ -110,14 +111,14 @@ export function Topbar() {
|
||||
),
|
||||
onSelect: () => {
|
||||
setLocale(l.code as UiLocale)
|
||||
toast.success(l.rtl ? '已切换为 RTL 布局(演示)' : `界面语言已切换:${l.nativeName}`)
|
||||
toast.success(l.rtl ? '已切换为 RTL 布局' : `界面语言已切换:${l.nativeName}`)
|
||||
},
|
||||
}))}
|
||||
/>
|
||||
|
||||
{/* 帮助 */}
|
||||
<button
|
||||
className="rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
className="rounded-[10px] p-2 text-muted-foreground hover:bg-slate-100 hover:text-foreground"
|
||||
title={t('topbar.help')}
|
||||
onClick={() => navigate('/help')}
|
||||
>
|
||||
@@ -127,7 +128,7 @@ export function Topbar() {
|
||||
{/* 通知 */}
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="relative rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground" title={t('topbar.notifications')}>
|
||||
<button className="relative rounded-[10px] p-2 text-muted-foreground hover:bg-slate-100 hover:text-foreground" title={t('topbar.notifications')}>
|
||||
<Bell className="h-[18px] w-[18px]" />
|
||||
{unread > 0 && (
|
||||
<span className="absolute right-1 top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium text-white">
|
||||
@@ -155,7 +156,7 @@ export function Topbar() {
|
||||
label: (
|
||||
<span className="flex flex-col gap-0.5 py-0.5">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Badge className={NOTIF_TYPE_COLOR[n.type]}>{n.type === 'inquiry' ? '询盘' : n.type === 'risk' ? '风险' : n.type === 'task' ? '任务' : n.type === 'channel' ? '渠道' : '系统'}</Badge>
|
||||
<Badge className={NOTIF_TYPE_COLOR[n.type]}>{n.type === 'inquiry' ? '询盘' : n.type === 'message' ? '消息' : n.type === 'risk' ? '风险' : n.type === 'task' ? '任务' : n.type === 'channel' ? '渠道' : '系统'}</Badge>
|
||||
{!n.read && <span className="h-1.5 w-1.5 rounded-full bg-red-500" />}
|
||||
<span className="text-[11px] text-muted-foreground">{formatRelative(n.createdAt)}</span>
|
||||
</span>
|
||||
@@ -171,7 +172,7 @@ export function Topbar() {
|
||||
{/* 用户菜单 */}
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="flex items-center gap-2 rounded-md px-1.5 py-1 hover:bg-muted">
|
||||
<button className="flex items-center gap-2 rounded-[10px] border border-transparent px-1.5 py-1 hover:border-slate-200 hover:bg-slate-50">
|
||||
<Avatar name={user?.name ?? '?'} size="sm" online />
|
||||
<span className="hidden text-start md:block">
|
||||
<span className="block max-w-[90px] truncate text-sm font-medium leading-tight">{user?.name}</span>
|
||||
@@ -183,22 +184,6 @@ export function Topbar() {
|
||||
items={[
|
||||
{ key: 'profile', label: t('topbar.profile'), icon: UserRound, onSelect: () => navigate('/settings') },
|
||||
'separator',
|
||||
...(Object.keys(ROLE_META) as RoleKey[]).map((r) => ({
|
||||
key: `role-${r}`,
|
||||
label: (
|
||||
<span className={cn('flex items-center gap-2', user?.role === r && 'font-medium text-primary')}>
|
||||
{ROLE_META[r].label}
|
||||
{user?.role === r && '✓'}
|
||||
</span>
|
||||
),
|
||||
icon: UsersRound,
|
||||
onSelect: () => {
|
||||
switchRole(r)
|
||||
queryClient.clear()
|
||||
toast.success(`已切换为「${ROLE_META[r].label}」(演示)`, '导航与按钮权限已同步更新')
|
||||
},
|
||||
})),
|
||||
'separator',
|
||||
{ key: 'logout', label: t('topbar.logout'), icon: LogOut, danger: true, onSelect: handleLogout },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -3,11 +3,13 @@ import { colorFromString, initials } from '@/utils/misc'
|
||||
|
||||
export function Avatar({
|
||||
name,
|
||||
src,
|
||||
size = 'md',
|
||||
className,
|
||||
online,
|
||||
}: {
|
||||
name: string
|
||||
src?: string
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
className?: string
|
||||
online?: boolean
|
||||
@@ -28,7 +30,18 @@ export function Avatar({
|
||||
colorFromString(name || '?'),
|
||||
)}
|
||||
>
|
||||
{initials(name)}
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={name}
|
||||
className="h-full w-full rounded-full object-cover"
|
||||
onError={(event) => {
|
||||
event.currentTarget.style.display = 'none'
|
||||
event.currentTarget.nextElementSibling?.classList.remove('hidden')
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<span className={cn(src && 'hidden')}>{initials(name)}</span>
|
||||
</span>
|
||||
{online !== undefined && (
|
||||
<span
|
||||
|
||||
@@ -4,13 +4,13 @@ import { Loader2 } from 'lucide-react'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 select-none',
|
||||
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-[10px] text-sm font-medium transition-[color,background-color,border-color,box-shadow,transform] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 select-none active:translate-y-px',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary-700 active:bg-primary-800',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-border',
|
||||
outline: 'border border-input bg-card hover:bg-muted',
|
||||
default: 'bg-primary text-primary-foreground shadow-[0_1px_2px_rgba(37,99,235,.22)] hover:bg-primary-700 hover:shadow-[0_6px_16px_-8px_rgba(37,99,235,.65)] active:bg-primary-800',
|
||||
secondary: 'bg-slate-100 text-slate-700 hover:bg-slate-200',
|
||||
outline: 'border border-slate-200 bg-white text-slate-700 shadow-sm hover:border-slate-300 hover:bg-slate-50',
|
||||
ghost: 'hover:bg-muted text-foreground',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-red-600',
|
||||
dangerOutline: 'border border-red-200 bg-card text-red-600 hover:bg-red-50',
|
||||
@@ -19,7 +19,7 @@ const buttonVariants = cva(
|
||||
size: {
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
default: 'h-9 px-4',
|
||||
lg: 'h-10 px-5',
|
||||
lg: 'h-11 px-5',
|
||||
icon: 'h-9 w-9',
|
||||
'icon-sm': 'h-7 w-7',
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cn } from '@/utils/cn'
|
||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('rounded-lg border border-border bg-card shadow-[0_1px_2px_rgba(16,24,40,0.04)]', className)}
|
||||
className={cn('rounded-[14px] border border-slate-200/90 bg-card shadow-[0_1px_2px_rgba(15,23,42,0.025),0_8px_24px_rgba(15,23,42,0.025)]', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -19,9 +19,9 @@ export function CardHeader({
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & { title?: ReactNode; description?: ReactNode; action?: ReactNode }) {
|
||||
return (
|
||||
<div className={cn('flex items-start justify-between gap-3 border-b border-border px-4 py-3', className)} {...props}>
|
||||
<div className={cn('flex items-start justify-between gap-3 border-b border-slate-100 px-5 py-4', className)} {...props}>
|
||||
<div className="min-w-0">
|
||||
{title && <h3 className="text-sm font-semibold text-foreground">{title}</h3>}
|
||||
{title && <h3 className="text-[14px] font-semibold tracking-[-0.01em] text-slate-900">{title}</h3>}
|
||||
{description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
|
||||
{children}
|
||||
</div>
|
||||
@@ -31,7 +31,7 @@ export function CardHeader({
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('p-4', className)} {...props} />
|
||||
return <div className={cn('p-5', className)} {...props} />
|
||||
}
|
||||
|
||||
export function CardFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
|
||||
@@ -47,7 +47,7 @@ export function Dropdown({
|
||||
{open && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute z-40 mt-1 min-w-[160px] overflow-hidden rounded-md border border-border bg-popover py-1 shadow-lg animate-slide-in-top',
|
||||
'absolute z-[80] mt-1 min-w-[160px] overflow-hidden rounded-md border border-border bg-popover py-1 shadow-lg animate-slide-in-top',
|
||||
align === 'end' ? 'right-0 rtl:right-auto rtl:left-0' : 'left-0 rtl:left-auto rtl:right-0',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -5,17 +5,20 @@ export function Switch({
|
||||
onCheckedChange,
|
||||
disabled,
|
||||
className,
|
||||
ariaLabel,
|
||||
}: {
|
||||
checked: boolean
|
||||
onCheckedChange?: (v: boolean) => void
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
ariaLabel?: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={ariaLabel}
|
||||
disabled={disabled}
|
||||
onClick={() => onCheckedChange?.(!checked)}
|
||||
className={cn(
|
||||
|
||||
@@ -10,26 +10,26 @@ export function Table({ className, ...props }: HTMLAttributes<HTMLTableElement>)
|
||||
}
|
||||
|
||||
export function THead({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
|
||||
return <thead className={cn('border-b border-border bg-muted/50', className)} {...props} />
|
||||
return <thead className={cn('border-b border-slate-200 bg-slate-50/80', className)} {...props} />
|
||||
}
|
||||
|
||||
export function TBody({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
|
||||
return <tbody className={cn('divide-y divide-border', className)} {...props} />
|
||||
return <tbody className={cn('divide-y divide-slate-100', className)} {...props} />
|
||||
}
|
||||
|
||||
export function Tr({ className, ...props }: HTMLAttributes<HTMLTableRowElement>) {
|
||||
return <tr className={cn('transition-colors hover:bg-muted/40', className)} {...props} />
|
||||
return <tr className={cn('transition-colors hover:bg-primary-50/35', className)} {...props} />
|
||||
}
|
||||
|
||||
export function Th({ className, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
|
||||
return (
|
||||
<th
|
||||
className={cn('whitespace-nowrap px-3 py-2.5 text-start text-xs font-medium text-muted-foreground', className)}
|
||||
className={cn('whitespace-nowrap px-4 py-3 text-start text-[11px] font-semibold uppercase tracking-[0.04em] text-slate-500', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Td({ className, colSpan, ...props }: TdHTMLAttributes<HTMLTableCellElement>) {
|
||||
return <td className={cn('px-3 py-2.5 align-middle', className)} colSpan={colSpan} {...props} />
|
||||
return <td className={cn('px-4 py-3 align-middle text-slate-700', className)} colSpan={colSpan} {...props} />
|
||||
}
|
||||
|
||||
@@ -57,23 +57,35 @@ export function Segmented<T extends string>({
|
||||
options,
|
||||
size = 'default',
|
||||
className,
|
||||
ariaLabel = '切换选项',
|
||||
}: {
|
||||
value: T
|
||||
onChange: (v: T) => void
|
||||
options: { value: T; label: ReactNode }[]
|
||||
size?: 'sm' | 'default'
|
||||
className?: string
|
||||
ariaLabel?: string
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('inline-flex items-center rounded-md bg-muted p-0.5', className)}>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label={ariaLabel}
|
||||
className={cn('inline-flex items-center rounded-lg border border-slate-200 bg-slate-100/90 p-0.5 shadow-inner', className)}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={o.value === value}
|
||||
data-state={o.value === value ? 'active' : 'inactive'}
|
||||
key={o.value}
|
||||
onClick={() => onChange(o.value)}
|
||||
className={cn(
|
||||
'rounded px-2.5 text-xs font-medium transition-colors',
|
||||
'relative rounded-md px-2.5 text-xs font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40',
|
||||
size === 'sm' ? 'h-6' : 'h-7',
|
||||
o.value === value ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
||||
o.value === value
|
||||
? 'bg-primary text-white shadow-sm shadow-primary/20'
|
||||
: 'text-slate-500 hover:bg-white/70 hover:text-slate-900',
|
||||
)}
|
||||
>
|
||||
{o.label}
|
||||
|
||||
+13
-2
@@ -26,7 +26,18 @@ export const COUNTRIES: CountryMeta[] = [
|
||||
]
|
||||
|
||||
export function countryMeta(code: string): CountryMeta {
|
||||
return COUNTRIES.find((c) => c.code === code) ?? { code, zh: code, en: code, flag: '🏳️', tzOffset: 0, lang: 'en' }
|
||||
const normalized = String(code || '').trim().toUpperCase()
|
||||
const known = COUNTRIES.find((c) => c.code === normalized)
|
||||
if (known) return known
|
||||
if (!/^[A-Z]{2}$/.test(normalized)) {
|
||||
return { code: '', zh: '国家待确认', en: 'Country not confirmed', flag: '🌐', tzOffset: Number.NaN, lang: '' }
|
||||
}
|
||||
const flag = String.fromCodePoint(
|
||||
...[...normalized].map((character) => 127397 + character.charCodeAt(0)),
|
||||
)
|
||||
const zh = new Intl.DisplayNames(['zh-CN'], { type: 'region' }).of(normalized) || normalized
|
||||
const en = new Intl.DisplayNames(['en'], { type: 'region' }).of(normalized) || normalized
|
||||
return { code: normalized, zh, en, flag, tzOffset: Number.NaN, lang: '' }
|
||||
}
|
||||
|
||||
/** 行业 */
|
||||
@@ -263,7 +274,7 @@ export const LANGUAGES_LIST = [
|
||||
'俄语', '法语', '德语', '土耳其语', '越南语', '印尼语', '日语', '韩语',
|
||||
]
|
||||
|
||||
/** 产品库(Mock 企业主营产品) */
|
||||
/** 企业主营产品选项 */
|
||||
export const PRODUCTS = [
|
||||
'双层玻璃杯 350ml',
|
||||
'高硼硅玻璃杯 500ml',
|
||||
|
||||
+3
-5
@@ -1,7 +1,5 @@
|
||||
/** 环境配置:Mock 与真实后端通过环境变量切换 */
|
||||
/** 灵犀跨境生产 API 配置 */
|
||||
export const env = {
|
||||
/** true = 使用内置 Mock 服务;false = 请求真实后端 */
|
||||
useMock: (import.meta.env.VITE_USE_MOCK ?? 'true') !== 'false',
|
||||
/** 真实后端地址 */
|
||||
apiBaseUrl: import.meta.env.VITE_API_BASE_URL ?? '/api',
|
||||
/** API 地址留空时使用当前域名,由 Nginx 代理 /api */
|
||||
apiBaseUrl: import.meta.env.VITE_API_BASE_URL ?? '',
|
||||
}
|
||||
|
||||
@@ -53,6 +53,49 @@ export const LANGUAGES: LanguageOption[] = [
|
||||
{ code: 'ro', name: '罗马尼亚语', nativeName: 'Română' },
|
||||
{ code: 'cs', name: '捷克语', nativeName: 'Čeština' },
|
||||
{ code: 'el', name: '希腊语', nativeName: 'Ελληνικά' },
|
||||
{ code: 'da', name: '丹麦语', nativeName: 'Dansk' },
|
||||
{ code: 'no', name: '挪威语', nativeName: 'Norsk' },
|
||||
{ code: 'fi', name: '芬兰语', nativeName: 'Suomi' },
|
||||
{ code: 'hu', name: '匈牙利语', nativeName: 'Magyar' },
|
||||
{ code: 'bg', name: '保加利亚语', nativeName: 'Български' },
|
||||
{ code: 'sr', name: '塞尔维亚语', nativeName: 'Српски' },
|
||||
{ code: 'hr', name: '克罗地亚语', nativeName: 'Hrvatski' },
|
||||
{ code: 'sk', name: '斯洛伐克语', nativeName: 'Slovenčina' },
|
||||
{ code: 'sl', name: '斯洛文尼亚语', nativeName: 'Slovenščina' },
|
||||
{ code: 'lt', name: '立陶宛语', nativeName: 'Lietuvių' },
|
||||
{ code: 'lv', name: '拉脱维亚语', nativeName: 'Latviešu' },
|
||||
{ code: 'et', name: '爱沙尼亚语', nativeName: 'Eesti' },
|
||||
{ code: 'ca', name: '加泰罗尼亚语', nativeName: 'Català' },
|
||||
{ code: 'sq', name: '阿尔巴尼亚语', nativeName: 'Shqip' },
|
||||
{ code: 'mk', name: '马其顿语', nativeName: 'Македонски' },
|
||||
{ code: 'ka', name: '格鲁吉亚语', nativeName: 'ქართული' },
|
||||
{ code: 'hy', name: '亚美尼亚语', nativeName: 'Հայերեն' },
|
||||
{ code: 'az', name: '阿塞拜疆语', nativeName: 'Azərbaycanca' },
|
||||
{ code: 'kk', name: '哈萨克语', nativeName: 'Қазақша' },
|
||||
{ code: 'uz', name: '乌兹别克语', nativeName: 'Oʻzbekcha' },
|
||||
{ code: 'mn', name: '蒙古语', nativeName: 'Монгол' },
|
||||
{ code: 'ne', name: '尼泊尔语', nativeName: 'नेपाली' },
|
||||
{ code: 'si', name: '僧伽罗语', nativeName: 'සිංහල' },
|
||||
{ code: 'ta', name: '泰米尔语', nativeName: 'தமிழ்' },
|
||||
{ code: 'te', name: '泰卢固语', nativeName: 'తెలుగు' },
|
||||
{ code: 'mr', name: '马拉地语', nativeName: 'मराठी' },
|
||||
{ code: 'gu', name: '古吉拉特语', nativeName: 'ગુજરાતી' },
|
||||
{ code: 'pa', name: '旁遮普语', nativeName: 'ਪੰਜਾਬੀ' },
|
||||
{ code: 'kn', name: '卡纳达语', nativeName: 'ಕನ್ನಡ' },
|
||||
{ code: 'ml', name: '马拉雅拉姆语', nativeName: 'മലയാളം' },
|
||||
{ code: 'fil', name: '菲律宾语', nativeName: 'Filipino' },
|
||||
{ code: 'km', name: '高棉语', nativeName: 'ខ្មែរ' },
|
||||
{ code: 'lo', name: '老挝语', nativeName: 'ລາວ' },
|
||||
{ code: 'my', name: '缅甸语', nativeName: 'မြန်မာ' },
|
||||
{ code: 'am', name: '阿姆哈拉语', nativeName: 'አማርኛ' },
|
||||
{ code: 'af', name: '南非荷兰语', nativeName: 'Afrikaans' },
|
||||
{ code: 'zu', name: '祖鲁语', nativeName: 'isiZulu' },
|
||||
{ code: 'ha', name: '豪萨语', nativeName: 'Hausa' },
|
||||
{ code: 'yo', name: '约鲁巴语', nativeName: 'Yorùbá' },
|
||||
{ code: 'ig', name: '伊博语', nativeName: 'Igbo' },
|
||||
{ code: 'so', name: '索马里语', nativeName: 'Soomaali' },
|
||||
{ code: 'ps', name: '普什图语', nativeName: 'پښتو', rtl: true },
|
||||
{ code: 'ku', name: '库尔德语', nativeName: 'Kurdî' },
|
||||
]
|
||||
|
||||
export const RTL_LANG_CODES = LANGUAGES.filter((l) => l.rtl).map((l) => l.code)
|
||||
|
||||
+11
-18
@@ -3,16 +3,12 @@ import {
|
||||
MessagesSquare,
|
||||
Users,
|
||||
KanbanSquare,
|
||||
Languages,
|
||||
AudioLines,
|
||||
ShieldAlert,
|
||||
BookOpen,
|
||||
BarChart3,
|
||||
Cable,
|
||||
UsersRound,
|
||||
CreditCard,
|
||||
Settings,
|
||||
GraduationCap,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import type { Permission } from './roles'
|
||||
@@ -24,21 +20,18 @@ export interface NavItem {
|
||||
labelKey: string
|
||||
icon: LucideIcon
|
||||
permission?: Permission
|
||||
group: 'core' | 'intelligence' | 'admin'
|
||||
}
|
||||
|
||||
export const NAV_ITEMS: NavItem[] = [
|
||||
{ key: 'dashboard', path: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard },
|
||||
{ key: 'inbox', path: '/inbox', labelKey: 'nav.inbox', icon: MessagesSquare },
|
||||
{ key: 'customers', path: '/customers', labelKey: 'nav.customers', icon: Users, permission: 'customer:view' },
|
||||
{ key: 'opportunities', path: '/opportunities', labelKey: 'nav.opportunities', icon: KanbanSquare },
|
||||
{ key: 'translate', path: '/translate', labelKey: 'nav.translate', icon: Languages },
|
||||
{ key: 'interpret', path: '/interpret', labelKey: 'nav.interpret', icon: AudioLines },
|
||||
{ key: 'risks', path: '/risks', labelKey: 'nav.risks', icon: ShieldAlert },
|
||||
{ key: 'knowledge', path: '/knowledge', labelKey: 'nav.knowledge', icon: BookOpen },
|
||||
{ key: 'analytics', path: '/analytics', labelKey: 'nav.analytics', icon: BarChart3, permission: 'analytics:view' },
|
||||
{ key: 'channels', path: '/channels', labelKey: 'nav.channels', icon: Cable },
|
||||
{ key: 'team', path: '/team', labelKey: 'nav.team', icon: UsersRound },
|
||||
{ key: 'billing', path: '/billing', labelKey: 'nav.billing', icon: CreditCard },
|
||||
{ key: 'settings', path: '/settings', labelKey: 'nav.settings', icon: Settings },
|
||||
{ key: 'help', path: '/help', labelKey: 'nav.help', icon: GraduationCap },
|
||||
{ key: 'dashboard', path: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard, group: 'core' },
|
||||
{ key: 'inbox', path: '/inbox', labelKey: 'nav.inbox', icon: MessagesSquare, group: 'core' },
|
||||
{ key: 'customers', path: '/customers', labelKey: 'nav.customers', icon: Users, permission: 'customer:view', group: 'core' },
|
||||
{ key: 'opportunities', path: '/opportunities', labelKey: 'nav.opportunities', icon: KanbanSquare, permission: 'opportunity:manage', group: 'core' },
|
||||
{ key: 'risks', path: '/risks', labelKey: 'nav.risks', icon: ShieldAlert, permission: 'risk:handle', group: 'core' },
|
||||
{ key: 'knowledge', path: '/knowledge', labelKey: 'nav.knowledge', icon: BookOpen, permission: 'knowledge:manage', group: 'intelligence' },
|
||||
{ key: 'analytics', path: '/analytics', labelKey: 'nav.analytics', icon: BarChart3, permission: 'analytics:view', group: 'intelligence' },
|
||||
{ key: 'channels', path: '/channels', labelKey: 'nav.channels', icon: Cable, permission: 'channel:manage', group: 'admin' },
|
||||
{ key: 'team', path: '/team', labelKey: 'nav.team', icon: UsersRound, permission: 'team:manage', group: 'admin' },
|
||||
{ key: 'settings', path: '/settings', labelKey: 'nav.settings', icon: Settings, permission: 'settings:manage', group: 'admin' },
|
||||
]
|
||||
|
||||
@@ -57,7 +57,7 @@ export const PRICING_PLANS: PricingPlan[] = [
|
||||
{
|
||||
key: 'pro',
|
||||
name: '专业版',
|
||||
tagline: '5—20 人团队的 AI 经营中枢',
|
||||
tagline: '多渠道客户沟通与团队协作',
|
||||
priceNote: '¥49,800/年',
|
||||
monthlyPrice: null,
|
||||
yearlyPrice: 49800,
|
||||
@@ -66,10 +66,9 @@ export const PRICING_PLANS: PricingPlan[] = [
|
||||
features: [
|
||||
'包含标准版全部能力',
|
||||
'客户情感分析与意图预测',
|
||||
'AI 智能回复建议(多策略)',
|
||||
'多策略回复建议',
|
||||
'风险预警与文化敏感检测',
|
||||
'老板经营看板',
|
||||
'同声传译',
|
||||
'业务数据看板',
|
||||
],
|
||||
limits: { members: 20, messagesPerMonth: 100000, translationsPerMonth: 200000, knowledgeStorageMb: 5120 },
|
||||
},
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
/** 角色与权限配置(前端 Mock 权限控制) */
|
||||
/** 角色与权限配置。后端会对每个请求再次执行同一角色边界校验。 */
|
||||
|
||||
export type RoleKey = 'admin' | 'manager' | 'sales' | 'service' | 'viewer'
|
||||
|
||||
@@ -24,7 +24,7 @@ export const ROLE_META: Record<RoleKey, { label: string; description: string; co
|
||||
admin: { label: '企业管理员', description: '管理企业、成员、权限、渠道、套餐与系统设置', color: 'bg-violet-100 text-violet-700' },
|
||||
manager: { label: '业务主管', description: '查看全部客户、团队数据、经营看板与客户分配', color: 'bg-blue-100 text-blue-700' },
|
||||
sales: { label: '外贸业务员', description: '查看分配给自己的客户、消息与跟进任务', color: 'bg-teal-100 text-teal-700' },
|
||||
service: { label: '客服人员', description: '处理跨渠道消息、翻译、AI回复与售后咨询', color: 'bg-amber-100 text-amber-700' },
|
||||
service: { label: '客服人员', description: '处理跨渠道消息、翻译、回复建议与售后咨询', color: 'bg-amber-100 text-amber-700' },
|
||||
viewer: { label: '只读成员', description: '仅可查看被授权的数据,不能编辑或发送消息', color: 'bg-gray-100 text-gray-600' },
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LocaleDict } from './zh'
|
||||
|
||||
/** 阿拉伯语(RTL 布局演示) */
|
||||
/** 阿拉伯语(RTL 布局) */
|
||||
export const ar: LocaleDict = {
|
||||
'app.name': 'لينغشي عبر الحدود',
|
||||
'app.slogan': 'نجعل التواصل عبر الحدود أبسط',
|
||||
@@ -39,12 +39,10 @@ export const ar: LocaleDict = {
|
||||
'common.reset': 'إعادة تعيين',
|
||||
'common.optional': 'اختياري',
|
||||
'common.required': 'مطلوب',
|
||||
'common.demoData': 'بيانات تجريبية',
|
||||
|
||||
'topbar.searchPlaceholder': 'ابحث عن العملاء والمحادثات والمعرفة والمهام…',
|
||||
'topbar.notifications': 'الإشعارات',
|
||||
'topbar.help': 'المساعدة',
|
||||
'topbar.switchRole': 'تبديل الدور التجريبي',
|
||||
'topbar.logout': 'تسجيل الخروج',
|
||||
'topbar.profile': 'الملف الشخصي',
|
||||
|
||||
@@ -65,7 +63,6 @@ export const ar: LocaleDict = {
|
||||
'login.forgot': 'نسيت كلمة المرور؟',
|
||||
'login.submit': 'تسجيل الدخول',
|
||||
'login.register': 'تسجيل شركة جديدة',
|
||||
'login.quick': 'دخول تجريبي بنقرة واحدة',
|
||||
'login.agreementPrefix': 'بتسجيل الدخول فأنت توافق على',
|
||||
'login.privacy': 'سياسة الخصوصية',
|
||||
'login.terms': 'شروط الخدمة',
|
||||
|
||||
@@ -6,7 +6,7 @@ export const en: LocaleDict = {
|
||||
'app.company': 'Xuzhou Ouhui Technology Co., Ltd.',
|
||||
|
||||
'nav.dashboard': 'Workspace',
|
||||
'nav.inbox': 'Unified Inbox',
|
||||
'nav.inbox': 'Messages',
|
||||
'nav.customers': 'Customers',
|
||||
'nav.opportunities': 'Deals & Follow-ups',
|
||||
'nav.translate': 'AI Translation',
|
||||
@@ -38,12 +38,10 @@ export const en: LocaleDict = {
|
||||
'common.reset': 'Reset',
|
||||
'common.optional': 'Optional',
|
||||
'common.required': 'Required',
|
||||
'common.demoData': 'Demo data',
|
||||
|
||||
'topbar.searchPlaceholder': 'Search customers, chats, knowledge, tasks…',
|
||||
'topbar.notifications': 'Notifications',
|
||||
'topbar.help': 'Help',
|
||||
'topbar.switchRole': 'Switch demo role',
|
||||
'topbar.logout': 'Sign out',
|
||||
'topbar.profile': 'Profile',
|
||||
|
||||
@@ -64,7 +62,6 @@ export const en: LocaleDict = {
|
||||
'login.forgot': 'Forgot password?',
|
||||
'login.submit': 'Sign in',
|
||||
'login.register': 'Register company',
|
||||
'login.quick': 'One-click demo login',
|
||||
'login.agreementPrefix': 'By signing in you agree to',
|
||||
'login.privacy': 'Privacy Policy',
|
||||
'login.terms': 'Terms of Service',
|
||||
|
||||
@@ -4,15 +4,15 @@ export const zh = {
|
||||
'app.company': '徐州欧汇科技有限公司',
|
||||
|
||||
'nav.dashboard': '工作台',
|
||||
'nav.inbox': '统一消息',
|
||||
'nav.inbox': '消息中心',
|
||||
'nav.customers': '客户管理',
|
||||
'nav.opportunities': '商机与跟进',
|
||||
'nav.translate': 'AI翻译',
|
||||
'nav.interpret': '同声传译',
|
||||
'nav.risks': '风险预警',
|
||||
'nav.risks': '风险工单',
|
||||
'nav.knowledge': '企业知识库',
|
||||
'nav.analytics': '数据分析',
|
||||
'nav.channels': '渠道中心',
|
||||
'nav.channels': '渠道与账号',
|
||||
'nav.team': '团队协作',
|
||||
'nav.billing': '套餐与账单',
|
||||
'nav.settings': '系统设置',
|
||||
@@ -36,12 +36,10 @@ export const zh = {
|
||||
'common.reset': '重置',
|
||||
'common.optional': '选填',
|
||||
'common.required': '必填',
|
||||
'common.demoData': '演示数据',
|
||||
|
||||
'topbar.searchPlaceholder': '搜索客户、会话、知识、任务…',
|
||||
'topbar.notifications': '通知',
|
||||
'topbar.help': '帮助',
|
||||
'topbar.switchRole': '切换演示角色',
|
||||
'topbar.logout': '退出登录',
|
||||
'topbar.profile': '个人中心',
|
||||
|
||||
@@ -62,7 +60,6 @@ export const zh = {
|
||||
'login.forgot': '忘记密码?',
|
||||
'login.submit': '登 录',
|
||||
'login.register': '注册新企业',
|
||||
'login.quick': '演示角色一键登录',
|
||||
'login.agreementPrefix': '登录即代表同意',
|
||||
'login.privacy': '《隐私政策》',
|
||||
'login.terms': '《服务协议》',
|
||||
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
/**
|
||||
* Mock 内存数据库
|
||||
* 深克隆种子数据,会话期间的增删改都在这里生效(刷新页面后重置)。
|
||||
*/
|
||||
import type { Customer } from '@/types/crm'
|
||||
import type { Conversation, Message, ChannelConnection, ChannelSyncLog } from '@/types/messaging'
|
||||
import type { FollowTask, Opportunity, RiskAlert, ActivityLog, CustomerFile } from '@/types/crm'
|
||||
import type { KnowledgeItem } from '@/types/knowledge'
|
||||
import type { TeamMember, OperationLog, LoginRecord, ApiKeyItem, AppNotification, BillingInfo, BillingOrder, Invoice, SystemSettings } from '@/types/org'
|
||||
|
||||
import { customers } from './seed/customers'
|
||||
import { conversations } from './seed/conversations'
|
||||
import { messages } from './seed/messages'
|
||||
import { tasks } from './seed/tasks'
|
||||
import { opportunities } from './seed/opportunities'
|
||||
import { risks } from './seed/risks'
|
||||
import { knowledgeItems } from './seed/knowledge'
|
||||
import { teamMembers } from './seed/team'
|
||||
import { channels, channelSyncLogs } from './seed/channels'
|
||||
import { notifications, activityLogs, customerFiles, apiKeys, billingInfo, billingOrders, invoices, defaultSettings, operationLogs, loginRecords } from './seed/misc'
|
||||
|
||||
export interface MockDb {
|
||||
customers: Customer[]
|
||||
conversations: Conversation[]
|
||||
messages: Message[]
|
||||
tasks: FollowTask[]
|
||||
opportunities: Opportunity[]
|
||||
risks: RiskAlert[]
|
||||
knowledge: KnowledgeItem[]
|
||||
team: TeamMember[]
|
||||
channels: ChannelConnection[]
|
||||
channelSyncLogs: ChannelSyncLog[]
|
||||
notifications: AppNotification[]
|
||||
activityLogs: ActivityLog[]
|
||||
customerFiles: CustomerFile[]
|
||||
apiKeys: ApiKeyItem[]
|
||||
billing: BillingInfo
|
||||
orders: BillingOrder[]
|
||||
invoices: Invoice[]
|
||||
settings: SystemSettings
|
||||
operationLogs: OperationLog[]
|
||||
loginRecords: LoginRecord[]
|
||||
}
|
||||
|
||||
function clone<T>(v: T): T {
|
||||
return JSON.parse(JSON.stringify(v)) as T
|
||||
}
|
||||
|
||||
function buildDb(): MockDb {
|
||||
const msgs = clone(messages)
|
||||
const convs = clone(conversations)
|
||||
// 计算每个会话的最后一条消息
|
||||
for (const c of convs) {
|
||||
const list = msgs
|
||||
.filter((m) => m.conversationId === c.id)
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
c.lastMessage = list[list.length - 1]
|
||||
}
|
||||
return {
|
||||
customers: clone(customers),
|
||||
conversations: convs,
|
||||
messages: msgs,
|
||||
tasks: clone(tasks),
|
||||
opportunities: clone(opportunities),
|
||||
risks: clone(risks),
|
||||
knowledge: clone(knowledgeItems),
|
||||
team: clone(teamMembers),
|
||||
channels: clone(channels),
|
||||
channelSyncLogs: clone(channelSyncLogs),
|
||||
notifications: clone(notifications),
|
||||
activityLogs: clone(activityLogs),
|
||||
customerFiles: clone(customerFiles),
|
||||
apiKeys: clone(apiKeys),
|
||||
billing: clone(billingInfo),
|
||||
orders: clone(billingOrders),
|
||||
invoices: clone(invoices),
|
||||
settings: clone(defaultSettings),
|
||||
operationLogs: clone(operationLogs),
|
||||
loginRecords: clone(loginRecords),
|
||||
}
|
||||
}
|
||||
|
||||
export const db: MockDb = buildDb()
|
||||
|
||||
/** 便捷查询 */
|
||||
export const dbHelpers = {
|
||||
customer(id: string) {
|
||||
return db.customers.find((c) => c.id === id)
|
||||
},
|
||||
member(id: string | null | undefined) {
|
||||
return db.team.find((m) => m.id === id)
|
||||
},
|
||||
conversation(id: string) {
|
||||
return db.conversations.find((c) => c.id === id)
|
||||
},
|
||||
messagesOf(conversationId: string) {
|
||||
return db.messages
|
||||
.filter((m) => m.conversationId === conversationId)
|
||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||
},
|
||||
touchConversation(conversationId: string, msg: Message) {
|
||||
const conv = db.conversations.find((c) => c.id === conversationId)
|
||||
if (conv) {
|
||||
conv.lastMessage = msg
|
||||
conv.updatedAt = msg.createdAt
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,208 +0,0 @@
|
||||
import type { AnalyticsData, DashboardOverview } from '@/types/analytics'
|
||||
import { seededRandom } from '@/utils/misc'
|
||||
import { lastNDays } from './helpers'
|
||||
import { customers } from './customers'
|
||||
import { tasks } from './tasks'
|
||||
import { risks } from './risks'
|
||||
|
||||
const rand = seededRandom(20260722)
|
||||
|
||||
function series(n: number, base: number, spread: number): number[] {
|
||||
let cur = base
|
||||
return Array.from({ length: n }, () => {
|
||||
cur = Math.max(0, cur + (rand() - 0.45) * spread)
|
||||
return Math.round(cur)
|
||||
})
|
||||
}
|
||||
|
||||
const days30 = lastNDays(30)
|
||||
const days14 = lastNDays(14)
|
||||
|
||||
const inquirySeries = series(30, 18, 10)
|
||||
const customerSeries = inquirySeries.map((v) => Math.max(2, Math.round(v * (0.35 + rand() * 0.2))))
|
||||
|
||||
/** 工作台总览(演示数据) */
|
||||
export const dashboardOverview: DashboardOverview = {
|
||||
stats: {
|
||||
newInquiriesToday: { value: 23, delta: 0.15 },
|
||||
pendingReply: { value: 9, delta: -0.08 },
|
||||
highIntentCustomers: { value: 7, delta: 0.17 },
|
||||
todayFollowUps: { value: 12, delta: 0.0 },
|
||||
openRisks: { value: 11, delta: 0.22 },
|
||||
avgFirstResponseMin: { value: 14, delta: -0.18, unit: '分钟' },
|
||||
monthConversionRate: { value: 18.6, delta: 0.023, isPercent: true },
|
||||
aiAdoptionRate: { value: 72.4, delta: 0.051, isPercent: true },
|
||||
},
|
||||
todos: tasks.filter((t) => t.status !== 'done').slice(0, 7),
|
||||
highValueCustomers: customers.filter((c) => c.intentScore >= 75).slice(0, 6),
|
||||
channelOverview: [
|
||||
{ channel: 'whatsapp', todayMessages: 46, pending: 4, unread: 5 },
|
||||
{ channel: 'telegram', todayMessages: 12, pending: 0, unread: 0 },
|
||||
{ channel: 'messenger', todayMessages: 8, pending: 1, unread: 1 },
|
||||
{ channel: 'email', todayMessages: 21, pending: 3, unread: 3 },
|
||||
{ channel: 'webform', todayMessages: 5, pending: 1, unread: 1 },
|
||||
],
|
||||
inquiryTrend: days14.map((date, i) => ({ date, inquiries: inquirySeries[i + 16], customers: customerSeries[i + 16] })),
|
||||
sourceDistribution: [
|
||||
{ name: 'WhatsApp', value: 38 },
|
||||
{ name: '企业邮箱', value: 24 },
|
||||
{ name: '独立站/落地页', value: 16 },
|
||||
{ name: 'Telegram', value: 10 },
|
||||
{ name: 'Messenger', value: 8 },
|
||||
{ name: '展会/转介绍', value: 4 },
|
||||
],
|
||||
funnel: [
|
||||
{ stage: 'initial', label: '初次咨询', count: 128 },
|
||||
{ stage: 'comparing', label: '价格比较', count: 86 },
|
||||
{ stage: 'sample', label: '样品确认', count: 47 },
|
||||
{ stage: 'closing', label: '成交推进', count: 31 },
|
||||
{ stage: 'won', label: '成交', count: 19 },
|
||||
],
|
||||
teamReplyRank: [
|
||||
{ memberId: 'u-003', name: '李婷', avgResponseMin: 8, replies: 42 },
|
||||
{ memberId: 'u-005', name: '周敏', avgResponseMin: 11, replies: 35 },
|
||||
{ memberId: 'u-004', name: '赵磊', avgResponseMin: 14, replies: 31 },
|
||||
{ memberId: 'u-008', name: '郑楠', avgResponseMin: 19, replies: 24 },
|
||||
{ memberId: 'u-006', name: '孙倩', avgResponseMin: 26, replies: 12 },
|
||||
],
|
||||
recentRisks: risks.slice(0, 5),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
/** 数据分析中心(演示数据) */
|
||||
export const analyticsData: AnalyticsData = {
|
||||
kpis: {
|
||||
newCustomers: { value: 186, delta: 0.12 },
|
||||
validInquiries: { value: 342, delta: 0.09 },
|
||||
highIntent: { value: 58, delta: 0.16 },
|
||||
conversionRate: { value: 18.6, delta: 0.023, isPercent: true },
|
||||
avgResponseMin: { value: 14, delta: -0.18, unit: '分钟' },
|
||||
aiTranslateUsage: { value: 12840, delta: 0.31 },
|
||||
aiAdoptionRate: { value: 72.4, delta: 0.051, isPercent: true },
|
||||
taskCompletionRate: { value: 86.2, delta: 0.04, isPercent: true },
|
||||
riskHandleRate: { value: 78.5, delta: 0.06, isPercent: true },
|
||||
repurchaseRate: { value: 23.8, delta: 0.018, isPercent: true },
|
||||
},
|
||||
customerGrowth: days30.map((date, i) => ({ date, count: customerSeries[i] })),
|
||||
channelInquiries: [
|
||||
{ channel: 'whatsapp', name: 'WhatsApp', count: 128 },
|
||||
{ channel: 'email', name: '企业邮箱', count: 82 },
|
||||
{ channel: 'webform', name: '独立站表单', count: 45 },
|
||||
{ channel: 'landing', name: '广告落地页', count: 38 },
|
||||
{ channel: 'telegram', name: 'Telegram', count: 27 },
|
||||
{ channel: 'messenger', name: 'Messenger', count: 22 },
|
||||
],
|
||||
countryDistribution: [
|
||||
{ country: 'PK', name: '巴基斯坦', count: 52 },
|
||||
{ country: 'BD', name: '孟加拉国', count: 44 },
|
||||
{ country: 'AE', name: '阿联酋', count: 38 },
|
||||
{ country: 'SA', name: '沙特阿拉伯', count: 31 },
|
||||
{ country: 'US', name: '美国', count: 28 },
|
||||
{ country: 'GB', name: '英国', count: 19 },
|
||||
{ country: 'ES', name: '西班牙', count: 16 },
|
||||
{ country: 'DE', name: '德国', count: 14 },
|
||||
],
|
||||
sourcePaths: [
|
||||
{ path: 'WhatsApp 官网按钮', count: 68 },
|
||||
{ path: 'Google 广告落地页', count: 52 },
|
||||
{ path: 'Facebook 主页/广告', count: 41 },
|
||||
{ path: '展会名片/二维码', count: 26 },
|
||||
{ path: '客户转介绍', count: 18 },
|
||||
{ path: 'LinkedIn 开发信', count: 12 },
|
||||
],
|
||||
funnel: [
|
||||
{ stage: 'initial', label: '新询盘', count: 342 },
|
||||
{ stage: 'requirement', label: '需求确认', count: 246 },
|
||||
{ stage: 'quoting', label: '报价', count: 168 },
|
||||
{ stage: 'sample', label: '样品/定制', count: 96 },
|
||||
{ stage: 'negotiation', label: '谈判', count: 78 },
|
||||
{ stage: 'won', label: '成交', count: 64 },
|
||||
],
|
||||
intentDistribution: [
|
||||
{ range: '80—100 高价值', count: 58 },
|
||||
{ range: '60—79 中高', count: 86 },
|
||||
{ range: '40—59 中等', count: 102 },
|
||||
{ range: '0—39 低', count: 96 },
|
||||
],
|
||||
sentimentTrend: days14.map((date) => ({
|
||||
date,
|
||||
positive: Math.round(8 + rand() * 8),
|
||||
neutral: Math.round(10 + rand() * 8),
|
||||
negative: Math.round(rand() * 4),
|
||||
})),
|
||||
riskTrend: days14.map((date) => ({ date, count: Math.round(2 + rand() * 6) })),
|
||||
productRank: [
|
||||
{ product: '定制Logo玻璃水杯', inquiries: 86 },
|
||||
{ product: '男士纯棉T恤', inquiries: 64 },
|
||||
{ product: 'TWS蓝牙耳机', inquiries: 52 },
|
||||
{ product: '高硼硅玻璃杯 500ml', inquiries: 47 },
|
||||
{ product: '女士针织开衫', inquiries: 38 },
|
||||
{ product: '北欧棉麻四件套', inquiries: 26 },
|
||||
{ product: '65W氮化镓充电器', inquiries: 24 },
|
||||
{ product: 'ins风陶瓷餐具套装', inquiries: 19 },
|
||||
],
|
||||
teamReplyRank: [
|
||||
{ name: '李婷', avgResponseMin: 8 },
|
||||
{ name: '周敏', avgResponseMin: 11 },
|
||||
{ name: '赵磊', avgResponseMin: 14 },
|
||||
{ name: '郑楠', avgResponseMin: 19 },
|
||||
{ name: '孙倩', avgResponseMin: 26 },
|
||||
],
|
||||
teamConversionRank: [
|
||||
{ name: '李婷', rate: 26.5 },
|
||||
{ name: '赵磊', rate: 22.8 },
|
||||
{ name: '郑楠', rate: 17.2 },
|
||||
{ name: '周敏', rate: 12.4 },
|
||||
],
|
||||
taskCompletion: [
|
||||
{ name: '李婷', done: 38, total: 44 },
|
||||
{ name: '赵磊', done: 31, total: 38 },
|
||||
{ name: '周敏', done: 27, total: 30 },
|
||||
{ name: '郑楠', done: 25, total: 34 },
|
||||
{ name: '孙倩', done: 12, total: 15 },
|
||||
],
|
||||
churnReasons: [
|
||||
{ reason: '价格未达成一致', count: 34 },
|
||||
{ reason: '交期无法满足', count: 22 },
|
||||
{ reason: '选择竞争对手', count: 18 },
|
||||
{ reason: '长时间未跟进', count: 15 },
|
||||
{ reason: '质量/售后问题', count: 9 },
|
||||
{ reason: '客户项目取消', count: 7 },
|
||||
],
|
||||
industryDistribution: [
|
||||
{ industry: '玻璃制品', count: 118 },
|
||||
{ industry: '服装纺织', count: 96 },
|
||||
{ industry: '3C电子', count: 82 },
|
||||
{ industry: '家居用品', count: 46 },
|
||||
],
|
||||
bossBoard: {
|
||||
urgentHighValue: [
|
||||
{ id: 'c-001', name: 'Ahmed Raza', country: 'PK', reason: '明天确认订单,今日需发阶梯报价', intentScore: 86 },
|
||||
{ id: 'c-012', name: 'Sarah Miller', country: 'US', reason: '季度框架单待签合同', intentScore: 88 },
|
||||
{ id: 'c-028', name: 'Saud Al-Harbi', country: 'SA', reason: '朝觐季大单交付节点逼近', intentScore: 85 },
|
||||
{ id: 'c-019', name: 'Tom Baker', country: 'US', reason: '紧急售后纠纷,最后通牒', intentScore: 38 },
|
||||
],
|
||||
overdueMembers: [
|
||||
{ id: 'u-004', name: '赵磊', overdueCount: 2 },
|
||||
{ id: 'u-003', name: '李婷', overdueCount: 1 },
|
||||
{ id: 'u-008', name: '郑楠', overdueCount: 1 },
|
||||
],
|
||||
bestChannel: { channel: 'whatsapp', name: 'WhatsApp', conversion: 24.8 },
|
||||
fastestCountries: [
|
||||
{ country: 'PK', name: '巴基斯坦', growth: 0.42 },
|
||||
{ country: 'SA', name: '沙特阿拉伯', growth: 0.35 },
|
||||
{ country: 'BD', name: '孟加拉国', growth: 0.28 },
|
||||
{ country: 'AE', name: '阿联酋', growth: 0.19 },
|
||||
],
|
||||
topProducts: [
|
||||
{ product: '定制Logo玻璃水杯', inquiries: 86 },
|
||||
{ product: '男士纯棉T恤', inquiries: 64 },
|
||||
{ product: 'TWS蓝牙耳机', inquiries: 52 },
|
||||
],
|
||||
churningHighIntent: [
|
||||
{ id: 'c-021', name: 'Hassan Ali', country: 'PK', daysSilent: 2, intentScore: 66 },
|
||||
{ id: 'c-007', name: 'Emma Wilson', country: 'GB', daysSilent: 1, intentScore: 58 },
|
||||
{ id: 'c-025', name: 'Imran Sheikh', country: 'PK', daysSilent: 1, intentScore: 79 },
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { ChannelConnection, ChannelSyncLog } from '@/types/messaging'
|
||||
import { daysAgo, hoursAgo, minutesAgo } from './helpers'
|
||||
|
||||
/** 13 个渠道连接状态(7个已连接/1异常/1暂停/4未连接) */
|
||||
export const channels: ChannelConnection[] = [
|
||||
{ id: 'ch-01', type: 'whatsapp', name: 'WhatsApp Business', status: 'connected', account: '+86 138****6621(徐州欧汇)', lastSyncAt: minutesAgo(2), todayMessages: 46, autoReply: true, defaultOwnerId: 'u-003' },
|
||||
{ id: 'ch-02', type: 'telegram', name: 'Telegram', status: 'connected', account: '@OuhuiTrading', lastSyncAt: minutesAgo(5), todayMessages: 12, autoReply: true, defaultOwnerId: 'u-004' },
|
||||
{ id: 'ch-03', type: 'messenger', name: 'Facebook Messenger', status: 'connected', account: 'Ouhui Tech 主页', lastSyncAt: minutesAgo(9), todayMessages: 8, autoReply: false, defaultOwnerId: 'u-008' },
|
||||
{ id: 'ch-04', type: 'email', name: '企业邮箱', status: 'connected', account: 'sales@ouhui.tech', lastSyncAt: minutesAgo(3), todayMessages: 21, autoReply: true, defaultOwnerId: 'u-003' },
|
||||
{ id: 'ch-05', type: 'gmail', name: 'Gmail', status: 'connected', account: 'ouhui.export@gmail.com', lastSyncAt: minutesAgo(14), todayMessages: 6, autoReply: false, defaultOwnerId: 'u-005' },
|
||||
{ id: 'ch-06', type: 'webform', name: '独立站表单', status: 'connected', account: 'www.ouhui-glass.com', lastSyncAt: minutesAgo(31), todayMessages: 5, autoReply: true, defaultOwnerId: 'u-003' },
|
||||
{ id: 'ch-07', type: 'landing', name: '广告落地页', status: 'connected', account: 'Google Ads / Facebook Ads', lastSyncAt: hoursAgo(1), todayMessages: 9, autoReply: true, defaultOwnerId: 'u-004' },
|
||||
{ id: 'ch-08', type: 'outlook', name: 'Outlook', status: 'error', account: 'ouhui@outlook.com', lastSyncAt: hoursAgo(26), todayMessages: 0, errorMessage: '授权令牌已过期,请重新授权', autoReply: false, defaultOwnerId: 'u-005' },
|
||||
{ id: 'ch-09', type: 'website', name: '官网咨询按钮', status: 'paused', account: 'www.ouhui-glass.com', lastSyncAt: daysAgo(2, 10), todayMessages: 0, autoReply: false, defaultOwnerId: 'u-005' },
|
||||
{ id: 'ch-10', type: 'social', name: '社交媒体主页', status: 'unconnected', todayMessages: 0, autoReply: false },
|
||||
{ id: 'ch-11', type: 'exhibition', name: '展会二维码', status: 'unconnected', todayMessages: 0, autoReply: false },
|
||||
{ id: 'ch-12', type: 'api', name: 'API 接口', status: 'unconnected', todayMessages: 0, autoReply: false },
|
||||
{ id: 'ch-13', type: 'webhook', name: 'Webhook', status: 'unconnected', todayMessages: 0, autoReply: false },
|
||||
]
|
||||
|
||||
export const channelSyncLogs: ChannelSyncLog[] = [
|
||||
{ id: 'sl-01', channelId: 'ch-01', action: '消息同步', detail: '同步 12 条新消息,3 个新会话建档', status: 'success', createdAt: minutesAgo(2) },
|
||||
{ id: 'sl-02', channelId: 'ch-04', action: '邮件拉取', detail: '拉取 7 封新邮件,1 封识别为询盘', status: 'success', createdAt: minutesAgo(3) },
|
||||
{ id: 'sl-03', channelId: 'ch-02', action: '消息同步', detail: '同步 2 条新消息', status: 'success', createdAt: minutesAgo(5) },
|
||||
{ id: 'sl-04', channelId: 'ch-03', action: '主页私信同步', detail: '同步 1 条私信', status: 'success', createdAt: minutesAgo(9) },
|
||||
{ id: 'sl-05', channelId: 'ch-08', action: '令牌刷新', detail: 'OAuth 令牌刷新失败:refresh_token expired', status: 'failed', createdAt: hoursAgo(26) },
|
||||
{ id: 'sl-06', channelId: 'ch-01', action: '自动回复', detail: '非工作时间自动回复触发 3 次', status: 'success', createdAt: hoursAgo(9) },
|
||||
{ id: 'sl-07', channelId: 'ch-06', action: '表单接收', detail: '新表单提交:Carmen López(西班牙)', status: 'success', createdAt: hoursAgo(9) },
|
||||
{ id: 'sl-08', channelId: 'ch-07', action: '线索同步', detail: 'Google Ads 落地页新线索 2 条', status: 'success', createdAt: hoursAgo(1) },
|
||||
]
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { Conversation } from '@/types/messaging'
|
||||
import { minutesAgo, hoursAgo, daysAgo } from './helpers'
|
||||
|
||||
/** 20 条会话(lastMessage 由 db 根据消息自动计算) */
|
||||
export const conversations: Conversation[] = [
|
||||
{ id: 'cv-001', customerId: 'c-001', channel: 'whatsapp', unreadCount: 2, status: 'pending', assigneeId: 'u-003', intentLevel: 'high', sentiment: 'neutral-positive', hasRisk: false, summary: '500个定制玻璃杯询价,关注价格/Logo/交期', updatedAt: minutesAgo(42) },
|
||||
{ id: 'cv-002', customerId: 'c-002', channel: 'email', unreadCount: 1, status: 'pending', assigneeId: 'u-003', intentLevel: 'medium-high', sentiment: 'positive', hasRisk: false, summary: '酒店集团2000个高硼硅杯,等待耐温测试报告', updatedAt: hoursAgo(3) },
|
||||
{ id: 'cv-003', customerId: 'c-003', channel: 'whatsapp', unreadCount: 1, status: 'pending', assigneeId: 'u-004', intentLevel: 'high', sentiment: 'positive', hasRisk: false, summary: '5000件T恤价格锁定$2.20,待付款条款', updatedAt: hoursAgo(1) },
|
||||
{ id: 'cv-004', customerId: 'c-004', channel: 'email', unreadCount: 1, status: 'pending', assigneeId: 'u-005', intentLevel: 'medium', sentiment: 'negative', hasRisk: true, summary: '样品两次延误,客户威胁更换供应商', updatedAt: hoursAgo(5) },
|
||||
{ id: 'cv-005', customerId: 'c-005', channel: 'webform', unreadCount: 1, status: 'pending', assigneeId: 'u-003', intentLevel: 'medium-high', sentiment: 'neutral', hasRisk: false, summary: '西班牙家居店首询:床品+餐具目录与MOQ', updatedAt: hoursAgo(7) },
|
||||
{ id: 'cv-006', customerId: 'c-006', channel: 'telegram', unreadCount: 0, status: 'replied', assigneeId: 'u-004', intentLevel: 'medium-high', sentiment: 'neutral-positive', hasRisk: false, summary: '1000个阿文书法Logo礼品杯,斋月前交付', updatedAt: hoursAgo(9) },
|
||||
{ id: 'cv-007', customerId: 'c-007', channel: 'messenger', unreadCount: 1, status: 'pending', assigneeId: 'u-008', intentLevel: 'medium', sentiment: 'neutral', hasRisk: false, summary: '800件开衫比价中,对方报价£3.95', updatedAt: hoursAgo(12) },
|
||||
{ id: 'cv-008', customerId: 'c-008', channel: 'email', unreadCount: 0, status: 'replied', assigneeId: 'u-004', intentLevel: 'medium-high', sentiment: 'neutral', hasRisk: false, summary: '德国客户3000个GaN充电器,样品已寄', updatedAt: hoursAgo(26) },
|
||||
{ id: 'cv-009', customerId: 'c-009', channel: 'whatsapp', unreadCount: 0, status: 'replied', assigneeId: 'u-003', intentLevel: 'medium', sentiment: 'neutral', hasRisk: false, summary: '1000件T恤询克重与颜色,已发色卡', updatedAt: hoursAgo(30) },
|
||||
{ id: 'cv-010', customerId: 'c-010', channel: 'whatsapp', unreadCount: 1, status: 'pending', assigneeId: 'u-005', intentLevel: 'medium-high', sentiment: 'positive', hasRisk: false, summary: '300套餐具,关心电商运输防碎包装', updatedAt: hoursAgo(8) },
|
||||
{ id: 'cv-011', customerId: 'c-011', channel: 'email', unreadCount: 0, status: 'replied', assigneeId: 'u-008', intentLevel: 'medium', sentiment: 'neutral', hasRisk: false, summary: '150套储物罐报价,询问巴塞罗那运输', updatedAt: daysAgo(1, 16) },
|
||||
{ id: 'cv-012', customerId: 'c-012', channel: 'landing', unreadCount: 1, status: 'pending', assigneeId: 'u-004', intentLevel: 'high', sentiment: 'positive', hasRisk: false, summary: '2000台TWS耳机季度框架,问蓝牙参数', updatedAt: minutesAgo(95) },
|
||||
{ id: 'cv-013', customerId: 'c-013', channel: 'messenger', unreadCount: 0, status: 'replied', assigneeId: 'u-008', intentLevel: 'medium-high', sentiment: 'neutral-positive', hasRisk: false, summary: 'POLO衫样品满意,待2000件最终报价', updatedAt: daysAgo(1, 11) },
|
||||
{ id: 'cv-014', customerId: 'c-014', channel: 'email', unreadCount: 1, status: 'pending', assigneeId: null, intentLevel: 'medium', sentiment: 'neutral', hasRisk: false, summary: '储物罐批发询价,未分配负责人', updatedAt: daysAgo(2, 14) },
|
||||
{ id: 'cv-015', customerId: 'c-015', channel: 'email', unreadCount: 0, status: 'replied', assigneeId: 'u-003', intentLevel: 'medium-high', sentiment: 'neutral-positive', hasRisk: false, summary: '600套床品£12/套达成,询问缩水率', updatedAt: daysAgo(1, 9) },
|
||||
{ id: 'cv-016', customerId: 'c-016', channel: 'webform', unreadCount: 0, status: 'replied', assigneeId: 'u-004', intentLevel: 'medium-high', sentiment: 'neutral', hasRisk: false, summary: '充电器+手表各1000,关注质保与DDP', updatedAt: daysAgo(1, 18) },
|
||||
{ id: 'cv-017', customerId: 'c-017', channel: 'whatsapp', unreadCount: 0, status: 'replied', assigneeId: 'u-008', intentLevel: 'high', sentiment: 'positive', hasRisk: false, summary: '1500只手表阿语适配,已报$11.2', updatedAt: hoursAgo(2) },
|
||||
{ id: 'cv-018', customerId: 'c-018', channel: 'telegram', unreadCount: 0, status: 'replied', assigneeId: 'u-005', intentLevel: 'medium', sentiment: 'neutral', hasRisk: false, summary: '与土耳其供应商比价,突出差异化', updatedAt: daysAgo(2, 10) },
|
||||
{ id: 'cv-019', customerId: 'c-019', channel: 'whatsapp', unreadCount: 1, status: 'pending', assigneeId: 'u-005', intentLevel: 'low', sentiment: 'angry', hasRisk: true, summary: '到货3%破损,客户强烈不满,最后通牒', updatedAt: minutesAgo(20) },
|
||||
{ id: 'cv-020', customerId: 'c-020', channel: 'whatsapp', unreadCount: 0, status: 'replied', assigneeId: 'u-003', intentLevel: 'high', sentiment: 'positive', hasRisk: false, summary: '老客户秋冬复购1200件开衫', updatedAt: hoursAgo(4) },
|
||||
]
|
||||
@@ -1,443 +0,0 @@
|
||||
import type { Customer } from '@/types/crm'
|
||||
import { daysAgo, daysFromNow, hoursAgo, minutesAgo } from './helpers'
|
||||
|
||||
/** 30 个演示客户(覆盖 8 国、4 大行业、多渠道来源) */
|
||||
export const customers: Customer[] = [
|
||||
{
|
||||
id: 'c-001', name: 'Ahmed Raza', company: 'Raza Trading Co.', country: 'PK', city: 'Lahore',
|
||||
email: 'ahmed.raza@razatrading.pk', phone: '+92 300 1234567', language: 'ur',
|
||||
channel: 'whatsapp', sourcePath: 'WhatsApp 直聊(官网按钮带入)',
|
||||
products: ['定制Logo玻璃水杯', '双层玻璃杯 350ml'],
|
||||
tags: ['重点客户', '交期敏感', '定制需求'], intentScore: 86, valueLevel: 'A',
|
||||
stage: 'comparing', sentiment: 'neutral-positive', riskLevel: 'medium',
|
||||
ownerId: 'u-003', lastContactAt: minutesAgo(42), nextFollowUpAt: daysFromNow(1),
|
||||
createdAt: daysAgo(4), isKey: true,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '500 个(意向扩展至 2000)', targetPrice: '$1.6—1.8/个',
|
||||
moqRequirement: '500 起订', sampleNeed: '需要 2 个带Logo样品',
|
||||
customRequirement: '双色 Logo 印刷,350ml 双层杯',
|
||||
deliveryTime: '期望 10 天内(门店开业)', logistics: '卡拉奇港海运或空运',
|
||||
payment: '倾向 30% 定金 + 见提单副本付款', aftersales: '破损补发政策',
|
||||
concerns: ['价格', 'Logo定制效果', '交付时间'],
|
||||
summary: '巴基斯坦拉合尔贸易商,为门店开业采购 500 个定制玻璃杯,对交期敏感,同时在比较 2—3 家供应商,价格接受区间明确。',
|
||||
nextActions: ['发送阶梯报价和定制案例', '确认加急生产可行性', '安排样品寄送'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-002', name: 'Khalid Al Mansouri', company: 'Gulf Hospitality Group', country: 'AE', city: 'Dubai',
|
||||
email: 'k.mansouri@gulfhospitality.ae', phone: '+971 50 234 5678', language: 'ar',
|
||||
channel: 'email', sourcePath: '企业邮箱(展会名片录入)',
|
||||
products: ['高硼硅玻璃杯 500ml'], tags: ['大客户潜力', '展会客户'],
|
||||
intentScore: 78, valueLevel: 'B', stage: 'sample', sentiment: 'positive', riskLevel: 'low',
|
||||
ownerId: 'u-003', lastContactAt: hoursAgo(3), nextFollowUpAt: daysFromNow(2),
|
||||
createdAt: daysAgo(12), isKey: false,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '2000 个(酒店集团)', targetPrice: '$2.2/个以内',
|
||||
sampleNeed: '已寄样,等待反馈', deliveryTime: '45 天内分批交付',
|
||||
payment: '信用证 L/C 或 T/T', concerns: ['杯体耐温差性能', '批量一致性'],
|
||||
summary: '迪拜酒店集团采购负责人,为其连锁酒店采购玻璃杯,样品确认阶段,决策链较长但预算充足。',
|
||||
nextActions: ['跟进样品反馈', '准备耐温差测试报告'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-003', name: 'Rahim Uddin', company: 'Dhaka Apparel Sourcing', country: 'BD', city: 'Dhaka',
|
||||
email: 'rahim@dhakaapparel.com', phone: '+880 1712 345678', language: 'bn',
|
||||
channel: 'whatsapp', sourcePath: 'WhatsApp(Facebook 广告落地页)',
|
||||
products: ['男士纯棉T恤'], tags: ['价格敏感', '大客户潜力'],
|
||||
intentScore: 82, valueLevel: 'A', stage: 'closing', sentiment: 'positive', riskLevel: 'none',
|
||||
ownerId: 'u-004', lastContactAt: hoursAgo(1), nextFollowUpAt: daysFromNow(1),
|
||||
createdAt: daysAgo(9), isKey: true,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '5000 件', targetPrice: '$2.1/件', moqRequirement: '3000 起订可接受',
|
||||
deliveryTime: '30 天', payment: 'T/T 30% 定金', concerns: ['克重与缩水率', '交期'],
|
||||
summary: '孟加拉服装采购商,5000 件纯棉T恤进入议价尾声,价格接近成交区间,需尽快锁定。',
|
||||
nextActions: ['发送最终报价', '确认付款方式'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-004', name: 'Mike Johnson', company: 'Pacific Tech Imports', country: 'US', city: 'Los Angeles',
|
||||
email: 'mike.j@pacifictech.io', phone: '+1 310 555 0142', language: 'en',
|
||||
channel: 'email', sourcePath: '企业邮箱(官网询盘表单)',
|
||||
products: ['TWS蓝牙耳机'], tags: ['高风险'],
|
||||
intentScore: 45, valueLevel: 'C', stage: 'aftersales', sentiment: 'negative', riskLevel: 'high',
|
||||
ownerId: 'u-005', lastContactAt: hoursAgo(5), nextFollowUpAt: hoursAgo(-4) as unknown as string,
|
||||
createdAt: daysAgo(35), isKey: false,
|
||||
aiProfile: {
|
||||
aftersales: '样品物流延误 6 天,客户不满', concerns: ['物流时效', '沟通响应'],
|
||||
summary: '美国3C进口商,样品包裹延误引发投诉,情绪消极,需优先安抚并给出补偿方案,存在流失风险。',
|
||||
nextActions: ['售后安抚', '提供加急补寄方案'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-005', name: 'Carmen López', company: 'Hogar & Estilo S.L.', country: 'ES', city: 'Madrid',
|
||||
email: 'carmen@hogaryestilo.es', phone: '+34 612 345 678', language: 'es',
|
||||
channel: 'webform', sourcePath: '独立站表单(Google 搜索广告)',
|
||||
products: ['北欧棉麻四件套', 'ins风陶瓷餐具套装'], tags: [],
|
||||
intentScore: 64, valueLevel: 'B', stage: 'initial', sentiment: 'neutral', riskLevel: 'none',
|
||||
ownerId: 'u-003', lastContactAt: hoursAgo(7), nextFollowUpAt: daysFromNow(2),
|
||||
createdAt: daysAgo(1), isKey: false,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '待定(首批试单)', concerns: ['起订量', '色牢度'],
|
||||
summary: '西班牙家居零售商,首次询盘,关注棉麻四件套与餐具,需要目录与报价单。',
|
||||
nextActions: ['发送产品目录', '补充资料'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-006', name: 'Fahad Al-Otaibi', company: 'Riyadh Gifts Trading', country: 'SA', city: 'Riyadh',
|
||||
email: 'fahad@riyadhgifts.sa', phone: '+966 55 678 9012', language: 'ar',
|
||||
channel: 'telegram', sourcePath: 'Telegram 频道私信',
|
||||
products: ['定制Logo玻璃水杯'], tags: ['定制需求', '斋月注意'],
|
||||
intentScore: 71, valueLevel: 'B', stage: 'custom', sentiment: 'neutral-positive', riskLevel: 'none',
|
||||
ownerId: 'u-004', lastContactAt: hoursAgo(9), nextFollowUpAt: daysFromNow(3),
|
||||
createdAt: daysAgo(6), isKey: false,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '1000 个(企业礼品)', customRequirement: '阿拉伯文书法Logo + 礼盒',
|
||||
deliveryTime: '斋月前交付', concerns: ['阿文排版效果', '礼盒材质'],
|
||||
summary: '沙特礼品贸易商,定制企业礼品玻璃杯,涉及阿拉伯文排版与宗教文化细节,需文化敏感校对。',
|
||||
nextActions: ['发送阿文排版案例', '确认礼盒打样'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-007', name: 'Emma Wilson', company: 'London Threads Ltd', country: 'GB', city: 'London',
|
||||
email: 'emma@londonthreads.co.uk', phone: '+44 20 7946 0812', language: 'en',
|
||||
channel: 'messenger', sourcePath: 'Facebook 主页私信',
|
||||
products: ['女士针织开衫'], tags: ['价格敏感'],
|
||||
intentScore: 58, valueLevel: 'C', stage: 'comparing', sentiment: 'neutral', riskLevel: 'none',
|
||||
ownerId: 'u-008', lastContactAt: hoursAgo(12), nextFollowUpAt: daysFromNow(4),
|
||||
createdAt: daysAgo(5), isKey: false,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '800 件', targetPrice: '£4.5/件以内', concerns: ['比价中', '色号准确性'],
|
||||
summary: '英国女装批发商,多渠道比价中,对价格敏感,需提供差异化卖点与阶梯价。',
|
||||
nextActions: ['发送阶梯报价', '强调质检流程'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-008', name: 'Jonas Weber', company: 'RheinTech GmbH', country: 'DE', city: 'Düsseldorf',
|
||||
email: 'j.weber@rheintech.de', phone: '+49 211 555 0143', language: 'de',
|
||||
channel: 'email', sourcePath: '企业邮箱(LinkedIn 开发信回复)',
|
||||
products: ['65W氮化镓充电器'], tags: ['需要样品'],
|
||||
intentScore: 74, valueLevel: 'B', stage: 'sample', sentiment: 'neutral', riskLevel: 'low',
|
||||
ownerId: 'u-004', lastContactAt: hoursAgo(26), nextFollowUpAt: daysFromNow(1),
|
||||
createdAt: daysAgo(14), isKey: false,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '3000 个', customRequirement: '欧规插脚 + CE/RoHS 认证',
|
||||
concerns: ['认证文件齐全性', '温升测试数据'],
|
||||
summary: '德国3C配件商,注重合规认证与技术参数,要求提供 CE/RoHS 证书和测试报告,沟通风格严谨。',
|
||||
nextActions: ['发送认证文件', '补测温升报告'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-009', name: 'Fatima Bibi', company: 'Karachi Textiles', country: 'PK', city: 'Karachi',
|
||||
email: 'fatima@karachitextiles.pk', phone: '+92 321 7654321', language: 'ur',
|
||||
channel: 'whatsapp', sourcePath: 'WhatsApp(客户转介绍)',
|
||||
products: ['男士纯棉T恤'], tags: [],
|
||||
intentScore: 49, valueLevel: 'C', stage: 'initial', sentiment: 'neutral', riskLevel: 'none',
|
||||
ownerId: 'u-003', lastContactAt: hoursAgo(30), nextFollowUpAt: daysFromNow(5),
|
||||
createdAt: daysAgo(2), isKey: false,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '约 1000 件', concerns: ['面料克重选择'],
|
||||
summary: '巴基斯坦纺织贸易公司初次接触,需求尚在了解阶段,需引导明确规格。',
|
||||
nextActions: ['询问采购时间', '发送面料色卡'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-010', name: 'Layla Hassan', company: 'Dubai Home Decor LLC', country: 'AE', city: 'Dubai',
|
||||
email: 'layla@dubaihomedecor.ae', phone: '+971 52 345 6789', language: 'ar',
|
||||
channel: 'whatsapp', sourcePath: 'WhatsApp(Instagram 主页)',
|
||||
products: ['ins风陶瓷餐具套装'], tags: [],
|
||||
intentScore: 67, valueLevel: 'B', stage: 'comparing', sentiment: 'positive', riskLevel: 'none',
|
||||
ownerId: 'u-005', lastContactAt: hoursAgo(8), nextFollowUpAt: daysFromNow(2),
|
||||
createdAt: daysAgo(7), isKey: false,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '300 套', concerns: ['包装设计', '易碎品运输'],
|
||||
summary: '阿联酋家居电商,社媒获客,关注餐具套装颜值与运输破损率,沟通氛围积极。',
|
||||
nextActions: ['发送包装方案', '说明破损赔付政策'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-011', name: 'Carlos Ruiz', company: 'Restaurante Sol', country: 'ES', city: 'Barcelona',
|
||||
email: 'carlos@restaurantesol.es', phone: '+34 933 456 789', language: 'es',
|
||||
channel: 'email', sourcePath: '企业邮箱(官网底部表单)',
|
||||
products: ['玻璃储物罐套装'], tags: [],
|
||||
intentScore: 52, valueLevel: 'C', stage: 'initial', sentiment: 'neutral', riskLevel: 'none',
|
||||
ownerId: 'u-008', lastContactAt: daysAgo(1, 16), nextFollowUpAt: daysFromNow(6),
|
||||
createdAt: daysAgo(3), isKey: false,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '150 套', concerns: ['密封性'],
|
||||
summary: '西班牙餐厅经营者,小批量采购储物罐,决策快但金额有限。',
|
||||
nextActions: ['发送报价', '介绍混批政策'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-012', name: 'Sarah Miller', company: 'BrightWave Electronics', country: 'US', city: 'Austin',
|
||||
email: 'sarah.m@brightwave.com', phone: '+1 512 555 0177', language: 'en',
|
||||
channel: 'landing', sourcePath: '广告落地页(Google Ads - earbuds)',
|
||||
products: ['TWS蓝牙耳机'], tags: ['重点客户', '决策人直联'],
|
||||
intentScore: 88, valueLevel: 'A', stage: 'closing', sentiment: 'positive', riskLevel: 'none',
|
||||
ownerId: 'u-004', lastContactAt: minutesAgo(95), nextFollowUpAt: daysFromNow(1),
|
||||
createdAt: daysAgo(11), isKey: true,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '2000 台(季度框架 8000)', targetPrice: '$8.5/台',
|
||||
customRequirement: '丝印Logo + 定制包装', payment: 'T/T,接受阿里信保',
|
||||
concerns: ['蓝牙版本与延迟', '售后换新政策'],
|
||||
summary: '美国消费电子品牌采购经理,决策人直联,季度框架订单谈判中,成交概率高,需本周内推进合同。',
|
||||
nextActions: ['发送合同草案', '确认首单交付计划'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-013', name: 'Tanvir Hasan', company: 'Chittagong Garments', country: 'BD', city: 'Chittagong',
|
||||
email: 'tanvir@ctggarments.bd', phone: '+880 1812 987654', language: 'bn',
|
||||
channel: 'messenger', sourcePath: 'Facebook 主页私信',
|
||||
products: ['运动速干POLO衫'], tags: ['需要样品'],
|
||||
intentScore: 61, valueLevel: 'B', stage: 'sample', sentiment: 'neutral-positive', riskLevel: 'none',
|
||||
ownerId: 'u-008', lastContactAt: daysAgo(1, 11), nextFollowUpAt: daysFromNow(3),
|
||||
createdAt: daysAgo(8), isKey: false,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '2000 件', sampleNeed: 'M/L 码各 2 件', concerns: ['速干面料检测报告'],
|
||||
summary: '孟加拉吉大港服装商,POLO衫样品阶段,关注面料功能性检测。',
|
||||
nextActions: ['跟进样品签收', '发送检测报告'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-014', name: 'Omar Farooq', company: 'Lahore Homeware', country: 'PK', city: 'Lahore',
|
||||
email: 'omar@lahorehomeware.pk', phone: '+92 333 4567890', language: 'ur',
|
||||
channel: 'email', sourcePath: '企业邮箱(阿里国际站名片)',
|
||||
products: ['玻璃储物罐套装'], tags: [],
|
||||
intentScore: 43, valueLevel: 'C', stage: 'initial', sentiment: 'neutral', riskLevel: 'none',
|
||||
ownerId: null, lastContactAt: daysAgo(2, 14), nextFollowUpAt: null,
|
||||
createdAt: daysAgo(2), isKey: false,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '待确认', concerns: ['批发价格体系'],
|
||||
summary: '巴基斯坦家居批发商来信询价,尚未分配负责人,需尽快响应避免线索流失。',
|
||||
nextActions: ['分配负责人', '发送报价'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-015', name: 'James Smith', company: 'Britannia Living', country: 'GB', city: 'Manchester',
|
||||
email: 'james@britannialiving.co.uk', phone: '+44 161 496 0223', language: 'en',
|
||||
channel: 'email', sourcePath: '企业邮箱(展会名片录入)',
|
||||
products: ['北欧棉麻四件套'], tags: ['展会客户'],
|
||||
intentScore: 76, valueLevel: 'B', stage: 'closing', sentiment: 'neutral-positive', riskLevel: 'low',
|
||||
ownerId: 'u-003', lastContactAt: daysAgo(1, 9), nextFollowUpAt: daysFromNow(2),
|
||||
createdAt: daysAgo(21), isKey: false,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '600 套', targetPrice: '£12/套', payment: 'T/T 或 PayPal 小额',
|
||||
concerns: ['洗后缩水率', '包装箱规'],
|
||||
summary: '英国家居连锁买手,展会接触后持续跟进,条款接近一致,处于成交推进期。',
|
||||
nextActions: ['确认最终条款', '准备形式发票'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-016', name: 'Anna Schmidt', company: 'München Elektro', country: 'DE', city: 'Munich',
|
||||
email: 'anna.schmidt@melektro.de', phone: '+49 89 555 0188', language: 'de',
|
||||
channel: 'webform', sourcePath: '独立站表单(自然搜索)',
|
||||
products: ['65W氮化镓充电器', '智能手表'], tags: [],
|
||||
intentScore: 69, valueLevel: 'B', stage: 'initial', sentiment: 'neutral', riskLevel: 'none',
|
||||
ownerId: 'u-004', lastContactAt: daysAgo(1, 18), nextFollowUpAt: daysFromNow(2),
|
||||
createdAt: daysAgo(2), isKey: false,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '各 1000', concerns: ['质保周期', '欧盟关税与VAT'],
|
||||
summary: '德国电子零售商,询盘充电器与手表,问题专业具体,重视质保与税务合规。',
|
||||
nextActions: ['发送产品规格书', '说明DDP条款'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-017', name: 'Abdullah Al-Rashid', company: 'Jeddah Gadgets', country: 'SA', city: 'Jeddah',
|
||||
email: 'abdullah@jeddahgadgets.sa', phone: '+966 56 789 0123', language: 'ar',
|
||||
channel: 'whatsapp', sourcePath: 'WhatsApp(TikTok 广告)',
|
||||
products: ['智能手表'], tags: ['大客户潜力'],
|
||||
intentScore: 81, valueLevel: 'A', stage: 'closing', sentiment: 'positive', riskLevel: 'none',
|
||||
ownerId: 'u-008', lastContactAt: hoursAgo(2), nextFollowUpAt: daysFromNow(1),
|
||||
createdAt: daysAgo(10), isKey: true,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '1500 只', targetPrice: '$11/只', customRequirement: '阿语表盘 + 开机画面',
|
||||
concerns: ['阿语系统适配', '本地售后'],
|
||||
summary: '沙特3C经销商,智能手表定制需求明确(阿语适配),意向强烈,临近下单。',
|
||||
nextActions: ['确认阿语固件方案', '发送最终报价'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-018', name: 'Lucía Fernández', company: 'Casa Mediterránea', country: 'ES', city: 'Valencia',
|
||||
email: 'lucia@casamediterranea.es', phone: '+34 654 321 098', language: 'es',
|
||||
channel: 'telegram', sourcePath: 'Telegram 群组推荐',
|
||||
products: ['ins风陶瓷餐具套装'], tags: ['价格敏感'],
|
||||
intentScore: 55, valueLevel: 'C', stage: 'comparing', sentiment: 'neutral', riskLevel: 'none',
|
||||
ownerId: 'u-005', lastContactAt: daysAgo(2, 10), nextFollowUpAt: daysFromNow(5),
|
||||
createdAt: daysAgo(6), isKey: false,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '200 套', targetPrice: '€6/套以内', concerns: ['与土耳其供应商比价'],
|
||||
summary: '西班牙家居网店主,比价中,对价格敏感,可突出设计与破损包赔差异化。',
|
||||
nextActions: ['发送差异化卖点', '阶梯报价'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-019', name: 'Tom Baker', company: 'Midwest Retail Group', country: 'US', city: 'Chicago',
|
||||
email: 'tom.baker@midwestretail.com', phone: '+1 773 555 0119', language: 'en',
|
||||
channel: 'whatsapp', sourcePath: 'WhatsApp(老客户导入)',
|
||||
products: ['双层玻璃杯 350ml'], tags: ['高风险', '重点客户'],
|
||||
intentScore: 38, valueLevel: 'B', stage: 'aftersales', sentiment: 'angry', riskLevel: 'urgent',
|
||||
ownerId: 'u-005', lastContactAt: minutesAgo(20), nextFollowUpAt: hoursAgo(-2) as unknown as string,
|
||||
createdAt: daysAgo(90), isKey: true,
|
||||
aiProfile: {
|
||||
aftersales: '到货 3% 破损,要求补发与赔偿', concerns: ['破损率', '赔付进度', '下次合作信任'],
|
||||
summary: '美国零售集团老客户,上批货物破损引发强烈不满,情绪激烈,存在纠纷升级与流失风险,需管理层介入。',
|
||||
nextActions: ['售后安抚', '管理层回电', '给出补发+赔付方案'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-020', name: 'Nusrat Jahan', company: 'Sylhet Fashion House', country: 'BD', city: 'Sylhet',
|
||||
email: 'nusrat@sylhetfashion.bd', phone: '+880 1912 111222', language: 'bn',
|
||||
channel: 'whatsapp', sourcePath: 'WhatsApp(历史成交客户)',
|
||||
products: ['女士针织开衫'], tags: ['复购客户', '老客户'],
|
||||
intentScore: 84, valueLevel: 'A', stage: 'repurchase', sentiment: 'positive', riskLevel: 'none',
|
||||
ownerId: 'u-003', lastContactAt: hoursAgo(4), nextFollowUpAt: daysFromNow(2),
|
||||
createdAt: daysAgo(150), isKey: true,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '复购 1200 件(秋冬款)', concerns: ['新色卡', '交期赶在销售季前'],
|
||||
summary: '孟加拉女装老客户,秋冬复购洽谈中,历史合作顺畅,关注新色卡与上市时间。',
|
||||
nextActions: ['发送秋冬色卡', '确认复购排产'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-021', name: 'Hassan Ali', company: 'Islamabad Traders', country: 'PK', city: 'Islamabad',
|
||||
email: 'hassan@islamabadtraders.pk', phone: '+92 345 6789012', language: 'ur',
|
||||
channel: 'telegram', sourcePath: 'Telegram 频道私信',
|
||||
products: ['高硼硅玻璃杯 500ml'], tags: [],
|
||||
intentScore: 66, valueLevel: 'B', stage: 'comparing', sentiment: 'neutral', riskLevel: 'none',
|
||||
ownerId: 'u-004', lastContactAt: daysAgo(1, 13), nextFollowUpAt: daysFromNow(3),
|
||||
createdAt: daysAgo(4), isKey: false,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '800 个', concerns: ['与本地供应商价差'],
|
||||
summary: '巴基斯坦贸易商,询价比价阶段,需突出耐温差品质与出口包装。',
|
||||
nextActions: ['发送测试视频', '阶梯报价'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-022', name: 'Mariam Al-Zaabi', company: 'Abu Dhabi Lifestyle', country: 'AE', city: 'Abu Dhabi',
|
||||
email: 'mariam@adlifestyle.ae', phone: '+971 54 987 6543', language: 'ar',
|
||||
channel: 'webform', sourcePath: '独立站表单(Instagram 广告)',
|
||||
products: ['北欧棉麻四件套'], tags: [],
|
||||
intentScore: 47, valueLevel: 'C', stage: 'initial', sentiment: 'neutral', riskLevel: 'none',
|
||||
ownerId: 'u-008', lastContactAt: daysAgo(2, 17), nextFollowUpAt: daysFromNow(6),
|
||||
createdAt: daysAgo(3), isKey: false,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '100 套试单', concerns: ['面料成分'],
|
||||
summary: '阿联酋家居电商新客,小额试单意向,需培育信任。',
|
||||
nextActions: ['发送材质说明', '介绍试单政策'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-023', name: 'Peter Jones', company: 'Manchester Supplies', country: 'GB', city: 'Manchester',
|
||||
email: 'peter@mansupplies.co.uk', phone: '+44 161 555 0134', language: 'en',
|
||||
channel: 'whatsapp', sourcePath: 'WhatsApp(官网按钮)',
|
||||
products: ['运动速干POLO衫'], tags: [],
|
||||
intentScore: 59, valueLevel: 'C', stage: 'comparing', sentiment: 'neutral', riskLevel: 'none',
|
||||
ownerId: 'u-003', lastContactAt: daysAgo(1, 15), nextFollowUpAt: daysFromNow(4),
|
||||
createdAt: daysAgo(5), isKey: false,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '1500 件', concerns: ['印字工艺牢度'],
|
||||
summary: '英国团购服饰供应商,询POLO衫印字款,比价阶段。',
|
||||
nextActions: ['发送印字工艺案例'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-024', name: 'Laura García', company: 'Valencia Home', country: 'ES', city: 'Valencia',
|
||||
email: 'laura@valenciahome.es', phone: '+34 611 222 333', language: 'es',
|
||||
channel: 'whatsapp', sourcePath: 'WhatsApp(Google 我的商家)',
|
||||
products: ['玻璃储物罐套装'], tags: ['需要样品'],
|
||||
intentScore: 72, valueLevel: 'B', stage: 'sample', sentiment: 'positive', riskLevel: 'none',
|
||||
ownerId: 'u-005', lastContactAt: hoursAgo(6), nextFollowUpAt: daysFromNow(2),
|
||||
createdAt: daysAgo(9), isKey: false,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '400 套', sampleNeed: '1 套完整样品', concerns: ['硅胶圈密封性'],
|
||||
summary: '西班牙家居连锁采购,样品已签收待测试反馈,进展良好。',
|
||||
nextActions: ['回访样品测试结果'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-025', name: 'Imran Sheikh', company: 'Multan Glass House', country: 'PK', city: 'Multan',
|
||||
email: 'imran@multanglass.pk', phone: '+92 301 7778889', language: 'ur',
|
||||
channel: 'landing', sourcePath: '广告落地页(Facebook - custom glass)',
|
||||
products: ['定制Logo玻璃水杯'], tags: ['定制需求'],
|
||||
intentScore: 79, valueLevel: 'B', stage: 'custom', sentiment: 'neutral-positive', riskLevel: 'medium',
|
||||
ownerId: 'u-004', lastContactAt: hoursAgo(10), nextFollowUpAt: daysFromNow(1),
|
||||
createdAt: daysAgo(7), isKey: false,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '600 个', customRequirement: '3色丝印 + 独立彩盒',
|
||||
deliveryTime: '20 天内', concerns: ['打样费用', '颜色还原度'],
|
||||
summary: '巴基斯坦玻璃制品经销商,定制需求明确,催问打样进度,对交期有硬要求,存在轻微流失风险。',
|
||||
nextActions: ['确认打样排期', '发送色卡对照'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-026', name: 'Markus Braun', company: 'Berlin Smart Living', country: 'DE', city: 'Berlin',
|
||||
email: 'markus@berlinsmartliving.de', phone: '+49 30 555 0145', language: 'de',
|
||||
channel: 'telegram', sourcePath: 'Telegram(展会群)',
|
||||
products: ['智能手表'], tags: ['展会客户'],
|
||||
intentScore: 63, valueLevel: 'B', stage: 'initial', sentiment: 'neutral', riskLevel: 'none',
|
||||
ownerId: 'u-008', lastContactAt: daysAgo(3, 10), nextFollowUpAt: daysFromNow(5),
|
||||
createdAt: daysAgo(3), isKey: false,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '500 只', concerns: ['GDPR 数据合规'],
|
||||
summary: '德国智能家居渠道商,展会群接触,关注数据合规与隐私条款。',
|
||||
nextActions: ['发送合规声明'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-027', name: 'Grace Thompson', company: 'Atlantic Kitchenware', country: 'US', city: 'New York',
|
||||
email: 'grace@atlantickitchen.com', phone: '+1 212 555 0161', language: 'en',
|
||||
channel: 'email', sourcePath: '企业邮箱(老客户推荐)',
|
||||
products: ['高硼硅玻璃杯 500ml'], tags: [],
|
||||
intentScore: 68, valueLevel: 'B', stage: 'closing', sentiment: 'neutral-positive', riskLevel: 'none',
|
||||
ownerId: 'u-003', lastContactAt: daysAgo(1, 8), nextFollowUpAt: daysFromNow(3),
|
||||
createdAt: daysAgo(16), isKey: false,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '1200 个', payment: 'T/T', concerns: ['FDA 食品级检测'],
|
||||
summary: '美国厨具进口商,推荐客户,条款谈判中,需提供 FDA 检测报告。',
|
||||
nextActions: ['发送FDA报告', '确认订单细节'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-028', name: 'Saud Al-Harbi', company: 'Mecca Trading', country: 'SA', city: 'Jeddah',
|
||||
email: 'saud@meccatrading.sa', phone: '+966 53 111 2233', language: 'ar',
|
||||
channel: 'email', sourcePath: '企业邮箱(官网询盘表单)',
|
||||
products: ['定制Logo玻璃水杯'], tags: ['重点客户', '斋月注意'],
|
||||
intentScore: 85, valueLevel: 'A', stage: 'closing', sentiment: 'positive', riskLevel: 'low',
|
||||
ownerId: 'u-004', lastContactAt: minutesAgo(150), nextFollowUpAt: daysFromNow(1),
|
||||
createdAt: daysAgo(13), isKey: true,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '3000 个(朝觐礼品季)', customRequirement: '礼盒 + 阿文祝福语烫金',
|
||||
deliveryTime: '季节硬性节点', concerns: ['宗教用语准确性', '节点前交付'],
|
||||
summary: '沙特大型贸易商,朝觐礼品季大单,金额高、节点硬,文案涉及宗教用语需严格校对,重点推进。',
|
||||
nextActions: ['宗教文案校对确认', '锁定排产与船期'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-029', name: 'Farhana Akter', company: 'Barisal Textiles', country: 'BD', city: 'Barisal',
|
||||
email: 'farhana@barisaltextiles.bd', phone: '+880 1712 555666', language: 'bn',
|
||||
channel: 'email', sourcePath: '企业邮箱(B2B平台名片)',
|
||||
products: ['男士纯棉T恤'], tags: [],
|
||||
intentScore: 41, valueLevel: 'D', stage: 'initial', sentiment: 'neutral', riskLevel: 'none',
|
||||
ownerId: null, lastContactAt: daysAgo(3, 14), nextFollowUpAt: null,
|
||||
createdAt: daysAgo(3), isKey: false,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '未明确', concerns: ['最低起订量'],
|
||||
summary: '孟加拉小型纺织商,询盘信息简略,未分配负责人,需低成本触达筛选。',
|
||||
nextActions: ['分配负责人', '发送公司简介'],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'c-030', name: 'Omar bin Zayed', company: 'Sharjah Souq Trading', country: 'AE', city: 'Sharjah',
|
||||
email: 'omar@sharjahsouq.ae', phone: '+971 56 444 5566', language: 'ar',
|
||||
channel: 'messenger', sourcePath: 'Facebook 主页私信',
|
||||
products: ['ins风陶瓷餐具套装'], tags: [],
|
||||
intentScore: 56, valueLevel: 'C', stage: 'comparing', sentiment: 'neutral', riskLevel: 'none',
|
||||
ownerId: 'u-005', lastContactAt: daysAgo(1, 19), nextFollowUpAt: daysFromNow(4),
|
||||
createdAt: daysAgo(5), isKey: false,
|
||||
aiProfile: {
|
||||
inquiryQuantity: '250 套', concerns: ['批发阶梯价'],
|
||||
summary: '阿联酋沙迦集市贸易商,习惯当面议价,线上比价阶段。',
|
||||
nextActions: ['发送阶梯报价'],
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -1,45 +0,0 @@
|
||||
/** Mock 时间辅助:所有演示时间相对当前时刻生成,保证演示时数据“新鲜” */
|
||||
|
||||
export function minutesAgo(n: number): string {
|
||||
return new Date(Date.now() - n * 60000).toISOString()
|
||||
}
|
||||
|
||||
export function hoursAgo(n: number): string {
|
||||
return new Date(Date.now() - n * 3600000).toISOString()
|
||||
}
|
||||
|
||||
export function daysAgo(n: number, hour = 10): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - n)
|
||||
d.setHours(hour, Math.floor(Math.random() * 50) + 5, 0, 0)
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
export function hoursFromNow(n: number): string {
|
||||
return new Date(Date.now() + n * 3600000).toISOString()
|
||||
}
|
||||
|
||||
export function daysFromNow(n: number, hour = 15): string {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() + n)
|
||||
d.setHours(hour, 0, 0, 0)
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
/** 今天某个时间 */
|
||||
export function todayAt(hour: number, minute = 0): string {
|
||||
const d = new Date()
|
||||
d.setHours(hour, minute, 0, 0)
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
/** 近 N 天的日期标签数组(MM-dd) */
|
||||
export function lastNDays(n: number): string[] {
|
||||
const out: string[] = []
|
||||
for (let i = n - 1; i >= 0; i--) {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - i)
|
||||
out.push(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { IndustryPack, KnowledgeItem } from '@/types/knowledge'
|
||||
import { daysAgo, hoursAgo } from './helpers'
|
||||
|
||||
/** 30 条企业知识 */
|
||||
export const knowledgeItems: KnowledgeItem[] = [
|
||||
{ id: 'k-001', title: '双层玻璃杯 350ml 产品规格书', category: 'product', content: '双层高硼硅玻璃杯,容量350ml,耐温-20°C—150°C,可定制1—3色Logo。杯身通透,双层隔热防烫。常规包装:白盒+气泡袋,可选彩盒。', tags: ['玻璃杯', '双层', '定制'], enabled: true, version: 3, updatedAt: daysAgo(10), updatedBy: '李婷', source: 'file', fileName: '双层玻璃杯350ml规格书.pdf', fileType: 'pdf', status: 'ready', citedCount: 128, languages: ['zh', 'en'], quality: 'good' },
|
||||
{ id: 'k-002', title: '高硼硅玻璃杯 500ml 规格与检测', category: 'product', content: '高硼硅3.3玻璃,500ml,耐温差冲击≥150°C(50次循环测试报告编号 TR-BS2024-07)。通过FDA食品级检测。适用酒店、礼品渠道。', tags: ['高硼硅', 'FDA', '酒店'], enabled: true, version: 2, updatedAt: daysAgo(8), updatedBy: '李婷', source: 'file', fileName: '高硼硅500ml检测报告.pdf', fileType: 'pdf', status: 'ready', citedCount: 96, languages: ['zh', 'en', 'ar'], quality: 'good' },
|
||||
{ id: 'k-003', title: '定制Logo玻璃水杯工艺说明', category: 'product', content: '丝印1—3色,支持潘通色号对色;另支持烤花、激光雕刻。打样周期3—5天,打样费$30(下单返还)。定制起订量500个。', tags: ['定制', '丝印', '打样'], enabled: true, version: 4, updatedAt: daysAgo(5), updatedBy: '赵磊', source: 'manual', status: 'ready', citedCount: 210, languages: ['zh', 'en', 'ur', 'ar'], quality: 'good' },
|
||||
{ id: 'k-004', title: '玻璃储物罐套装(竹盖硅胶圈)', category: 'product', content: '三件套/五件套可选,竹木盖+食品级硅胶密封圈,密封防潮。容量200—1500ml。适合家居、餐饮渠道混批。', tags: ['储物罐', '套装'], enabled: true, version: 1, updatedAt: daysAgo(15), updatedBy: '周敏', source: 'file', fileName: '储物罐套装.xlsx', fileType: 'excel', status: 'ready', citedCount: 34, languages: ['zh', 'en', 'es'], quality: 'normal' },
|
||||
{ id: 'k-005', title: '男士纯棉T恤(180gsm)产品资料', category: 'product', content: '180克精梳棉,预缩工艺缩水率≤3%,24色可选,S—XXL。支持定制领标、印花、刺绣。SGS检测报告齐全。', tags: ['T恤', '纯棉', '服装'], enabled: true, version: 2, updatedAt: daysAgo(6), updatedBy: '赵磊', source: 'file', fileName: 'T恤产品册.pdf', fileType: 'pdf', status: 'ready', citedCount: 88, languages: ['zh', 'en', 'bn'], quality: 'good' },
|
||||
{ id: 'k-006', title: '女士针织开衫秋冬款资料', category: 'product', content: '12针针织,70%粘胶30%涤纶,手感柔软不易起球。24色,支持定制唛头。秋冬新色卡18色。', tags: ['开衫', '针织', '秋冬'], enabled: true, version: 1, updatedAt: daysAgo(4), updatedBy: '李婷', source: 'manual', status: 'ready', citedCount: 45, languages: ['zh', 'en', 'bn'], quality: 'normal' },
|
||||
{ id: 'k-007', title: 'TWS蓝牙耳机规格(BT5.4)', category: 'product', content: '蓝牙5.4,游戏模式延迟45ms,ENC通话降噪,续航6+24小时。支持丝印Logo与定制包装。12个月免费换新。', tags: ['耳机', 'TWS', '3C'], enabled: true, version: 5, updatedAt: daysAgo(3), updatedBy: '赵磊', source: 'file', fileName: 'TWS耳机规格书.pdf', fileType: 'pdf', status: 'ready', citedCount: 156, languages: ['zh', 'en'], quality: 'good' },
|
||||
{ id: 'k-008', title: '65W氮化镓充电器规格与认证', category: 'product', content: 'GaN 65W,双C口+单A口,满载外壳温升≤42°C。CE/RoHS/REACH齐全,TÜV检测,18个月质保。欧规/美规/英规插脚可选。', tags: ['充电器', 'GaN', '认证'], enabled: true, version: 3, updatedAt: daysAgo(7), updatedBy: '赵磊', source: 'file', fileName: 'GaN充电器认证包.zip', fileType: 'pdf', status: 'ready', citedCount: 112, languages: ['zh', 'en', 'de'], quality: 'good' },
|
||||
{ id: 'k-009', title: '智能手表(阿语版)功能说明', category: 'product', content: '1.85寸屏,菜单与通知完整支持阿拉伯语,开机画面可定制Logo。心率/血氧/睡眠监测。GDPR数据合规声明可提供。', tags: ['智能手表', '阿语', '中东'], enabled: true, version: 2, updatedAt: daysAgo(9), updatedBy: '郑楠', source: 'manual', status: 'ready', citedCount: 67, languages: ['zh', 'en', 'ar'], quality: 'good' },
|
||||
{ id: 'k-010', title: '玻璃杯阶梯报价规则(2026 Q3)', category: 'pricing', content: '双层350ml:500个$1.85|1000个$1.68|2000个$1.52(含单色Logo,双色+$0.08/色)。高硼硅500ml:1000个$2.10|2000个$1.95|5000个$1.78。加急费$80(10天)。', tags: ['报价', '阶梯价', '玻璃杯'], enabled: true, version: 6, updatedAt: daysAgo(2), updatedBy: '陈昊', source: 'manual', status: 'ready', citedCount: 320, languages: ['zh', 'en'], quality: 'good' },
|
||||
{ id: 'k-011', title: '服装类报价规则', category: 'pricing', content: 'T恤180gsm:1000件$2.55|3000件$2.35|5000件$2.20。开衫:800件£4.20|1500件£4.05。定制领标500件起免费。', tags: ['报价', '服装'], enabled: true, version: 4, updatedAt: daysAgo(3), updatedBy: '陈昊', source: 'manual', status: 'ready', citedCount: 186, languages: ['zh', 'en'], quality: 'good' },
|
||||
{ id: 'k-012', title: '3C产品报价规则', category: 'pricing', content: 'TWS耳机:2000台$8.90|季度框架8000台$8.30。GaN充电器:3000个$6.50|5000个$6.10。智能手表:1000只$11.8|1500只$11.2。', tags: ['报价', '3C'], enabled: true, version: 3, updatedAt: daysAgo(4), updatedBy: '陈昊', source: 'manual', status: 'ready', citedCount: 145, languages: ['zh', 'en'], quality: 'good' },
|
||||
{ id: 'k-013', title: 'MOQ 起订量规则总表', category: 'moq', content: '玻璃杯定制500起、光杯100起;T恤500件起、开衫500件起;3C类1000台起;家居餐具100套起(首单试单可50套,价格上浮5%)。', tags: ['MOQ', '起订量'], enabled: true, version: 2, updatedAt: daysAgo(12), updatedBy: '陈昊', source: 'manual', status: 'ready', citedCount: 98, languages: ['zh', 'en'], quality: 'good' },
|
||||
{ id: 'k-014', title: '定制流程与打样政策', category: 'customization', content: '流程:需求确认→设计稿→客户确认→打样(3—5天)→确认→大货。打样费$30—50下单返还。阿文等右向左文字需专业排版校对。', tags: ['定制', '打样', '流程'], enabled: true, version: 2, updatedAt: daysAgo(11), updatedBy: '李婷', source: 'manual', status: 'ready', citedCount: 76, languages: ['zh', 'en', 'ar'], quality: 'good' },
|
||||
{ id: 'k-015', title: '物流政策:中东/南亚线路', category: 'logistics', content: '阿联酋/沙特:空运5—7天,海运18—25天(杰贝阿里/达曼港)。巴基斯坦:海运卡拉奇港22—28天,支持DDP拉合尔。孟加拉:海运吉大港18—22天。', tags: ['物流', '中东', '南亚'], enabled: true, version: 5, updatedAt: daysAgo(4), updatedBy: '周敏', source: 'manual', status: 'ready', citedCount: 203, languages: ['zh', 'en'], quality: 'good' },
|
||||
{ id: 'k-016', title: '物流政策:欧美线路', category: 'logistics', content: '美国:海运美西18—22天,空运5—8天,DDP可选。欧洲:海运28—35天,铁路18—22天,DDP慕尼黑/瓦伦西亚可选。样品默认DHL/FedEx。', tags: ['物流', '欧美'], enabled: true, version: 4, updatedAt: daysAgo(6), updatedBy: '周敏', source: 'manual', status: 'ready', citedCount: 167, languages: ['zh', 'en'], quality: 'good' },
|
||||
{ id: 'k-017', title: '易碎品包装与破损赔付政策', category: 'logistics', content: '玻璃/陶瓷类:气泡膜+珍珠棉独立包装+五层瓦楞外箱,破损率承诺<0.5%。破损免费补发或按货值120%赔付,需提供开箱照片。', tags: ['包装', '破损', '赔付'], enabled: true, version: 3, updatedAt: daysAgo(5), updatedBy: '周敏', source: 'manual', status: 'ready', citedCount: 142, languages: ['zh', 'en', 'es', 'ar'], quality: 'good' },
|
||||
{ id: 'k-018', title: '付款方式与条款', category: 'payment', content: '常规:T/T 30%定金+70%见提单副本。支持:信用证L/C(金额>$10,000)、阿里信保、PayPal(小额样品单)。老客户可申请月结。', tags: ['付款', 'T/T', '信用证'], enabled: true, version: 2, updatedAt: daysAgo(20), updatedBy: '王琳', source: 'manual', status: 'ready', citedCount: 88, languages: ['zh', 'en'], quality: 'good' },
|
||||
{ id: 'k-019', title: '售后政策总纲', category: 'aftersales', content: '质量问题:7天内免费退换;补发默认海运,可加急空运(责任在我方时免费)。3C产品:12—18个月质保,DOA 7天内换新。投诉24小时内响应。', tags: ['售后', '质保', '退换'], enabled: true, version: 3, updatedAt: daysAgo(14), updatedBy: '王琳', source: 'manual', status: 'ready', citedCount: 119, languages: ['zh', 'en'], quality: 'good' },
|
||||
{ id: 'k-020', title: 'FAQ:玻璃杯定制常见问题', category: 'faq', content: 'Q: Logo会掉色吗?A: 高温丝印,洗碗机200次测试不掉色。Q: 能做双色吗?A: 支持1—3色,每加一色+$0.08。Q: 最小定制量?A: 500个。', tags: ['FAQ', '玻璃杯'], enabled: true, version: 2, updatedAt: daysAgo(9), updatedBy: '周敏', source: 'manual', status: 'ready', citedCount: 154, languages: ['zh', 'en', 'ur'], quality: 'good' },
|
||||
{ id: 'k-021', title: 'FAQ:服装面料与尺码', category: 'faq', content: 'Q: 180gsm是什么概念?A: 中厚款,四季可穿不透。Q: 缩水率?A: 预缩工艺≤3%。Q: 欧码如何对应?A: 提供尺码对照表,支持定制。', tags: ['FAQ', '服装'], enabled: true, version: 1, updatedAt: daysAgo(16), updatedBy: '周敏', source: 'manual', status: 'ready', citedCount: 52, languages: ['zh', 'en', 'bn'], quality: 'normal' },
|
||||
{ id: 'k-022', title: '企业介绍(标准版)', category: 'company', content: '徐州欧汇科技有限公司,专注玻璃制品、服装纺织与3C配件出口12年,服务全球2000+客户。自有质检团队,支持OEM/ODM,年出口额$8M。', tags: ['企业介绍'], enabled: true, version: 4, updatedAt: daysAgo(30), updatedBy: '王琳', source: 'manual', status: 'ready', citedCount: 74, languages: ['zh', 'en', 'ar', 'es'], quality: 'good' },
|
||||
{ id: 'k-023', title: '产品认证清单与证书编号', category: 'certification', content: '玻璃杯:FDA(编号FDA-2024-8842)、LFGB。3C:CE/RoHS/REACH/TÜV。服装:OEKO-TEX Standard 100、SGS检测。证书原件扫描件可发客户。', tags: ['认证', 'FDA', 'CE'], enabled: true, version: 2, updatedAt: daysAgo(18), updatedBy: '赵磊', source: 'file', fileName: '认证清单.pdf', fileType: 'pdf', status: 'ready', citedCount: 93, languages: ['zh', 'en', 'de'], quality: 'good' },
|
||||
{ id: 'k-024', title: '成功回复案例:交期敏感客户促单', category: 'success-reply', content: '场景:客户因开业节点要求10天交付。成功话术:承认节点重要性→给出加急方案(10天+$80)→附同类开业客户案例→限定优惠时限。转化率:该场景下68%。', tags: ['成功案例', '促单', '交期'], enabled: true, version: 1, updatedAt: daysAgo(13), updatedBy: '李婷', source: 'reply', status: 'ready', citedCount: 87, languages: ['zh', 'en'], quality: 'good' },
|
||||
{ id: 'k-025', title: '成功回复案例:比价客户差异化', category: 'success-reply', content: '场景:客户持竞品低价报价。成功话术:不跟价→拆解成本差异(面料/工艺/质检)→提供增值选项→给客户保留面子的台阶。转化率54%。', tags: ['成功案例', '比价'], enabled: true, version: 1, updatedAt: daysAgo(15), updatedBy: '赵磊', source: 'reply', status: 'ready', citedCount: 79, languages: ['zh', 'en'], quality: 'good' },
|
||||
{ id: 'k-026', title: '外贸术语库(中英对照)', category: 'terminology', content: 'MOQ=最低起订量;FOB=离岸价;CIF=到岸价;DDP=完税后交货;OEM=代工;ODM=原创设计制造;Lead Time=交期;PI=形式发票;B/L=提单;GSM=克重。', tags: ['术语', '中英'], enabled: true, version: 3, updatedAt: daysAgo(25), updatedBy: '周敏', source: 'manual', status: 'ready', citedCount: 234, languages: ['zh', 'en'], quality: 'good' },
|
||||
{ id: 'k-027', title: '玻璃行业术语(多语种)', category: 'terminology', content: 'Borosilicate glass=高硼硅玻璃;Double-wall=双层;Thermal shock resistance=耐温差冲击;Silk screen printing=丝印;Tempered=钢化。含乌尔都语/阿拉伯语对应表达。', tags: ['术语', '玻璃'], enabled: true, version: 2, updatedAt: daysAgo(21), updatedBy: '李婷', source: 'manual', status: 'ready', citedCount: 61, languages: ['zh', 'en', 'ur', 'ar'], quality: 'normal' },
|
||||
{ id: 'k-028', title: '中东客户文化注意事项', category: 'culture', content: '斋月期间白天避免提及饮食;祝福用语谨慎引用宗教经文;商务问候宜正式(如 السلام عليكم);周五为主麻日避免催单;避免酒类、猪肉相关元素;右手递物。', tags: ['文化', '中东', '斋月'], enabled: true, version: 2, updatedAt: daysAgo(28), updatedBy: '王琳', source: 'manual', status: 'ready', citedCount: 108, languages: ['zh', 'en', 'ar'], quality: 'good' },
|
||||
{ id: 'k-029', title: '南亚客户沟通注意事项', category: 'culture', content: '巴基斯坦/孟加拉客户重视关系建立,开场寒暄很重要;议价是常态,预留谈判空间;乌尔都语/孟加拉语问候能显著提升好感;注意节假日(开斋节、宰牲节)。', tags: ['文化', '南亚'], enabled: true, version: 1, updatedAt: daysAgo(22), updatedBy: '李婷', source: 'manual', status: 'ready', citedCount: 66, languages: ['zh', 'en', 'ur', 'bn'], quality: 'normal' },
|
||||
{ id: 'k-030', title: '欧美客户沟通注意事项', category: 'culture', content: '欧美客户偏好直接、数据化的沟通;德国客户重视认证与技术细节;美国客户决策快但重视履约记录;西班牙客户午后回复延迟属正常(午休文化)。', tags: ['文化', '欧美'], enabled: true, version: 1, updatedAt: hoursAgo(36), updatedBy: '陈昊', source: 'manual', status: 'parsing', citedCount: 12, languages: ['zh', 'en', 'de', 'es'], quality: 'low' },
|
||||
]
|
||||
|
||||
/** 行业知识包 */
|
||||
export const industryPacks: IndustryPack[] = [
|
||||
{ key: 'glass', name: '玻璃杯行业包', description: '玻璃材质、工艺、检测、定制与包装知识 86 条', itemCount: 86, installed: true },
|
||||
{ key: 'apparel', name: '服装行业包', description: '面料、克重、工艺、尺码与验货知识 124 条', itemCount: 124, installed: true },
|
||||
{ key: '3c', name: '3C电子行业包', description: '参数、认证、质保与合规知识 98 条', itemCount: 98, installed: false },
|
||||
{ key: 'home', name: '家居用品行业包', description: '材质、包装、物流与平台话术知识 72 条', itemCount: 72, installed: false },
|
||||
{ key: 'general', name: '通用外贸知识包', description: '贸易术语、付款、物流、谈判通用知识 156 条', itemCount: 156, installed: true },
|
||||
]
|
||||
@@ -1,176 +0,0 @@
|
||||
import type { Message, MessageSender } from '@/types/messaging'
|
||||
import { minutesAgo, hoursAgo, daysAgo } from './helpers'
|
||||
|
||||
let seq = 0
|
||||
function m(
|
||||
conversationId: string,
|
||||
sender: MessageSender,
|
||||
originalText: string,
|
||||
translatedText: string | undefined,
|
||||
originalLang: string,
|
||||
createdAt: string,
|
||||
extra?: Partial<Message>,
|
||||
): Message {
|
||||
seq += 1
|
||||
return {
|
||||
id: `msg-${String(seq).padStart(4, '0')}`,
|
||||
conversationId,
|
||||
sender,
|
||||
type: 'text',
|
||||
originalText,
|
||||
translatedText,
|
||||
originalLang,
|
||||
status: sender === 'agent' ? 'read' : 'delivered',
|
||||
createdAt,
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
/** 105 条多语种演示消息(乌尔都语/阿拉伯语/孟加拉语/西班牙语/英语) */
|
||||
export const messages: Message[] = [
|
||||
// ===== cv-001 巴基斯坦 Ahmed Raza:500个定制玻璃杯(核心演示场景) =====
|
||||
m('cv-001', 'customer', 'السلام علیکم! مجھے اپنی دکان کے لیے 500 کسٹم گلاس کپ چاہئیں۔ کیا آپ لوگو پرنٹ کر سکتے ہیں؟', '你好!我需要为我的商店订购500个定制玻璃杯。你们可以印Logo吗?', 'ur', hoursAgo(4)),
|
||||
m('cv-001', 'agent', 'Wa alaikum assalam! Yes, we support custom logo printing in 1-3 colors. Could you share your logo file and the cup size you prefer?', '你好!可以的,我们支持1—3色Logo定制印刷。方便发一下Logo文件和您想要的杯型尺寸吗?', 'en', hoursAgo(3.8)),
|
||||
m('cv-001', 'customer', 'ہمارا لوگو دو رنگوں میں ہے۔ 350ml ڈبل وال کپ چاہیے۔ قیمت کیا ہوگی؟', '我们的Logo是双色的,需要350ml双层杯。价格是多少?', 'ur', hoursAgo(3.5)),
|
||||
m('cv-001', 'agent', 'For 500pcs double-wall 350ml with 2-color logo: $1.85/pc including logo setup. Lead time 12-15 days. Shall I send the formal quotation?', '500个双层350ml杯、双色Logo:含制版费单价$1.85,交期12—15天。我给您发正式报价单吗?', 'en', hoursAgo(3.2)),
|
||||
m('cv-001', 'customer', 'قیمت ٹھیک ہے، لیکن ڈیلیوری 10 دن میں ممکن ہے؟ ہماری دکان کی افتتاحی تقریب ہے۔', '价格可以,但10天内能交货吗?我们店要举办开业活动。', 'ur', hoursAgo(2.8), { isImportant: true }),
|
||||
m('cv-001', 'agent', 'We can arrange expedited production for your opening event: 10 days with a $80 rush fee. I will also prepare a tiered quotation (500/1000/2000pcs) with past customization cases.', '我们可以为您的开业活动安排加急生产:10天交付,加急费$80。我同时准备阶梯报价(500/1000/2000个)和过往定制案例。', 'en', hoursAgo(2.5)),
|
||||
m('cv-001', 'customer', 'Very good. Please also send some photos of previous logo work.', '很好。也请发一些之前的Logo定制案例照片。', 'en', hoursAgo(2.2)),
|
||||
m('cv-001', 'agent', '[图片] 双色Logo定制案例-01.jpg', undefined, 'zh', hoursAgo(2), { type: 'image', attachmentName: '双色Logo定制案例-01.jpg' }),
|
||||
m('cv-001', 'agent', '[图片] 双色Logo定制案例-02.jpg', undefined, 'zh', hoursAgo(2), { type: 'image', attachmentName: '双色Logo定制案例-02.jpg' }),
|
||||
m('cv-001', 'customer', 'بہت خوب! میں مالک سے بات کر کے کل تک کنفرم کرتا ہوں۔', '很好!我跟老板商量后明天确认。', 'ur', minutesAgo(42)),
|
||||
m('cv-001', 'system', 'AI 分析完成:意向评分 86(高价值)|阶段:价格比较|关注点:价格 / Logo定制 / 交付时间|建议:发送阶梯报价和定制案例', undefined, 'zh', minutesAgo(40)),
|
||||
|
||||
// ===== cv-002 阿联酋 Khalid:酒店集团采购 =====
|
||||
m('cv-002', 'customer', 'السلام عليكم ورحمة الله. نحن مجموعة فنادق في دبي ونبحث عن مورد أكواب زجاجية بوروسيليكات 500 مل بكمية 2000 قطعة.', '愿平安归于你。我们是迪拜的酒店集团,寻找500ml高硼硅玻璃杯供应商,数量2000个。', 'ar', daysAgo(2, 11)),
|
||||
m('cv-002', 'agent', 'Wa alaikum assalam. Thank you for your inquiry. Our borosilicate cups withstand -20°C to 150°C. I will send full specs and quotation today.', '您好,感谢您的询盘。我们的高硼硅杯可承受-20°C至150°C温差。今天给您发完整规格和报价。', 'en', daysAgo(2, 14)),
|
||||
m('cv-002', 'customer', 'ممتاز. هل يمكن إرسال عينة إلى دبي؟ نحتاج تقييم الجودة أولاً.', '很好。能寄样品到迪拜吗?我们需要先评估质量。', 'ar', daysAgo(2, 16)),
|
||||
m('cv-002', 'agent', 'Samples sent via DHL, tracking AE8842116. Usually 3-4 days to Dubai.', '样品已通过DHL寄出,单号AE8842116,一般3—4天到迪拜。', 'en', daysAgo(1, 10)),
|
||||
m('cv-002', 'customer', 'تم استلام العينة. الجودة جيدة بشكل عام، لكن نريد اختبار تحمل الحرارة.', '样品已收到,整体质量不错,但我们想测试耐温性能。', 'ar', daysAgo(1, 15)),
|
||||
m('cv-002', 'agent', 'I will share the thermal shock test report (-20°C to 150°C, 50 cycles) and can arrange a live video test if needed.', '我给您发耐温差冲击测试报告(-20°C至150°C,50次循环),需要的话也可以安排视频实测。', 'en', hoursAgo(4)),
|
||||
m('cv-002', 'customer', 'جيد، في انتظار التقرير.', '好,等你的报告。', 'ar', hoursAgo(3)),
|
||||
|
||||
// ===== cv-003 孟加拉 Rahim:5000件T恤 =====
|
||||
m('cv-003', 'customer', 'আসসালামু আলাইকুম। ৫০০০ পিস কটন টি-শার্টের দাম জানতে চাই। 180 GSM।', '你好,想咨询5000件纯棉T恤价格,180克重。', 'bn', hoursAgo(6)),
|
||||
m('cv-003', 'agent', 'Wa alaikum assalam! $2.35/pc for 5000pcs, 180gsm combed cotton, S-XXL mixed sizes.', '你好!5000件、180克精梳棉、S—XXL混码,单价$2.35。', 'en', hoursAgo(5.6)),
|
||||
m('cv-003', 'customer', 'দাম একটু বেশি। আমাদের টার্গেট $2.10।', '价格有点高,我们的目标是$2.10。', 'bn', hoursAgo(5)),
|
||||
m('cv-003', 'agent', 'For $2.10 we can offer 170gsm, or keep 180gsm at $2.20 with free custom neck labels. Which works better for you?', '$2.10可以做170克;或者保持180克、$2.20并免费做定制领标。您看哪种更合适?', 'en', hoursAgo(4.5)),
|
||||
m('cv-003', 'customer', '১৮০ GSM রাখুন। শ্রিংকেজ রেট কত?', '保持180克。缩水率是多少?', 'bn', hoursAgo(3.8)),
|
||||
m('cv-003', 'agent', 'Pre-shrunk fabric, shrinkage ≤3%. SGS test report available.', '预缩工艺,缩水率≤3%,可提供SGS检测报告。', 'en', hoursAgo(2)),
|
||||
m('cv-003', 'customer', 'ঠিক আছে, $2.20 মেনে নিচ্ছি। পেমেন্ট টার্মস পাঠান।', '好的,接受$2.20。请发付款条款。', 'bn', hoursAgo(1), { isImportant: true }),
|
||||
|
||||
// ===== cv-004 美国 Mike:样品延误投诉(风险演示) =====
|
||||
m('cv-004', 'customer', 'Hi, following up on the TWS earbuds sample order #S-2205. It was supposed to arrive last Friday.', '你好,跟进一下TWS耳机样品订单#S-2205,原定上周五到货。', 'en', daysAgo(1, 9)),
|
||||
m('cv-004', 'agent', 'Hi Mike, DHL shows a customs delay at LAX. Expected delivery Tuesday. Sorry for the wait.', 'Mike你好,DHL显示在洛杉矶清关延误,预计周二送达,非常抱歉。', 'en', daysAgo(1, 10)),
|
||||
m('cv-004', 'customer', 'This is the second delay. We have a product launch review on Wednesday. This is unacceptable.', '这已经是第二次延误了。我们周三有新品发布评审,这无法接受。', 'en', daysAgo(1, 14), { isImportant: true }),
|
||||
m('cv-004', 'agent', 'Totally understand. I have escalated with DHL and prepared a backup unit via FedEx Priority (arrives Tuesday morning) at our cost.', '完全理解。我已向DHL升级催办,同时自费用FedEx优先件补寄一台备用机(周二上午到)。', 'en', daysAgo(1, 15)),
|
||||
m('cv-004', 'customer', 'Fine. But if it misses Wednesday, we will look at other suppliers.', '好吧。但如果赶不上周三,我们会考虑其他供应商。', 'en', hoursAgo(6), { isImportant: true }),
|
||||
m('cv-004', 'system', 'AI 风险预警:客户情绪转为消极,提及“更换供应商”,流失风险高,建议2小时内升级处理。', undefined, 'zh', hoursAgo(5.9)),
|
||||
m('cv-004', 'agent', 'Understood. You will have the FedEx tracking within 2 hours, and I will personally confirm the delivery on Tuesday.', '明白。2小时内给您FedEx单号,周二我会亲自确认签收。', 'en', hoursAgo(5)),
|
||||
|
||||
// ===== cv-005 西班牙 Carmen:独立站表单首询 =====
|
||||
m('cv-005', 'customer', 'Hola, me interesan los juegos de cama de lino y la vajilla de cerámica. ¿Pueden enviar catálogo y precios?', '你好,我对棉麻床品套装和陶瓷餐具感兴趣,能发一下产品目录和价格吗?', 'es', hoursAgo(9)),
|
||||
m('cv-005', 'agent', '¡Hola Carmen! Claro, adjunto el catálogo y la lista de precios mayoristas. MOQ 100 juegos, se pueden mezclar modelos.', 'Carmen你好!当然,附上产品目录和批发价目表。起订量100套,可混款。', 'es', hoursAgo(8.5)),
|
||||
m('cv-005', 'customer', '¿El MOQ es negociable? Somos una tienda pequeña y queremos probar primero.', '起订量可以商量吗?我们是小店,想先试单。', 'es', hoursAgo(8)),
|
||||
m('cv-005', 'agent', 'Para el primer pedido de prueba aceptamos 50 juegos con un ajuste de precio del 5%.', '首单试单我们接受50套,价格上浮5%。', 'es', hoursAgo(7.6)),
|
||||
m('cv-005', 'customer', 'Perfecto. ¿Los colores resisten bien el lavado?', '完美。颜色耐洗吗?', 'es', hoursAgo(7.2)),
|
||||
m('cv-005', 'agent', 'Sí, solidez del color grado 4-5. Te envío el informe de prueba junto con el catálogo.', '是的,色牢度4—5级。检测报告随目录一起发您。', 'es', hoursAgo(7)),
|
||||
|
||||
// ===== cv-006 沙特 Fahad:阿文书法礼品杯 =====
|
||||
m('cv-006', 'customer', 'السلام عليكم، أريد 1000 كوب زجاجي كهدايا شركات، مع شعار بالخط العربي وعلبة هدايا.', '你好,我需要1000个玻璃杯作为企业礼品,配阿拉伯文书法Logo和礼盒。', 'ar', hoursAgo(14)),
|
||||
m('cv-006', 'agent', 'وعليكم السلام. ندعم الطباعة بالخط العربي (نسخ/رقعة) وعلب الهدايا. سأرسل نماذج سابقة.', '您好。我们支持阿拉伯文书法印刷(誊抄体/波斯体)和礼盒包装,我发一些过往案例。', 'ar', hoursAgo(13)),
|
||||
m('cv-006', 'customer', 'ممتاز. هل التسليم قبل رمضان ممكن؟', '很好。斋月前能交付吗?', 'ar', hoursAgo(12)),
|
||||
m('cv-006', 'agent', 'نعم، الإنتاج 18 يوم + الشحن الجوي 5 أيام. ننصح بتأكيد التصميم هذا الأسبوع.', '可以,生产18天+空运5天。建议本周内确认设计稿。', 'ar', hoursAgo(11)),
|
||||
m('cv-006', 'customer', 'سأرسل ملف الشعار اليوم. أرجو مراعاة الدقة في الخط العربي.', '我今天发Logo文件,请务必注意阿拉伯书法的准确性。', 'ar', hoursAgo(10), { isImportant: true }),
|
||||
m('cv-006', 'agent', 'حتماً. لدينا مصمم عربي يراجع التصميم قبل الطباعة، وسنرسل لك المعاينة للاعتماد.', '一定。我们有阿拉伯设计师在印刷前校对设计稿,并发送预览给您确认。', 'ar', hoursAgo(9)),
|
||||
|
||||
// ===== cv-007 英国 Emma:开衫比价 =====
|
||||
m('cv-007', 'customer', "Hi! Do you do women's knit cardigans? Looking for around 800 pcs.", '你好!你们做女士针织开衫吗?大约要800件。', 'en', hoursAgo(15)),
|
||||
m('cv-007', 'agent', 'Hi Emma! Yes - 12gg knit, 70% viscose 30% polyester, 24 colors available. MOQ 500. Want the color card?', 'Emma你好!做的——12针针织,70%粘胶30%涤纶,24色可选,起订量500件。需要色卡吗?', 'en', hoursAgo(14)),
|
||||
m('cv-007', 'customer', "Yes please. Also your best price for 800 - I'm comparing a few suppliers.", '需要。另外800件的最优价是多少——我在比较几家供应商。', 'en', hoursAgo(13.5)),
|
||||
m('cv-007', 'agent', '£4.20/pc at 800pcs including custom labels, with a free pre-production sample.', '800件单价£4.20,含定制唛头,并提供免费产前样。', 'en', hoursAgo(13)),
|
||||
m('cv-007', 'customer', 'Hmm, another factory quoted £3.95. Can you match?', '嗯,另一家工厂报£3.95,你们能匹配吗?', 'en', hoursAgo(12.5), { isImportant: true }),
|
||||
m('cv-007', 'agent', '£3.95 is below our yarn cost. I can do £4.05 with upgraded shell buttons, or keep £4.20 with free hang tags and polybags.', '£3.95已低于我们的纱线成本。我可以做到£4.05并升级贝壳扣,或者保持£4.20免费送吊牌和包装袋。', 'en', hoursAgo(12)),
|
||||
|
||||
// ===== cv-008 德国 Jonas:GaN充电器认证 =====
|
||||
m('cv-008', 'customer', 'Guten Tag. We need 3000 pcs 65W GaN chargers, EU plug. Please confirm CE and RoHS certificates.', '您好。我们需要3000个65W氮化镓充电器(欧规插脚),请确认CE和RoHS认证。', 'en', daysAgo(2, 9)),
|
||||
m('cv-008', 'agent', 'Hello Mr. Weber. CE, RoHS and REACH certificates are all available, TÜV tested. Sending certificates and datasheet now.', 'Weber先生您好。CE、RoHS、REACH证书齐全,TÜV检测。现在给您发证书和规格书。', 'en', daysAgo(2, 10)),
|
||||
m('cv-008', 'customer', 'Danke. What is the temperature rise at full load?', '谢谢。满载温升是多少?', 'en', daysAgo(1, 9)),
|
||||
m('cv-008', 'agent', 'Max 42°C case temperature at 65W full load (25°C ambient). Test report attached.', '65W满载时外壳最高42°C(环境25°C),测试报告见附件。', 'en', daysAgo(1, 10)),
|
||||
m('cv-008', 'customer', 'OK. Please send 2 samples to Düsseldorf for our lab test.', '好的,请寄2个样品到杜塞尔多夫给我们实验室测试。', 'en', daysAgo(1, 14)),
|
||||
m('cv-008', 'agent', 'Samples shipped today, UPS tracking 1Z8842EU. Lab test video is also available.', '样品今天已寄出,UPS单号1Z8842EU,也可提供实验室测试视频。', 'en', hoursAgo(26)),
|
||||
|
||||
// ===== cv-009 巴基斯坦 Fatima:面料咨询 =====
|
||||
m('cv-009', 'customer', 'السلام علیکم، مجھے گارمنٹس کا کاروبار ہے۔ 1000 ٹی شرٹس کی قیمت بتائیں۔', '你好,我做服装生意,请报1000件T恤的价格。', 'ur', daysAgo(1, 11)),
|
||||
m('cv-009', 'agent', 'Wa alaikum assalam! For 1000pcs the price is $2.55/pc, 180gsm combed cotton.', '你好!1000件单价$2.55,180克精梳棉。', 'en', daysAgo(1, 12)),
|
||||
m('cv-009', 'customer', 'کپڑا کون سا GSM ہے اور رنگ کتنے ہیں؟', '面料多少克重,有多少种颜色?', 'ur', daysAgo(1, 13)),
|
||||
m('cv-009', 'agent', '180gsm combed cotton, 24 colors. Sending you the color card now.', '180克精梳棉,24种颜色。现在发色卡给您。', 'en', daysAgo(1, 13)),
|
||||
m('cv-009', 'customer', 'شکریہ، میں دیکھ کر بتاؤں گی۔', '谢谢,我看完之后回复你。', 'ur', hoursAgo(30)),
|
||||
|
||||
// ===== cv-010 阿联酋 Layla:餐具防碎 =====
|
||||
m('cv-010', 'customer', 'مرحبا! شفت طقم الأواني عندكم بالانستقرام. كم سعر 300 طقم؟', '你好!在Instagram上看到你们的餐具套装,300套多少钱?', 'ar', hoursAgo(11)),
|
||||
m('cv-010', 'agent', 'أهلاً ليلى! 300 طقم بسعر 7.8$ للطقم، شامل تغليف الهدايا.', 'Layla你好!300套,每套$7.8,含礼品包装。', 'ar', hoursAgo(10)),
|
||||
m('cv-010', 'customer', 'التغليف كويس؟ أبيع أونلاين ولازم يوصل بدون كسر.', '包装好吗?我做电商销售,必须保证到货不碎。', 'ar', hoursAgo(9.5), { isImportant: true }),
|
||||
m('cv-010', 'agent', 'كل قطعة مغلفة بفقاعات وفوم، نسبة الكسر أقل من 0.5%، وأي كسر نعوضه مجاناً.', '每件都有气泡膜+珍珠棉独立包装,破损率低于0.5%,破损免费补发。', 'ar', hoursAgo(9)),
|
||||
m('cv-010', 'customer', 'ممتاز! أرسلي صور التغليف لو سمحتي.', '太好了!请发一下包装照片。', 'ar', hoursAgo(8)),
|
||||
|
||||
// ===== cv-011 西班牙 Carlos =====
|
||||
m('cv-011', 'customer', 'Buenas, ¿precio para 150 juegos de tarros de vidrio con tapa?', '你好,150套带盖玻璃储物罐什么价格?', 'es', daysAgo(2, 10)),
|
||||
m('cv-011', 'agent', 'Hola Carlos, 150 juegos a €3.9/juego, tapa de bambú con junta de silicona.', 'Carlos你好,150套单价€3.9,竹盖配硅胶密封圈。', 'es', daysAgo(2, 11)),
|
||||
m('cv-011', 'customer', '¿Hacen envíos a Barcelona? ¿Qué plazo?', '你们发到巴塞罗那吗?多久?', 'es', daysAgo(2, 15)),
|
||||
m('cv-011', 'agent', 'Sí, DDP Barcelona en 18-22 días. También tenemos servicio exprés de 10 días.', '可以,DDP巴塞罗那18—22天,也有10天加急服务。', 'es', daysAgo(1, 16)),
|
||||
|
||||
// ===== cv-012 美国 Sarah:TWS耳机框架单 =====
|
||||
m('cv-012', 'customer', 'Saw your Google ad. Need 2000 TWS earbuds with our logo, quarterly commitment 8000. Best price?', '看到你们的Google广告。需要2000台印Logo的TWS耳机,季度框架8000台。最优价?', 'en', hoursAgo(3)),
|
||||
m('cv-012', 'agent', '$8.90/unit at 2000, or $8.30 with the 8000 quarterly commitment. Custom logo and packaging included.', '2000台单价$8.90;承诺季度8000台则$8.30。含Logo定制与包装。', 'en', hoursAgo(2.6)),
|
||||
m('cv-012', 'customer', 'Bluetooth 5.4? What is the latency for gaming?', '是蓝牙5.4吗?游戏延迟多少?', 'en', hoursAgo(2)),
|
||||
m('cv-012', 'agent', 'BT 5.4 with 45ms low-latency game mode. Sending the spec sheet and warranty policy (12 months, free replacement).', '蓝牙5.4,游戏模式延迟45ms。给您发规格书和保修政策(12个月免费换新)。', 'en', minutesAgo(95)),
|
||||
|
||||
// ===== cv-013 孟加拉 Tanvir:POLO样品反馈 =====
|
||||
m('cv-013', 'customer', 'পোলো শার্টের স্যাম্পল পেয়েছি, কাপড় ভালো লেগেছে।', 'POLO衫样品收到了,面料感觉不错。', 'bn', daysAgo(2, 9)),
|
||||
m('cv-013', 'agent', 'Thank you! I have sent the dry-fit test report, please check.', '谢谢!速干检测报告已发您,请查收。', 'en', daysAgo(2, 10)),
|
||||
m('cv-013', 'customer', 'রিপোর্ট ঠিক আছে। ২০০০ পিসের ফাইনাল প্রাইস দিন।', '报告没问题。给2000件的最终价格。', 'bn', daysAgo(1, 10)),
|
||||
m('cv-013', 'agent', '$3.10/pc for 2000pcs, with free hang tags and polybags.', '2000件单价$3.10,免费送吊牌和包装袋。', 'en', daysAgo(1, 11)),
|
||||
|
||||
// ===== cv-014 巴基斯坦 Omar(未分配) =====
|
||||
m('cv-014', 'customer', 'Please quote glass storage jar sets for wholesale. We supply shops across Punjab.', '请报玻璃储物罐套装的批发价,我们给旁遮普省的商店供货。', 'en', daysAgo(3, 10)),
|
||||
m('cv-014', 'customer', 'کیا آپ پاکستان بھیجتے ہیں؟', '你们发到巴基斯坦吗?', 'ur', daysAgo(3, 10)),
|
||||
m('cv-014', 'system', 'AI 提示:该会话尚未分配负责人,已超过 24 小时未响应,建议立即分配。', undefined, 'zh', daysAgo(2, 14)),
|
||||
|
||||
// ===== cv-015 英国 James =====
|
||||
m('cv-015', 'customer', 'Following up on the bedding sets. £12/set is our ceiling for 600 sets.', '跟进一下床品套装,600套我们的上限是£12/套。', 'en', daysAgo(2, 8)),
|
||||
m('cv-015', 'agent', 'We can meet £12 for the 4-piece set in 200TC cotton-linen blend. PI is ready to send.', '200TC棉麻混纺四件套可以做到£12,形式发票已备好。', 'en', daysAgo(2, 9)),
|
||||
m('cv-015', 'customer', 'What is the shrinkage after washing?', '水洗后的缩水率是多少?', 'en', daysAgo(1, 8)),
|
||||
m('cv-015', 'agent', '≤3% with pre-shrunk, OEKO-TEX certified fabric. I will include the certificate with the PI.', '预缩面料缩水率≤3%,OEKO-TEX认证,证书随PI一起发您。', 'en', daysAgo(1, 9)),
|
||||
|
||||
// ===== cv-016 德国 Anna =====
|
||||
m('cv-016', 'customer', 'Hallo, interested in 1000 GaN chargers and 1000 smart watches. What are the warranty terms?', '你好,对1000个GaN充电器和1000只智能手表感兴趣,质保条款是什么?', 'en', daysAgo(2, 9)),
|
||||
m('cv-016', 'agent', '18-month warranty on chargers, 12 months on watches, DOA replacement within 7 days.', '充电器18个月质保,手表12个月,到货7天内故障免费换新。', 'en', daysAgo(2, 10)),
|
||||
m('cv-016', 'customer', 'Do you handle EU VAT and customs? We need DDP Munich.', '你们处理欧盟VAT和清关吗?我们需要DDP慕尼黑。', 'en', daysAgo(1, 17)),
|
||||
m('cv-016', 'agent', 'Yes, DDP Munich is available with all duties included. The detailed quote will reach you tomorrow.', '可以,DDP慕尼黑含全部税费,详细报价明天发您。', 'en', daysAgo(1, 18)),
|
||||
|
||||
// ===== cv-017 沙特 Abdullah =====
|
||||
m('cv-017', 'customer', 'الساعة الذكية تدعم اللغة العربية كاملة؟ الواجهة والقوائم؟', '智能手表完整支持阿拉伯语吗?界面和菜单?', 'ar', hoursAgo(5)),
|
||||
m('cv-017', 'agent', 'نعم، القوائم والإشعارات بالعربية بالكامل، ويمكن تخصيص شاشة التشغيل بشعاركم.', '是的,菜单和通知完整支持阿拉伯语,开机画面可定制您的Logo。', 'ar', hoursAgo(4)),
|
||||
m('cv-017', 'customer', 'ممتاز. كم سعر 1500 قطعة؟', '很好。1500只多少钱?', 'ar', hoursAgo(3)),
|
||||
m('cv-017', 'agent', '$11.2 للقطعة، مع شاشة تشغيل مخصصة وضمان محلي عبر شريكنا في جدة.', '每只$11.2,含定制开机画面,并通过吉达合作伙伴提供本地保修。', 'ar', hoursAgo(2)),
|
||||
|
||||
// ===== cv-018 西班牙 Lucía =====
|
||||
m('cv-018', 'customer', '¿Precio de 200 juegos de vajilla? Tengo otra oferta de Turquía.', '200套餐具什么价格?我还有一个土耳其的报价。', 'es', daysAgo(3, 9)),
|
||||
m('cv-018', 'agent', '200 juegos a €6.4/juego. Nuestro esmaltado es grado A y las roturas están cubiertas al 100%.', '200套单价€6.4。我们的釉面是A级,破损100%包赔。', 'es', daysAgo(3, 10)),
|
||||
m('cv-018', 'customer', 'Turquía me da €6.0. ¿Qué me pueden ofrecer mejor?', '土耳其给我€6.0,你们能提供什么更好的条件?', 'es', daysAgo(2, 9)),
|
||||
m('cv-018', 'agent', 'Diseños exclusivos sin molde compartido, reposición gratuita de roturas y envío DDP Valencia.', '独家设计不开公共模、破损免费补发、DDP瓦伦西亚送货。', 'es', daysAgo(2, 10)),
|
||||
|
||||
// ===== cv-019 美国 Tom:破损投诉(紧急风险) =====
|
||||
m('cv-019', 'customer', 'The shipment arrived with 3% broken cups. 37 cartons damaged. This is the second time!', '到货有3%的杯子破损,37箱受损,这已经是第二次了!', 'en', hoursAgo(1.2), { isImportant: true }),
|
||||
m('cv-019', 'agent', 'Tom, I am really sorry. We will replace all broken units for free and cover the return shipping.', 'Tom,非常抱歉。我们将免费补发所有破损件并承担退货运费。', 'en', hoursAgo(1)),
|
||||
m('cv-019', 'customer', 'Replacement takes 3 weeks. My promotion starts in 10 days. What are you going to do about it?', '补发要3周,我的促销10天后开始。你们打算怎么解决?', 'en', minutesAgo(40), { isImportant: true }),
|
||||
m('cv-019', 'agent', 'We will air-ship replacements at our cost (5 days) plus an 8% credit on this order. My manager will call you today.', '我们将自费空运补发(5天到),并给本单8%的补偿额度。我的主管今天会亲自致电您。', 'en', minutesAgo(30)),
|
||||
m('cv-019', 'customer', "You'd better. This is your last chance.", '最好如此,这是你们最后的机会。', 'en', minutesAgo(20), { isImportant: true }),
|
||||
|
||||
// ===== cv-020 孟加拉 Nusrat:复购 =====
|
||||
m('cv-020', 'customer', 'আমাদের শীতের জন্য কার্ডিগান রিঅর্ডার করতে চাই।', '我们想为冬季复购开衫。', 'bn', hoursAgo(5)),
|
||||
m('cv-020', 'agent', 'Great! Sending the new winter color card - 18 new colors this season.', '太好了!给您发新的冬季色卡——本季新增18个颜色。', 'en', hoursAgo(4.6)),
|
||||
m('cv-020', 'customer', '১২০০ পিস। আগের মতো কোয়ালিটি চাই। ডেলিভারি কবে?', '1200件,品质要和之前一样。什么时候交貨?', 'bn', hoursAgo(4.2)),
|
||||
m('cv-020', 'agent', 'Same fabric and workmanship, delivery in 25 days - well before November.', '同样的面料和工艺,25天交付——远在11月之前。', 'en', hoursAgo(4)),
|
||||
]
|
||||
@@ -1,109 +0,0 @@
|
||||
import type { ActivityLog, CustomerFile } from '@/types/crm'
|
||||
import type { ApiKeyItem, AppNotification, BillingInfo, BillingOrder, Invoice, LoginRecord, OperationLog, SystemSettings } from '@/types/org'
|
||||
import { daysAgo, hoursAgo, minutesAgo } from './helpers'
|
||||
|
||||
export const notifications: AppNotification[] = [
|
||||
{ id: 'n-001', type: 'inquiry', title: '新询盘:Sarah Miller(美国)', description: '通过广告落地页咨询 2000 台 TWS 耳机,意向评分 88', createdAt: minutesAgo(90), read: false, link: '/inbox?c=cv-012' },
|
||||
{ id: 'n-002', type: 'risk', title: '紧急风险:Tom Baker 售后纠纷', description: '客户情绪不满,发出最后通牒,请立即处理', createdAt: minutesAgo(18), read: false, link: '/risks' },
|
||||
{ id: 'n-003', type: 'task', title: '任务即将到期:发送阶梯报价', description: 'Ahmed Raza 的报价任务今天 16:00 截止', createdAt: minutesAgo(45), read: false, link: '/opportunities' },
|
||||
{ id: 'n-004', type: 'channel', title: 'Outlook 渠道授权失效', description: '授权令牌已过期,请重新授权以恢复同步', createdAt: hoursAgo(26), read: true, link: '/channels' },
|
||||
{ id: 'n-005', type: 'inquiry', title: '新询盘:Carmen López(西班牙)', description: '独立站表单提交,咨询床品与餐具', createdAt: hoursAgo(7), read: true, link: '/inbox?c=cv-005' },
|
||||
{ id: 'n-006', type: 'system', title: '系统公告:同声传译功能上线', description: '实时语音翻译已开放试用,欢迎体验', createdAt: daysAgo(2, 9), read: true, link: '/interpret' },
|
||||
]
|
||||
|
||||
export const activityLogs: ActivityLog[] = [
|
||||
{ id: 'al-001', customerId: 'c-001', actorName: '李婷', action: '发送消息', detail: 'WhatsApp 发送定制案例图片 2 张', createdAt: hoursAgo(2) },
|
||||
{ id: 'al-002', customerId: 'c-001', actorName: 'AI 助手', action: '意向评分更新', detail: '76 → 86(明确数量与开业节点)', createdAt: minutesAgo(40) },
|
||||
{ id: 'al-003', customerId: 'c-001', actorName: '李婷', action: '创建任务', detail: '发送阶梯报价(今天 16:00 截止)', createdAt: daysAgo(1, 18) },
|
||||
{ id: 'al-004', customerId: 'c-001', actorName: '系统', action: '自动建档', detail: '来源:WhatsApp(官网按钮),匹配手机号建档', createdAt: daysAgo(4, 9) },
|
||||
{ id: 'al-005', customerId: 'c-019', actorName: '周敏', action: '风险确认', detail: '确认售后投诉风险,创建补偿方案任务', createdAt: minutesAgo(30) },
|
||||
{ id: 'al-006', customerId: 'c-019', actorName: '王琳', action: '添加备注', detail: '管理层今日 14:00 前致电客户', createdAt: minutesAgo(25) },
|
||||
{ id: 'al-007', customerId: 'c-012', actorName: '赵磊', action: '修改阶段', detail: '商务谈判 → 待成交', createdAt: hoursAgo(3) },
|
||||
{ id: 'al-008', customerId: 'c-003', actorName: '赵磊', action: '发送消息', detail: '报价 $2.20/pc 被接受', createdAt: hoursAgo(1) },
|
||||
]
|
||||
|
||||
export const customerFiles: CustomerFile[] = [
|
||||
{ id: 'f-001', customerId: 'c-001', name: '报价单-RAZA-500pcs.pdf', kind: 'quote', size: '128 KB', uploadedBy: '李婷', createdAt: daysAgo(1) },
|
||||
{ id: 'f-002', customerId: 'c-001', name: '客户Logo源文件.ai', kind: 'image', size: '2.4 MB', uploadedBy: 'Ahmed Raza', createdAt: daysAgo(2) },
|
||||
{ id: 'f-003', customerId: 'c-002', name: '耐温差测试报告-TR-BS2024-07.pdf', kind: 'doc', size: '860 KB', uploadedBy: '李婷', createdAt: daysAgo(1) },
|
||||
{ id: 'f-004', customerId: 'c-019', name: '破损照片-37箱.zip', kind: 'image', size: '18 MB', uploadedBy: 'Tom Baker', createdAt: hoursAgo(1) },
|
||||
{ id: 'f-005', customerId: 'c-012', name: '框架合同草案-v2.docx', kind: 'contract', size: '96 KB', uploadedBy: '赵磊', createdAt: hoursAgo(3) },
|
||||
]
|
||||
|
||||
export const apiKeys: ApiKeyItem[] = [
|
||||
{ id: 'ak-001', name: 'ERP 对接(测试)', keyPreview: 'lx_live_8f2k••••••••9d21', createdAt: daysAgo(45), lastUsedAt: hoursAgo(6), status: 'active' },
|
||||
{ id: 'ak-002', name: '官网表单 Webhook', keyPreview: 'lx_live_3m9q••••••••77ab', createdAt: daysAgo(120), lastUsedAt: minutesAgo(31), status: 'active' },
|
||||
]
|
||||
|
||||
export const billingInfo: BillingInfo = {
|
||||
planKey: 'pro',
|
||||
planName: '专业版',
|
||||
expireAt: daysAgo(-217),
|
||||
autoRenew: true,
|
||||
usage: {
|
||||
members: { used: 8, total: 20, unit: '席' },
|
||||
messages: { used: 42680, total: 100000, unit: '条/月' },
|
||||
translations: { used: 12840, total: 200000, unit: '次/月' },
|
||||
knowledgeStorage: { used: 486, total: 5120, unit: 'MB' },
|
||||
},
|
||||
}
|
||||
|
||||
export const billingOrders: BillingOrder[] = [
|
||||
{ id: 'bo-001', orderNo: 'LX20250715001', planName: '专业版(年付)', period: '2025-07-15 至 2026-07-14', amount: 49800, status: 'paid', createdAt: daysAgo(372) },
|
||||
{ id: 'bo-002', orderNo: 'LX20240712003', planName: '标准工作台版(年付)', period: '2024-07-12 至 2025-07-11', amount: 19800, status: 'paid', createdAt: daysAgo(740) },
|
||||
{ id: 'bo-003', orderNo: 'LX20260702008', planName: '行业知识包-玻璃杯(加购)', period: '一次性', amount: 1200, status: 'paid', createdAt: daysAgo(20) },
|
||||
]
|
||||
|
||||
export const invoices: Invoice[] = [
|
||||
{ id: 'inv-001', invoiceNo: 'INV-2025-0715', title: '徐州欧汇科技有限公司', amount: 49800, status: 'issued', createdAt: daysAgo(365) },
|
||||
{ id: 'inv-002', invoiceNo: 'INV-2026-0702', title: '徐州欧汇科技有限公司', amount: 1200, status: 'issued', createdAt: daysAgo(18) },
|
||||
]
|
||||
|
||||
export const defaultSettings: SystemSettings = {
|
||||
company: {
|
||||
name: '徐州欧汇科技有限公司',
|
||||
industry: '玻璃制品',
|
||||
size: '21—50人',
|
||||
contact: '0516-8888-6621',
|
||||
email: 'contact@ouhui.tech',
|
||||
intro: '专注玻璃制品、服装纺织与3C配件出口12年,服务全球2000+客户,支持OEM/ODM。',
|
||||
products: ['定制Logo玻璃水杯', '高硼硅玻璃杯 500ml', '男士纯棉T恤'],
|
||||
regions: ['中东', '南亚', '北美', '欧洲'],
|
||||
},
|
||||
ai: {
|
||||
replyLanguage: 'auto', replyTone: 'professional', autoGenerate: true,
|
||||
requireConfirm: true, sentimentAnalysis: true, riskAlert: true,
|
||||
cultureCheck: true, allowHistoryRef: true,
|
||||
},
|
||||
notification: {
|
||||
newInquiry: true, highIntent: true, riskAlert: true, taskDue: true,
|
||||
taskOverdue: true, channelOffline: true, planExpire: true, byEmail: true, bySms: false,
|
||||
},
|
||||
translate: {
|
||||
industry: '玻璃制品', formality: 'professional', targetCountry: 'auto',
|
||||
localization: true, keepBrand: true, keepModel: true, autoUnit: true,
|
||||
cultureReminder: true, religionReminder: true,
|
||||
},
|
||||
language: 'zh', timezone: 'Asia/Shanghai', sessionRetentionMonths: 24,
|
||||
brandColor: '#247b71',
|
||||
onboarding: { step1: true, step2: true, step3: true, step4: true, step5: false, step6: true, step7: false, step8: false },
|
||||
}
|
||||
|
||||
export const operationLogs: OperationLog[] = [
|
||||
{ id: 'op-001', actorId: 'u-001', actorName: '王琳', action: '邀请成员', target: '郑楠', detail: '邀请加入,角色:外贸业务员', ip: '112.84.12.66', createdAt: daysAgo(120) },
|
||||
{ id: 'op-002', actorId: 'u-002', actorName: '陈昊', action: '批量分配客户', target: '8 个客户', detail: '分配给 李婷(4)、郑楠(4)', ip: '112.84.12.71', createdAt: daysAgo(3, 10) },
|
||||
{ id: 'op-003', actorId: 'u-003', actorName: '李婷', action: '导出客户', target: '客户列表', detail: '导出 30 条客户数据(CSV)', ip: '112.84.12.83', createdAt: daysAgo(2, 15) },
|
||||
{ id: 'op-004', actorId: 'u-004', actorName: '赵磊', action: '修改商机阶段', target: 'Sarah Miller 框架单', detail: '商务谈判 → 待成交', ip: '112.84.12.90', createdAt: hoursAgo(3) },
|
||||
{ id: 'op-005', actorId: 'u-005', actorName: '周敏', action: '处理风险', target: 'Tom Baker 投诉', detail: '确认风险并创建补偿任务', ip: '112.84.12.95', createdAt: minutesAgo(30) },
|
||||
{ id: 'op-006', actorId: 'u-001', actorName: '王琳', action: '修改AI设置', target: '系统设置', detail: '开启“发送前必须人工确认”', ip: '112.84.12.66', createdAt: daysAgo(1, 11) },
|
||||
{ id: 'op-007', actorId: 'u-008', actorName: '郑楠', action: '上传知识', target: '智能手表(阿语版)功能说明', detail: '新增知识条目', ip: '112.84.12.99', createdAt: daysAgo(9, 14) },
|
||||
]
|
||||
|
||||
export const loginRecords: LoginRecord[] = [
|
||||
{ id: 'lr-001', memberId: 'u-003', memberName: '李婷', device: 'Chrome / Windows 11', ip: '112.84.12.83', location: '江苏徐州', status: 'success', createdAt: minutesAgo(8) },
|
||||
{ id: 'lr-002', memberId: 'u-004', memberName: '赵磊', device: 'Chrome / macOS', ip: '119.23.44.12', location: '境外(拉合尔)', status: 'abnormal', createdAt: hoursAgo(16) },
|
||||
{ id: 'lr-003', memberId: 'u-008', memberName: '郑楠', device: 'App / iPhone 15', ip: '112.84.15.201', location: '江苏徐州', status: 'success', createdAt: minutesAgo(3) },
|
||||
{ id: 'lr-004', memberId: 'u-005', memberName: '周敏', device: 'Edge / Windows 10', ip: '112.84.12.95', location: '江苏徐州', status: 'success', createdAt: minutesAgo(15) },
|
||||
{ id: 'lr-005', memberId: 'u-007', memberName: '吴迪', device: 'Safari / macOS', ip: '112.84.18.40', location: '江苏徐州', status: 'failed', createdAt: daysAgo(1, 9) },
|
||||
{ id: 'lr-006', memberId: 'u-001', memberName: '王琳', device: 'Chrome / Windows 11', ip: '112.84.12.66', location: '江苏徐州', status: 'success', createdAt: minutesAgo(12) },
|
||||
]
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { Opportunity } from '@/types/crm'
|
||||
import { daysAgo, daysFromNow, hoursAgo } from './helpers'
|
||||
|
||||
/** 18 个商机(覆盖看板各阶段) */
|
||||
export const opportunities: Opportunity[] = [
|
||||
{ id: 'o-001', name: '500个定制Logo玻璃杯(开业单)', customerId: 'c-001', customerName: 'Ahmed Raza', country: 'PK', product: '定制Logo玻璃水杯', quantity: '500个', amount: 925, probability: 65, stage: 'quoting', ownerId: 'u-003', expectedDealAt: daysFromNow(4), lastSummary: '客户接受$1.85单价,卡交期10天', nextAction: '发送阶梯报价和定制案例', updatedAt: hoursAgo(2) },
|
||||
{ id: 'o-002', name: '酒店集团2000个高硼硅杯', customerId: 'c-002', customerName: 'Khalid Al Mansouri', country: 'AE', product: '高硼硅玻璃杯 500ml', quantity: '2000个', amount: 4200, probability: 55, stage: 'sample', ownerId: 'u-003', expectedDealAt: daysFromNow(20), lastSummary: '样品已签收,等待耐温测试报告反馈', nextAction: '发送测试报告并回访', updatedAt: hoursAgo(3) },
|
||||
{ id: 'o-003', name: '5000件纯棉T恤', customerId: 'c-003', customerName: 'Rahim Uddin', country: 'BD', product: '男士纯棉T恤', quantity: '5000件', amount: 11000, probability: 80, stage: 'negotiation', ownerId: 'u-004', expectedDealAt: daysFromNow(5), lastSummary: '$2.20/pc 价格锁定,待付款条款', nextAction: '发送PI确认付款', updatedAt: hoursAgo(1) },
|
||||
{ id: 'o-004', name: 'TWS耳机首单(样品阶段)', customerId: 'c-004', customerName: 'Mike Johnson', country: 'US', product: 'TWS蓝牙耳机', quantity: '1000台', amount: 8900, probability: 25, stage: 'aftersales', ownerId: 'u-005', expectedDealAt: daysFromNow(30), lastSummary: '样品两次延误引发不满', nextAction: '加急补寄+安抚', updatedAt: hoursAgo(5) },
|
||||
{ id: 'o-005', name: '床品+餐具首单试单', customerId: 'c-005', customerName: 'Carmen López', country: 'ES', product: '北欧棉麻四件套', quantity: '50套', amount: 780, probability: 40, stage: 'new', ownerId: 'u-003', expectedDealAt: daysFromNow(15), lastSummary: '首询,已发目录,谈MOQ', nextAction: '发送试单政策', updatedAt: hoursAgo(7) },
|
||||
{ id: 'o-006', name: '1000个阿文书法礼品杯', customerId: 'c-006', customerName: 'Fahad Al-Otaibi', country: 'SA', product: '定制Logo玻璃水杯', quantity: '1000个', amount: 2100, probability: 60, stage: 'custom', ownerId: 'u-004', expectedDealAt: daysFromNow(12), lastSummary: '斋月前交付,等客户Logo文件', nextAction: '设计稿内审后发确认', updatedAt: hoursAgo(9) },
|
||||
{ id: 'o-007', name: '800件针织开衫', customerId: 'c-007', customerName: 'Emma Wilson', country: 'GB', product: '女士针织开衫', quantity: '800件', amount: 4100, probability: 45, stage: 'quoting', ownerId: 'u-008', expectedDealAt: daysFromNow(18), lastSummary: '比价中,对手£3.95', nextAction: '差异化报价', updatedAt: hoursAgo(12) },
|
||||
{ id: 'o-008', name: '3000个GaN充电器', customerId: 'c-008', customerName: 'Jonas Weber', country: 'DE', product: '65W氮化镓充电器', quantity: '3000个', amount: 19500, probability: 55, stage: 'sample', ownerId: 'u-004', expectedDealAt: daysFromNow(35), lastSummary: '样品寄出,等实验室测试', nextAction: '跟进测试结果', updatedAt: hoursAgo(26) },
|
||||
{ id: 'o-009', name: '300套餐具电商订单', customerId: 'c-010', customerName: 'Layla Hassan', country: 'AE', product: 'ins风陶瓷餐具套装', quantity: '300套', amount: 2340, probability: 55, stage: 'quoting', ownerId: 'u-005', expectedDealAt: daysFromNow(8), lastSummary: '关注防碎包装,已承诺破损包赔', nextAction: '发包装照片', updatedAt: hoursAgo(8) },
|
||||
{ id: 'o-010', name: '2000台TWS耳机季度框架', customerId: 'c-012', customerName: 'Sarah Miller', country: 'US', product: 'TWS蓝牙耳机', quantity: '2000台/季', amount: 17800, probability: 75, stage: 'pending-deal', ownerId: 'u-004', expectedDealAt: daysFromNow(7), lastSummary: '参数确认完毕,待合同', nextAction: '发送合同草案', updatedAt: minutesAgoLocal() },
|
||||
{ id: 'o-011', name: '2000件速干POLO衫', customerId: 'c-013', customerName: 'Tanvir Hasan', country: 'BD', product: '运动速干POLO衫', quantity: '2000件', amount: 6200, probability: 65, stage: 'negotiation', ownerId: 'u-008', expectedDealAt: daysFromNow(10), lastSummary: '样品满意,最终报价已发', nextAction: '等客户确认', updatedAt: daysAgo(1, 11) },
|
||||
{ id: 'o-012', name: '600套棉麻四件套', customerId: 'c-015', customerName: 'James Smith', country: 'GB', product: '北欧棉麻四件套', quantity: '600套', amount: 9200, probability: 70, stage: 'pending-deal', ownerId: 'u-003', expectedDealAt: daysFromNow(6), lastSummary: '£12/套达成一致,PI已备', nextAction: '回访PI确认', updatedAt: daysAgo(1, 9) },
|
||||
{ id: 'o-013', name: '1500只智能手表(阿语定制)', customerId: 'c-017', customerName: 'Abdullah Al-Rashid', country: 'SA', product: '智能手表', quantity: '1500只', amount: 16800, probability: 70, stage: 'pending-deal', ownerId: 'u-008', expectedDealAt: daysFromNow(9), lastSummary: '阿语适配确认,报价$11.2', nextAction: '发送固件演示', updatedAt: hoursAgo(2) },
|
||||
{ id: 'o-014', name: '1200件开衫秋冬复购', customerId: 'c-020', customerName: 'Nusrat Jahan', country: 'BD', product: '女士针织开衫', quantity: '1200件', amount: 5900, probability: 85, stage: 'won', ownerId: 'u-003', expectedDealAt: daysFromNow(2), lastSummary: '复购意向明确,色卡确认中', nextAction: '确认排产', updatedAt: hoursAgo(4) },
|
||||
{ id: 'o-015', name: '3000个朝觐礼品杯', customerId: 'c-028', customerName: 'Saud Al-Harbi', country: 'SA', product: '定制Logo玻璃水杯', quantity: '3000个', amount: 6900, probability: 75, stage: 'negotiation', ownerId: 'u-004', expectedDealAt: daysFromNow(11), lastSummary: '烫金阿文祝福语,节点硬', nextAction: '宗教文案校对', updatedAt: minutesAgoLocal() },
|
||||
{ id: 'o-016', name: '600个三色丝印定制杯', customerId: 'c-025', customerName: 'Imran Sheikh', country: 'PK', product: '定制Logo玻璃水杯', quantity: '600个', amount: 1350, probability: 60, stage: 'custom', ownerId: 'u-004', expectedDealAt: daysFromNow(14), lastSummary: '催打样排期', nextAction: '确认打样', updatedAt: hoursAgo(10) },
|
||||
{ id: 'o-017', name: '1200个高硼硅杯(FDA)', customerId: 'c-027', customerName: 'Grace Thompson', country: 'US', product: '高硼硅玻璃杯 500ml', quantity: '1200个', amount: 2700, probability: 65, stage: 'negotiation', ownerId: 'u-003', expectedDealAt: daysFromNow(13), lastSummary: '待FDA检测报告', nextAction: '发送报告', updatedAt: daysAgo(1, 8) },
|
||||
{ id: 'o-018', name: '500只智能手表', customerId: 'c-026', customerName: 'Markus Braun', country: 'DE', product: '智能手表', quantity: '500只', amount: 5600, probability: 35, stage: 'requirement', ownerId: 'u-008', expectedDealAt: daysFromNow(25), lastSummary: '关注GDPR合规', nextAction: '发送合规声明', updatedAt: daysAgo(3, 10) },
|
||||
{ id: 'o-019', name: '800个高硼硅杯(比价中)', customerId: 'c-021', customerName: 'Hassan Ali', country: 'PK', product: '高硼硅玻璃杯 500ml', quantity: '800个', amount: 1680, probability: 40, stage: 'quoting', ownerId: 'u-004', expectedDealAt: daysFromNow(16), lastSummary: '与本地供应商比价', nextAction: '发测试视频', updatedAt: daysAgo(1, 13) },
|
||||
{ id: 'o-020', name: '37箱破损补发与赔付', customerId: 'c-019', customerName: 'Tom Baker', country: 'US', product: '双层玻璃杯 350ml', quantity: '补发111个', amount: 0, probability: 50, stage: 'aftersales', ownerId: 'u-005', expectedDealAt: daysFromNow(5), lastSummary: '空运补发+8%补偿额度', nextAction: '管理层致电', updatedAt: minutesAgoLocal() },
|
||||
]
|
||||
|
||||
function minutesAgoLocal(): string {
|
||||
return new Date(Date.now() - 90 * 60000).toISOString()
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
import type { RiskAlert } from '@/types/crm'
|
||||
import { daysAgo, hoursAgo, minutesAgo } from './helpers'
|
||||
|
||||
/** 15 条风险预警 */
|
||||
export const risks: RiskAlert[] = [
|
||||
{
|
||||
id: 'r-001', type: 'negative-sentiment', level: 'urgent',
|
||||
customerId: 'c-019', customerName: 'Tom Baker', conversationId: 'cv-019',
|
||||
reason: '客户使用“最后一次机会”等激烈措辞,情绪识别为“不满”',
|
||||
aiExplanation: '近5条消息中客户负面情绪强度持续上升,出现 ultimatum 类表达(last chance / second time),且涉及金额较大的历史订单。破损问题叠加促销节点,若48小时内无实质补偿方案,纠纷升级概率约78%。',
|
||||
suggestion: '1. 管理层今日内亲自致电;2. 空运补发(5天)+8%订单补偿额度;3. 出具书面预防方案(包装升级+出货全检)。',
|
||||
ownerId: 'u-005', createdAt: minutesAgo(18), status: 'confirmed',
|
||||
},
|
||||
{
|
||||
id: 'r-002', type: 'churn', level: 'high',
|
||||
customerId: 'c-004', customerName: 'Mike Johnson', conversationId: 'cv-004',
|
||||
reason: '客户明确表示“若周三收不到样品将考虑其他供应商”',
|
||||
aiExplanation: '样品两次延误后客户信任度显著下降,消息中出现明确竞品转向信号。该客户处于样品验证关键期,流失将直接影响约$8,900首单及后续框架合作。',
|
||||
suggestion: '2小时内提供FedEx加急单号,周二主动确认签收;同步准备备选供应商对比优势话术。',
|
||||
ownerId: 'u-005', createdAt: hoursAgo(5), status: 'open',
|
||||
},
|
||||
{
|
||||
id: 'r-003', type: 'delivery', level: 'high',
|
||||
customerId: 'c-028', customerName: 'Saud Al-Harbi', conversationId: undefined,
|
||||
reason: '朝觐礼品季订单交付节点为硬性时间窗,当前排产余量不足',
|
||||
aiExplanation: '该商机预计交付窗口前仅剩23天,而定制工艺(烫金+礼盒)标准周期为18天+物流5天,缓冲为0。任何环节延误都将导致整单错过销售季,客户流失与索赔风险高。',
|
||||
suggestion: '立即锁定排产与船期;与客户确认设计稿最终版;预留空运备选方案。',
|
||||
ownerId: 'u-004', createdAt: hoursAgo(8), status: 'open',
|
||||
},
|
||||
{
|
||||
id: 'r-004', type: 'religion', level: 'medium',
|
||||
customerId: 'c-006', customerName: 'Fahad Al-Otaibi', conversationId: 'cv-006',
|
||||
reason: '定制内容涉及阿拉伯文书法与宗教场景用语,未经专业校对',
|
||||
aiExplanation: '客户礼品杯用于斋月场景,阿文书法字形、祝福语用词存在宗教敏感性。机器翻译或自行排版可能出现断字、连写错误或用词不当,在中东市场属于高风险失误。',
|
||||
suggestion: '设计稿必须经阿拉伯语设计师双人校对;祝福语使用通用商务表达,避免直接引用宗教经文。',
|
||||
ownerId: 'u-004', createdAt: hoursAgo(10), status: 'open',
|
||||
},
|
||||
{
|
||||
id: 'r-005', type: 'price-objection', level: 'medium',
|
||||
customerId: 'c-007', customerName: 'Emma Wilson', conversationId: 'cv-007',
|
||||
reason: '客户明确告知竞争对手报价低6%,要求价格匹配',
|
||||
aiExplanation: '客户处于比价阶段且主动披露对手报价(£3.95 vs £4.20),说明已进入决策后期。若无法在价值维度差异化,降价竞争概率大,预计毛利压缩4—6%。',
|
||||
suggestion: '不直接降价,改用增值方案(升级辅料/免费吊牌/产前样)回应;同步发送质检流程资料建立信任。',
|
||||
ownerId: 'u-008', createdAt: hoursAgo(12), status: 'open',
|
||||
},
|
||||
{
|
||||
id: 'r-006', type: 'follow-up-delay', level: 'high',
|
||||
customerId: 'c-014', customerName: 'Omar Farooq', conversationId: 'cv-014',
|
||||
reason: '高意向新询盘超过24小时未分配负责人、未响应',
|
||||
aiExplanation: '该客户主动二次留言(英语+乌尔都语),采购意向明确(批发储物罐)。数据显示:询盘1小时内响应的转化率是24小时后响应的7倍。当前线索处于流失边缘。',
|
||||
suggestion: '立即分配负责人并发送首响话术;纳入“无负责人会话”自动分配规则。',
|
||||
ownerId: null, createdAt: daysAgo(1, 14), status: 'open',
|
||||
},
|
||||
{
|
||||
id: 'r-007', type: 'task-overdue', level: 'medium',
|
||||
customerId: 'c-002', customerName: 'Khalid Al Mansouri', conversationId: 'cv-002',
|
||||
reason: '“跟进样品反馈”任务已逾期20小时',
|
||||
aiExplanation: '酒店集团决策链长,样品反馈窗口期是关键跟进点。逾期跟进会让客户感知专业度下降,该商机金额$4,200且为集团客户,建议优先处理。',
|
||||
suggestion: '今日内发送耐温测试报告并预约视频实测;更新任务完成时间。',
|
||||
ownerId: 'u-003', createdAt: hoursAgo(20), status: 'open',
|
||||
},
|
||||
{
|
||||
id: 'r-008', type: 'task-overdue', level: 'high',
|
||||
customerId: 'c-025', customerName: 'Imran Sheikh', conversationId: undefined,
|
||||
reason: '客户三次催问打样排期,相关任务已逾期3小时',
|
||||
aiExplanation: '客户在定制确认阶段反复催问,消息间隔缩短(24h→8h→2h),是不耐烦的典型信号。定制类客户一旦转向竞品,召回成本极高。',
|
||||
suggestion: '2小时内回复明确打样排期(具体到日期);如产线紧张,提供加急打样选项。',
|
||||
ownerId: 'u-004', createdAt: hoursAgo(3), status: 'open',
|
||||
},
|
||||
{
|
||||
id: 'r-009', type: 'complaint', level: 'high',
|
||||
customerId: 'c-019', customerName: 'Tom Baker', conversationId: 'cv-019',
|
||||
reason: '到货破损率3%(37箱),为同一客户第二次投诉',
|
||||
aiExplanation: '重复性质量问题投诉表明包装或物流环节存在系统性缺陷。该客户年采购额约$24,000,二次投诉后续约概率降至31%。',
|
||||
suggestion: '启动质量复盘(包装跌落测试);本单补偿外,提供下单专属质检报告。',
|
||||
ownerId: 'u-005', createdAt: minutesAgo(35), status: 'confirmed',
|
||||
},
|
||||
{
|
||||
id: 'r-010', type: 'dispute', level: 'medium',
|
||||
customerId: 'c-019', customerName: 'Tom Baker', conversationId: 'cv-019',
|
||||
reason: '赔付金额与补发时效存在分歧,可能升级为平台纠纷',
|
||||
aiExplanation: '客户要求10天内到货以配合促销,常规补发需3周。若协商失败客户可能通过信用卡拒付或平台仲裁,产生额外损失与账号风险。',
|
||||
suggestion: '书面确认空运时效与补偿额度,留存沟通记录;必要时提供部分退款选项。',
|
||||
ownerId: 'u-005', createdAt: minutesAgo(30), status: 'open',
|
||||
},
|
||||
{
|
||||
id: 'r-011', type: 'culture', level: 'low',
|
||||
customerId: 'c-010', customerName: 'Layla Hassan', conversationId: 'cv-010',
|
||||
reason: '待发送的营销素材中包含酒类场景摆拍图(餐具套装场景图#7)',
|
||||
aiExplanation: '阿联酋客户,伊斯兰文化背景下酒类场景图片可能引起不适。素材库检测到待发图片包含高脚杯盛酒场景。',
|
||||
suggestion: '替换为家庭聚餐/咖啡场景图;中东客户素材默认过滤酒类与猪肉元素。',
|
||||
ownerId: 'u-005', createdAt: hoursAgo(7), status: 'open',
|
||||
},
|
||||
{
|
||||
id: 'r-012', type: 'follow-up-delay', level: 'medium',
|
||||
customerId: 'c-021', customerName: 'Hassan Ali', conversationId: undefined,
|
||||
reason: '比价阶段客户48小时未回复,未触发挽回跟进',
|
||||
aiExplanation: '客户处于价格比较阶段,沉默48小时后成交概率每日衰减约12%。',
|
||||
suggestion: '发送差异化价值内容(耐温差测试视频)+ 限时阶梯价,激活对话。',
|
||||
ownerId: 'u-004', createdAt: daysAgo(1, 10), status: 'open',
|
||||
},
|
||||
{
|
||||
id: 'r-013', type: 'account-abnormal', level: 'medium',
|
||||
customerId: 'c-001', customerName: '系统安全', conversationId: undefined,
|
||||
reason: '成员账号 zhaolei@ouhui.tech 在新设备登录(IP属地异常)',
|
||||
aiExplanation: '该账号常用登录地为徐州,本次登录IP属地为境外(拉合尔),且为非工作时间。可能是本人出差,也可能是账号泄露。',
|
||||
suggestion: '已强制二次验证通过。建议确认是否为本人操作;如非本人立即重置密码并审查操作日志。',
|
||||
ownerId: 'u-001', createdAt: hoursAgo(16), status: 'confirmed',
|
||||
},
|
||||
{
|
||||
id: 'r-014', type: 'logistics', level: 'medium',
|
||||
customerId: 'c-011', customerName: 'Carlos Ruiz', conversationId: 'cv-011',
|
||||
reason: '巴塞罗那航线舱位紧张,DDP时效可能延误3—5天',
|
||||
aiExplanation: '物流监控显示近期华东—巴塞罗那航线爆仓率上升,承诺的18—22天时效存在滑期风险。客户为新客首单,首次交付体验直接影响复购。',
|
||||
suggestion: '提前与客户沟通时效缓冲;报价时同时提供10天加急方案作备份。',
|
||||
ownerId: 'u-008', createdAt: daysAgo(1, 16), status: 'open',
|
||||
},
|
||||
{
|
||||
id: 'r-015', type: 'churn', level: 'low',
|
||||
customerId: 'c-018', customerName: 'Lucía Fernández', conversationId: 'cv-018',
|
||||
reason: '比价客户72小时未回复,且竞品价格更低',
|
||||
aiExplanation: '客户明确持有土耳其供应商更低报价(€6.0 vs €6.4),沉默期延长。但客户体量小(200套),投入产出比一般,建议低成本自动化培育。',
|
||||
suggestion: '发送一次差异化卖点总结后纳入7天自动培育队列;不投入过多人工。',
|
||||
ownerId: 'u-005', createdAt: daysAgo(2, 11), status: 'ignored',
|
||||
},
|
||||
]
|
||||
@@ -1,26 +0,0 @@
|
||||
import type { FollowTask } from '@/types/crm'
|
||||
import { daysAgo, daysFromNow, hoursAgo, hoursFromNow, todayAt } from './helpers'
|
||||
|
||||
/** 20 个跟进任务 */
|
||||
export const tasks: FollowTask[] = [
|
||||
{ id: 't-001', title: '发送阶梯报价(500/1000/2000)与定制案例', type: 'quote', priority: 'high', status: 'pending', customerId: 'c-001', customerName: 'Ahmed Raza', conversationId: 'cv-001', ownerId: 'u-003', dueAt: todayAt(16), note: '客户明天确认,今天务必发出', relatedSummary: '500个定制玻璃杯,开业节点10天', createdAt: daysAgo(1) },
|
||||
{ id: 't-002', title: '发送耐温差冲击测试报告', type: 'material', priority: 'high', status: 'pending', customerId: 'c-002', customerName: 'Khalid Al Mansouri', conversationId: 'cv-002', ownerId: 'u-003', dueAt: todayAt(18), note: '报告编号 TR-BS2024-07', createdAt: daysAgo(1) },
|
||||
{ id: 't-003', title: '确认 T 恤付款条款并发送 PI', type: 'quote', priority: 'high', status: 'pending', customerId: 'c-003', customerName: 'Rahim Uddin', conversationId: 'cv-003', ownerId: 'u-004', dueAt: todayAt(17), relatedSummary: '$2.20/pc 已接受,5000件', createdAt: daysAgo(1) },
|
||||
{ id: 't-004', title: 'FedEx 补寄单号同步给 Mike', type: 'aftersales', priority: 'high', status: 'pending', customerId: 'c-004', customerName: 'Mike Johnson', conversationId: 'cv-004', ownerId: 'u-005', dueAt: hoursFromNow(2), note: '客户威胁换供应商,2小时内必须响应', createdAt: hoursAgo(5) },
|
||||
{ id: 't-005', title: '发送产品目录+检测报告(西班牙语)', type: 'material', priority: 'medium', status: 'pending', customerId: 'c-005', customerName: 'Carmen López', conversationId: 'cv-005', ownerId: 'u-003', dueAt: daysFromNow(1, 11), createdAt: hoursAgo(7) },
|
||||
{ id: 't-006', title: '阿文书法Logo设计稿内审', type: 'custom', priority: 'medium', status: 'pending', customerId: 'c-006', customerName: 'Fahad Al-Otaibi', conversationId: 'cv-006', ownerId: 'u-004', dueAt: daysFromNow(2, 10), note: '需阿拉伯设计师校对后再发客户', createdAt: daysAgo(1) },
|
||||
{ id: 't-007', title: '回复 Emma 差异化报价方案', type: 'quote', priority: 'medium', status: 'pending', customerId: 'c-007', customerName: 'Emma Wilson', conversationId: 'cv-007', ownerId: 'u-008', dueAt: daysFromNow(1, 15), createdAt: hoursAgo(12) },
|
||||
{ id: 't-008', title: '跟进 Jonas 样品签收与实验室排期', type: 'sample', priority: 'medium', status: 'pending', customerId: 'c-008', customerName: 'Jonas Weber', conversationId: 'cv-008', ownerId: 'u-004', dueAt: daysFromNow(3, 10), createdAt: daysAgo(1) },
|
||||
{ id: 't-009', title: '发送秋冬色卡给 Nusrat', type: 'material', priority: 'high', status: 'pending', customerId: 'c-020', customerName: 'Nusrat Jahan', conversationId: 'cv-020', ownerId: 'u-003', dueAt: todayAt(15), relatedSummary: '复购1200件开衫', createdAt: hoursAgo(4) },
|
||||
{ id: 't-010', title: '管理层致电 Tom 并确认空运补发', type: 'aftersales', priority: 'high', status: 'pending', customerId: 'c-019', customerName: 'Tom Baker', conversationId: 'cv-019', ownerId: 'u-005', dueAt: hoursFromNow(1), note: '紧急:客户最后通牒', createdAt: hoursAgo(1) },
|
||||
{ id: 't-011', title: 'Sarah 合同草案与首单排产计划', type: 'quote', priority: 'high', status: 'pending', customerId: 'c-012', customerName: 'Sarah Miller', conversationId: 'cv-012', ownerId: 'u-004', dueAt: daysFromNow(1, 10), createdAt: hoursAgo(3) },
|
||||
{ id: 't-012', title: '回访 Laura 样品密封性测试结果', type: 'sample', priority: 'medium', status: 'pending', customerId: 'c-024', customerName: 'Laura García', ownerId: 'u-005', dueAt: daysFromNow(2, 12), createdAt: daysAgo(1) },
|
||||
{ id: 't-013', title: '宗教文案校对(Saud 朝觐礼品单)', type: 'custom', priority: 'high', status: 'pending', customerId: 'c-028', customerName: 'Saud Al-Harbi', ownerId: 'u-004', dueAt: daysFromNow(1, 9), note: '涉及宗教祝福语,必须双人校对', createdAt: daysAgo(1) },
|
||||
{ id: 't-014', title: '发送 FDA 检测报告给 Grace', type: 'material', priority: 'medium', status: 'pending', customerId: 'c-027', customerName: 'Grace Thompson', ownerId: 'u-003', dueAt: daysFromNow(2, 14), createdAt: daysAgo(1) },
|
||||
{ id: 't-015', title: ' Abdullah 阿语固件演示视频', type: 'material', priority: 'medium', status: 'pending', customerId: 'c-017', customerName: 'Abdullah Al-Rashid', conversationId: 'cv-017', ownerId: 'u-008', dueAt: daysFromNow(1, 16), createdAt: hoursAgo(2) },
|
||||
{ id: 't-016', title: '回访 James:PI 是否收到并确认', type: 'revisit', priority: 'low', status: 'pending', customerId: 'c-015', customerName: 'James Smith', ownerId: 'u-003', dueAt: daysFromNow(2, 16), createdAt: daysAgo(1) },
|
||||
{ id: 't-017', title: 'Imran 打样排期确认与色卡对照', type: 'custom', priority: 'high', status: 'pending', customerId: 'c-025', customerName: 'Imran Sheikh', ownerId: 'u-004', dueAt: hoursAgo(3), note: '已逾期风险:客户催打样', createdAt: daysAgo(2) },
|
||||
{ id: 't-018', title: '跟进 Khalid 样品反馈(已逾期)', type: 'revisit', priority: 'medium', status: 'pending', customerId: 'c-002', customerName: 'Khalid Al Mansouri', ownerId: 'u-003', dueAt: hoursAgo(20), createdAt: daysAgo(3) },
|
||||
{ id: 't-019', title: '季度复购提醒:Atlantic Kitchenware', type: 'repurchase', priority: 'low', status: 'done', customerId: 'c-027', customerName: 'Grace Thompson', ownerId: 'u-003', dueAt: daysAgo(2), createdAt: daysAgo(5), completedAt: daysAgo(2) },
|
||||
{ id: 't-020', title: '发送物流单号(Carlos 储物罐报价单)', type: 'logistics', priority: 'low', status: 'done', customerId: 'c-011', customerName: 'Carlos Ruiz', ownerId: 'u-008', dueAt: daysAgo(1, 12), createdAt: daysAgo(3), completedAt: daysAgo(1, 12) },
|
||||
]
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { TeamMember } from '@/types/org'
|
||||
import { daysAgo, hoursAgo, minutesAgo } from './helpers'
|
||||
|
||||
/** 8 名团队成员 */
|
||||
export const teamMembers: TeamMember[] = [
|
||||
{
|
||||
id: 'u-001', name: '王琳', email: 'wanglin@ouhui.tech', phone: '138****6621',
|
||||
department: '总经办', role: 'admin', online: true, customerCount: 0, todoCount: 3,
|
||||
lastLoginAt: minutesAgo(12), status: 'active', joinedAt: daysAgo(420),
|
||||
},
|
||||
{
|
||||
id: 'u-002', name: '陈昊', email: 'chenhao@ouhui.tech', phone: '139****8810',
|
||||
department: '外贸一部', role: 'manager', online: true, customerCount: 0, todoCount: 6,
|
||||
lastLoginAt: minutesAgo(35), status: 'active', joinedAt: daysAgo(380),
|
||||
},
|
||||
{
|
||||
id: 'u-003', name: '李婷', email: 'liting@ouhui.tech', phone: '137****5523',
|
||||
department: '外贸一部', role: 'sales', online: true, customerCount: 12, todoCount: 7,
|
||||
lastLoginAt: minutesAgo(8), status: 'active', joinedAt: daysAgo(350),
|
||||
},
|
||||
{
|
||||
id: 'u-004', name: '赵磊', email: 'zhaolei@ouhui.tech', phone: '135****9012',
|
||||
department: '外贸二部', role: 'sales', online: true, customerCount: 10, todoCount: 5,
|
||||
lastLoginAt: minutesAgo(22), status: 'active', joinedAt: daysAgo(320),
|
||||
},
|
||||
{
|
||||
id: 'u-005', name: '周敏', email: 'zhoumin@ouhui.tech', phone: '136****3345',
|
||||
department: '客服部', role: 'service', online: true, customerCount: 6, todoCount: 4,
|
||||
lastLoginAt: minutesAgo(15), status: 'active', joinedAt: daysAgo(300),
|
||||
},
|
||||
{
|
||||
id: 'u-006', name: '孙倩', email: 'sunqian@ouhui.tech', phone: '158****7789',
|
||||
department: '客服部', role: 'service', online: false, customerCount: 2, todoCount: 2,
|
||||
lastLoginAt: hoursAgo(18), status: 'active', joinedAt: daysAgo(260),
|
||||
},
|
||||
{
|
||||
id: 'u-007', name: '吴迪', email: 'wudi@ouhui.tech', phone: '150****2234',
|
||||
department: '财务部', role: 'viewer', online: false, customerCount: 0, todoCount: 0,
|
||||
lastLoginAt: daysAgo(1, 9), status: 'active', joinedAt: daysAgo(200),
|
||||
},
|
||||
{
|
||||
id: 'u-008', name: '郑楠', email: 'zhengnan@ouhui.tech', phone: '186****4456',
|
||||
department: '外贸二部', role: 'sales', online: true, customerCount: 8, todoCount: 9,
|
||||
lastLoginAt: minutesAgo(3), status: 'active', joinedAt: daysAgo(120),
|
||||
},
|
||||
]
|
||||
@@ -1,20 +1,28 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { PageLoading, ErrorState } from '@/components/ui/feedback'
|
||||
import { PageHeader } from '@/components/common/PageHeader'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardHeader } from '@/components/ui/card'
|
||||
import { StatCard } from '@/components/common/StatCard'
|
||||
import { DemoNote } from '@/components/common/DemoNote'
|
||||
import { LiveDataBadge } from '@/components/common/LiveDataBadge'
|
||||
import { CountryFlag } from '@/components/common/CountryFlag'
|
||||
import { ChartCard, TrendChart, DonutChart, FunnelChart, RankBars, SimpleBarChart } from '@/components/charts'
|
||||
import { getAnalytics } from '@/services/api/org'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { Activity, BadgePercent, TrendingUp, Users, Zap, Timer, AlertTriangle, Target } from 'lucide-react'
|
||||
import { Activity, BadgePercent, Users, Zap, Target } from 'lucide-react'
|
||||
|
||||
const PERIOD_OPTIONS = [
|
||||
{ value: 'today', label: '今日' },
|
||||
{ value: '7d', label: '近 7 天' },
|
||||
{ value: '30d', label: '近 30 天' },
|
||||
{ value: 'quarter', label: '本季度' },
|
||||
] as const
|
||||
|
||||
export function AnalyticsPage() {
|
||||
const [period, setPeriod] = useState<(typeof PERIOD_OPTIONS)[number]['value']>('30d')
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ['analytics'],
|
||||
queryFn: () => getAnalytics(),
|
||||
queryKey: ['analytics', period],
|
||||
queryFn: () => getAnalytics({ period }),
|
||||
})
|
||||
|
||||
if (isLoading) return <PageLoading />
|
||||
@@ -23,21 +31,35 @@ export function AnalyticsPage() {
|
||||
const k = data.kpis
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="page-shell">
|
||||
<PageHeader
|
||||
title="数据分析中心"
|
||||
description="企业经营数据全景 · 当前展示演示数据"
|
||||
actions={<DemoNote />}
|
||||
extra={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{['today', '7d', '30d', 'quarter'].map((p) => (
|
||||
<button key={p} className="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground hover:bg-border">{p === '30d' ? '近30天' : p === '7d' ? '近7天' : p === 'today' ? '今日' : '本季度'}</button>
|
||||
))}
|
||||
title="业务分析"
|
||||
description="从客户获取、沟通效率到成交转化,查看完整经营链路"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<LiveDataBadge />
|
||||
<div className="flex rounded-[10px] border border-slate-200 bg-white p-1 shadow-sm">
|
||||
{PERIOD_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={period === option.value}
|
||||
onClick={() => setPeriod(option.value)}
|
||||
className={`rounded-[7px] px-3 py-1.5 text-xs font-medium transition ${
|
||||
period === option.value
|
||||
? 'bg-slate-900 text-white shadow-sm'
|
||||
: 'text-slate-500 hover:bg-slate-50 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-5 mb-4">
|
||||
<div className="grid grid-cols-2 gap-3 xl:grid-cols-5">
|
||||
<StatCard title="新增客户" value={k.newCustomers.value} delta={k.newCustomers.delta} icon={Users} />
|
||||
<StatCard title="有效询盘" value={k.validInquiries.value} delta={k.validInquiries.delta} icon={Activity} />
|
||||
<StatCard title="高意向客户" value={k.highIntent.value} delta={k.highIntent.delta} icon={Target} />
|
||||
@@ -45,27 +67,62 @@ export function AnalyticsPage() {
|
||||
<StatCard title="AI采纳率" value={`${k.aiAdoptionRate.value}%`} delta={k.aiAdoptionRate.delta} icon={Zap} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<ChartCard title="客户增长趋势" height={260}><TrendChart data={data.customerGrowth} series={[{ key: 'count', name: '新增客户', color: '#247b71' }]} /></ChartCard>
|
||||
<ChartCard title="各渠道询盘数量" height={260}><SimpleBarChart data={data.channelInquiries} xKey="name" barKey="count" name="询盘数" /></ChartCard>
|
||||
<ChartCard title="客户国家/地区分布" height={260}><DonutChart data={data.countryDistribution} /></ChartCard>
|
||||
<ChartCard title="客户阶段转化漏斗" height={260}><FunnelChart data={data.funnel} /></ChartCard>
|
||||
<ChartCard title="意向分数分布" height={260}><DonutChart data={data.intentDistribution} nameKey="range" /></ChartCard>
|
||||
<ChartCard title="产品关注度排行" height={260}><RankBars data={data.productRank.map((p) => ({ name: p.product, value: p.inquiries }))} /></ChartCard>
|
||||
<ChartCard title="团队回复效率排行" height={240}><RankBars data={data.teamReplyRank.map((r) => ({ name: r.name, value: r.avgResponseMin }))} unit="分钟" max={30} /></ChartCard>
|
||||
<ChartCard title="团队客户转化排行" height={240}><RankBars data={data.teamConversionRank.map((r) => ({ name: r.name, value: r.rate }))} unit="%" /></ChartCard>
|
||||
</div>
|
||||
<section className="page-section">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2 className="section-title">增长与转化</h2>
|
||||
<p className="section-description">观察客户增长速度、渠道贡献和阶段流失</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-12">
|
||||
<ChartCard className="xl:col-span-7" title="客户增长趋势" description="所选周期内的新增客户变化" height={290}>
|
||||
<TrendChart data={data.customerGrowth} series={[{ key: 'count', name: '新增客户', color: '#2563eb' }]} />
|
||||
</ChartCard>
|
||||
<ChartCard className="xl:col-span-5" title="客户阶段转化漏斗" description="识别转化链路中的主要流失阶段" height={290}>
|
||||
<FunnelChart data={data.funnel} />
|
||||
</ChartCard>
|
||||
<ChartCard className="xl:col-span-4" title="各渠道询盘数量" height={255}>
|
||||
<SimpleBarChart data={data.channelInquiries} xKey="name" barKey="count" name="询盘数" />
|
||||
</ChartCard>
|
||||
<ChartCard className="xl:col-span-4" title="客户国家/地区分布" height={255}>
|
||||
<DonutChart data={data.countryDistribution} />
|
||||
</ChartCard>
|
||||
<ChartCard className="xl:col-span-4" title="意向分数分布" height={255}>
|
||||
<DonutChart data={data.intentDistribution} nameKey="range" />
|
||||
</ChartCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 老板看板 */}
|
||||
<h2 className="mt-6 mb-3 text-lg font-semibold">老板看板 <DemoNote className="align-middle" /></h2>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<Card><CardHeader title="🔥 今日需处理的高价值客户" /><div className="divide-y divide-border">{data.bossBoard.urgentHighValue.map((c) => (<div key={c.id} className="flex items-center justify-between px-4 py-2.5 text-sm"><span>{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="text-xs text-muted-foreground">{c.reason.slice(0, 20)}…</span></div>))}</div></Card>
|
||||
<Card><CardHeader title="⚠️ 存在逾期跟进的业务员" /><div className="divide-y divide-border">{data.bossBoard.overdueMembers.map((m) => (<div key={m.id} className="flex items-center justify-between px-4 py-2.5 text-sm"><span>{m.name}</span><Badge variant="danger">{m.overdueCount} 项逾期</Badge></div>))}</div></Card>
|
||||
<Card><CardHeader title="📈 客户增长最快的国家" /><div className="divide-y divide-border">{data.bossBoard.fastestCountries.map((c) => (<div key={c.country} className="flex items-center justify-between px-4 py-2.5 text-sm"><CountryFlag code={c.country} /><Badge variant="success">+{(c.growth * 100).toFixed(0)}%</Badge></div>))}</div></Card>
|
||||
<Card><CardHeader title="🏅 最优渠道" /><div className="p-4 text-center"><p className="text-2xl font-semibold text-primary-700">{data.bossBoard.bestChannel.name}</p><p className="text-sm text-muted-foreground">转化率 {data.bossBoard.bestChannel.conversion}%</p></div></Card>
|
||||
<Card><CardHeader title="📊 最热门产品" /><div className="space-y-2 p-4">{data.bossBoard.topProducts.map((p) => (<div key={p.product} className="flex items-center justify-between text-sm"><span>{p.product}</span><span className="text-muted-foreground">{p.inquiries} 次询盘</span></div>))}</div></Card>
|
||||
<Card><CardHeader title="🚨 正在流失的高意向客户" /><div className="divide-y divide-border">{data.bossBoard.churningHighIntent.map((c) => (<div key={c.id} className="px-4 py-2.5 text-sm"><span>{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="text-xs text-muted-foreground ml-2">{c.daysSilent}天未联系 · 意向{c.intentScore}分</span></div>))}</div></Card>
|
||||
</div>
|
||||
<section className="page-section">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2 className="section-title">产品与团队效率</h2>
|
||||
<p className="section-description">比较产品热度、响应速度和团队转化结果</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<ChartCard title="产品关注度排行" height={250}><RankBars data={data.productRank.map((p) => ({ name: p.product, value: p.inquiries }))} /></ChartCard>
|
||||
<ChartCard title="团队回复效率" height={250}><RankBars data={data.teamReplyRank.map((r) => ({ name: r.name, value: r.avgResponseMin }))} unit="分钟" max={30} /></ChartCard>
|
||||
<ChartCard title="团队客户转化" height={250}><RankBars data={data.teamConversionRank.map((r) => ({ name: r.name, value: r.rate }))} unit="%" /></ChartCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="page-section">
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<h2 className="section-title">经营提醒</h2>
|
||||
<p className="section-description">需要优先处理的客户、跟进和市场变化</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<Card><CardHeader title="重点跟进客户" /><div className="divide-y divide-slate-100">{data.bossBoard.urgentHighValue.map((c) => (<div key={c.id} className="flex items-center justify-between px-5 py-3 text-sm"><span className="font-medium text-slate-900">{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="max-w-[55%] truncate text-xs text-slate-500">{c.reason}</span></div>))}</div></Card>
|
||||
<Card><CardHeader title="逾期跟进" /><div className="divide-y divide-slate-100">{data.bossBoard.overdueMembers.map((m) => (<div key={m.id} className="flex items-center justify-between px-5 py-3 text-sm"><span className="font-medium text-slate-900">{m.name}</span><Badge variant="danger">{m.overdueCount} 项逾期</Badge></div>))}</div></Card>
|
||||
<Card><CardHeader title="增长较快的市场" /><div className="divide-y divide-slate-100">{data.bossBoard.fastestCountries.map((c) => (<div key={c.country} className="flex items-center justify-between px-5 py-3 text-sm"><CountryFlag code={c.country} /><Badge variant="success">+{(c.growth * 100).toFixed(0)}%</Badge></div>))}</div></Card>
|
||||
<Card><CardHeader title="渠道转化" /><div className="px-5 py-6"><p className="text-2xl font-semibold tracking-tight text-slate-950">{data.bossBoard.bestChannel.name}</p><p className="mt-1 text-sm text-slate-500">当前转化率 {data.bossBoard.bestChannel.conversion}%</p></div></Card>
|
||||
<Card><CardHeader title="产品询盘" /><div className="divide-y divide-slate-100 px-5">{data.bossBoard.topProducts.map((p) => (<div key={p.product} className="flex items-center justify-between py-3 text-sm"><span className="font-medium text-slate-900">{p.product}</span><span className="text-slate-500">{p.inquiries} 次</span></div>))}</div></Card>
|
||||
<Card><CardHeader title="久未联系的高意向客户" /><div className="divide-y divide-slate-100">{data.bossBoard.churningHighIntent.map((c) => (<div key={c.id} className="px-5 py-3 text-sm"><span className="font-medium text-slate-900">{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="ml-2 text-xs text-slate-500">{c.daysSilent} 天未联系 · 意向 {c.intentScore} 分</span></div>))}</div></Card>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+107
-211
@@ -1,264 +1,160 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Sparkles, MessageCircle, Languages, ShieldCheck, UsersRound } from 'lucide-react'
|
||||
import { Globe, LockKeyhole, UserRound } from 'lucide-react'
|
||||
import { login } from '@/services/api/auth'
|
||||
import { useAuthStore } from '@/app/store/auth'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { toast, errorMessage } from '@/app/store/toast'
|
||||
import { ROLE_META, type RoleKey } from '@/config/roles'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input, Field } from '@/components/ui/input'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Tabs } from '@/components/ui/tabs'
|
||||
import { Dialog } from '@/components/ui/dialog'
|
||||
import { UI_LOCALES, type UiLocale } from '@/config/languages'
|
||||
import { Dropdown } from '@/components/ui/dropdown'
|
||||
import { Globe } from 'lucide-react'
|
||||
import { AuthShell } from '@/components/auth/AuthShell'
|
||||
import { UI_LOCALES, type UiLocale } from '@/config/languages'
|
||||
|
||||
const schema = z.object({
|
||||
account: z.string().min(4, '请输入正确的手机号或邮箱'),
|
||||
password: z.string().optional(),
|
||||
code: z.string().optional(),
|
||||
password: z.string().min(1, '请输入密码'),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
export function LoginPage() {
|
||||
const { t, locale, setLocale } = useI18n()
|
||||
const { locale, setLocale } = useI18n()
|
||||
const navigate = useNavigate()
|
||||
const setSession = useAuthStore((s) => s.setSession)
|
||||
const [mode, setMode] = useState<'code' | 'password'>('code')
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const setSession = useAuthStore((state) => state.setSession)
|
||||
const [remember, setRemember] = useState(true)
|
||||
const [agreed, setAgreed] = useState(true)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [codeSent, setCodeSent] = useState(0)
|
||||
const [agreementOpen, setAgreementOpen] = useState<'privacy' | 'terms' | null>(null)
|
||||
const [forgotOpen, setForgotOpen] = useState(false)
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { account: '', password: '', code: '' } })
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { account: '', password: '' },
|
||||
})
|
||||
|
||||
const doLogin = async (values: FormValues, role?: RoleKey) => {
|
||||
useEffect(() => {
|
||||
if (searchParams.get('reason') !== 'session-expired') return
|
||||
toast.warning('登录状态已过期,请重新登录')
|
||||
setSearchParams({}, { replace: true })
|
||||
}, [searchParams, setSearchParams])
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
if (!agreed) {
|
||||
toast.warning('请先勾选并同意《隐私政策》和《服务协议》')
|
||||
return
|
||||
}
|
||||
if (mode === 'password' && !values.password) {
|
||||
toast.warning('请输入密码')
|
||||
return
|
||||
}
|
||||
if (mode === 'code' && (values.code ?? '').length !== 6) {
|
||||
toast.warning('请输入6位验证码(演示环境任意6位数字即可)')
|
||||
toast.warning('请先阅读并同意隐私政策和服务协议')
|
||||
return
|
||||
}
|
||||
try {
|
||||
setLoading(true)
|
||||
const session = await login({ ...values, mode, remember, role })
|
||||
const session = await login({ ...values, mode: 'password', remember })
|
||||
setSession(session)
|
||||
toast.success(`欢迎回来,${session.user.name}`)
|
||||
navigate('/dashboard')
|
||||
} catch (e) {
|
||||
toast.error(errorMessage(e))
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const quickLogin = (role: RoleKey) => {
|
||||
doLogin({ account: 'demo@ouhui.tech', code: '888888', password: 'demo123456' }, role)
|
||||
}
|
||||
|
||||
const sendCode = () => {
|
||||
setCodeSent(60)
|
||||
const timer = setInterval(() => {
|
||||
setCodeSent((s) => {
|
||||
if (s <= 1) clearInterval(timer)
|
||||
return s - 1
|
||||
})
|
||||
}, 1000)
|
||||
toast.success('验证码已发送(演示:任意6位数字均可登录)')
|
||||
}
|
||||
const localeMenu = (
|
||||
<Dropdown
|
||||
trigger={(
|
||||
<button className="flex h-9 items-center gap-2 rounded-lg border border-slate-200 bg-white px-3 text-xs font-medium text-slate-600 transition hover:border-slate-300 hover:bg-slate-50">
|
||||
<Globe className="h-3.5 w-3.5" />
|
||||
{UI_LOCALES.find((item) => item.code === locale)?.nativeName}
|
||||
</button>
|
||||
)}
|
||||
items={UI_LOCALES.map((item) => ({
|
||||
key: item.code,
|
||||
label: item.nativeName,
|
||||
onSelect: () => setLocale(item.code as UiLocale),
|
||||
}))}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-background">
|
||||
{/* 品牌区 */}
|
||||
<div className="hidden w-[46%] flex-col justify-between bg-primary-900 p-10 text-primary-50 lg:flex">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/10">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-base font-semibold">{t('app.name')}</p>
|
||||
<p className="text-xs text-primary-200">{t('app.slogan')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<h1 className="text-2xl font-semibold leading-snug">
|
||||
把分散在全球渠道的客户对话,
|
||||
<br />
|
||||
沉淀为可成交的企业资产。
|
||||
</h1>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{ icon: MessageCircle, text: 'WhatsApp / Telegram / 邮件 / 独立站,统一收件箱' },
|
||||
{ icon: Languages, text: 'AI 翻译与行业术语,跨语种沟通专业自然' },
|
||||
{ icon: ShieldCheck, text: '情感分析、风险预警、文化敏感检测' },
|
||||
{ icon: UsersRound, text: '客户自动建档、意向评分与团队协同跟进' },
|
||||
].map((f) => (
|
||||
<div key={f.text} className="flex items-center gap-3 text-sm text-primary-100">
|
||||
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-white/10">
|
||||
<f.icon className="h-4 w-4" />
|
||||
</span>
|
||||
{f.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-primary-300">© 2026 {t('app.company')} · 演示环境(Mock 数据)</p>
|
||||
<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 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
|
||||
trigger={
|
||||
<button className="flex items-center gap-1 rounded-md px-2 py-1 text-sm text-muted-foreground hover:bg-muted">
|
||||
<Globe className="h-4 w-4" />
|
||||
{UI_LOCALES.find((l) => l.code === locale)?.nativeName}
|
||||
</button>
|
||||
}
|
||||
items={UI_LOCALES.map((l) => ({
|
||||
key: l.code,
|
||||
label: l.nativeName,
|
||||
onSelect: () => setLocale(l.code as UiLocale),
|
||||
}))}
|
||||
<form className="space-y-5" onSubmit={handleSubmit(onSubmit)}>
|
||||
<Field label="手机号或邮箱" error={errors.account?.message}>
|
||||
<div className="relative">
|
||||
<UserRound className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<Input
|
||||
className="h-11 rounded-xl border-slate-200 bg-white pl-10 focus-visible:ring-blue-100"
|
||||
placeholder="请输入手机号或邮箱"
|
||||
autoComplete="username"
|
||||
{...register('account')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 className="text-xl font-semibold">{t('login.title')}</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{t('login.subtitle')}</p>
|
||||
|
||||
<div className="mt-6">
|
||||
<Tabs
|
||||
value={mode}
|
||||
onChange={(v) => setMode(v as 'code' | 'password')}
|
||||
items={[
|
||||
{ value: 'code', label: t('login.phoneTab') },
|
||||
{ value: 'password', label: t('login.passwordTab') },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form className="mt-5 space-y-4" onSubmit={handleSubmit((v) => doLogin(v))}>
|
||||
<Field error={errors.account?.message}>
|
||||
<Input placeholder={t('login.accountPlaceholder')} autoComplete="username" {...register('account')} />
|
||||
</Field>
|
||||
{mode === 'code' ? (
|
||||
<div className="flex gap-2">
|
||||
<Input placeholder={t('login.codePlaceholder')} maxLength={6} {...register('code')} />
|
||||
<Button type="button" variant="outline" className="shrink-0" disabled={codeSent > 0} onClick={sendCode}>
|
||||
{codeSent > 0 ? `${codeSent}s` : t('login.getCode')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Field error={errors.password?.message}>
|
||||
<Input type="password" placeholder={t('login.passwordPlaceholder')} autoComplete="current-password" {...register('password')} />
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<Checkbox label={t('login.remember')} checked={remember} onCheckedChange={setRemember} />
|
||||
<button type="button" className="text-primary hover:underline" onClick={() => setForgotOpen(true)}>
|
||||
{t('login.forgot')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" size="lg" loading={loading}>
|
||||
{t('login.submit')}
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
还没有账号?{' '}
|
||||
<Link to="/register" className="font-medium text-primary hover:underline">
|
||||
{t('login.register')}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* 演示角色一键登录 */}
|
||||
<div className="mt-8 rounded-lg border border-dashed border-border bg-muted/40 p-4">
|
||||
<p className="mb-3 text-center text-xs text-muted-foreground">{t('login.quick')}</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(Object.keys(ROLE_META) as RoleKey[]).map((r) => (
|
||||
<Button key={r} variant="outline" size="sm" loading={loading} onClick={() => quickLogin(r)}>
|
||||
{ROLE_META[r].label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-center text-xs text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={agreed}
|
||||
onCheckedChange={setAgreed}
|
||||
className="me-1 align-middle"
|
||||
/>
|
||||
{t('login.agreementPrefix')}
|
||||
<button type="button" className="text-primary hover:underline" onClick={() => setAgreementOpen('privacy')}>
|
||||
{t('login.privacy')}
|
||||
</button>
|
||||
{t('login.and')}
|
||||
<button type="button" className="text-primary hover:underline" onClick={() => setAgreementOpen('terms')}>
|
||||
{t('login.terms')}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 协议弹窗 */}
|
||||
<Dialog open={!!agreementOpen} onOpenChange={() => setAgreementOpen(null)} title={agreementOpen === 'privacy' ? '隐私政策' : '服务协议'}>
|
||||
<div className="space-y-3 text-sm leading-relaxed text-muted-foreground">
|
||||
<p>本页面为产品演示环境,以下协议为示意文本。</p>
|
||||
<p>1. 我们仅收集为您提供跨境沟通与客户管理服务所必需的信息,包括账号信息、企业信息与您主动录入的客户数据。</p>
|
||||
<p>2. 您的会话数据采用全链路加密存储,未经您授权,我们不会向任何第三方披露。</p>
|
||||
<p>3. 您可以随时导出或删除您的数据;企业注销后,相关数据将在 30 天内完成清理。</p>
|
||||
<p>4. 使用 AI 功能时,相关内容仅用于为您生成翻译与回复建议,不会用于其他用途。</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
{/* 忘记密码 */}
|
||||
<Dialog
|
||||
open={forgotOpen}
|
||||
onOpenChange={setForgotOpen}
|
||||
title="重置密码"
|
||||
footer={
|
||||
<Button
|
||||
onClick={() => {
|
||||
setForgotOpen(false)
|
||||
toast.success('重置链接已发送(演示)', '请在邮箱或短信中查收重置指引')
|
||||
}}
|
||||
>
|
||||
发送重置链接
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Field label="手机号或邮箱" hint="我们将向该账号发送重置密码的链接">
|
||||
<Input placeholder="请输入注册时使用的手机号或邮箱" />
|
||||
</Field>
|
||||
<Field label="密码" error={errors.password?.message}>
|
||||
<div className="relative">
|
||||
<LockKeyhole className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||||
<Input
|
||||
className="h-11 rounded-xl border-slate-200 bg-white pl-10 focus-visible:ring-blue-100"
|
||||
type="password"
|
||||
placeholder="请输入登录密码"
|
||||
autoComplete="current-password"
|
||||
{...register('password')}
|
||||
/>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Checkbox label="保持登录" checked={remember} onCheckedChange={setRemember} />
|
||||
<span className="text-xs text-slate-400">安全会话 · 12 小时</span>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="h-11 w-full rounded-xl shadow-[0_10px_24px_-14px_rgba(37,99,235,.8)]" loading={loading}>
|
||||
登录
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-slate-500">
|
||||
还没有账号?{' '}
|
||||
<Link to="/register" className="font-medium text-blue-600 hover:text-blue-700">
|
||||
创建企业
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 border-t border-slate-100 pt-5">
|
||||
<label className="flex items-start gap-2 text-xs leading-5 text-slate-400">
|
||||
<Checkbox checked={agreed} onCheckedChange={setAgreed} className="mt-0.5" />
|
||||
<span>
|
||||
登录即表示同意
|
||||
<button type="button" className="mx-1 text-slate-600 hover:text-blue-600" onClick={() => setAgreementOpen('privacy')}>隐私政策</button>
|
||||
和
|
||||
<button type="button" className="ml-1 text-slate-600 hover:text-blue-600" onClick={() => setAgreementOpen('terms')}>服务协议</button>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(agreementOpen)}
|
||||
onOpenChange={() => setAgreementOpen(null)}
|
||||
title={agreementOpen === 'privacy' ? '隐私政策' : '服务协议'}
|
||||
>
|
||||
<div className="space-y-3 text-sm leading-7 text-slate-600">
|
||||
<p>使用本服务前,请阅读并确认以下数据处理与服务条款。</p>
|
||||
<p>我们仅处理提供跨境沟通、客户管理和团队协作所必需的数据。</p>
|
||||
<p>使用翻译与回复建议时,相关内容会发送至企业已配置的模型服务商处理。</p>
|
||||
<p>数据导出、删除和账号注销按照正式发布的政策与协议执行。</p>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { register as registerApi } from '@/services/api/auth'
|
||||
import { useAuthStore } from '@/app/store/auth'
|
||||
import { toast, errorMessage } from '@/app/store/toast'
|
||||
@@ -11,24 +10,25 @@ import { Button } from '@/components/ui/button'
|
||||
import { Input, Field } from '@/components/ui/input'
|
||||
import { Select } from '@/components/ui/select'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { AuthShell } from '@/components/auth/AuthShell'
|
||||
import { INDUSTRIES } from '@/config/constants'
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(2, '请输入您的姓名'),
|
||||
account: z.string().min(5, '请输入手机号或邮箱'),
|
||||
password: z.string().min(6, '密码至少6位').max(32, '密码最长32位'),
|
||||
password: z.string().min(8, '密码至少 8 位').max(32, '密码最长 32 位'),
|
||||
company: z.string().min(2, '请输入企业名称'),
|
||||
industry: z.string().min(1, '请选择所属行业'),
|
||||
teamSize: z.string().min(1, '请选择团队规模'),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
const TEAM_SIZES = ['仅我一人', '2—5人', '6—20人', '21—50人', '50人以上']
|
||||
const fieldClass = 'h-10 rounded-xl border-slate-200 bg-white focus-visible:ring-blue-100'
|
||||
|
||||
export function RegisterPage() {
|
||||
const navigate = useNavigate()
|
||||
const setSession = useAuthStore((s) => s.setSession)
|
||||
const setSession = useAuthStore((state) => state.setSession)
|
||||
const [agreed, setAgreed] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
@@ -43,77 +43,74 @@ export function RegisterPage() {
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
if (!agreed) {
|
||||
toast.warning('请先同意《隐私政策》和《服务协议》')
|
||||
toast.warning('请先同意隐私政策和服务协议')
|
||||
return
|
||||
}
|
||||
try {
|
||||
setLoading(true)
|
||||
const session = await registerApi(values)
|
||||
setSession(session)
|
||||
toast.success('企业创建成功', '接下来请完成企业初始化配置')
|
||||
navigate('/onboarding')
|
||||
} catch (e) {
|
||||
toast.error(errorMessage(e))
|
||||
navigate('/channels?setup=choose')
|
||||
} catch (error) {
|
||||
toast.error(errorMessage(error))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-6">
|
||||
<div className="w-full max-w-[440px]">
|
||||
<div className="mb-8 flex items-center gap-2.5">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary text-white">
|
||||
<Sparkles className="h-5 w-5" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-base font-semibold">灵犀跨境</p>
|
||||
<p className="text-xs text-muted-foreground">注册并创建您的企业</p>
|
||||
</div>
|
||||
</div>
|
||||
<AuthShell
|
||||
heading="从第一条消息开始,建立清晰的客户关系。"
|
||||
description="创建企业工作台后,可按需连接 Telegram、WhatsApp 或企业邮箱,并邀请团队成员协作。"
|
||||
topAction={(
|
||||
<p className="text-xs text-slate-500">
|
||||
已有账号? <Link to="/login" className="font-medium text-blue-600 hover:text-blue-700">登录</Link>
|
||||
</p>
|
||||
)}
|
||||
>
|
||||
<div className="mb-7">
|
||||
<p className="text-xs font-semibold tracking-[0.15em] text-blue-600">CREATE WORKSPACE</p>
|
||||
<h2 className="mt-3 text-[28px] font-semibold tracking-[-0.035em] text-slate-950">创建企业工作台</h2>
|
||||
<p className="mt-2 text-sm text-slate-500">填写基础信息,稍后再连接消息渠道。</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="姓名" required error={errors.name?.message}>
|
||||
<Input placeholder="您的姓名" {...register('name')} />
|
||||
<Input className={fieldClass} placeholder="您的姓名" autoComplete="name" {...register('name')} />
|
||||
</Field>
|
||||
<Field label="手机号或邮箱" required error={errors.account?.message}>
|
||||
<Input placeholder="用于登录与接收通知" {...register('account')} />
|
||||
<Input className={fieldClass} placeholder="用于登录" autoComplete="username" {...register('account')} />
|
||||
</Field>
|
||||
<Field label="设置密码" required error={errors.password?.message} hint="6—32位,建议包含字母与数字">
|
||||
<Input type="password" placeholder="请设置登录密码" {...register('password')} />
|
||||
</div>
|
||||
<Field label="设置密码" required error={errors.password?.message} hint="8—32 位">
|
||||
<Input className={fieldClass} type="password" placeholder="请设置登录密码" autoComplete="new-password" {...register('password')} />
|
||||
</Field>
|
||||
<Field label="企业名称" required error={errors.company?.message}>
|
||||
<Input className={fieldClass} placeholder="请输入企业名称" {...register('company')} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field label="所属行业" required error={errors.industry?.message}>
|
||||
<Select placeholder="请选择" options={INDUSTRIES.map((item) => ({ value: item, label: item }))} {...register('industry')} />
|
||||
</Field>
|
||||
<Field label="企业名称" required error={errors.company?.message}>
|
||||
<Input placeholder="如:徐州欧汇科技有限公司" {...register('company')} />
|
||||
<Field label="团队规模" required error={errors.teamSize?.message}>
|
||||
<Select placeholder="请选择" options={TEAM_SIZES.map((item) => ({ value: item, label: item }))} {...register('teamSize')} />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="所属行业" required error={errors.industry?.message}>
|
||||
<Select placeholder="请选择" options={INDUSTRIES.map((i) => ({ value: i, label: i }))} {...register('industry')} />
|
||||
</Field>
|
||||
<Field label="团队规模" required error={errors.teamSize?.message}>
|
||||
<Select placeholder="请选择" options={TEAM_SIZES.map((s) => ({ value: s, label: s }))} {...register('teamSize')} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-1">
|
||||
<Checkbox
|
||||
checked={agreed}
|
||||
onCheckedChange={setAgreed}
|
||||
label="我已阅读并同意《隐私政策》和《服务协议》"
|
||||
/>
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={agreed}
|
||||
onCheckedChange={setAgreed}
|
||||
label="我已阅读并同意《隐私政策》和《服务协议》"
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full" size="lg" loading={loading}>
|
||||
创建企业并开始使用
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
已有账号?{' '}
|
||||
<Link to="/login" className="font-medium text-primary hover:underline">
|
||||
直接登录
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" className="h-11 w-full rounded-xl shadow-[0_10px_24px_-14px_rgba(37,99,235,.8)]" loading={loading}>
|
||||
创建企业
|
||||
</Button>
|
||||
<p className="text-center text-[11px] leading-5 text-slate-400">
|
||||
注册后先选择需要接入的渠道,也可以暂时跳过。
|
||||
</p>
|
||||
</form>
|
||||
</AuthShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { CreditCard, Check, Download, ExternalLink } from 'lucide-react'
|
||||
import { toast, errorMessage } from '@/app/store/toast'
|
||||
import { PageHeader } from '@/components/common/PageHeader'
|
||||
@@ -13,16 +13,17 @@ import { getBilling, upgradePlan } from '@/services/api/org'
|
||||
import { PRICING_PLANS, type PlanKey } from '@/config/pricingPlans'
|
||||
import { formatMoney, formatDateTime } from '@/utils/format'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { DemoNote } from '@/components/common/DemoNote'
|
||||
|
||||
export function BillingPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data } = useQuery({ queryKey: ['billing'], queryFn: getBilling })
|
||||
const [upgradeTo, setUpgradeTo] = useState<PlanKey | null>(null)
|
||||
|
||||
const upgradeMut = useMutation({
|
||||
mutationFn: (key: PlanKey) => upgradePlan(key),
|
||||
onSuccess: () => { toast.success('套餐升级成功', '新套餐权益已生效'); queryClient.invalidateQueries({ queryKey: ['billing'] }); setUpgradeTo(null) },
|
||||
onSuccess: (result) => {
|
||||
toast.success('正在跳转到 Stripe 安全支付页面')
|
||||
window.location.assign(result.checkoutUrl)
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
})
|
||||
|
||||
@@ -32,7 +33,7 @@ export function BillingPage() {
|
||||
|
||||
return (
|
||||
<div className="p-4 md:p-6">
|
||||
<PageHeader title="套餐与账单" description="管理订阅与查看使用量" actions={<DemoNote text="价格来自演示配置" />} />
|
||||
<PageHeader title="套餐与账单" description="管理套餐、订阅和支付记录" />
|
||||
|
||||
{info && (
|
||||
<Card className="mb-4 p-4">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user