2 Commits

109 changed files with 10130 additions and 4091 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules
server/node_modules
dist
.git
.env
*.log
+39 -6
View File
@@ -1,7 +1,40 @@
# ===== 灵犀跨境 前端环境变量 =====
# 是否使用内置 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
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=
+2
View File
@@ -1,6 +1,8 @@
node_modules
dist
dist-ssr
.env
server/.env
*.local
.env.local
.env.*.local
+12
View File
@@ -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
+42 -145
View File
@@ -1,156 +1,53 @@
# 灵犀跨境 · 跨境 AI 经营服务平台(前端)
# 灵犀跨境
> 让跨境沟通更简单 — 徐州欧汇科技有限公司
灵犀跨境是面向跨境业务团队的 Telegram 客户沟通与 AI 经营平台。当前版本只开放 Telegram,所有开放功能均访问真实 API、PostgreSQL、DeepSeek 和 Telegram Gateway,不包含前端 Mock 数据。
本仓库为「灵犀跨境」SaaS 平台的前端部分,面向中小外贸企业、跨境卖家和产业带企业,将 WhatsApp / Telegram / 邮件 / 独立站表单等渠道的客户咨询统一接入,通过 AI 翻译、意图识别、情感分析、风险预警与客户建档,将聊天记录沉淀为可追踪、可成交的客户资产。
## 服务组成
## 技术栈
- React + TypeScript 前端,由 Nginx 提供静态资源和 API 反向代理。
- Node.js API,负责独立租户、用户认证、CRM、会话、AI 和网关适配。
- PostgreSQL,持久化租户隔离的业务数据。
- 已有 Telegram Gateway,负责真实 Telegram 账号与 TDLib 操作。
- DeepSeek Anthropic-compatible API,负责翻译、情感、意图、风险、摘要和回复建议。
- **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** — 图标
## Docker 启动
## 快速开始
1. 复制 `.env.example` 为 `.env`。
2. 生成数据库、JWT、Webhook 随机密钥。
3. 设置轮换后的 `DEEPSEEK_API_KEY`。
4. 设置 Telegram Gateway 的租户令牌、租户 ID 和账号 ID。
5. 启动:
```bash
# 1. 安装依赖
```powershell
docker compose --env-file .env up -d --build
```
默认访问地址:`http://127.0.0.1:8088`。
健康检查:
```powershell
Invoke-RestMethod http://127.0.0.1:8088/health
Invoke-RestMethod http://127.0.0.1:8088/api/dashboard/overview
```
业务 API 需要通过注册或登录取得 Bearer Token。浏览器永远不会收到 DeepSeek 或 Telegram Gateway 服务凭据。
## 开发检查
```powershell
npm install
npm run typecheck
npm run build
# 2. 启动开发服务器(默认 Mock 模式)
npm run dev
# 3. 浏览器打开 http://localhost:5173
Set-Location server
npm install
node --check src/index.js
```
登录页点击下方**演示角色一键登录**即可进入系统:
- 企业管理员 / 业务主管 / 外贸业务员 / 客服人员 / 只读成员
## 安全说明
也可以使用验证码模式:任意手机号 / 邮箱 + 任意 6 位验证码即可登录。
## 构建生产包
```bash
npm run build # TypeScript 检查 + Vite 构建
npm run preview # 预览构建结果
```
## 环境变量与模式切换
项目通过 `.env` 文件控制数据来源:
```env
# 使用内置 Mock 服务(默认,无需后端)
VITE_USE_MOCK=true
# 真实后端 API 地址(VITE_USE_MOCK=false 时生效)
VITE_API_BASE_URL=http://localhost:8080/api
```
- **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。
> ⚠️ **安全提示**:请勿在前端代码中存放任何真实 API 密钥。所有密钥应由服务端环境变量管理。
## 项目结构
```
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 变量
```
## 接入真实后端
项目中预留了完整的 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` 已加入 `.gitignore`,严禁提交真实密钥。
- AI 回复只提供人工确认建议,不会自动向 Telegram 发送。
- 所有业务资源都通过 JWT 中的租户 ID 隔离。
- Webhook 必须配置独立随机密钥;生产部署应进一步使用 HTTPS、反向代理和 IP 白名单。
+14
View File
@@ -0,0 +1,14 @@
name: lingxi-email-protocol
services:
greenmail:
image: lingxi-greenmail-test:2.1.8-runtime
ports:
- "127.0.0.1:3025:3025"
- "127.0.0.1:3143:3143"
healthcheck:
test: ["CMD-SHELL", "nc -z 127.0.0.1 3025"]
interval: 3s
timeout: 2s
retries: 20
restart: unless-stopped
+79
View File
@@ -0,0 +1,79 @@
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
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}
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
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:
+4
View File
@@ -0,0 +1,4 @@
FROM lingxi-greenmail-test:2.1.8
ENTRYPOINT ["java"]
CMD ["-Dgreenmail.setup.test.smtp", "-Dgreenmail.setup.test.imap", "-Dgreenmail.hostname=0.0.0.0", "-Dgreenmail.users.login=email", "-Dgreenmail.users=sender:pwd@local.test,receiver:pwd@local.test", "-jar", "/tmp/greenmail.jar"]
+34
View File
@@ -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;
}
}
+295 -21
View File
@@ -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",
+7 -3
View File
@@ -3,12 +3,14 @@
"private": true,
"version": "1.0.0",
"type": "module",
"description": "灵犀跨境 - 跨境AI经营服务平台前端(演示环境,Mock数据)",
"description": "灵犀跨境 - Telegram 跨境 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",
+20
View File
@@ -0,0 +1,20 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
timeout: 30_000,
expect: { timeout: 10_000 },
fullyParallel: false,
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',
})
+22
View File
@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320" viewBox="0 0 320 320">
<g fill="none" stroke="#6f96bd" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" opacity=".13">
<path d="M25 31h39a9 9 0 0 1 9 9v19a9 9 0 0 1-9 9H47l-11 9 2-9H25a9 9 0 0 1-9-9V40a9 9 0 0 1 9-9Z"/>
<path d="M29 45h31M29 54h20"/>
<path d="m221 26 36 15-36 15 7-12 16-3-16-3-7-12Z"/>
<circle cx="132" cy="91" r="18"/>
<path d="M114 91h36M132 73c6 6 9 12 9 18s-3 12-9 18c-6-6-9-12-9-18s3-12 9-18Z"/>
<path d="m278 105 3 8 8 3-8 3-3 8-3-8-8-3 8-3 3-8Z"/>
<rect x="25" y="174" width="46" height="32" rx="7"/>
<path d="m29 180 19 14 19-14"/>
<path d="M168 195a20 20 0 0 1 40 0v18M168 195v12a7 7 0 0 0 7 7h5v-19h-5a7 7 0 0 0-7 7M208 195v19h5a7 7 0 0 0 7-7v-5a7 7 0 0 0-7-7h-5Z"/>
<path d="M179 226h17a8 8 0 0 0 8-8"/>
<path d="M270 228h27a8 8 0 0 1 8 8v16a8 8 0 0 1-8 8h-8l-8 7 1-7h-12a8 8 0 0 1-8-8v-16a8 8 0 0 1 8-8Z"/>
<path d="M274 239h19M274 247h13"/>
<circle cx="93" cy="267" r="12"/>
<path d="M88 267h10M93 262v10"/>
<path d="m31 278 2 5 5 2-5 2-2 5-2-5-5-2 5-2 2-5Z"/>
<circle cx="252" cy="164" r="3"/>
<circle cx="102" cy="155" r="2"/>
<circle cx="292" cy="58" r="2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+8
View File
@@ -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"]
+1964
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"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",
"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"
}
}
+64
View File
@@ -0,0 +1,64 @@
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, json = false) {
const apiKey = process.env.DEEPSEEK_API_KEY
if (!apiKey) throw Object.assign(new Error('DEEPSEEK_API_KEY 尚未配置'), { status: 503 })
const endpoint = `${(process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/anthropic').replace(/\/$/, '')}/v1/messages`
const attempts = json ? 2 : 1
let prompt = user
let lastError
for (let attempt = 0; attempt < attempts; attempt += 1) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), Number(process.env.AI_TIMEOUT_MS || 45000))
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(process.env.AI_MAX_TOKENS || 4096),
temperature: json ? 0 : 0.3,
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;字符串中的双引号、换行和反斜杠必须正确转义。`
}
} finally {
clearTimeout(timeout)
}
}
throw Object.assign(new Error('AI 返回格式异常,自动重试后仍无法解析'), { status: 502, cause: lastError })
}
export function conversationContext(messages) {
return messages.slice(-80).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')
}
+76
View File
@@ -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
}
+97
View File
@@ -0,0 +1,97 @@
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 = {}) {
await pool.query(
'INSERT INTO audit_logs(id,tenant_id,user_id,action,target,detail,ip) VALUES($1,$2,$3,$4,$5,$6,$7)',
[uid(), req.auth?.tenantId ?? null, req.auth?.userId ?? null, action, target, detail, req.ip],
)
}
+176
View File
@@ -0,0 +1,176 @@
import crypto from 'node:crypto'
import nodemailer from 'nodemailer'
import { ImapFlow } from 'imapflow'
import { simpleParser } from 'mailparser'
function encryptionKey() {
const secret = process.env.EMAIL_CREDENTIAL_KEY || process.env.JWT_SECRET
if (!secret) throw Object.assign(new Error('EMAIL_CREDENTIAL_KEY 或 JWT_SECRET 尚未配置'), { status: 503 })
return crypto.createHash('sha256').update(secret).digest()
}
export function encryptEmailPassword(password) {
const iv = crypto.randomBytes(12)
const cipher = crypto.createCipheriv('aes-256-gcm', encryptionKey(), iv)
const encrypted = Buffer.concat([cipher.update(String(password), 'utf8'), cipher.final()])
return `v1:${iv.toString('base64url')}:${cipher.getAuthTag().toString('base64url')}:${encrypted.toString('base64url')}`
}
export function decryptEmailPassword(value) {
const [version, ivValue, tagValue, encryptedValue] = String(value || '').split(':')
if (version !== 'v1' || !ivValue || !tagValue || !encryptedValue) {
throw Object.assign(new Error('Email 凭据无效,请重新连接邮箱'), { status: 409 })
}
const decipher = crypto.createDecipheriv('aes-256-gcm', encryptionKey(), Buffer.from(ivValue, 'base64url'))
decipher.setAuthTag(Buffer.from(tagValue, 'base64url'))
return Buffer.concat([
decipher.update(Buffer.from(encryptedValue, 'base64url')),
decipher.final(),
]).toString('utf8')
}
function imapClient(channel, password) {
return new ImapFlow({
host: channel.imapHost,
port: Number(channel.imapPort || 993),
secure: channel.imapSecure !== false,
auth: { user: channel.account, pass: password },
logger: false,
tls: { rejectUnauthorized: channel.allowInvalidCertificate !== true },
})
}
function smtpTransport(channel, password) {
return nodemailer.createTransport({
host: channel.smtpHost,
port: Number(channel.smtpPort || 465),
secure: channel.smtpSecure !== false,
auth: { user: channel.account, pass: password },
tls: { rejectUnauthorized: channel.allowInvalidCertificate !== true },
})
}
export async function verifyEmailConnection(channel, password) {
const startedAt = Date.now()
const imap = imapClient(channel, password)
try {
await imap.connect()
await imap.mailboxOpen(channel.imapFolder || 'INBOX', { readOnly: true })
} finally {
if (imap.usable) await imap.logout().catch(() => undefined)
}
const smtp = smtpTransport(channel, password)
await smtp.verify()
smtp.close()
return { latencyMs: Date.now() - startedAt }
}
function address(value) {
const item = value?.value?.[0]
return {
name: String(item?.name || item?.address || '').trim(),
email: String(item?.address || '').trim().toLowerCase(),
}
}
export async function fetchEmailMessages(channel, { full = false } = {}) {
const password = decryptEmailPassword(channel.encryptedPassword)
const client = imapClient(channel, password)
const messages = []
try {
await client.connect()
await client.mailboxOpen(channel.imapFolder || 'INBOX')
let uids
if (!full && Number(channel.lastUid || 0) > 0) {
uids = await client.search({ uid: `${Number(channel.lastUid) + 1}:*` }, { uid: true })
} else {
const days = Math.max(1, Math.min(365, Number(channel.syncDays || 30)))
uids = await client.search({ since: new Date(Date.now() - days * 86400000) }, { uid: true })
}
uids = (uids || []).slice(-500)
if (!uids.length) return { messages, lastUid: Number(channel.lastUid || 0) }
for await (const row of client.fetch(uids, {
uid: true,
envelope: true,
flags: true,
internalDate: true,
source: true,
}, { uid: true })) {
const parsed = await simpleParser(row.source)
const sender = address(parsed.from)
const recipients = parsed.to?.value || []
const attachments = (parsed.attachments || []).map((attachment, index) => ({
id: `${row.uid}-${index + 1}`,
name: attachment.filename || `attachment-${index + 1}`,
mimeType: attachment.contentType || 'application/octet-stream',
size: attachment.size || attachment.content?.length || 0,
contentId: attachment.contentId,
base64: attachment.content?.length <= 20 * 1024 * 1024
? attachment.content.toString('base64')
: undefined,
}))
messages.push({
uid: Number(row.uid),
messageId: String(parsed.messageId || `imap-${row.uid}`),
inReplyTo: parsed.inReplyTo || undefined,
references: Array.isArray(parsed.references) ? parsed.references : parsed.references ? [parsed.references] : [],
subject: String(parsed.subject || '(无主题)').trim(),
from: sender,
to: recipients.map((item) => ({ name: item.name || item.address || '', email: String(item.address || '').toLowerCase() })),
text: String(parsed.text || '').trim() || String(parsed.html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(),
html: typeof parsed.html === 'string' ? parsed.html : undefined,
date: (parsed.date || row.internalDate || new Date()).toISOString(),
seen: row.flags?.has('\\Seen') || false,
attachments,
})
}
return {
messages: messages.sort((a, b) => Date.parse(a.date) - Date.parse(b.date)),
lastUid: Math.max(Number(channel.lastUid || 0), ...messages.map((message) => message.uid)),
}
} finally {
if (client.usable) await client.logout().catch(() => undefined)
}
}
export async function sendEmailMessage(channel, {
to,
subject,
text,
html,
inReplyTo,
references,
attachments,
}) {
const password = decryptEmailPassword(channel.encryptedPassword)
const transport = smtpTransport(channel, password)
try {
return await transport.sendMail({
from: channel.displayName ? { name: channel.displayName, address: channel.account } : channel.account,
to,
subject,
text,
html,
inReplyTo,
references,
attachments,
})
} finally {
transport.close()
}
}
export async function markEmailUidsSeen(channel, uids) {
const values = [...new Set(uids.map(Number).filter(Number.isFinite))]
if (!values.length) return
const password = decryptEmailPassword(channel.encryptedPassword)
const client = imapClient(channel, password)
try {
await client.connect()
await client.mailboxOpen(channel.imapFolder || 'INBOX')
await client.messageFlagsAdd(values, ['\\Seen'], { uid: true })
} finally {
if (client.usable) await client.logout().catch(() => undefined)
}
}
+79
View File
@@ -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 })
}
+2754
View File
File diff suppressed because it is too large Load Diff
+54
View File
@@ -0,0 +1,54 @@
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')
assert.throws(() => decryptEmailPassword(`${encrypted.slice(0, -1)}x`))
})
+169
View File
@@ -0,0 +1,169 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
encryptEmailPassword,
fetchEmailMessages,
sendEmailMessage,
} from '../src/email.js'
const enabled = process.env.RUN_EMAIL_API_E2E === '1'
const baseUrl = process.env.EMAIL_API_E2E_BASE_URL || 'http://127.0.0.1:8088'
function localChannel(account) {
return {
account,
encryptedPassword: encryptEmailPassword('pwd'),
imapHost: '127.0.0.1',
imapPort: 3143,
imapSecure: false,
imapFolder: 'INBOX',
smtpHost: '127.0.0.1',
smtpPort: 3025,
smtpSecure: false,
syncDays: 7,
}
}
function unwrap(payload) {
return payload && typeof payload === 'object' && 'data' in payload ? payload.data : payload
}
async function request(path, { token, method = 'GET', body } = {}) {
const response = await fetch(`${baseUrl}${path}`, {
method,
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
},
body: body === undefined ? undefined : JSON.stringify(body),
})
const text = await response.text()
const payload = text ? JSON.parse(text) : null
if (!response.ok) throw new Error(`${method} ${path} -> ${response.status}: ${payload?.message || text}`)
return unwrap(payload)
}
async function waitForSenderMessage(predicate) {
const sender = localChannel('sender@local.test')
for (let attempt = 0; attempt < 20; attempt += 1) {
const result = await fetchEmailMessages(sender, { full: true })
const message = result.messages.find(predicate)
if (message) return message
await new Promise((resolve) => setTimeout(resolve, 150))
}
throw new Error('等待网关发出的邮件进入 sender IMAP 超时')
}
test('真实 HTTP 网关完成 Email 连接、全量同步、附件读取、回复与已读', { skip: !enabled }, async () => {
process.env.JWT_SECRET ||= 'lingxi-email-api-e2e-secret'
const suffix = Date.now()
const senderChannel = localChannel('sender@local.test')
const olderSubject = `Lingxi sorting older ${suffix}`
const newerSubject = `Lingxi sorting newer ${suffix}`
await sendEmailMessage(senderChannel, {
to: 'receiver@local.test',
subject: olderSubject,
text: 'Older conversation used for stable ordering',
})
await new Promise((resolve) => setTimeout(resolve, 30))
await sendEmailMessage(senderChannel, {
to: 'receiver@local.test',
subject: newerSubject,
text: 'Newer conversation must remain above the opened older conversation',
})
const session = await request('/api/auth/register', {
method: 'POST',
body: {
name: 'Email API E2E',
account: `email-api-${suffix}@local.test`,
password: 'Lingxi-Email-Api-2026!',
company: 'Lingxi Protocol Lab',
industry: '跨境电商',
teamSize: '1-10人',
},
})
assert.ok(session.token)
const connected = await request('/api/channels/email/connect', {
token: session.token,
method: 'POST',
body: {
name: '本地协议邮箱',
displayName: 'Lingxi Receiver',
account: 'receiver@local.test',
password: 'pwd',
imapHost: 'host.docker.internal',
imapPort: 3143,
imapSecure: false,
imapFolder: 'INBOX',
smtpHost: 'host.docker.internal',
smtpPort: 3025,
smtpSecure: false,
syncDays: 7,
},
})
assert.equal(connected.status, 'connected')
assert.equal(connected.encryptedPassword, undefined)
const conversations = await request('/api/conversations?channel=email', { token: session.token })
assert.ok(conversations.list.length >= 2)
const newerConversation = conversations.list.find((item) => item.emailSubject === newerSubject)
const olderConversation = conversations.list.find((item) => item.emailSubject === olderSubject)
assert.ok(newerConversation)
assert.ok(olderConversation)
assert.ok(conversations.list.indexOf(newerConversation) < conversations.list.indexOf(olderConversation))
const olderMessages = await request(`/api/conversations/${olderConversation.id}/messages`, { token: session.token })
await request(`/api/conversations/${olderConversation.id}/read`, {
token: session.token,
method: 'POST',
body: { messageIds: olderMessages.map((message) => message.id) },
})
const afterRead = await request('/api/conversations?channel=email', { token: session.token })
const relevantOrder = afterRead.list
.filter((item) => [newerConversation.id, olderConversation.id].includes(item.id))
.map((item) => item.id)
assert.deepEqual(relevantOrder, [newerConversation.id, olderConversation.id])
let conversation
let incoming
for (const candidate of conversations.list.filter((item) => item.customer?.email === 'sender@local.test')) {
const candidateMessages = await request(`/api/conversations/${candidate.id}/messages`, { token: session.token })
const candidateIncoming = candidateMessages.find((message) => message.sender === 'customer' && message.emailAttachments?.length)
if (candidateIncoming) {
conversation = candidate
incoming = candidateIncoming
break
}
}
assert.ok(conversation)
assert.ok(incoming)
const attachment = await request(
`/api/conversations/${conversation.id}/messages/${incoming.id}/attachment?attachmentId=${encodeURIComponent(incoming.emailAttachments[0].id)}`,
{ token: session.token },
)
assert.equal(attachment.name, 'lingxi-proof.txt')
assert.ok(attachment.dataUrl.startsWith('data:text/plain;base64,'))
const replyText = `Lingxi gateway API reply ${suffix}`
const sent = await request(`/api/conversations/${conversation.id}/messages`, {
token: session.token,
method: 'POST',
body: { text: replyText, autoTranslate: false },
})
assert.equal(sent.status, 'sent')
assert.ok(sent.emailMessageId)
const receivedBySender = await waitForSenderMessage((message) => message.messageId === sent.emailMessageId)
assert.equal(receivedBySender.text, replyText)
assert.ok(receivedBySender.inReplyTo)
await request(`/api/conversations/${conversation.id}/read`, {
token: session.token,
method: 'POST',
body: { messageIds: [incoming.id] },
})
const receiver = await fetchEmailMessages(localChannel('receiver@local.test'), { full: true })
assert.equal(receiver.messages.find((message) => message.uid === incoming.emailUid)?.seen, true)
})
+86
View File
@@ -0,0 +1,86 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
encryptEmailPassword,
fetchEmailMessages,
markEmailUidsSeen,
sendEmailMessage,
verifyEmailConnection,
} from '../src/email.js'
const enabled = process.env.RUN_EMAIL_PROTOCOL_TEST === '1'
function channel(account) {
return {
id: `protocol-${account}`,
type: 'email',
account,
displayName: account.startsWith('sender') ? 'Lingxi Sender' : 'Lingxi Receiver',
encryptedPassword: encryptEmailPassword('pwd'),
imapHost: process.env.EMAIL_TEST_HOST || '127.0.0.1',
imapPort: Number(process.env.EMAIL_TEST_IMAP_PORT || 3143),
imapSecure: false,
imapFolder: 'INBOX',
smtpHost: process.env.EMAIL_TEST_HOST || '127.0.0.1',
smtpPort: Number(process.env.EMAIL_TEST_SMTP_PORT || 3025),
smtpSecure: false,
syncDays: 7,
}
}
async function waitForMessage(target, predicate) {
for (let attempt = 0; attempt < 20; attempt += 1) {
const result = await fetchEmailMessages(target, { full: true })
const message = result.messages.find(predicate)
if (message) return { message, result }
await new Promise((resolve) => setTimeout(resolve, 150))
}
throw new Error('等待邮件进入 IMAP INBOX 超时')
}
test('真实 SMTP 发件、IMAP 收件、附件解析、已读与回复头', { skip: !enabled }, async () => {
process.env.JWT_SECRET ||= 'lingxi-email-protocol-test-secret'
const sender = channel('sender@local.test')
const receiver = channel('receiver@local.test')
const subject = `Lingxi protocol ${Date.now()}`
const senderCheck = await verifyEmailConnection(sender, 'pwd')
const receiverCheck = await verifyEmailConnection(receiver, 'pwd')
assert.ok(senderCheck.latencyMs >= 0)
assert.ok(receiverCheck.latencyMs >= 0)
assert.ok(!sender.encryptedPassword.includes('pwd'))
const sent = await sendEmailMessage(sender, {
to: receiver.account,
subject,
text: 'Lingxi IMAP/SMTP real protocol message',
attachments: [{
filename: 'lingxi-proof.txt',
content: Buffer.from('real email attachment'),
contentType: 'text/plain',
}],
})
assert.ok(sent.messageId)
const received = await waitForMessage(receiver, (message) => message.subject === subject)
assert.equal(received.message.text, 'Lingxi IMAP/SMTP real protocol message')
assert.equal(received.message.from.email, sender.account)
assert.equal(received.message.attachments.length, 1)
assert.equal(received.message.attachments[0].name, 'lingxi-proof.txt')
assert.equal(Buffer.from(received.message.attachments[0].base64, 'base64').toString(), 'real email attachment')
await markEmailUidsSeen(receiver, [received.message.uid])
const marked = await fetchEmailMessages(receiver, { full: true })
assert.equal(marked.messages.find((message) => message.uid === received.message.uid)?.seen, true)
const reply = await sendEmailMessage(receiver, {
to: sender.account,
subject: `Re: ${subject}`,
text: 'Reply received by Lingxi',
inReplyTo: received.message.messageId,
references: [received.message.messageId],
})
const replyReceived = await waitForMessage(sender, (message) => message.messageId === reply.messageId)
assert.equal(replyReceived.message.inReplyTo, received.message.messageId)
assert.ok(replyReceived.message.references.includes(received.message.messageId))
})
+15 -41
View File
@@ -5,23 +5,6 @@ import 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)
@@ -38,14 +21,6 @@ function GuestOnly({ children }: { children: ReactNode }) {
const router = createBrowserRouter([
{ path: '/login', element: <GuestOnly><LoginPage /></GuestOnly> },
{ path: '/register', element: <GuestOnly><RegisterPage /></GuestOnly> },
{
path: '/onboarding',
element: (
<RequireAuth>
<OnboardingPage />
</RequireAuth>
),
},
{
path: '/',
element: (
@@ -55,22 +30,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 }) },
],
},
])
-5
View File
@@ -2,7 +2,6 @@ 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'
interface AuthState {
@@ -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' },
+3 -3
View File
@@ -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 }}>
-18
View File
@@ -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>
)
}
+17
View File
@@ -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>
)
}
+8 -5
View File
@@ -27,17 +27,20 @@ export function StatCard({
const DeltaIcon = neutral ? Minus : (delta ?? 0) > 0 ? TrendingUp : TrendingDown
return (
<Card
className={cn('p-4', onClick && 'cursor-pointer transition-shadow hover:shadow-md')}
className={cn(
'group p-5 transition-all duration-200',
onClick && 'cursor-pointer hover:-translate-y-0.5 hover:border-primary-200 hover:shadow-[0_14px_35px_rgba(15,23,42,.07)]',
)}
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-xl 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: 'AI 综合预测成交概率' },
{ 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">AI 六维购买力画像 · 随会话实时更新</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} · AI 评估`}
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>
)
}
+62 -31
View File
@@ -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,12 +19,15 @@ export function AiReplies({
conversationId,
onAdopt,
onEdit,
onClose,
}: {
conversationId: string
onAdopt: (content: string) => void
onEdit: (content: string) => void
onAdopt: (content: string, translationCn: string) => void
onEdit: (content: string, translationCn: string) => void
onClose: () => void
}) {
const [items, setItems] = useState<AiReplySuggestion[] | null>(null)
useEffect(() => setItems(null), [conversationId])
const mutation = useMutation({
mutationFn: () => generateReplies(conversationId),
@@ -36,27 +39,32 @@ export function AiReplies({
})
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" />
<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>
AI 智能回复
</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="关闭 AI 智能回复">
<X className="h-4 w-4" />
</Button>
</div>
</div>
{mutation.isPending && (
@@ -76,7 +84,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 +94,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" />
发送给客户 · English
</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 +133,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 +145,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)}>
<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 +168,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>
)
+93 -39
View File
@@ -10,6 +10,8 @@ import {
Shrink,
Expand,
ChevronDown,
Reply,
X,
} from 'lucide-react'
import { Segmented } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
@@ -17,7 +19,7 @@ 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'
const QUICK_PHRASES = [
{ label: '报价催促', text: 'Friendly reminder: the quotation is valid until this Friday. Please confirm at your earliest convenience.' },
@@ -39,27 +41,54 @@ const TONES = [
export function Composer({
onSend,
onMedia,
value,
onChange,
disabled,
replyingTo,
onCancelReply,
channel = 'telegram',
}: {
onSend: (text: string, translatedText?: string) => Promise<void>
onSend: (text: string, autoTranslate?: boolean) => 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
}) {
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)
toast.success(`${files.length} 个文件已发送到${channel === 'email' ? '客户邮箱' : ' Telegram'}`)
} catch {
toast.error('媒体发送失败')
} finally {
setSending(false)
}
}
const send = async () => {
if (!text.trim()) return
setSending(true)
try {
await onSend(text, autoTranslate)
setText('')
setPreviewText('')
} finally {
setSending(false)
}
@@ -73,7 +102,7 @@ export function Composer({
}
const insert = (txt: string) => {
setText((prev) => (prev ? `${prev}\n${txt}` : txt))
setText(text ? `${text}\n${txt}` : txt)
textarea.current?.focus()
}
@@ -93,11 +122,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 +136,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 +188,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 +196,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,28 +206,37 @@ 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">
<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} />
中文自动译为英文{channel === 'email' ? '邮件' : '发送'}
</label>
{aiLoading && (
<Badge variant="primary" className="animate-pulse">
@@ -192,7 +246,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,64 @@
import { Brain, ChevronRight } from 'lucide-react'
import type { 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,
}: {
customer: Customer
conversation: Conversation
onOpenProfile: () => void
}) {
const stage = validStages.has(customer.stage) ? customer.stage : 'initial'
const summary = customer.aiProfile?.summary || conversation.summary || 'AI 正在根据会话完善客户画像'
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">
AI 客户洞察
</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">
<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">
<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>
)
}
+159 -29
View File
@@ -1,4 +1,5 @@
import { Search, ShieldAlert } from 'lucide-react'
import { useRef, useState } from 'react'
import { Eraser, Mail, 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,6 +22,7 @@ const FILTERS = [
{ value: 'risk', label: '有风险' },
{ value: 'unassigned', label: '无负责人' },
{ value: 'mine', label: '我的客户' },
{ value: 'archived', label: '已归档' },
]
export function ConversationList({
@@ -32,6 +34,11 @@ export function ConversationList({
onFilterChange,
keyword,
onKeywordChange,
channel,
onChannelChange,
onAdd,
onClear,
onDelete,
}: {
items: ConversationListItem[]
loading: boolean
@@ -41,34 +48,82 @@ 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="添加 Telegram 用户或群组"
aria-label="添加 Telegram 用户或群组"
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: '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 +133,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 +152,125 @@ export function ConversationList({
const c = item.customer
const active = item.id === activeId
const intent = intentLevelMeta(item.intentLevel)
const isTelegramChannel = String(item.telegramChatType || '').toLowerCase() === 'channel'
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">
{item.channel === 'telegram' && (
<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 || ''}
onTouchStart={(event) => {
if (item.channel !== 'telegram') 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>
+73 -20
View File
@@ -14,7 +14,9 @@ import {
} from 'lucide-react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { updateCustomer } from '@/services/api/customers'
import { createTask } from '@/services/api/crm'
import { analyzeConversation } from '@/services/api/ai'
import { useAuthStore } from '@/app/store/auth'
import type { Customer, Conversation } from '@/types'
import { Avatar } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
@@ -29,6 +31,8 @@ import { IntentScoreBadge, CustomerStageBadge, SentimentBadge, RiskLevelBadge }
import { CUSTOMER_STAGES, NEXT_ACTIONS, countryMeta, INTENT_LEVELS } from '@/config/constants'
import { toast, errorMessage } from '@/app/store/toast'
import { cn } from '@/utils/cn'
import { CustomerProfileRadar } from '@/components/customer/CustomerProfileRadar'
import { CustomerSemanticTag } from '@/components/customer/CustomerSemanticTag'
export function InsightPanel({
customer,
@@ -38,6 +42,7 @@ 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)
@@ -54,6 +59,25 @@ 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: `由 AI 客户洞察建议创建:${title}`,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] })
toast.success('已创建跟进任务', '默认明天到期')
},
onError: (error) => toast.error(errorMessage(error)),
})
const handleAnalyze = async () => {
setAnalyzing(true)
@@ -67,13 +91,16 @@ export function InsightPanel({
}
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" />
@@ -117,7 +144,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 +175,14 @@ 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-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">AI 洞察摘要</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 +193,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 || 'AI 正在根据会话完善客户画像。'}</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">AI 推荐下一步</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>
+280 -28
View File
@@ -12,17 +12,68 @@ import {
ListTodo,
BookOpen,
MoreHorizontal,
Languages,
Globe,
ChevronDown,
ChevronUp,
Download,
FileText,
LoaderCircle,
} from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import type { 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 +82,8 @@ export function MessageBubble({
onConvertToTask,
onAddToKnowledge,
onAddNote,
customerName,
customerAvatar,
}: {
msg: Message
viewMode: ViewMode
@@ -39,11 +92,36 @@ export function MessageBubble({
onConvertToTask: () => void
onAddToKnowledge: () => void
onAddNote: () => void
customerName?: string
customerAvatar?: string
}) {
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 rawTranslation = String(msg.translatedText || '')
const translationCorrupted =
/(?:^|\n)\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:[\d.]+Z\s+(?:客户|坐席):/m.test(rawTranslation) ||
rawTranslation.length > (msg.originalText.length <= 20 ? 160 : Math.max(500, msg.originalText.length * 8))
const translatedText = translationCorrupted ? '' : rawTranslation
const hasTranslation = translatedText && translatedText !== msg.originalText
const outgoingChineseNote = isMe
? String(
msg.translationCn ||
(hasTranslation && /[\u3400-\u9fff]/u.test(msg.originalText) ? msg.originalText : ''),
).trim()
: ''
const outgoingSentText = isMe && translatedText ? translatedText : msg.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 { data: attachment, isLoading: attachmentLoading, error: attachmentError } = useQuery({
queryKey: ['message-attachment', msg.conversationId, msg.id, msg.telegramFileId],
queryFn: () => getMessageAttachment(msg.conversationId, msg.id),
enabled: Boolean(msg.telegramFileId),
staleTime: Infinity,
retry: 1,
})
if (isSystem) {
return (
@@ -53,33 +131,147 @@ export function MessageBubble({
)
}
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.type !== 'text') {
const labels = { image: '图片', audio: '语音', file: '文件', product: '产品卡片' }
return msg.originalText ? (
<p className="mb-2 whitespace-pre-wrap break-words text-sm leading-6 text-slate-700">{msg.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 (!String(msg.originalText || '').trim()) {
return <p className="text-xs text-slate-500">Telegram 服务通知</p>
}
if (
(viewMode === 'bilingual' || viewMode === 'translated') &&
msg.sender === 'customer' &&
msg.type === 'text' &&
(!translatedText || translationCorrupted)
) {
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>
<div className="space-y-2.5">
<p className="whitespace-pre-wrap break-words text-sm leading-6">{msg.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 ? '英文译文' : '原文'}
</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">{msg.originalText}</p>
</div>
<div className="border-t border-border/60 pt-2">
<span className="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-0.5 text-[10px] font-medium text-primary">
<Languages className="h-3 w-3" />
{isMe ? '发送内容 · 英文' : 'AI 译文 · 中文'}
</span>
<div className="mt-1.5">{translationContent()}</div>
</div>
</div>
)
}
if (viewMode === 'translated' && translatedText) {
return (
<div>
<span className="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-0.5 text-[10px] font-medium text-primary">
<Languages className="h-3 w-3" />
{isMe ? `${sourceLanguage} → 英文发送内容` : `${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')}>
{msg.originalText}
</p>
</div>
)
}
@@ -104,7 +296,6 @@ export function MessageBubble({
const moreActions = [
{ key: 'copy', label: '复制', icon: Copy, onSelect: () => { navigator.clipboard.writeText(msg.originalText); toast.success('已复制') } },
{ key: 'reply', label: '引用回复', icon: Reply, onSelect: onQuote },
{ key: 'forward', label: '转发', icon: Forward, onSelect: () => toast.info('转发功能:选择目标会话(演示)') },
{ key: 'note', label: '添加内部备注', icon: StickyNote, onSelect: onAddNote },
{ key: 'important', label: msg.isImportant ? '取消重要' : '标记重要', icon: Star, onSelect: onMarkImportant },
{ key: 'task', label: '转为待办', icon: ListTodo, onSelect: onConvertToTask },
@@ -113,30 +304,91 @@ export function MessageBubble({
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 || 'Telegram 用户'}
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',
)}
>
{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>
)}
{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.telegramFileId && attachmentLoading && (
<div className="mt-2 flex items-center gap-2 rounded-lg bg-muted/70 px-3 py-2 text-xs text-muted-foreground">
<LoaderCircle className="h-3.5 w-3.5 animate-spin" /> 正在读取 Telegram 文件…
</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}
</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>
)}
{msg.telegramFileId && 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}
/>
))}
</div>
{/* 底栏:时间 + 状态 */}
+3 -1
View File
@@ -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">
<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-white/35 scrollbar-thin" id="main-scroll">
<Outlet />
</main>
</div>
+63
View File
@@ -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
}
+31 -11
View File
@@ -26,33 +26,49 @@ 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-300/80 bg-[#f8fafc] shadow-[4px_0_16px_-14px_rgba(15,23,42,.32)] transition-[width] duration-200',
sidebarCollapsed ? 'w-[68px]' : 'w-[228px]',
)}
>
{/* 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 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">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-primary-600 to-primary-800 text-primary-foreground shadow-sm shadow-primary-900/10">
<Sparkles className="h-4.5 w-4.5 h-[18px] w-[18px]" />
</span>
{!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-2 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 +76,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',
'flex items-center gap-3 rounded-xl 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/90 font-semibold text-primary-800 shadow-[inset_0_0_0_1px_rgba(37,99,235,.08)]'
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900',
sidebarCollapsed && 'justify-center px-0',
)
}
@@ -71,7 +87,11 @@ 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>
{/* 底部:套餐与企业信息 */}
+7 -22
View File
@@ -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,7 +62,7 @@ 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="flex h-16 shrink-0 items-center gap-3 border-b border-slate-200/70 bg-white/90 px-3 backdrop-blur-xl md:px-5">
{/* 移动端菜单 */}
<button
className="rounded-md p-2 text-muted-foreground hover:bg-muted lg:hidden"
@@ -81,7 +82,7 @@ export function Topbar() {
</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') },
]}
@@ -110,7 +111,7 @@ export function Topbar() {
),
onSelect: () => {
setLocale(l.code as UiLocale)
toast.success(l.rtl ? '已切换为 RTL 布局(演示)' : `界面语言已切换:${l.nativeName}`)
toast.success(l.rtl ? '已切换为 RTL 布局' : `界面语言已切换:${l.nativeName}`)
},
}))}
/>
@@ -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>
@@ -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 },
]}
/>
+14 -1
View File
@@ -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
+3 -3
View File
@@ -4,7 +4,7 @@ import { cn } from '@/utils/cn'
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('rounded-lg border border-border bg-card shadow-[0_1px_2px_rgba(16,24,40,0.04)]', className)}
className={cn('rounded-2xl border border-slate-200/80 bg-card shadow-[0_1px_2px_rgba(15,23,42,0.035),0_10px_30px_rgba(15,23,42,0.025)]', className)}
{...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-sm font-semibold tracking-tight text-foreground">{title}</h3>}
{description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
{children}
</div>
+1 -1
View File
@@ -47,7 +47,7 @@ export function Dropdown({
{open && (
<div
className={cn(
'absolute z-40 mt-1 min-w-[160px] overflow-hidden rounded-md border border-border bg-popover py-1 shadow-lg animate-slide-in-top',
'absolute z-[80] mt-1 min-w-[160px] overflow-hidden rounded-md border border-border bg-popover py-1 shadow-lg animate-slide-in-top',
align === 'end' ? 'right-0 rtl:right-auto rtl:left-0' : 'left-0 rtl:left-auto rtl:right-0',
)}
>
+15 -3
View File
@@ -57,23 +57,35 @@ export function Segmented<T extends string>({
options,
size = 'default',
className,
ariaLabel = '切换选项',
}: {
value: T
onChange: (v: T) => void
options: { value: T; label: ReactNode }[]
size?: 'sm' | 'default'
className?: string
ariaLabel?: string
}) {
return (
<div className={cn('inline-flex items-center rounded-md bg-muted p-0.5', className)}>
<div
role="radiogroup"
aria-label={ariaLabel}
className={cn('inline-flex items-center rounded-lg border border-slate-200 bg-slate-100/90 p-0.5 shadow-inner', className)}
>
{options.map((o) => (
<button
type="button"
role="radio"
aria-checked={o.value === value}
data-state={o.value === value ? 'active' : 'inactive'}
key={o.value}
onClick={() => onChange(o.value)}
className={cn(
'rounded px-2.5 text-xs font-medium transition-colors',
'relative rounded-md px-2.5 text-xs font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40',
size === 'sm' ? 'h-6' : 'h-7',
o.value === value ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
o.value === value
? 'bg-primary text-white shadow-sm shadow-primary/20'
: 'text-slate-500 hover:bg-white/70 hover:text-slate-900',
)}
>
{o.label}
+1 -1
View File
@@ -263,7 +263,7 @@ export const LANGUAGES_LIST = [
'俄语', '法语', '德语', '土耳其语', '越南语', '印尼语', '日语', '韩语',
]
/** 产品库(Mock 企业主营产品) */
/** 企业主营产品选项 */
export const PRODUCTS = [
'双层玻璃杯 350ml',
'高硼硅玻璃杯 500ml',
+3 -5
View File
@@ -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 ?? '',
}
+11 -18
View File
@@ -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' },
]
-1
View File
@@ -69,7 +69,6 @@ export const PRICING_PLANS: PricingPlan[] = [
'AI 智能回复建议(多策略)',
'风险预警与文化敏感检测',
'老板经营看板',
'同声传译',
],
limits: { members: 20, messagesPerMonth: 100000, translationsPerMonth: 200000, knowledgeStorageMb: 5120 },
},
+1 -1
View File
@@ -1,4 +1,4 @@
/** 角色与权限配置(前端 Mock 权限控制) */
/** 角色与权限配置。后端会对每个请求再次执行同一角色边界校验。 */
export type RoleKey = 'admin' | 'manager' | 'sales' | 'service' | 'viewer'
+1 -4
View File
@@ -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': 'شروط الخدمة',
+1 -4
View File
@@ -6,7 +6,7 @@ export const en: LocaleDict = {
'app.company': 'Xuzhou Ouhui Technology Co., Ltd.',
'nav.dashboard': 'Workspace',
'nav.inbox': 'Unified Inbox',
'nav.inbox': 'Messages',
'nav.customers': 'Customers',
'nav.opportunities': 'Deals & Follow-ups',
'nav.translate': 'AI Translation',
@@ -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',
+3 -6
View File
@@ -4,15 +4,15 @@ export const zh = {
'app.company': '徐州欧汇科技有限公司',
'nav.dashboard': '工作台',
'nav.inbox': '统一消息',
'nav.inbox': '消息中心',
'nav.customers': '客户管理',
'nav.opportunities': '商机与跟进',
'nav.translate': 'AI翻译',
'nav.interpret': '同声传译',
'nav.risks': '风险预警',
'nav.risks': '风险工单',
'nav.knowledge': '企业知识库',
'nav.analytics': '数据分析',
'nav.channels': '渠道中心',
'nav.channels': '渠道与账号',
'nav.team': '团队协作',
'nav.billing': '套餐与账单',
'nav.settings': '系统设置',
@@ -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
View File
@@ -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
}
},
}
-208
View File
@@ -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 },
],
},
}
-30
View File
@@ -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) },
]
-26
View File
@@ -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) },
]
-443
View File
@@ -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: ['发送阶梯报价'],
},
},
]
-45
View File
@@ -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
}
-45
View File
@@ -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 },
]
-176
View File
@@ -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)),
]
-109
View File
@@ -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) },
]
-30
View File
@@ -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()
}
-126
View File
@@ -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',
},
]
-26
View File
@@ -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) },
]
-46
View File
@@ -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),
},
]
+5 -5
View File
@@ -4,7 +4,7 @@ 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'
@@ -26,8 +26,8 @@ export function AnalyticsPage() {
<div className="p-4 md:p-6">
<PageHeader
title="数据分析中心"
description="企业经营数据全景 · 当前展示演示数据"
actions={<DemoNote />}
description="企业经营数据全景 · 基于当前企业实时数据"
actions={<LiveDataBadge />}
extra={
<div className="flex flex-wrap items-center gap-2">
{['today', '7d', '30d', 'quarter'].map((p) => (
@@ -46,7 +46,7 @@ export function AnalyticsPage() {
</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}><TrendChart data={data.customerGrowth} series={[{ key: 'count', name: '新增客户', color: '#2563eb' }]} /></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>
@@ -57,7 +57,7 @@ export function AnalyticsPage() {
</div>
{/* 老板看板 */}
<h2 className="mt-6 mb-3 text-lg font-semibold">老板看板 <DemoNote className="align-middle" /></h2>
<h2 className="mt-6 mb-3 text-lg font-semibold">老板看板 <LiveDataBadge className="align-middle" /></h2>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card><CardHeader title="🔥 今日需处理的高价值客户" /><div className="divide-y divide-border">{data.bossBoard.urgentHighValue.map((c) => (<div key={c.id} className="flex items-center justify-between px-4 py-2.5 text-sm"><span>{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="text-xs text-muted-foreground">{c.reason.slice(0, 20)}…</span></div>))}</div></Card>
<Card><CardHeader title="⚠️ 存在逾期跟进的业务员" /><div className="divide-y divide-border">{data.bossBoard.overdueMembers.map((m) => (<div key={m.id} className="flex items-center justify-between px-4 py-2.5 text-sm"><span>{m.name}</span><Badge variant="danger">{m.overdueCount} 项逾期</Badge></div>))}</div></Card>
+8 -52
View File
@@ -8,7 +8,6 @@ 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'
@@ -30,13 +29,12 @@ export function LoginPage() {
const { t, locale, setLocale } = useI18n()
const navigate = useNavigate()
const setSession = useAuthStore((s) => s.setSession)
const [mode, setMode] = useState<'code' | 'password'>('code')
const [mode, setMode] = useState<'code' | 'password'>('password')
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,
@@ -44,7 +42,7 @@ export function LoginPage() {
formState: { errors },
} = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { account: '', password: '', code: '' } })
const doLogin = async (values: FormValues, role?: RoleKey) => {
const doLogin = async (values: FormValues) => {
if (!agreed) {
toast.warning('请先勾选并同意《隐私政策》和《服务协议》')
return
@@ -54,12 +52,12 @@ export function LoginPage() {
return
}
if (mode === 'code' && (values.code ?? '').length !== 6) {
toast.warning('请输入6位验证码(演示环境任意6位数字即可)')
toast.warning('请输入6位验证码')
return
}
try {
setLoading(true)
const session = await login({ ...values, mode, remember, role })
const session = await login({ ...values, mode, remember })
setSession(session)
toast.success(`欢迎回来,${session.user.name}`)
navigate('/dashboard')
@@ -70,10 +68,6 @@ export function LoginPage() {
}
}
const quickLogin = (role: RoleKey) => {
doLogin({ account: 'demo@ouhui.tech', code: '888888', password: 'demo123456' }, role)
}
const sendCode = () => {
setCodeSent(60)
const timer = setInterval(() => {
@@ -82,7 +76,7 @@ export function LoginPage() {
return s - 1
})
}, 1000)
toast.success('验证码已发送(演示:任意6位数字均可登录)')
toast.success('验证码已发送')
}
return (
@@ -120,7 +114,7 @@ export function LoginPage() {
))}
</div>
</div>
<p className="text-xs text-primary-300">© 2026 {t('app.company')} · 演示环境(Mock 数据)</p>
<p className="text-xs text-primary-300">© 2026 {t('app.company')}</p>
</div>
{/* 表单区 */}
@@ -155,10 +149,7 @@ export function LoginPage() {
<Tabs
value={mode}
onChange={(v) => setMode(v as 'code' | 'password')}
items={[
{ value: 'code', label: t('login.phoneTab') },
{ value: 'password', label: t('login.passwordTab') },
]}
items={[{ value: 'password', label: t('login.passwordTab') }]}
/>
</div>
@@ -181,9 +172,6 @@ export function LoginPage() {
<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}>
@@ -198,18 +186,6 @@ export function LoginPage() {
</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}
@@ -231,7 +207,7 @@ export function LoginPage() {
{/* 协议弹窗 */}
<Dialog open={!!agreementOpen} onOpenChange={() => setAgreementOpen(null)} title={agreementOpen === 'privacy' ? '隐私政策' : '服务协议'}>
<div className="space-y-3 text-sm leading-relaxed text-muted-foreground">
<p>本页面为产品演示环境,以下协议为示意文本。</p>
<p>使用本服务前,请阅读并确认以下数据处理与服务条款。</p>
<p>1. 我们仅收集为您提供跨境沟通与客户管理服务所必需的信息,包括账号信息、企业信息与您主动录入的客户数据。</p>
<p>2. 您的会话数据采用全链路加密存储,未经您授权,我们不会向任何第三方披露。</p>
<p>3. 您可以随时导出或删除您的数据;企业注销后,相关数据将在 30 天内完成清理。</p>
@@ -239,26 +215,6 @@ export function LoginPage() {
</div>
</Dialog>
{/* 忘记密码 */}
<Dialog
open={forgotOpen}
onOpenChange={setForgotOpen}
title="重置密码"
footer={
<Button
onClick={() => {
setForgotOpen(false)
toast.success('重置链接已发送(演示)', '请在邮箱或短信中查收重置指引')
}}
>
发送重置链接
</Button>
}
>
<Field label="手机号或邮箱" hint="我们将向该账号发送重置密码的链接">
<Input placeholder="请输入注册时使用的手机号或邮箱" />
</Field>
</Dialog>
</div>
)
}
+3 -3
View File
@@ -16,7 +16,7 @@ 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, '请选择团队规模'),
@@ -51,7 +51,7 @@ export function RegisterPage() {
const session = await registerApi(values)
setSession(session)
toast.success('企业创建成功', '接下来请完成企业初始化配置')
navigate('/onboarding')
navigate('/channels?setup=telegram')
} catch (e) {
toast.error(errorMessage(e))
} finally {
@@ -79,7 +79,7 @@ export function RegisterPage() {
<Field label="手机号或邮箱" required error={errors.account?.message}>
<Input placeholder="用于登录与接收通知" {...register('account')} />
</Field>
<Field label="设置密码" required error={errors.password?.message} hint="6—32位,建议包含字母与数字">
<Field label="设置密码" required error={errors.password?.message} hint="8—32位,建议包含字母与数字">
<Input type="password" placeholder="请设置登录密码" {...register('password')} />
</Field>
<Field label="企业名称" required error={errors.company?.message}>
+6 -5
View File
@@ -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="通过 Stripe Checkout 管理真实订阅与支付" />
{info && (
<Card className="mb-4 p-4">
+643 -34
View File
@@ -1,6 +1,23 @@
import { useState, useEffect } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Cable, Plus, Pause, Play, RefreshCw, Trash2, History, Settings } from 'lucide-react'
import {
Activity,
ArrowUpRight,
Cable,
CheckCircle2,
History,
Mail,
MessageSquareText,
Pause,
Play,
RefreshCw,
Settings,
ShieldCheck,
Smartphone,
Trash2,
Workflow,
} from 'lucide-react'
import { toast, errorMessage } from '@/app/store/toast'
import { confirmDialog } from '@/app/store/confirm'
import { PageHeader } from '@/components/common/PageHeader'
@@ -14,10 +31,13 @@ import { Switch } from '@/components/ui/switch'
import { Input, Field } from '@/components/ui/input'
import { PermissionGate } from '@/components/common/PermissionGate'
import { ChannelIcon } from '@/components/common/ChannelIcon'
import { getChannels, connectChannel, testChannel, updateChannel, deleteChannel, getChannelLogs } from '@/services/api/channels'
import {
getChannels, testChannel, updateChannel, deleteChannel, getChannelLogs,
connectEmailChannel, startTelegramLogin, submitTelegramCode, submitTelegramPassword,
getTelegramLoginState, syncTelegramChannel,
} from '@/services/api/channels'
import { CHANNEL_META } from '@/config/constants'
import { formatRelative } from '@/utils/format'
import { cn } from '@/utils/cn'
import type { ChannelType, ChannelConnection, ChannelSyncLog } from '@/types'
const STATUS_MAP: Record<string, { label: string; color: string }> = {
@@ -28,13 +48,44 @@ const STATUS_MAP: Record<string, { label: string; color: string }> = {
unconnected: { label: '未连接', color: 'bg-gray-100 text-gray-400' },
}
const EMAIL_PRESETS = {
gmail: { label: 'Gmail', imapHost: 'imap.gmail.com', imapPort: 993, imapSecure: true, smtpHost: 'smtp.gmail.com', smtpPort: 465, smtpSecure: true },
outlook: { label: 'Outlook / Microsoft 365', imapHost: 'outlook.office365.com', imapPort: 993, imapSecure: true, smtpHost: 'smtp.office365.com', smtpPort: 587, smtpSecure: false },
qq: { label: 'QQ 企业邮箱', imapHost: 'imap.exmail.qq.com', imapPort: 993, imapSecure: true, smtpHost: 'smtp.exmail.qq.com', smtpPort: 465, smtpSecure: true },
netease: { label: '网易企业邮箱', imapHost: 'imap.qiye.163.com', imapPort: 993, imapSecure: true, smtpHost: 'smtp.qiye.163.com', smtpPort: 465, smtpSecure: true },
custom: { label: '自定义服务器', imapHost: '', imapPort: 993, imapSecure: true, smtpHost: '', smtpPort: 465, smtpSecure: true },
} as const
export function ChannelsPage() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const queryClient = useQueryClient()
const { data: channels } = useQuery({ queryKey: ['channels'], queryFn: getChannels })
const [wizard, setWizard] = useState<{ open: boolean; type: ChannelType | null; step: number }>({ open: false, type: null, step: 0 })
const [telegramAccount, setTelegramAccount] = useState('')
const [telegramCode, setTelegramCode] = useState('')
const [telegramPassword, setTelegramPassword] = useState('')
const [loginChannelId, setLoginChannelId] = useState('')
const [authState, setAuthState] = useState('')
const [connecting, setConnecting] = useState(false)
const [emailPreset, setEmailPreset] = useState<keyof typeof EMAIL_PRESETS>('custom')
const [emailForm, setEmailForm] = useState({
displayName: '',
account: '',
password: '',
imapHost: '',
imapPort: 993,
imapSecure: true,
imapFolder: 'INBOX',
smtpHost: '',
smtpPort: 465,
smtpSecure: true,
syncDays: 30,
})
const [logDrawer, setLogDrawer] = useState<string | null>(null)
const [settingsDrawer, setSettingsDrawer] = useState<ChannelConnection | null>(null)
const [logItems, setLogItems] = useState<ChannelSyncLog[]>([])
const setupOpenedRef = useRef(false)
const loadLogs = async (chId: string) => {
const logs = await getChannelLogs(chId)
@@ -50,73 +101,564 @@ export function ChannelsPage() {
const list = channels ?? []
return (
<div className="p-4 md:p-6">
<PageHeader title="渠道中心" description={`${list.filter((c) => c.status === 'connected').length} 个渠道已连接 · 共 ${list.length} 个可连接渠道`} />
const resetTelegramWizard = () => {
setTelegramAccount('')
setTelegramCode('')
setTelegramPassword('')
setLoginChannelId('')
setAuthState('')
}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
const setTelegramStepFromState = (state: string) => {
if (state === 'waiting_password') {
setAuthState('waiting_password')
setWizard({ open: true, type: 'telegram', step: 2 })
return
}
setAuthState('waiting_code')
setWizard({ open: true, type: 'telegram', step: 1 })
}
const completeTelegramBinding = async (channelId: string) => {
let syncSummary = ''
try {
const result = await syncTelegramChannel(channelId)
syncSummary = `,已同步 ${result.chats} 个会话和 ${result.messages} 条消息`
} catch (error) {
syncSummary = `;账号已连接,但首次同步失败:${errorMessage(error)}`
}
toast.success(`Telegram 绑定成功${syncSummary}`)
resetTelegramWizard()
setWizard({ open: false, type: null, step: 0 })
await queryClient.invalidateQueries()
}
const openTelegramWizard = async (channel?: ChannelConnection) => {
resetTelegramWizard()
setTelegramAccount(channel?.account || '')
setWizard({ open: true, type: 'telegram', step: 0 })
if (!channel || channel.id === 'telegram-available') return
setConnecting(true)
try {
const result = await getTelegramLoginState(channel.id)
setLoginChannelId(channel.id)
if (result.status === 'ready' && result.connectionState === 'ready') {
await completeTelegramBinding(channel.id)
return
}
setTelegramStepFromState(result.status)
toast.info(result.status === 'waiting_password' ? '请继续输入二步验证密码' : '请继续输入 Telegram 验证码')
} catch (error) {
toast.error(errorMessage(error))
} finally {
setConnecting(false)
}
}
useEffect(() => {
if (!channels || setupOpenedRef.current || searchParams.get('setup') !== 'telegram') return
setupOpenedRef.current = true
const telegram = channels.find((item) => item.type === 'telegram')
void openTelegramWizard(telegram)
setSearchParams({}, { replace: true })
// 新注册后的 setup 参数只消费一次,避免查询刷新时重复打开向导。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [channels, searchParams, setSearchParams])
const applyEmailPreset = (value: keyof typeof EMAIL_PRESETS) => {
setEmailPreset(value)
const preset = EMAIL_PRESETS[value]
setEmailForm((current) => ({
...current,
imapHost: preset.imapHost,
imapPort: preset.imapPort,
imapSecure: preset.imapSecure,
smtpHost: preset.smtpHost,
smtpPort: preset.smtpPort,
smtpSecure: preset.smtpSecure,
}))
}
const connectedCount = list.filter((channel) => channel.status === 'connected').length
const telegramChannel = list.find((channel) => channel.type === 'telegram')
const telegramConnected = telegramChannel?.status === 'connected'
const todayMessages = list.reduce((total, channel) => total + Number(channel.todayMessages || 0), 0)
const latestSync = list
.map((channel) => channel.lastSyncAt)
.filter(Boolean)
.sort()
.at(-1)
return (
<div className="mx-auto max-w-[1480px] p-4 md:p-7">
<section className="relative mb-6 overflow-hidden rounded-[28px] border border-slate-200/80 bg-gradient-to-br from-white via-sky-50/40 to-emerald-50/60 px-5 py-6 shadow-[0_18px_60px_rgba(15,23,42,.06)] md:px-7">
<div className="pointer-events-none absolute -right-20 -top-24 h-64 w-64 rounded-full bg-sky-200/25 blur-3xl" />
<div className="relative flex flex-col gap-5 xl:flex-row xl:items-end xl:justify-between">
<div>
<div className="mb-3 inline-flex items-center gap-2 rounded-full border border-primary-200/80 bg-white/85 px-3 py-1 text-[11px] font-semibold text-primary-700 shadow-sm">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary-400 opacity-45" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-primary-500" />
</span>
Unified communication hub
</div>
<h1 className="text-2xl font-bold tracking-[-0.035em] text-slate-950 md:text-3xl">渠道与账号</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-500">
将 Telegram 即时消息与企业邮箱汇入同一个客户工作台,统一翻译、画像、分配和跟进。
</p>
</div>
<div className="grid grid-cols-3 gap-2 sm:min-w-[460px]">
{[
{ label: '在线账号', value: `${connectedCount}/${list.length}`, icon: CheckCircle2 },
{ label: '今日消息', value: String(todayMessages), icon: MessageSquareText },
{ label: '最近同步', value: latestSync ? formatRelative(latestSync) : '等待首次同步', icon: Activity },
].map((stat) => (
<div key={stat.label} className="rounded-2xl border border-white/80 bg-white/75 p-3 shadow-sm backdrop-blur">
<stat.icon className="mb-2 h-4 w-4 text-primary" />
<p className="truncate text-base font-bold tracking-tight text-slate-900">{stat.value}</p>
<p className="mt-0.5 text-[10px] font-medium text-slate-400">{stat.label}</p>
</div>
))}
</div>
</div>
</section>
<PageHeader
title="通信账号"
description="连接状态、同步进度与安全设置"
/>
{!telegramConnected && (
<Card
data-testid="telegram-onboarding"
className="mb-5 overflow-hidden border-primary-200/80 bg-gradient-to-r from-primary-50/90 via-white to-sky-50 shadow-[0_16px_45px_rgba(37,99,235,.10)]"
>
<div className="grid gap-5 p-5 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-center lg:p-6">
<div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="primary">首次配置 · 约 2 分钟</Badge>
<span className="text-xs font-medium text-slate-500">使用你自己的 Telegram 工作账号</span>
</div>
<h2 className="mt-3 text-lg font-bold tracking-tight text-slate-950">先绑定 Telegram,开始接收真实客户消息</h2>
<p className="mt-1.5 max-w-3xl text-sm leading-6 text-slate-600">
输入国际格式手机号,依次完成 Telegram 验证码和可选的二步密码。授权成功后系统会自动注册实时 Webhook,并首次同步历史会话。
</p>
<div className="mt-4 grid gap-2 sm:grid-cols-3">
{[
{ icon: Smartphone, step: '01', title: '验证手机号', text: '例如 +8613800138000' },
{ icon: ShieldCheck, step: '02', title: '完成安全验证', text: '验证码与二步密码' },
{ icon: RefreshCw, step: '03', title: '自动同步消息', text: '历史会话与实时消息' },
].map((item) => (
<div key={item.step} className="flex items-center gap-3 rounded-xl border border-white bg-white/80 px-3 py-2.5 shadow-sm">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-50 text-primary">
<item.icon className="h-4 w-4" />
</span>
<div className="min-w-0">
<p className="text-xs font-semibold text-slate-800">{item.step} · {item.title}</p>
<p className="mt-0.5 truncate text-[10px] text-slate-400">{item.text}</p>
</div>
</div>
))}
</div>
</div>
<Button
size="lg"
className="w-full shadow-lg shadow-primary/20 lg:w-auto"
loading={connecting}
onClick={() => void openTelegramWizard(telegramChannel)}
>
<Cable className="h-4 w-4" />
{telegramChannel && telegramChannel.id !== 'telegram-available' ? '继续 Telegram 授权' : '开始绑定 Telegram'}
</Button>
</div>
</Card>
)}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
{list.map((ch) => {
const meta = CHANNEL_META[ch.type]
const st = STATUS_MAP[ch.status]
return (
<Card key={ch.id} className="p-4">
<Card data-testid={`channel-card-${ch.type}`} key={ch.id} className="group overflow-hidden border-slate-200/80 bg-white transition-all duration-300 hover:-translate-y-0.5 hover:border-slate-300 hover:shadow-[0_18px_50px_rgba(15,23,42,.08)]">
<div className={ch.type === 'telegram' ? 'h-1 bg-gradient-to-r from-sky-400 to-cyan-300' : 'h-1 bg-gradient-to-r from-slate-500 to-slate-300'} />
<div className="p-5">
<div className="flex items-start justify-between">
<ChannelIcon channel={ch.type} size="lg" withBg />
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-50 ring-1 ring-slate-100 transition-transform duration-300 group-hover:scale-105">
<ChannelIcon channel={ch.type} size="lg" />
</div>
<Badge className={st.color}>{st.label}</Badge>
</div>
<p className="mt-3 text-sm font-medium">{meta.name}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{meta.desc}</p>
{ch.account && <p className="mt-1 text-xs text-muted-foreground">账号:{ch.account}</p>}
{ch.todayMessages > 0 && <p className="mt-1 text-xs">今日消息:{ch.todayMessages} 条</p>}
{ch.lastSyncAt && <p className="mt-0.5 text-xs text-muted-foreground">最近同步:{formatRelative(ch.lastSyncAt)}</p>}
<div className="mt-4">
<p className="text-base font-semibold tracking-tight text-slate-950">{meta.name}</p>
<p className="mt-1 text-xs leading-5 text-slate-500">{meta.desc}</p>
{ch.account && <p className="mt-2 truncate text-xs font-medium text-slate-700">{ch.account}</p>}
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
<div className="rounded-xl bg-slate-50 px-3 py-2.5">
<p className="text-[10px] font-medium text-slate-400">今日消息</p>
<p className="mt-0.5 text-sm font-semibold text-slate-800">{Number(ch.todayMessages || 0)} 条</p>
</div>
<div className="rounded-xl bg-slate-50 px-3 py-2.5">
<p className="text-[10px] font-medium text-slate-400">同步状态</p>
<p className="mt-0.5 truncate text-sm font-semibold text-slate-800">{ch.lastSyncAt ? formatRelative(ch.lastSyncAt) : '等待同步'}</p>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
{(ch.type === 'email'
? ['IMAP 收信', 'SMTP 发信', '附件同步']
: ['实时收发', '历史同步', '媒体消息']
).map((capability) => (
<span key={capability} className="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[10px] font-medium text-slate-500">
{capability}
</span>
))}
</div>
{ch.errorMessage && <p className="mt-1 text-xs text-red-600">{ch.errorMessage}</p>}
<div className="mt-3 flex flex-wrap gap-1">
<div className="mt-5 flex flex-wrap items-center gap-1 border-t border-slate-100 pt-4">
{ch.status === 'connected' ? (
<>
<Button size="sm" variant="outline" onClick={() => updateChannel(ch.id, { status: 'paused' }).then(() => queryClient.invalidateQueries({ queryKey: ['channels'] }))}><Pause className="h-3 w-3" />暂停</Button>
<Button size="sm" variant="outline" onClick={async () => {
try {
const result = await syncTelegramChannel(ch.id)
toast.success(`同步完成:${result.chats} 个会话,${result.messages} 条新消息`)
queryClient.invalidateQueries()
} catch (error) {
toast.error(errorMessage(error))
}
}}><RefreshCw className="h-3 w-3" />立即同步</Button>
<Button size="sm" variant="outline" onClick={async () => {
try {
await updateChannel(ch.id, { status: 'paused' })
toast.success('渠道已暂停,实时 Webhook 已停用')
queryClient.invalidateQueries({ queryKey: ['channels'] })
} catch (error) {
toast.error(errorMessage(error))
}
}}><Pause className="h-3 w-3" />暂停</Button>
<Button size="sm" variant="outline" onClick={() => delMut.mutate(ch.id)}><Trash2 className="h-3 w-3" /></Button>
</>
) : ch.status === 'paused' ? (
<Button size="sm" variant="outline" onClick={() => updateChannel(ch.id, { status: 'connected' }).then(() => queryClient.invalidateQueries({ queryKey: ['channels'] }))}><Play className="h-3 w-3" />恢复</Button>
<Button size="sm" variant="outline" onClick={async () => {
try {
await updateChannel(ch.id, { status: 'connected' })
toast.success('渠道已恢复,实时 Webhook 已启用')
queryClient.invalidateQueries({ queryKey: ['channels'] })
} catch (error) {
toast.error(errorMessage(error))
}
}}><Play className="h-3 w-3" />恢复</Button>
) : ch.status === 'error' ? (
<Button size="sm" variant="outline" onClick={() => { setWizard({ open: true, type: ch.type, step: 0 }); toast.info('重新授权') }}><RefreshCw className="h-3 w-3" />重新授权</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
if (ch.type === 'email') {
applyEmailPreset('custom')
setWizard({ open: true, type: ch.type, step: 0 })
} else {
void openTelegramWizard(ch)
}
}}
><RefreshCw className="h-3 w-3" />重新授权</Button>
) : (
<Button size="sm" variant="default" onClick={() => setWizard({ open: true, type: ch.type, step: 1 })}><Cable className="h-3 w-3" />连接</Button>
<Button
size="sm"
variant="default"
onClick={() => {
if (ch.type === 'email') {
applyEmailPreset('custom')
setWizard({ open: true, type: ch.type, step: 0 })
} else {
void openTelegramWizard(ch)
}
}}
>
{ch.type === 'email' ? <Mail className="h-3 w-3" /> : <Cable className="h-3 w-3" />}
{ch.type === 'telegram' && ch.id !== 'telegram-available' ? '继续授权' : '连接'}
</Button>
)}
<Button size="sm" variant="ghost" onClick={() => loadLogs(ch.id)}><History className="h-3 w-3" /></Button>
<Button size="sm" variant="ghost" onClick={() => setSettingsDrawer(ch)}><Settings className="h-3 w-3" /></Button>
</div>
</div>
</Card>
)
})}
<Card className="relative overflow-hidden border-dashed border-slate-300 bg-slate-50/60 p-5">
<div className="absolute -right-10 -top-10 h-32 w-32 rounded-full bg-primary/5" />
<div className="relative">
<span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary text-white shadow-lg shadow-primary/20">
<Workflow className="h-5 w-5" />
</span>
<h3 className="mt-4 text-base font-semibold text-slate-900">统一处理链路</h3>
<p className="mt-1 text-xs leading-5 text-slate-500">无论消息来自 Telegram 还是 Email,都进入同一套真实业务流程。</p>
<div className="mt-4 space-y-2">
{[
['01', '实时接收与自动同步'],
['02', 'AI 翻译、情绪与意图识别'],
['03', '客户画像、风险与任务联动'],
].map(([step, label]) => (
<div key={step} className="flex items-center gap-3 rounded-xl bg-white px-3 py-2.5 shadow-sm ring-1 ring-slate-100">
<span className="text-[10px] font-bold text-primary">{step}</span>
<span className="text-xs font-medium text-slate-700">{label}</span>
</div>
))}
</div>
<Button className="mt-4 w-full" variant="outline" onClick={() => navigate('/inbox')}>
进入消息中心 <ArrowUpRight className="h-3.5 w-3.5" />
</Button>
</div>
</Card>
</div>
{/* 连接向导 */}
<Dialog
open={wizard.open}
onOpenChange={(v) => setWizard({ ...wizard, open: v })}
onOpenChange={(open) => {
setWizard({ ...wizard, open })
if (!open) {
setEmailForm((current) => ({ ...current, password: '' }))
resetTelegramWizard()
}
}}
title={`连接 ${wizard.type ? CHANNEL_META[wizard.type].name : ''}`}
footer={
<Button
loading={connecting}
onClick={async () => {
const t = wizard.type!
await connectChannel({ type: t, account: 'demo-account@mock', autoReply: true })
toast.success(`${CHANNEL_META[t].name} 连接成功(演示)`)
setWizard({ open: false, type: null, step: 0 })
queryClient.invalidateQueries({ queryKey: ['channels'] })
setConnecting(true)
try {
if (wizard.type === 'email') {
await connectEmailChannel(emailForm)
toast.success('企业邮箱连接成功,历史邮件已开始同步')
setWizard({ open: false, type: null, step: 0 })
setEmailForm((current) => ({ ...current, password: '' }))
queryClient.invalidateQueries()
return
}
if (!loginChannelId) {
const phone = telegramAccount.replace(/\s+/g, '')
if (!/^\+\d{7,15}$/.test(phone)) {
toast.warning('请输入国际格式手机号,例如 +8613800138000')
return
}
const channel = await startTelegramLogin(phone)
setTelegramAccount(phone)
setLoginChannelId(channel.id)
if (channel.status === 'connected' || channel.authState === 'ready') {
await completeTelegramBinding(channel.id)
return
}
setTelegramStepFromState(channel.authState || 'waiting_code')
toast.success(channel.authState === 'waiting_password'
? '请继续输入 Telegram 二步验证密码'
: '验证码已发送到 Telegram')
return
}
if (authState === 'waiting_code') {
const code = telegramCode.replace(/\s+/g, '')
if (!/^\d{4,8}$/.test(code)) {
toast.warning('请输入 4—8 位 Telegram 验证码')
return
}
const result = await submitTelegramCode(loginChannelId, code)
if (result.status === 'waiting_password') {
setTelegramStepFromState('waiting_password')
toast.info('该账号启用了二步验证,请输入密码')
return
}
if (result.status !== 'ready') {
setTelegramStepFromState(result.status)
toast.info('Telegram 仍在等待验证,请检查验证码后重试')
return
}
} else if (authState === 'waiting_password') {
if (!telegramPassword) {
toast.warning('请输入 Telegram 二步验证密码')
return
}
const result = await submitTelegramPassword(loginChannelId, telegramPassword)
if (result.status !== 'ready') {
setTelegramStepFromState(result.status)
toast.info('Telegram 授权尚未完成,请重试')
return
}
}
await completeTelegramBinding(loginChannelId)
} catch (error) {
toast.error(errorMessage(error))
} finally {
setConnecting(false)
}
}}
>完成连接</Button>
>
{wizard.type === 'email'
? '验证并连接邮箱'
: !loginChannelId
? '发送 Telegram 验证码'
: authState === 'waiting_password'
? '验证二步密码并完成绑定'
: '验证并完成绑定'}
</Button>
}
>
<div className="space-y-3">
<div className="flex items-center gap-2">
<Badge variant="primary">步骤 {wizard.step + 1}/7</Badge>
<span className="text-sm font-medium">模拟授权连接</span>
{wizard.type === 'email' ? (
<div className="space-y-4">
<div className="rounded-xl border border-sky-100 bg-sky-50/70 p-3">
<div className="flex items-center gap-2 text-sm font-semibold text-sky-900">
<Mail className="h-4 w-4" />
IMAP 收信 + SMTP 发信
</div>
<p className="mt-1 text-xs leading-5 text-sky-700">
密码在服务端使用 AES-256-GCM 加密保存,不会返回浏览器。Gmail、Outlook 等通常需要应用专用密码。
</p>
</div>
<Field label="邮箱服务商">
<Select
value={emailPreset}
onChange={(event) => applyEmailPreset(event.target.value as keyof typeof EMAIL_PRESETS)}
options={Object.entries(EMAIL_PRESETS).map(([value, preset]) => ({ value, label: preset.label }))}
/>
</Field>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="发件人名称">
<Input
value={emailForm.displayName}
onChange={(event) => setEmailForm({ ...emailForm, displayName: event.target.value })}
placeholder="例如:Lingxi Sales"
/>
</Field>
<Field label="邮箱地址">
<Input
type="email"
value={emailForm.account}
onChange={(event) => setEmailForm({ ...emailForm, account: event.target.value })}
placeholder="sales@company.com"
/>
</Field>
</div>
<Field label="邮箱密码 / 应用专用密码">
<Input
type="password"
value={emailForm.password}
onChange={(event) => setEmailForm({ ...emailForm, password: event.target.value })}
placeholder="仅提交到本地服务端验证"
/>
</Field>
<div className="grid grid-cols-[1fr_96px] gap-3">
<Field label="IMAP 服务器">
<Input value={emailForm.imapHost} onChange={(event) => setEmailForm({ ...emailForm, imapHost: event.target.value })} placeholder="imap.company.com" />
</Field>
<Field label="端口">
<Input type="number" value={emailForm.imapPort} onChange={(event) => setEmailForm({ ...emailForm, imapPort: Number(event.target.value) })} />
</Field>
<Field label="SMTP 服务器">
<Input value={emailForm.smtpHost} onChange={(event) => setEmailForm({ ...emailForm, smtpHost: event.target.value })} placeholder="smtp.company.com" />
</Field>
<Field label="端口">
<Input type="number" value={emailForm.smtpPort} onChange={(event) => setEmailForm({ ...emailForm, smtpPort: Number(event.target.value) })} />
</Field>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="同步文件夹">
<Input value={emailForm.imapFolder} onChange={(event) => setEmailForm({ ...emailForm, imapFolder: event.target.value })} />
</Field>
<Field label="首次同步范围">
<Select
value={String(emailForm.syncDays)}
onChange={(event) => setEmailForm({ ...emailForm, syncDays: Number(event.target.value) })}
options={[7, 30, 90, 180, 365].map((days) => ({ value: String(days), label: `最近 ${days} 天` }))}
/>
</Field>
</div>
<div className="flex flex-wrap gap-5">
<label className="flex items-center gap-2 text-sm text-slate-600">
<Switch checked={emailForm.imapSecure} onCheckedChange={(checked) => setEmailForm({ ...emailForm, imapSecure: checked })} />
IMAP SSL/TLS
</label>
<label className="flex items-center gap-2 text-sm text-slate-600">
<Switch checked={emailForm.smtpSecure} onCheckedChange={(checked) => setEmailForm({ ...emailForm, smtpSecure: checked })} />
SMTP 直连 SSL
</label>
</div>
</div>
<p className="text-sm text-muted-foreground">演示环境连接向导:选择渠道 → 模拟授权 → 选择同步范围 → 设置默认负责人 → 设置自动回复 → 测试连接 → 完成。</p>
<p className="text-sm text-muted-foreground">点击「完成连接」即可一键模拟全部步骤。</p>
</div>
) : (
<div className="space-y-4">
<div className="rounded-xl border border-primary-100 bg-primary-50/70 p-3.5">
<div className="flex items-center gap-2">
<ShieldCheck className="h-4 w-4 text-primary" />
<span className="text-sm font-semibold text-primary-900">绑定你自己的 Telegram 工作账号</span>
</div>
<p className="mt-1 text-xs leading-5 text-primary-700">
每个企业独立绑定账号,不会共享其他租户的 Telegram 会话或凭据。
</p>
</div>
<div aria-label="Telegram 绑定进度" className="grid grid-cols-3 gap-2">
{[
{ step: 0, label: '手机号' },
{ step: 1, label: '验证码' },
{ step: 2, label: '二步密码' },
].map((item) => {
const active = wizard.step === item.step
const complete = wizard.step > item.step
return (
<div
key={item.step}
className={`rounded-lg border px-2 py-2 text-center text-[11px] font-semibold transition-colors ${
active
? 'border-primary bg-primary text-white'
: complete
? 'border-primary-200 bg-primary-50 text-primary'
: 'border-slate-200 bg-white text-slate-400'
}`}
>
{complete ? <CheckCircle2 className="mx-auto mb-1 h-3.5 w-3.5" /> : <span className="mb-1 block">{item.step + 1}</span>}
{item.label}
</div>
)
})}
</div>
{!loginChannelId && (
<Field label="Telegram 手机号" required hint="必须包含国家/地区代码,不要输入空格或短横线">
<Input
type="tel"
autoComplete="tel"
value={telegramAccount}
onChange={(e) => setTelegramAccount(e.target.value)}
placeholder="+8613800138000"
/>
</Field>
)}
{authState === 'waiting_code' && (
<Field label="Telegram 验证码" required hint="验证码由 Telegram 官方发送,请勿转发给他人">
<Input
inputMode="numeric"
autoComplete="one-time-code"
value={telegramCode}
onChange={(e) => setTelegramCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
placeholder="请输入 4—8 位验证码"
/>
</Field>
)}
{authState === 'waiting_password' && (
<Field label="Telegram 二步验证密码" required hint="仅当该 Telegram 账号已启用两步验证时需要">
<Input
type="password"
autoComplete="current-password"
value={telegramPassword}
onChange={(e) => setTelegramPassword(e.target.value)}
placeholder="输入 Telegram 二步验证密码"
/>
</Field>
)}
<p className="text-xs leading-5 text-muted-foreground">
手机号、验证码和二步密码只通过加密连接发送到本企业的 Telegram Gateway,不写入浏览器本地存储。授权成功后将自动完成 Webhook 注册和首次历史同步。
</p>
</div>
)}
</Dialog>
{/* 同步日志 */}
@@ -129,6 +671,73 @@ export function ChannelsPage() {
</div>
))}
</Drawer>
<Drawer
open={!!settingsDrawer}
onOpenChange={() => setSettingsDrawer(null)}
title={settingsDrawer ? `${CHANNEL_META[settingsDrawer.type].name} 连接详情` : '连接详情'}
width="w-[420px]"
>
{settingsDrawer && (
<div className="space-y-5 p-4">
<div className="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 p-3">
<ChannelIcon channel={settingsDrawer.type} size="lg" withBg />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{settingsDrawer.account || settingsDrawer.name}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{STATUS_MAP[settingsDrawer.status]?.label} · 最近同步 {settingsDrawer.lastSyncAt ? formatRelative(settingsDrawer.lastSyncAt) : '尚未同步'}
</p>
</div>
</div>
{settingsDrawer.type === 'email' && (
<div className="grid grid-cols-2 gap-2 text-xs">
{[
['IMAP', `${settingsDrawer.imapHost}:${settingsDrawer.imapPort}`],
['SMTP', `${settingsDrawer.smtpHost}:${settingsDrawer.smtpPort}`],
['文件夹', settingsDrawer.imapFolder || 'INBOX'],
['同步范围', `最近 ${settingsDrawer.syncDays || 30} 天`],
].map(([label, value]) => (
<div key={label} className="rounded-lg border border-slate-100 p-3">
<p className="text-slate-400">{label}</p>
<p className="mt-1 break-all font-medium text-slate-700">{value}</p>
</div>
))}
</div>
)}
<div className="space-y-2">
<label className="flex items-center justify-between rounded-xl border border-slate-100 px-3 py-2.5 text-sm">
自动回复
<Switch
checked={settingsDrawer.autoReply}
onCheckedChange={async (checked) => {
const updated = await updateChannel(settingsDrawer.id, { autoReply: checked })
setSettingsDrawer(updated)
queryClient.invalidateQueries({ queryKey: ['channels'] })
}}
/>
</label>
</div>
<Button
variant="outline"
className="w-full"
onClick={async () => {
try {
const result = await testChannel(settingsDrawer.id)
toast.success(`${result.message} · ${result.latencyMs}ms`)
} catch (error) {
toast.error(errorMessage(error))
}
}}
>
<Activity className="h-4 w-4" />
测试收发连接
</Button>
</div>
)}
</Drawer>
</div>
)
}
+101 -46
View File
@@ -3,6 +3,8 @@ import { useNavigate, useParams } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, Edit, Send, UserRoundPlus, Tag, GitMerge, Download, ExternalLink } from 'lucide-react'
import { getCustomer, updateCustomer } from '@/services/api/customers'
import { createTask } from '@/services/api/crm'
import { useAuthStore } from '@/app/store/auth'
import { useI18n } from '@/i18n'
import { toast, errorMessage } from '@/app/store/toast'
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback'
@@ -22,6 +24,8 @@ import { CountryFlag } from '@/components/common/CountryFlag'
import { ChannelIcon } from '@/components/common/ChannelIcon'
import { IntentScoreBadge, CustomerStageBadge, SentimentBadge, RiskLevelBadge } from '@/components/common/StatusBadges'
import { ChipPicker } from '@/components/customer/CustomerFormDialog'
import { CustomerProfileRadar } from '@/components/customer/CustomerProfileRadar'
import { CustomerSemanticTag, CustomerTagLegend, customerTagCategory } from '@/components/customer/CustomerSemanticTag'
import { CUSTOMER_STAGES, CUSTOMER_TAGS, NEXT_ACTIONS } from '@/config/constants'
import { formatRelative, formatDateTime } from '@/utils/format'
import { cn } from '@/utils/cn'
@@ -31,6 +35,7 @@ export function CustomerDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const user = useAuthStore((state) => state.user)
const [tab, setTab] = useState('overview')
const [tagDialog, setTagDialog] = useState(false)
@@ -52,22 +57,42 @@ export function CustomerDetailPage() {
if (isError || !data) return <ErrorState onRetry={() => refetch()} />
const c = data.customer
const createSuggestedTask = async (title: string) => {
try {
await createTask({
title,
type: 'revisit',
priority: c.riskLevel === 'urgent' || c.riskLevel === 'high' ? 'high' : 'medium',
status: 'pending',
customerId: c.id,
customerName: c.name,
ownerId: user?.id || '',
dueAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
note: `由客户画像的 AI 建议创建:${title}`,
})
await queryClient.invalidateQueries({ queryKey: ['tasks'] })
toast.success('已创建跟进任务', '默认明天到期,可在商机与跟进中调整')
} catch (error) {
toast.error(errorMessage(error))
}
}
const FILE_ICONS: Record<string, string> = { quote: '📄', contract: '📝', image: '🖼️', doc: '📁', other: '📎' }
return (
<div className="p-4 md:p-6">
<div className="mx-auto max-w-[1500px] p-4 md:p-7">
<button onClick={() => navigate(-1)} className="mb-3 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="h-4 w-4 rtl-flip" /> 返回
</button>
{/* 顶部客户画像卡片 */}
<Card className="p-4 md:p-5">
<Card className="overflow-hidden border-slate-200/80 p-5 md:p-6">
<div className="flex flex-wrap gap-4">
<Avatar name={c.name} size="xl" />
<Avatar name={c.name} src={c.avatarUrl} size="xl" />
<div className="min-w-0 flex-1">
<p className="eyebrow mb-1">Customer intelligence</p>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-lg font-semibold">{c.name}</h1>
<h1 className="text-2xl font-semibold tracking-[-0.035em] text-slate-950">{c.name}</h1>
<CountryFlag code={c.country} />
<ChannelIcon channel={c.channel} size="xs" />
{c.isKey && <Badge variant="warning">⭐ 重点客户</Badge>}
@@ -80,8 +105,19 @@ export function CustomerDetailPage() {
{c.riskLevel !== 'none' && <RiskLevelBadge level={c.riskLevel} />}
<span className="text-muted-foreground">负责人:{c.ownerId === 'u-003' ? '李婷' : c.ownerId === 'u-004' ? '赵磊' : c.ownerId === 'u-005' ? '周敏' : c.ownerId === 'u-008' ? '郑楠' : '未分配'}</span>
</div>
<div className="mt-2 flex flex-wrap gap-1">
{c.tags.map((t) => <Badge key={t} variant="outline">{t}</Badge>)}
<div className="mt-3 rounded-xl border border-slate-100 bg-slate-50/50 p-3">
<CustomerTagLegend tags={c.tags} />
{c.tags.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{[...c.tags]
.sort((a, b) => customerTagCategory(a).localeCompare(customerTagCategory(b)))
.map((tag) => <CustomerSemanticTag key={tag} tag={tag} />)}
</div>
) : (
<p className="text-xs leading-5 text-slate-400">
暂无标签,AI 将在收到有效客户消息后自动补全。
</p>
)}
</div>
</div>
<div className="flex flex-wrap items-start gap-2">
@@ -119,43 +155,46 @@ export function CustomerDetailPage() {
<div className="mt-4">
{tab === 'overview' && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader title="自动建档流程" description="客户从渠道识别到完整建档案的自动化历程" />
<div className="space-y-3 px-4 pb-4">
{[
'✅ 识别来源渠道(WhatsApp)',
'✅ 记录来源参数(官网按钮带入)',
'✅ 匹配客户身份(手机号 + 姓名字段)',
'✅ 自动发现重复客户(未匹配到重复)',
'✅ 人工确认建档(管理员 / 业务主管确认)',
'✅ 完整客户档案已建立',
].map((step, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span className={cn('flex h-5 w-5 items-center justify-center rounded-full text-xs', step.startsWith('✅') ? 'bg-emerald-100 text-emerald-700' : 'bg-muted text-muted-foreground')}>{i + 1}</span>
{step.replace('✅ ', '')}
<div className="grid grid-cols-1 gap-4 xl:grid-cols-12">
<CustomerProfileRadar customer={c} className="xl:col-span-5" />
<Card className="xl:col-span-7">
<CardHeader title="AI 客户洞察" description="结合完整会话、购买信号和风险变化生成" />
<div className="p-5">
<div className="grid grid-cols-3 gap-2">
{[
['成交概率', c.aiProfile.dealProbability || 0, 'text-emerald-700 bg-emerald-50'],
['流失风险', c.aiProfile.churnProbability || 0, 'text-amber-700 bg-amber-50'],
['分析可信度', c.aiProfile.confidenceScore || 0, 'text-blue-700 bg-blue-50'],
].map(([label, value, color]) => (
<div key={String(label)} className={cn('rounded-2xl px-3 py-3', String(color))}>
<p className="text-[11px] opacity-75">{label}</p>
<p className="mt-1 text-xl font-semibold tracking-tight">{Number(value)}%</p>
</div>
))}
</div>
<p className="mt-5 text-sm leading-7 text-slate-600">
{c.aiProfile.summary || 'AI 正在根据 Telegram 会话补全客户画像。'}
</p>
{c.aiProfile.evidence?.length ? (
<div className="mt-5 border-t border-slate-100 pt-4">
<p className="text-xs font-semibold text-slate-900">关键判断依据</p>
<div className="mt-2 space-y-2">
{c.aiProfile.evidence.slice(0, 4).map((item) => (
<div key={item} className="flex gap-2 text-xs leading-5 text-slate-500">
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary-400" />
{item}
</div>
))}
</div>
</div>
))}
) : null}
</div>
</Card>
<Card>
<CardHeader title="AI 客户画像摘要" />
<p className="px-4 pb-4 text-sm text-muted-foreground leading-relaxed">{c.aiProfile.summary}</p>
{c.aiProfile.nextActions.length > 0 && (
<div className="px-4 pb-4">
<h4 className="text-xs font-semibold mb-2">推荐动作</h4>
<div className="flex flex-wrap gap-1.5">
{c.aiProfile.nextActions.map((a) => (
<button key={a} onClick={() => toast.info(a)} className="rounded-full border border-primary-200 bg-primary-50 px-2 py-1 text-xs text-primary-700">{a}</button>
))}
</div>
</div>
)}
</Card>
{/* AI 提取信息 */}
<Card className="lg:col-span-2">
<CardHeader title="AI 自动提取信息" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 px-4 pb-4 text-sm">
<Card className="xl:col-span-8">
<CardHeader title="采购情报" description="从 Telegram 对话中自动结构化提取" />
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-b-2xl bg-slate-100 md:grid-cols-4">
{Object.entries({
询价数量: c.aiProfile.inquiryQuantity,
目标价格: c.aiProfile.targetPrice,
@@ -165,16 +204,32 @@ export function CustomerDetailPage() {
交付时间: c.aiProfile.deliveryTime,
物流方式: c.aiProfile.logistics,
付款方式: c.aiProfile.payment,
售后诉求: c.aiProfile.aftersales,
主要顾虑: c.aiProfile.concerns?.join('、'),
}).filter(([, v]) => v).map(([k, v]) => (
<div key={k} className="rounded-md bg-muted/60 p-2">
<span className="text-xs text-muted-foreground">{k}</span>
<p className="text-sm font-medium mt-0.5">{v as string}</p>
}).map(([k, v]) => (
<div key={k} className="min-h-24 bg-white p-4">
<span className="text-[11px] font-medium text-slate-400">{k}</span>
<p className={cn('mt-2 text-sm font-medium leading-5', v ? 'text-slate-800' : 'text-slate-300')}>
{(v as string) || '待识别'}
</p>
</div>
))}
</div>
</Card>
<Card className="xl:col-span-4">
<CardHeader title="建议下一步" description={c.aiProfile.followUpTiming || '按当前客户热度排序'} />
<div className="space-y-2 p-4">
{(c.aiProfile.nextActions.length ? c.aiProfile.nextActions : ['补充确认采购数量', '确认目标交期']).slice(0, 5).map((action, index) => (
<button
key={action}
onClick={() => void createSuggestedTask(action)}
className="flex w-full items-center gap-3 rounded-xl border border-slate-100 px-3 py-2.5 text-start text-sm text-slate-700 transition-colors hover:border-primary-200 hover:bg-primary-50"
>
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-[11px] font-semibold text-slate-500">{index + 1}</span>
{action}
</button>
))}
</div>
</Card>
</div>
)}
+93 -24
View File
@@ -15,7 +15,8 @@ import {
Upload,
UserRoundPlus,
} from 'lucide-react'
import { getCustomers, deleteCustomer, importCustomers, exportCustomers, getDuplicateCustomers, mergeCustomers } from '@/services/api/customers'
import { getCustomers, deleteCustomer, importCustomers, exportCustomers, getDuplicateCustomers, mergeCustomers, updateCustomer } from '@/services/api/customers'
import { getTeam } from '@/services/api/org'
import { useI18n } from '@/i18n'
import { useAuthStore } from '@/app/store/auth'
import { usePermission } from '@/hooks/usePermission'
@@ -24,7 +25,7 @@ import { confirmDialog } from '@/app/store/confirm'
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback'
import { PageHeader } from '@/components/common/PageHeader'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Input, Field } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
@@ -71,14 +72,17 @@ export function CustomersPage() {
const [importDialog, setImportDialog] = useState(false)
const [mergeDialog, setMergeDialog] = useState(false)
const [duplicateList, setDuplicateList] = useState<any[]>([])
const [batchDialog, setBatchDialog] = useState<'assign' | 'tag' | null>(null)
const [batchValue, setBatchValue] = useState('')
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['customers', 'all'],
queryFn: () => getCustomers({ keyword, country, channel, stage, intentLevel }),
})
const { data: team = [] } = useQuery({ queryKey: ['team'], queryFn: getTeam })
const updateMut = useMutation({
mutationFn: (p: { id: string; data: Partial<Customer> }) => import('@/services/api/customers').then((api) => api.updateCustomer(p.id, p.data)),
mutationFn: (p: { id: string; data: Partial<Customer> }) => updateCustomer(p.id, p.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customers'] })
toast.success('已更新')
@@ -87,28 +91,32 @@ export function CustomersPage() {
})
const batchAssign = async () => {
const owner = prompt('请输入负责人姓名(演示环境请输入 李婷/赵磊/周敏/郑楠):')
if (!owner) return
const team = (await import('@/services/api/org').then((api) => api.getTeam())).find((m) => m.name === owner)
if (!team) return toast.error('未找到该成员')
const member = team.find((item) => item.id === batchValue)
if (!member) return toast.error('请选择负责人')
const count = selected.size
for (const id of selected) {
try { await updateMut.mutateAsync({ id, data: { ownerId: team.id } }) } catch {}
try { await updateMut.mutateAsync({ id, data: { ownerId: member.id } }) } catch {}
}
setSelected(new Set())
toast.success(`已分配 ${selected.size} 位客户给 ${owner}`)
setBatchDialog(null)
setBatchValue('')
toast.success(`已分配 ${count} 位客户给 ${member.name}`)
}
const batchTag = async () => {
const tag = prompt('请输入要添加的标签:')
const tag = batchValue.trim()
if (!tag) return
const count = selected.size
for (const id of selected) {
const c = data?.list.find((x: Customer) => x.id === id)
if (c) {
try { await updateMut.mutateAsync({ id, data: { tags: [...c.tags, tag] } }) } catch {}
try { await updateMut.mutateAsync({ id, data: { tags: Array.from(new Set([...c.tags, tag])) } }) } catch {}
}
}
setSelected(new Set())
toast.success(`已为 ${selected.size} 位客户添加标签「${tag}」`)
setBatchDialog(null)
setBatchValue('')
toast.success(`已为 ${count} 位客户添加标签「${tag}」`)
}
const handleExport = async () => {
@@ -118,11 +126,16 @@ export function CustomersPage() {
toast.success('导出成功')
}
const handleImport = async () => {
const rows = [
{ name: 'Demo Import 1', country: 'US', email: 'demo1@example.com' },
{ name: 'Demo Import 2', country: 'PK', email: 'demo2@example.com' },
]
const handleImport = async (file: File) => {
const text = await file.text()
const lines = text.replace(/^\uFEFF/, '').split(/\r?\n/).filter(Boolean)
if (lines.length < 2) return toast.error('CSV 文件没有可导入的数据行')
const headers = lines[0].split(',').map((x) => x.trim().replace(/^"|"$/g, '').toLowerCase())
const rows = lines.slice(1).map((line) => {
const values = line.split(',').map((x) => x.trim().replace(/^"|"$/g, ''))
return Object.fromEntries(headers.map((header, index) => [header, values[index] || '']))
}).filter((row) => row.name || row.email || row.phone)
if (!rows.length) return toast.error('未识别到客户数据,请包含 name、email 或 phone 列')
const res = await importCustomers(rows)
toast.success(`导入完成:${res.imported} 条成功`)
setImportDialog(false)
@@ -225,8 +238,8 @@ export function CustomersPage() {
<div className="mb-3 flex items-center gap-2 rounded-lg bg-primary-50 px-3 py-2">
<span className="text-sm font-medium text-primary-700">已选 {selected.size} 位客户</span>
<div className="flex-1" />
<Button size="sm" variant="outline" onClick={batchAssign}><UserRoundPlus className="h-4 w-4" />批量分配</Button>
<Button size="sm" variant="outline" onClick={batchTag}><Tag className="h-4 w-4" />批量加标签</Button>
<Button size="sm" variant="outline" onClick={() => { setBatchValue(''); setBatchDialog('assign') }}><UserRoundPlus className="h-4 w-4" />批量分配</Button>
<Button size="sm" variant="outline" onClick={() => { setBatchValue(''); setBatchDialog('tag') }}><Tag className="h-4 w-4" />批量加标签</Button>
<Button size="sm" variant="ghost" onClick={() => setSelected(new Set())}>取消选择</Button>
</div>
)}
@@ -257,11 +270,11 @@ export function CustomersPage() {
<Tr><Td colSpan={13}><EmptyState title="暂无匹配客户" description="尝试调整筛选条件" /></Td></Tr>
)}
{list.map((customer: Customer) => (
<Tr key={customer.id} className="cursor-pointer">
<Tr key={customer.id} data-testid="customer-row" className="cursor-pointer">
<Td onClick={(e) => e.stopPropagation()}><Checkbox checked={selected.has(customer.id)} onCheckedChange={(v) => setSelected((s) => { const ns = new Set(s); v ? ns.add(customer.id) : ns.delete(customer.id); return ns })} /></Td>
<Td onClick={() => navigate(`/customers/${customer.id}`)}>
<div className="flex items-center gap-2">
<Avatar name={customer.name} size="xs" />
<Avatar name={customer.name} src={customer.avatarUrl} size="xs" />
<div>
<p className="text-sm font-medium hover:text-primary">{customer.name}</p>
{customer.company && <p className="text-xs text-muted-foreground">{customer.company}</p>}
@@ -303,7 +316,7 @@ export function CustomersPage() {
<Card key={customer.id} className="cursor-pointer p-4 hover:shadow-md transition-shadow" onClick={() => navigate(`/customers/${customer.id}`)}>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2.5">
<Avatar name={customer.name} size="sm" />
<Avatar name={customer.name} src={customer.avatarUrl} size="sm" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{customer.name}</p>
<p className="text-xs text-muted-foreground">{customer.company || '—'}</p>
@@ -347,7 +360,7 @@ export function CustomersPage() {
onClick={() => navigate(`/customers/${customer.id}`)}
>
<div className="flex items-center gap-2">
<Avatar name={customer.name} size="xs" />
<Avatar name={customer.name} src={customer.avatarUrl} size="xs" />
<span className="text-sm font-medium truncate">{customer.name}</span>
</div>
<div className="mt-1.5 flex items-center gap-2">
@@ -365,6 +378,48 @@ export function CustomersPage() {
<CustomerFormDialog open={formDialog} onOpenChange={setFormDialog} customer={editCustomer} />
<Dialog
open={batchDialog !== null}
onOpenChange={(open) => { if (!open) { setBatchDialog(null); setBatchValue('') } }}
title={batchDialog === 'assign' ? '批量分配客户' : '批量添加标签'}
description={`当前已选择 ${selected.size} 位客户`}
footer={
<>
<Button variant="ghost" onClick={() => setBatchDialog(null)}>取消</Button>
<Button
loading={updateMut.isPending}
disabled={!batchValue}
onClick={batchDialog === 'assign' ? batchAssign : batchTag}
>
确认保存
</Button>
</>
}
>
{batchDialog === 'assign' ? (
<Field label="负责人">
<Select
value={batchValue}
onChange={(event) => setBatchValue(event.target.value)}
placeholder="选择团队成员"
options={team.filter((member) => member.status === 'active').map((member) => ({
value: member.id,
label: `${member.name} · ${member.department || member.role}`,
}))}
/>
</Field>
) : (
<Field label="客户标签">
<Input
autoFocus
value={batchValue}
onChange={(event) => setBatchValue(event.target.value)}
placeholder="例如:重点客户、展会线索"
/>
</Field>
)}
</Dialog>
{/* 去重合并弹窗 */}
<Dialog open={mergeDialog} onOpenChange={setMergeDialog} title="重复客户检测" width="max-w-2xl">
{duplicateList.length === 0 ? (
@@ -391,7 +446,21 @@ export function CustomersPage() {
open={importDialog}
onOpenChange={setImportDialog}
title="批量导入客户"
footer={<Button onClick={handleImport}>模拟导入 2 条演示数据</Button>}
footer={
<label className="inline-flex h-9 cursor-pointer items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground">
选择 CSV 并导入
<input
type="file"
accept=".csv,text/csv"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0]
if (file) void handleImport(file)
event.target.value = ''
}}
/>
</label>
}
>
<p className="text-sm text-muted-foreground">
支持 Excel(.xlsx)或 CSV 文件上传,系统将自动识别列名(姓名/公司/国家/邮箱/电话)并去重建档。
+39 -29
View File
@@ -39,7 +39,7 @@ import { Badge } from '@/components/ui/badge'
import { Avatar } from '@/components/ui/avatar'
import { Checkbox } from '@/components/ui/checkbox'
import { StatCard } from '@/components/common/StatCard'
import { DemoNote } from '@/components/common/DemoNote'
import { LiveDataBadge } from '@/components/common/LiveDataBadge'
import { ChannelIcon } from '@/components/common/ChannelIcon'
import { CountryFlag } from '@/components/common/CountryFlag'
import { IntentScoreBadge, CustomerStageBadge } from '@/components/common/StatusBadges'
@@ -92,43 +92,53 @@ export function DashboardPage() {
const s = data.stats
return (
<div className="p-4 md:p-6">
<div className="mx-auto max-w-[1600px] p-4 md:p-7">
{/* 顶部欢迎区 */}
<div className="mb-5 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold">
{greeting},{user?.name}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{dateStr} · {tenant?.name} · {t('dashboard.updatedAt')} {formatDateTime(data.updatedAt).slice(11)}
</p>
<div className="mb-7 overflow-hidden rounded-3xl border border-slate-200/80 bg-white px-5 py-5 shadow-[0_16px_50px_rgba(15,23,42,.035)] md:px-7 md:py-6">
<div className="flex flex-wrap items-start justify-between gap-5">
<div>
<p className="eyebrow">Business command center</p>
<h1 className="mt-2 text-2xl font-semibold tracking-[-0.035em] text-slate-950 md:text-[28px]">
{greeting},{user?.name}
</h1>
<p className="mt-1.5 text-sm text-slate-500">
{dateStr} · {tenant?.name} · 数据更新于 {formatDateTime(data.updatedAt).slice(11)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="primary" className="h-7 rounded-full px-3">{plan.name}</Badge>
<LiveDataBadge />
<Button size="sm" onClick={() => navigate('/inbox')}>
<Send className="h-4 w-4" />
处理客户消息
</Button>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="primary" className="h-7 px-2.5">{plan.name}</Badge>
<DemoNote />
<Button size="sm" onClick={() => navigate('/channels')}>
<Cable className="h-4 w-4" />
快速连接渠道
</Button>
<div className="mt-5 flex gap-2 overflow-x-auto border-t border-slate-100 pt-4 scrollbar-thin">
{quickActions.slice(0, 5).map((a) => (
<Button key={a.label} variant="ghost" size="sm" className="shrink-0 rounded-xl bg-slate-50 text-slate-600 hover:bg-primary-50 hover:text-primary-800" onClick={a.onClick}>
<a.icon className="h-3.5 w-3.5" />
{a.label}
</Button>
))}
</div>
</div>
{/* 快捷操作 */}
<div className="mb-5 flex flex-wrap gap-2">
{quickActions.map((a) => (
<Button key={a.label} variant="outline" size="sm" onClick={a.onClick}>
<a.icon className="h-3.5 w-3.5" />
{a.label}
</Button>
))}
</div>
{/* 核心指标 */}
<div className="mb-3 flex items-end justify-between">
<div>
<p className="eyebrow">Today</p>
<h2 className="mt-1 text-lg font-semibold tracking-tight">今日经营重点</h2>
</div>
<button onClick={() => navigate('/analytics')} className="text-xs font-medium text-primary hover:text-primary-700">查看完整分析 →</button>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatCard title="今日新增询盘" value={s.newInquiriesToday.value} delta={s.newInquiriesToday.delta} icon={MessageSquarePlus} onClick={() => navigate('/inbox')} />
<StatCard title="待回复消息" value={s.pendingReply.value} delta={s.pendingReply.delta} icon={MessageSquareWarning} onClick={() => navigate('/inbox?filter=pending')} />
<StatCard title="高意向客户" value={s.highIntentCustomers.value} delta={s.highIntentCustomers.delta} icon={Target} onClick={() => navigate('/customers?intentLevel=high')} />
<StatCard title="今日待跟进" value={s.todayFollowUps.value} delta={s.todayFollowUps.delta} icon={CalendarClock} onClick={() => navigate('/opportunities?tab=tasks')} />
</div>
<div className="mt-3 grid grid-cols-2 gap-3 md:grid-cols-4">
<StatCard title="风险预警" value={s.openRisks.value} delta={s.openRisks.delta} icon={AlertTriangle} onClick={() => navigate('/risks')} />
<StatCard title="平均首次响应" value={s.avgFirstResponseMin.value} suffix="分钟" delta={s.avgFirstResponseMin.delta} icon={Timer} invertDelta />
<StatCard title="本月客户转化率" value={`${s.monthConversionRate.value}%`} delta={s.monthConversionRate.delta} icon={BadgePercent} />
@@ -192,7 +202,7 @@ export function DashboardPage() {
<div className="max-h-[340px] divide-y divide-border overflow-y-auto scrollbar-thin">
{data.highValueCustomers.map((c) => (
<Link key={c.id} to={`/customers/${c.id}`} className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50">
<Avatar name={c.name} size="sm" />
<Avatar name={c.name} src={c.avatarUrl} size="sm" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{c.name}</span>
@@ -216,7 +226,7 @@ export function DashboardPage() {
<CardHeader
title="实时消息概览"
description="各渠道今日消息量"
action={<DemoNote />}
action={<LiveDataBadge />}
/>
<div className="divide-y divide-border">
{data.channelOverview.map((ch) => (
+17 -76
View File
@@ -1,92 +1,33 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Check, ExternalLink, GraduationCap, HelpCircle, PlayCircle, Cable, BookOpen, FileUp, Headphones, Ticket, Calendar } from 'lucide-react'
import { Cable, BookOpen, FileUp, Languages, UsersRound, CreditCard } from 'lucide-react'
import { PageHeader } from '@/components/common/PageHeader'
import { Button } from '@/components/ui/button'
import { Card, CardHeader } from '@/components/ui/card'
import { Dialog } from '@/components/ui/dialog'
import { Input, Field, Textarea } from '@/components/ui/input'
import { DemoNote } from '@/components/common/DemoNote'
import { toast } from '@/app/store/toast'
const DELIVERY_STEPS = [
{ key: '1', title: '账号开通', done: true },
{ key: '2', title: '渠道连接', done: true },
{ key: '3', title: '历史数据迁移', done: true },
{ key: '4', title: '功能配置', done: true },
{ key: '5', title: '员工培训', done: false },
{ key: '6', title: '正式上线', done: false },
{ key: '7', title: '运营支持', done: false },
{ key: '8', title: '使用反馈', done: false },
{ key: '9', title: '产品持续迭代', done: false },
]
import { Card } from '@/components/ui/card'
const HELP_ITEMS = [
{ icon: GraduationCap, title: '新手指引', desc: '10分钟快速上手灵犀跨境' },
{ icon: PlayCircle, title: '视频教程', desc: '核心功能操作教学视频' },
{ icon: HelpCircle, title: '常见问题', desc: 'FAQ 与排查指引' },
{ icon: Cable, title: '渠道连接文档', desc: 'WhatsApp、邮件、表单等连接指南' },
{ icon: BookOpen, title: '知识库建设教程', desc: '如何构建高效的企业知识库' },
{ icon: FileUp, title: '客户导入教程', desc: '批量导入与去重操作指南' },
{ icon: Cable, title: '连接 Telegram', desc: '手机号、验证码和二步密码登录,并同步历史会话', path: '/channels' },
{ icon: BookOpen, title: '建设知识库', desc: '上传 TXT、Markdown、PDF 或 DOCX 并解析为企业知识', path: '/knowledge' },
{ icon: FileUp, title: '导入与管理客户', desc: '维护客户资料、标签、负责人和跟进状态', path: '/customers' },
{ icon: Languages, title: 'AI 上下文翻译', desc: '结合当前会话上下文进行跨境商务翻译', path: '/translate' },
{ icon: UsersRound, title: '邀请团队成员', desc: '通过已配置的企业 SMTP 发送真实邀请邮件', path: '/team' },
{ icon: CreditCard, title: '订阅与支付', desc: '通过 Stripe Checkout 管理套餐订阅', path: '/billing' },
]
export function HelpPage() {
const navigate = useNavigate()
const [ticketOpen, setTicketOpen] = useState(false)
const doneCount = DELIVERY_STEPS.filter((s) => s.done).length
return (
<div className="p-4 md:p-6">
<PageHeader title="帮助与培训" description="从开通到深度运营,全程伴随" actions={<DemoNote />} />
{/* 交付进度 */}
<Card className="mb-4 p-4">
<h2 className="text-sm font-semibold mb-3">企业开通进度 <span className="text-xs text-muted-foreground font-normal">({doneCount}/{DELIVERY_STEPS.length} 步完成)</span></h2>
<div className="flex flex-wrap gap-3">
{DELIVERY_STEPS.map((s, i) => (
<div key={s.key} className="flex items-center gap-2 text-sm">
<span className={`flex h-6 w-6 items-center justify-center rounded-full text-xs ${s.done ? 'bg-emerald-100 text-emerald-700' : 'bg-muted text-muted-foreground'}`}>
{s.done ? <Check className="h-3.5 w-3.5" /> : i + 1}
</span>
<span className="text-muted-foreground">{s.title}</span>
{i < DELIVERY_STEPS.length - 1 && <span className="text-border">→</span>}
</div>
))}
</div>
</Card>
{/* 帮助卡片 */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 mb-6">
<PageHeader title="帮助与配置入口" description="直接进入可用功能完成配置和操作" />
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3">
{HELP_ITEMS.map((item) => (
<Card key={item.title} className="cursor-pointer p-4 hover:shadow-md transition-shadow" onClick={() => toast.success(`打开:${item.title}(演示内容)`)}>
<item.icon className="h-8 w-8 text-primary-600 mb-2" />
<p className="text-sm font-medium">{item.title}</p>
<p className="text-xs text-muted-foreground mt-1">{item.desc}</p>
</Card>
<button key={item.title} className="text-left" onClick={() => navigate(item.path)}>
<Card className="h-full p-4 transition-shadow hover:shadow-md">
<item.icon className="mb-2 h-8 w-8 text-primary-600" />
<p className="text-sm font-medium">{item.title}</p>
<p className="mt-1 text-xs text-muted-foreground">{item.desc}</p>
</Card>
</button>
))}
</div>
{/* 客服与工单 */}
<div className="flex flex-wrap gap-3">
<Button variant="outline" onClick={() => toast.info('在线客服:工作日 9:00—18:00(演示)')}>
<Headphones className="h-4 w-4" /> 联系客服
</Button>
<Button variant="outline" onClick={() => setTicketOpen(true)}>
<Ticket className="h-4 w-4" /> 提交工单
</Button>
<Button variant="outline" onClick={() => toast.info('预约培训已提交(演示)')}>
<Calendar className="h-4 w-4" /> 预约培训
</Button>
</div>
<Dialog open={ticketOpen} onOpenChange={setTicketOpen} title="提交工单" footer={
<Button onClick={() => { setTicketOpen(false); toast.success('工单已提交', '客服将在24小时内回复') }}>
提交
</Button>
}>
<Field label="工单主题" required><Input placeholder="请描述您的问题" /></Field>
<Field label="问题描述" className="mt-3"><Textarea rows={4} placeholder="详细描述您遇到的问题或需求" /></Field>
</Dialog>
</div>
)
}
File diff suppressed because it is too large Load Diff
-152
View File
@@ -1,152 +0,0 @@
import { useState, useRef, useEffect } from 'react'
import { Mic, MicOff, Pause, Play, Square, PhoneCall, Download, ListTodo } from 'lucide-react'
import { useMutation } from '@tanstack/react-query'
import { PageHeader } from '@/components/common/PageHeader'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardHeader } from '@/components/ui/card'
import { Select } from '@/components/ui/select'
import { DemoNote } from '@/components/common/DemoNote'
import { nextInterpretLine } from '@/services/api/ai'
import { toast } from '@/app/store/toast'
import { LANGUAGES } from '@/config/languages'
import { cn } from '@/utils/cn'
import type { InterpretLine } from '@/types/ai'
const langOptions = LANGUAGES.slice(1).map(l => ({ value: l.code, label: `${l.nativeName} (${l.name})` }))
const STATES = {
idle: { label: '空闲', color: 'bg-gray-100 text-gray-600' },
connecting: { label: '正在连接', color: 'bg-blue-100 text-blue-600' },
listening: { label: '正在识别', color: 'bg-emerald-100 text-emerald-600' },
translating: { label: '正在翻译', color: 'bg-teal-100 text-teal-600' },
unstable: { label: '网络不稳定', color: 'bg-amber-100 text-amber-600' },
ended: { label: '会话已结束', color: 'bg-gray-200 text-gray-500' },
}
export function InterpretPage() {
const [myLang, setMyLang] = useState('zh')
const [peerLang, setPeerLang] = useState('ar')
const [state, setState] = useState<keyof typeof STATES>('idle')
const [lines, setLines] = useState<InterpretLine[]>([])
const [paused, setPaused] = useState(false)
const [vol, setVol] = useState(0)
const timerRef = useRef<ReturnType<typeof setInterval>>()
const lineIndex = useRef(0)
const simMut = useMutation({
mutationFn: () => nextInterpretLine(lineIndex.current),
onSuccess: (line) => {
lineIndex.current += 1
setLines((l) => [...l, { ...line, speaker: lineIndex.current % 2 === 0 ? 'me' : 'peer' }])
setState('translating')
setTimeout(() => { setState('listening'); setVol(Math.random()) }, 800)
},
})
useEffect(() => {
if (state === 'listening' && !paused) {
timerRef.current = setInterval(() => {
simMut.mutate()
}, 4000)
}
return () => clearInterval(timerRef.current)
}, [state, paused])
const start = () => {
setState('connecting')
setTimeout(() => { setState('listening'); setVol(0.6) }, 1200)
toast.success('同声传译会话已创建', `${LANGUAGES.find(l => l.code === myLang)?.name} ↔ ${LANGUAGES.find(l => l.code === peerLang)?.name}`)
}
const stop = () => {
setState('ended')
clearInterval(timerRef.current)
toast.info('会话已结束,可导出记录')
}
const statusInfo = STATES[state]
return (
<div className="p-4 md:p-6">
<PageHeader title="同声传译" description="实时语音翻译与双语字幕 · 支持模拟演示" actions={<DemoNote />} />
<div className="mx-auto max-w-2xl">
{/* 设置区 */}
<Card className="mb-4 p-4">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm">我的语言</span>
<Select options={langOptions} value={myLang} onChange={(e) => setMyLang(e.target.value)} className="w-32" />
<span className="text-sm text-muted-foreground">↔</span>
<span className="text-sm">对方语言</span>
<Select options={langOptions} value={peerLang} onChange={(e) => setPeerLang(e.target.value)} className="w-32" />
<div className="flex-1" />
{state === 'idle' && <Button onClick={start}><PhoneCall className="h-4 w-4" />开始会话</Button>}
{state === 'ended' && <Button onClick={() => { setLines([]); setState('idle'); lineIndex.current = 0 }}>新建会话</Button>}
</div>
</Card>
{/* 状态区 */}
<Card className="mb-4 p-6 text-center">
<Badge className={cn('text-sm px-4 py-1.5', statusInfo.color)}>{statusInfo.label}</Badge>
<div className="mt-4 flex items-center justify-center gap-8">
<div className="text-center">
<div className={cn('flex h-12 w-12 items-center justify-center rounded-full mx-auto', state === 'listening' ? 'bg-emerald-100 text-emerald-600' : 'bg-muted text-muted-foreground')}>
<Mic className="h-6 w-6" />
</div>
<p className="mt-1 text-xs text-muted-foreground">{LANGUAGES.find(l => l.code === myLang)?.name}</p>
{state === 'listening' && vol > 0 && (
<div className="mt-1 h-1.5 w-16 mx-auto overflow-hidden rounded-full bg-muted">
<div className="h-full bg-emerald-500 transition-all" style={{ width: `${vol * 100}%` }} />
</div>
)}
</div>
<div className="flex gap-2">
{state !== 'idle' && state !== 'ended' && (
<>
<Button variant={paused ? 'default' : 'outline'} size="icon" onClick={() => { setPaused(!paused); toast.info(paused ? '已继续' : '已暂停') }} title="暂停/继续">
{paused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
</Button>
<Button variant="destructive" size="icon" onClick={stop} title="结束会话"><Square className="h-4 w-4" /></Button>
</>
)}
</div>
<div className="text-center">
<div className={cn('flex h-12 w-12 items-center justify-center rounded-full mx-auto', state === 'listening' ? 'bg-teal-100 text-teal-600' : 'bg-muted text-muted-foreground')}>
<Mic className="h-6 w-6" />
</div>
<p className="mt-1 text-xs text-muted-foreground">{LANGUAGES.find(l => l.code === peerLang)?.name}</p>
</div>
</div>
</Card>
{/* 字幕区 */}
<Card>
<CardHeader
title="双语字幕记录"
action={
<div className="flex gap-1">
<Button variant="ghost" size="sm" disabled={lines.length === 0} onClick={() => { const t = lines.map(l => `[${l.speaker}] ${l.original}\n → ${l.translated}`).join('\n\n'); const b = new Blob([t]); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = '同声传译记录.txt'; a.click(); toast.success('已导出') }}>
<Download className="h-4 w-4" /> 导出
</Button>
</div>
}
/>
<div className="max-h-80 overflow-y-auto scrollbar-thin">
{lines.length === 0 && <p className="py-10 text-center text-sm text-muted-foreground">字幕将在此实时显示</p>}
{lines.map((line) => (
<div key={line.id} className={cn('p-3 border-b border-border', line.speaker === 'me' ? 'bg-primary-50/30' : 'bg-card')}>
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline">{line.speaker === 'me' ? '我' : '对方'}</Badge>
<span className="text-[11px] text-muted-foreground">{line.at ? new Date(line.at).toLocaleTimeString('zh-CN') : ''}</span>
</div>
<p className="text-sm">{line.original}</p>
<p className="mt-1 text-sm text-muted-foreground">→ {line.translated}</p>
</div>
))}
</div>
</Card>
</div>
</div>
)
}
+26 -7
View File
@@ -12,7 +12,6 @@ import { Switch } from '@/components/ui/switch'
import { Input, Textarea, Field } from '@/components/ui/input'
import { Tabs } from '@/components/ui/tabs'
import { Dialog } from '@/components/ui/dialog'
import { DemoNote } from '@/components/common/DemoNote'
import {
getKnowledge, createKnowledge, updateKnowledge, deleteKnowledge,
uploadKnowledgeFiles, knowledgeSearchTest, getKnowledgeStats,
@@ -38,7 +37,7 @@ export function KnowledgePage() {
const { data: stats } = useQuery({ queryKey: ['knowledge-stats'], queryFn: getKnowledgeStats, enabled: tab === 'stats' })
const [searchResult, setSearchResult] = useState<any>(null)
const [searchLoading, setSearchLoading] = useState(false)
const [uploadFiles, setUploadFiles] = useState<{ name: string; type: string }[]>([])
const [uploadFiles, setUploadFiles] = useState<File[]>([])
const [uploadDialog, setUploadDialog] = useState(false)
const createMut = useMutation({
@@ -187,12 +186,32 @@ export function KnowledgePage() {
</Dialog>
<Dialog open={uploadDialog} onOpenChange={setUploadDialog} title="上传文件" footer={
<Button onClick={() => {
uploadKnowledgeFiles([{ name: '产品手册-2026Q3.pdf', type: 'pdf' }, { name: '报价政策.docx', type: 'docx' }])
.then(() => { toast.success('文件已开始解析,约3—5秒完成'); queryClient.invalidateQueries({ queryKey: ['knowledge'] }); setUploadDialog(false) })
}}>上传</Button>
<Button disabled={!uploadFiles.length} onClick={async () => {
const payload = await Promise.all(uploadFiles.map(async (file) => ({
name: file.name,
type: file.type,
base64: await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(reader.error)
reader.onload = () => resolve(String(reader.result).split(',')[1] || '')
reader.readAsDataURL(file)
}),
})))
await uploadKnowledgeFiles(payload)
toast.success(`${payload.length} 个知识文件解析完成`)
queryClient.invalidateQueries({ queryKey: ['knowledge'] })
setUploadFiles([])
setUploadDialog(false)
}}>上传并解析</Button>
}>
<p className="text-sm text-muted-foreground">支持 Word/PDF/TXT/Excel/图片,AI 将自动解析为可引用的知识条目。</p>
<input
type="file"
multiple
accept=".pdf,.docx,.txt,.md,.csv,.json,.html"
onChange={(e) => setUploadFiles(Array.from(e.target.files || []))}
className="block w-full rounded-md border border-input p-2 text-sm"
/>
<p className="mt-2 text-sm text-muted-foreground">支持 PDF、DOCX、TXT、Markdown、CSV、JSON 和 HTML,文件内容由服务端真实解析并写入知识库。</p>
</Dialog>
</div>
)
-278
View File
@@ -1,278 +0,0 @@
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Check, ChevronLeft, ChevronRight, Sparkles, Upload } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input, Field, Textarea } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Progress } from '@/components/ui/feedback'
import { ChannelIcon } from '@/components/common/ChannelIcon'
import { CHANNEL_META, COUNTRIES, INDUSTRIES, LANGUAGES_LIST } from '@/config/constants'
import { useAuthStore } from '@/app/store/auth'
import { toast } from '@/app/store/toast'
import { cn } from '@/utils/cn'
import type { ChannelType } from '@/types'
const STEPS = [
{ key: 'step1', title: '企业资料', desc: '完善企业基本信息' },
{ key: 'step2', title: '主营行业', desc: '选择您的主营行业' },
{ key: 'step3', title: '目标市场', desc: '主要出口国家和地区' },
{ key: 'step4', title: '沟通语言', desc: '常用沟通语言' },
{ key: 'step5', title: '邀请成员', desc: '组建您的团队' },
{ key: 'step6', title: '连接渠道', desc: '接入客户沟通渠道' },
{ key: 'step7', title: '导入客户', desc: '导入历史客户数据' },
{ key: 'step8', title: '知识库', desc: '上传企业知识资料' },
]
export function OnboardingPage() {
const navigate = useNavigate()
const { tenant, setOnboarded } = useAuthStore()
const [step, setStep] = useState(0)
const [done, setDone] = useState<Set<number>>(new Set())
const [company, setCompany] = useState({ name: tenant?.name ?? '', industry: '', size: '', contact: '', intro: '' })
const [industries, setIndustries] = useState<string[]>([])
const [countries, setCountries] = useState<string[]>([])
const [langs, setLangs] = useState<string[]>(['zh', 'en'])
const [emails, setEmails] = useState('')
const [connected, setConnected] = useState<ChannelType[]>([])
const progress = useMemo(() => Math.round(((done.size + (step > done.size ? step - done.size : 0)) / STEPS.length) * 100), [done, step])
const markDone = () => setDone((s) => new Set(s).add(step))
const next = () => {
markDone()
if (step < STEPS.length - 1) setStep(step + 1)
else finish()
}
const skip = () => {
if (step < STEPS.length - 1) setStep(step + 1)
else finish()
}
const finish = () => {
setOnboarded(true)
toast.success('企业初始化完成', '欢迎使用灵犀跨境,祝您开单顺利')
navigate('/dashboard')
}
const toggleIn = (list: string[], v: string, set: (l: string[]) => void) =>
set(list.includes(v) ? list.filter((x) => x !== v) : [...list, v])
const channelTypes: ChannelType[] = ['whatsapp', 'telegram', 'messenger', 'email', 'webform', 'landing']
return (
<div className="flex min-h-screen bg-background">
{/* 左侧步骤条 */}
<aside className="hidden w-72 shrink-0 flex-col border-e border-border bg-card p-6 md:flex">
<div className="mb-6 flex items-center gap-2">
<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">企业初始化</span>
</div>
<div className="space-y-1">
{STEPS.map((s, i) => {
const isDone = done.has(i)
const isCurrent = i === step
return (
<button
key={s.key}
onClick={() => setStep(i)}
className={cn(
'flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-start transition-colors',
isCurrent ? 'bg-primary-50' : 'hover:bg-muted',
)}
>
<span
className={cn(
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-medium',
isDone ? 'bg-primary text-white' : isCurrent ? 'bg-primary-100 text-primary-700' : 'bg-muted text-muted-foreground',
)}
>
{isDone ? <Check className="h-3.5 w-3.5" /> : i + 1}
</span>
<span>
<span className={cn('block text-sm', isCurrent ? 'font-medium text-primary-700' : 'text-foreground')}>{s.title}</span>
<span className="block text-xs text-muted-foreground">{s.desc}</span>
</span>
</button>
)
})}
</div>
<div className="mt-auto pt-6">
<div className="mb-1.5 flex justify-between text-xs text-muted-foreground">
<span>完成进度</span>
<span>{progress}%</span>
</div>
<Progress value={progress} />
</div>
</aside>
{/* 右侧内容 */}
<main className="flex flex-1 flex-col p-6 md:p-10">
<div className="mx-auto w-full max-w-2xl flex-1">
<div className="mb-2 flex items-center gap-2 text-sm text-muted-foreground md:hidden">
<span>
第 {step + 1}/{STEPS.length} 步
</span>
<Progress value={progress} className="flex-1" />
</div>
<h1 className="text-xl font-semibold">{STEPS[step].title}</h1>
<p className="mt-1 text-sm text-muted-foreground">{STEPS[step].desc},也可以暂时跳过稍后在设置中完成。</p>
<div className="mt-6 rounded-lg border border-border bg-card p-6">
{step === 0 && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field label="企业名称" required>
<Input value={company.name} onChange={(e) => setCompany({ ...company, name: e.target.value })} />
</Field>
<Field label="所属行业">
<Select
placeholder="请选择"
options={INDUSTRIES.map((i) => ({ value: i, label: i }))}
value={company.industry}
onChange={(e) => setCompany({ ...company, industry: e.target.value })}
/>
</Field>
<Field label="企业规模">
<Select
placeholder="请选择"
options={['1—5人', '6—20人', '21—50人', '50人以上'].map((s) => ({ value: s, label: s }))}
value={company.size}
onChange={(e) => setCompany({ ...company, size: e.target.value })}
/>
</Field>
<Field label="联系方式">
<Input placeholder="电话或邮箱" value={company.contact} onChange={(e) => setCompany({ ...company, contact: e.target.value })} />
</Field>
<Field label="企业简介" className="md:col-span-2">
<Textarea rows={3} placeholder="一句话介绍您的主营业务" value={company.intro} onChange={(e) => setCompany({ ...company, intro: e.target.value })} />
</Field>
</div>
)}
{step === 1 && (
<ChipGrid options={INDUSTRIES} selected={industries} onToggle={(v) => toggleIn(industries, v, setIndustries)} />
)}
{step === 2 && (
<ChipGrid
options={COUNTRIES.filter((c) => c.code !== 'CN').map((c) => `${c.flag} ${c.zh}`)}
selected={countries}
onToggle={(v) => toggleIn(countries, v, setCountries)}
/>
)}
{step === 3 && (
<ChipGrid options={LANGUAGES_LIST} selected={langs} onToggle={(v) => toggleIn(langs, v, setLangs)} />
)}
{step === 4 && (
<div className="space-y-4">
<Field label="成员邮箱" hint="每行一个邮箱,邀请链接将发送到对方邮箱">
<Textarea rows={5} placeholder={'zhangsan@company.com\nlisi@company.com'} value={emails} onChange={(e) => setEmails(e.target.value)} />
</Field>
<Button
variant="outline"
onClick={() => {
const count = emails.split('\n').filter((e) => e.includes('@')).length
if (count === 0) return toast.warning('请先输入至少一个有效邮箱')
toast.success(`已向 ${count} 位成员发送邀请(演示)`)
}}
>
发送邀请
</Button>
</div>
)}
{step === 5 && (
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
{channelTypes.map((type) => {
const isConn = connected.includes(type)
return (
<button
key={type}
onClick={() => {
if (isConn) return
setConnected([...connected, type])
toast.success(`${CHANNEL_META[type].name} 连接成功(演示)`)
}}
className={cn(
'flex flex-col items-center gap-2 rounded-lg border p-4 text-center transition-colors',
isConn ? 'border-primary bg-primary-50' : 'border-border hover:border-primary-300',
)}
>
<ChannelIcon channel={type} size="lg" />
<span className="text-sm font-medium">{CHANNEL_META[type].name}</span>
<span className={cn('text-xs', isConn ? 'text-primary-700' : 'text-muted-foreground')}>
{isConn ? '已连接' : '点击模拟连接'}
</span>
</button>
)
})}
</div>
)}
{(step === 6 || step === 7) && (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-primary-600">
<Upload className="h-6 w-6" />
</span>
<p className="text-sm font-medium">{step === 6 ? '导入历史客户' : '上传知识库资料'}</p>
<p className="max-w-sm text-xs text-muted-foreground">
{step === 6
? '支持 Excel / CSV 格式的客户名单,系统将自动识别字段并去重建档。'
: '支持 Word / PDF / TXT / Excel / 图片,AI 将自动解析为可引用的知识条目。'}
</p>
<Button
variant="outline"
onClick={() => toast.success('文件解析完成(演示)', '相关数据已加入您的企业空间')}
>
<Upload className="h-4 w-4" />
选择文件
</Button>
</div>
)}
</div>
<div className="mt-6 flex items-center justify-between">
<Button variant="ghost" disabled={step === 0} onClick={() => setStep(step - 1)}>
<ChevronLeft className="h-4 w-4 rtl-flip" />
上一步
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={skip}>
暂时跳过
</Button>
<Button onClick={next}>
{step === STEPS.length - 1 ? '完成并进入工作台' : '下一步'}
<ChevronRight className="h-4 w-4 rtl-flip" />
</Button>
</div>
</div>
</div>
</main>
</div>
)
}
function ChipGrid({ options, selected, onToggle }: { options: string[]; selected: string[]; onToggle: (v: string) => void }) {
return (
<div className="flex flex-wrap gap-2">
{options.map((opt) => {
const active = selected.includes(opt)
return (
<button
key={opt}
type="button"
onClick={() => onToggle(opt)}
className={cn(
'rounded-md border px-3 py-1.5 text-sm transition-colors',
active ? 'border-primary bg-primary-50 font-medium text-primary-700' : 'border-border bg-card text-muted-foreground hover:border-primary-300',
)}
>
{opt}
</button>
)
})}
</div>
)
}
@@ -12,7 +12,6 @@ import { Avatar } from '@/components/ui/avatar'
import { Checkbox } from '@/components/ui/checkbox'
import { Tabs } from '@/components/ui/tabs'
import { Table, THead, TBody, Tr, Th, Td } from '@/components/ui/table'
import { DemoNote } from '@/components/common/DemoNote'
import { CountryFlag } from '@/components/common/CountryFlag'
import { IntentScoreBadge, OpportunityStageBadge, TaskPriorityBadge } from '@/components/common/StatusBadges'
import { TaskFormDialog } from '@/components/crm/TaskFormDialog'
+18 -5
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast, errorMessage } from '@/app/store/toast'
import { PageLoading, ErrorState } from '@/components/ui/feedback'
@@ -20,10 +21,12 @@ import { AlertTriangle, Settings } from 'lucide-react'
export function RisksPage() {
const queryClient = useQueryClient()
const navigate = useNavigate()
const [level, setLevel] = useState('')
const [type, setType] = useState('')
const [status, setStatus] = useState('')
const [detailId, setDetailId] = useState<string | null>(null)
const [noteDraft, setNoteDraft] = useState('')
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['risks', level, type, status],
@@ -54,8 +57,8 @@ export function RisksPage() {
title="风险预警中心"
description="智能识别交易风险、客户流失信号与敏感表达,帮助团队主动应对"
actions={
<PermissionGate permission="risk:handle">
<Button size="sm" onClick={() => toast.info('风险处理操作已可用')}><Settings className="h-4 w-4" />规则设置</Button>
<PermissionGate permission="settings:manage">
<Button size="sm" variant="outline" onClick={() => navigate('/settings')}><Settings className="h-4 w-4" />风险识别设置</Button>
</PermissionGate>
}
/>
@@ -77,7 +80,10 @@ export function RisksPage() {
{list.map((r) => {
const meta = riskLevelMeta(r.level)
return (
<Card key={r.id} className="cursor-pointer p-4 hover:bg-muted/40 transition-colors" onClick={() => setDetailId(r.id)}>
<Card key={r.id} className="cursor-pointer p-4 hover:bg-muted/40 transition-colors" onClick={() => {
setDetailId(r.id)
setNoteDraft(r.note || '')
}}>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2 min-w-0">
<span className={cn('mt-0.5 h-2 w-2 shrink-0 rounded-full', meta.dot)} />
@@ -119,8 +125,15 @@ export function RisksPage() {
</div>
<div>
<h4 className="text-sm font-semibold mb-1">添加备注</h4>
<Textarea rows={3} placeholder="添加处理备注…" />
<Button size="sm" className="mt-2" onClick={() => { updateMut.mutate({ id: detail.id, data: { note: '已备注' } }); toast.success('备注已保存') }}>保存备注</Button>
<Textarea rows={3} placeholder="添加处理备注…" value={noteDraft} onChange={(event) => setNoteDraft(event.target.value)} />
<Button
size="sm"
className="mt-2"
disabled={!noteDraft.trim()}
onClick={() => updateMut.mutate({ id: detail.id, data: { note: noteDraft.trim() } })}
>
保存备注
</Button>
</div>
</div>
)}
+78 -42
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Settings, ShieldCheck, KeyRound, Bell, Globe, Database, ServerCog, Languages, UserCog, Palette, FileUp, FileDown, Clock } from 'lucide-react'
import { ShieldCheck, KeyRound, Bell, Globe, Languages, UserCog, Copy, Trash2 } from 'lucide-react'
import { toast, errorMessage } from '@/app/store/toast'
import { PageHeader } from '@/components/common/PageHeader'
import { Button } from '@/components/ui/button'
@@ -8,22 +8,18 @@ import { Card, CardHeader } from '@/components/ui/card'
import { Input, Field } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Tabs } from '@/components/ui/tabs'
import { PermissionGate } from '@/components/common/PermissionGate'
import { getSettings, updateSettings } from '@/services/api/org'
import { useI18n } from '@/i18n'
import { changePassword, createApiKey, getApiKeys, revokeApiKey } from '@/services/api/auth'
import { useAuthStore } from '@/app/store/auth'
const SECTIONS = [
{ key: 'company', label: '企业资料', icon: UserCog },
{ key: 'brand', label: '品牌设置', icon: Palette },
{ key: 'lang', label: '语言与时区', icon: Globe },
{ key: 'notifications', label: '消息通知', icon: Bell },
{ key: 'ai', label: 'AI回复设置', icon: Languages },
{ key: 'translate', label: '翻译设置', icon: Languages },
{ key: 'safety', label: '安全设置', icon: ShieldCheck },
{ key: 'api', label: 'API与Webhook', icon: KeyRound },
{ key: 'import-export', label: '数据导入导出', icon: Database },
{ key: 'deploy', label: '私有化部署', icon: ServerCog },
{ key: 'api', label: 'API Key', icon: KeyRound },
]
export function SettingsPage() {
@@ -31,6 +27,26 @@ export function SettingsPage() {
const [section, setSection] = useState('company')
const { data: settings, isLoading } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
const { locale, setLocale, t } = useI18n()
const logout = useAuthStore((state) => state.logout)
const [companyDraft, setCompanyDraft] = useState({ name: '', industry: '', contact: '', email: '', intro: '' })
const [passwords, setPasswords] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' })
const [localeDraft, setLocaleDraft] = useState(locale)
const [timezoneDraft, setTimezoneDraft] = useState('Asia/Hong_Kong')
const [apiName, setApiName] = useState('业务集成')
const [newApiKey, setNewApiKey] = useState('')
const { data: apiKeys } = useQuery({ queryKey: ['api-keys'], queryFn: getApiKeys, enabled: section === 'api' })
useEffect(() => {
if (settings?.company) setCompanyDraft({
name: settings.company.name || '',
industry: settings.company.industry || '',
contact: settings.company.contact || '',
email: settings.company.email || '',
intro: settings.company.intro || '',
})
if (settings?.language) setLocaleDraft(settings.language.startsWith('zh') ? 'zh' : settings.language as any)
if (settings?.timezone) setTimezoneDraft(settings.timezone)
}, [settings])
const updateMut = useMutation({
mutationFn: (payload: any) => updateSettings(payload),
@@ -52,12 +68,12 @@ export function SettingsPage() {
<Card>
<CardHeader title="企业资料" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4">
<Field label="企业名称"><Input defaultValue={settings.company.name} onChange={(e) => updateMut.mutate({ company: { ...settings.company, name: e.target.value } })} /></Field>
<Field label="所属行业"><Input defaultValue={settings.company.industry} /></Field>
<Field label="联系方式"><Input defaultValue={settings.company.contact} /></Field>
<Field label="邮箱"><Input defaultValue={settings.company.email} /></Field>
<Field label="企业简介" className="md:col-span-2"><Input defaultValue={settings.company.intro} /></Field>
<div className="md:col-span-2"><Button onClick={() => toast.success('企业资料已保存')}>保存修改</Button></div>
<Field label="企业名称"><Input value={companyDraft.name} onChange={(e) => setCompanyDraft((v) => ({ ...v, name: e.target.value }))} /></Field>
<Field label="所属行业"><Input value={companyDraft.industry} onChange={(e) => setCompanyDraft((v) => ({ ...v, industry: e.target.value }))} /></Field>
<Field label="联系方式"><Input value={companyDraft.contact} onChange={(e) => setCompanyDraft((v) => ({ ...v, contact: e.target.value }))} /></Field>
<Field label="邮箱"><Input value={companyDraft.email} onChange={(e) => setCompanyDraft((v) => ({ ...v, email: e.target.value }))} /></Field>
<Field label="企业简介" className="md:col-span-2"><Input value={companyDraft.intro} onChange={(e) => setCompanyDraft((v) => ({ ...v, intro: e.target.value }))} /></Field>
<div className="md:col-span-2"><Button onClick={() => updateMut.mutate({ company: companyDraft })} loading={updateMut.isPending}>保存修改</Button></div>
</div>
</Card>
)}
@@ -112,12 +128,19 @@ export function SettingsPage() {
<Card>
<CardHeader title="安全设置" />
<div className="space-y-4 p-4">
<Field label="修改密码"><Input type="password" placeholder="输入新密码…" /></Field>
<div className="flex items-center justify-between"><span className="text-sm">两步验证</span><Switch checked={false} onCheckedChange={() => toast.info('两步验证设置(演示)')} /></div>
<div className="flex items-center justify-between"><span className="text-sm">数据导出权限</span><Switch checked={true} /></div>
<Field label="会话保存周期"><Select options={[{value:'6',label:'6个月'},{value:'12',label:'12个月'},{value:'24',label:'24个月'},{value:'forever',label:'永久'}]} /></Field>
<div className="rounded bg-emerald-50 p-3 text-sm text-emerald-700"><ShieldCheck className="inline h-4 w-4 mr-1" />全链路加密已启用(TLS 1.3 + AES-256)</div>
<Button onClick={() => toast.success('安全设置已保存')}>保存</Button>
<Field label="当前密码"><Input type="password" value={passwords.currentPassword} onChange={(e) => setPasswords((v) => ({ ...v, currentPassword: e.target.value }))} /></Field>
<Field label="新密码"><Input type="password" placeholder="至少10位,包含字母和数字" value={passwords.newPassword} onChange={(e) => setPasswords((v) => ({ ...v, newPassword: e.target.value }))} /></Field>
<Field label="确认新密码"><Input type="password" value={passwords.confirmPassword} onChange={(e) => setPasswords((v) => ({ ...v, confirmPassword: e.target.value }))} /></Field>
<div className="rounded bg-emerald-50 p-3 text-sm text-emerald-700"><ShieldCheck className="inline h-4 w-4 mr-1" />密码使用 bcrypt 加密;修改后所有旧登录 Token 会立即失效。</div>
<Button onClick={async () => {
if (passwords.newPassword !== passwords.confirmPassword) return toast.error('两次输入的新密码不一致')
try {
await changePassword({ currentPassword: passwords.currentPassword, newPassword: passwords.newPassword })
toast.success('密码已修改,请重新登录')
logout()
window.location.assign('/login')
} catch (error) { toast.error(errorMessage(error)) }
}}>修改密码并撤销旧会话</Button>
</div>
</Card>
)}
@@ -125,9 +148,34 @@ export function SettingsPage() {
<Card>
<CardHeader title="API 与 Webhook" />
<div className="space-y-3 p-4">
<p className="text-sm text-muted-foreground">通过 API 或 Webhook 将灵犀跨境的客户数据、消息与风险预警接入您的自有系统。</p>
<div className="rounded bg-muted p-3 font-mono text-sm break-all">lx_live_8f2k••••••••9d21</div>
<Button variant="outline" onClick={() => toast.success('新 API Key 已生成')}>生成新 Key</Button>
<p className="text-sm text-muted-foreground">密钥只在创建时完整显示一次,数据库仅保存 SHA-256 摘要。</p>
<div className="flex gap-2">
<Input value={apiName} onChange={(e) => setApiName(e.target.value)} placeholder="密钥名称" />
<Button variant="outline" onClick={async () => {
try {
const result = await createApiKey(apiName)
setNewApiKey(result.key || '')
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
} catch (error) { toast.error(errorMessage(error)) }
}}>生成新 Key</Button>
</div>
{newApiKey && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3">
<p className="text-xs font-medium text-amber-800">请立即复制,关闭后无法再次查看</p>
<div className="mt-2 flex items-center gap-2">
<code className="min-w-0 flex-1 break-all text-xs">{newApiKey}</code>
<Button size="icon-sm" variant="ghost" onClick={() => navigator.clipboard.writeText(newApiKey).then(() => toast.success('已复制'))}><Copy className="h-4 w-4" /></Button>
</div>
</div>
)}
<div className="divide-y divide-border rounded-lg border border-border">
{(apiKeys || []).map((key) => (
<div key={key.id} className="flex items-center gap-3 px-3 py-2">
<div className="min-w-0 flex-1"><p className="text-sm font-medium">{key.name}</p><p className="text-xs text-muted-foreground">{key.prefix}•••••• · {key.revoked ? '已撤销' : '有效'}</p></div>
{!key.revoked && <Button size="icon-sm" variant="ghost" onClick={() => revokeApiKey(key.id).then(() => queryClient.invalidateQueries({ queryKey: ['api-keys'] }))}><Trash2 className="h-4 w-4 text-red-500" /></Button>}
</div>
))}
</div>
</div>
</Card>
)}
@@ -136,28 +184,16 @@ export function SettingsPage() {
<CardHeader title="语言与时区" />
<div className="space-y-4 p-4">
<Field label="界面语言">
<Select value={locale} options={[{value:'zh',label:'简体中文'},{value:'en',label:'English'},{value:'ar',label:'العربية (RTL)'}]} onChange={(e) => setLocale(e.target.value as any)} />
<Select value={localeDraft} options={[{value:'zh',label:'简体中文'},{value:'en',label:'English'},{value:'ar',label:'العربية (RTL)'}]} onChange={(e) => setLocaleDraft(e.target.value as any)} />
</Field>
<Field label="时区"><Select options={[{value:'Asia/Shanghai',label:'东八区 (UTC+8)'},{value:'Asia/Dubai',label:'迪拜 (UTC+4)'},{value:'America/New_York',label:'纽约 (UTC-5)'}]} /></Field>
<Button onClick={() => toast.success('语言与时区已保存')}>保存</Button>
<Field label="时区"><Select value={timezoneDraft} onChange={(e) => setTimezoneDraft(e.target.value)} options={[{value:'Asia/Hong_Kong',label:'香港 (UTC+8)'},{value:'Asia/Shanghai',label:'上海 (UTC+8)'},{value:'Asia/Dubai',label:'迪拜 (UTC+4)'},{value:'America/New_York',label:'纽约 (UTC-5)'}]} /></Field>
<Button loading={updateMut.isPending} onClick={() => updateMut.mutate(
{ language: localeDraft === 'zh' ? 'zh-CN' : localeDraft, timezone: timezoneDraft },
{ onSuccess: () => setLocale(localeDraft as any) },
)}>保存</Button>
</div>
</Card>
)}
{section === 'deploy' && (
<Card>
<CardHeader title="私有化部署咨询" />
<div className="p-4 space-y-3">
<p className="text-sm text-muted-foreground">增长版/定制版支持本地或私有云部署,数据不出企业、全链路审计。</p>
<p className="text-sm">📞 咨询热线:0516-8888-6621(演示)</p>
<p className="text-sm">📧 邮箱:sales@ouhui.tech</p>
</div>
</Card>
)}
{![ 'company', 'ai', 'notifications', 'safety', 'api', 'lang', 'deploy'].includes(section) && (
<Card className="p-8 text-center">
<p className="text-sm text-muted-foreground">{section === 'brand' ? '品牌设置' : section === 'translate' ? '翻译设置' : section === 'import-export' ? '数据导入导出' : '此设置模块'}在演示环境中为简化版,接入后端后可完整配置。</p>
</Card>
)}
</div>
</div>
)
+28 -26
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { ArrowLeftRight, Copy, FileText, History, Languages, RefreshCw, Save, Upload } from 'lucide-react'
import { translate } from '@/services/api/ai'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowLeftRight, Copy, Languages, RefreshCw, Save } from 'lucide-react'
import { favoriteTranslation, getTranslationHistory, translate } from '@/services/api/ai'
import { toast, errorMessage } from '@/app/store/toast'
import { PageHeader } from '@/components/common/PageHeader'
import { Button } from '@/components/ui/button'
@@ -11,14 +11,15 @@ import { Select } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Tabs } from '@/components/ui/tabs'
import { Spinner } from '@/components/ui/feedback'
import { DemoNote } from '@/components/common/DemoNote'
import { LiveDataBadge } from '@/components/common/LiveDataBadge'
import { TRANSLATE_SCENARIOS } from '@/config/constants'
import { LANGUAGES, supportedLanguageCount } from '@/config/languages'
import { cn } from '@/utils/cn'
const DEMO_TEXT = '你好,非常感谢贵司对500个定制双层玻璃杯的询盘。我们很高兴为您提供报价:单价$1.85/个(含双色Logo印刷),标准交期12-15天,加急10天可交付。如有任何问题或需要调整,请随时告知。期待与贵司建立长期合作。'
const SAMPLE_TEXT = '你好,非常感谢贵司对500个定制双层玻璃杯的询盘。我们很高兴为您提供报价:单价$1.85/个(含双色Logo印刷),标准交期12-15天,加急10天可交付。如有任何问题或需要调整,请随时告知。期待与贵司建立长期合作。'
export function TranslatePage() {
const queryClient = useQueryClient()
const [tab, setTab] = useState('text')
const [source, setSource] = useState('')
const [target, setTarget] = useState('en')
@@ -26,7 +27,11 @@ export function TranslatePage() {
const [result, setResult] = useState('')
const [detected, setDetected] = useState('')
const [terms, setTerms] = useState<any[]>([])
const [history, setHistory] = useState<{ source: string; target: string; text: string; result: string }[]>([])
const [currentRecordId, setCurrentRecordId] = useState('')
const { data: history = [] } = useQuery({
queryKey: ['translation-history'],
queryFn: getTranslationHistory,
})
const translateMut = useMutation({
mutationFn: () => translate({ text, sourceLang: source || undefined, targetLang: target }),
@@ -34,10 +39,19 @@ export function TranslatePage() {
setResult(r.translated)
setDetected(r.detectedLang)
setTerms(r.terms ?? [])
setHistory((h) => [{ source: source || r.detectedLang, target, text, result: r.translated }, ...h].slice(0, 20))
setCurrentRecordId(r.historyItem?.id || '')
void queryClient.invalidateQueries({ queryKey: ['translation-history'] })
},
onError: (e) => toast.error(errorMessage(e)),
})
const favoriteMut = useMutation({
mutationFn: () => favoriteTranslation(currentRecordId, true),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['translation-history'] })
toast.success('已收藏到翻译历史')
},
onError: (error) => toast.error(errorMessage(error)),
})
const swap = () => {
if (detected && !source) setSource(detected)
@@ -54,7 +68,7 @@ export function TranslatePage() {
<PageHeader
title="AI 翻译中心"
description={`跨境商务场景专用翻译引擎,支持全球主流贸易国家和地区语言(可配置 ${supportedLanguageCount} 种)。`}
actions={<DemoNote />}
actions={<LiveDataBadge />}
/>
<Tabs
@@ -63,7 +77,6 @@ export function TranslatePage() {
className="mb-4"
items={[
{ value: 'text', label: '文本翻译' },
{ value: 'file', label: '文件翻译' },
{ value: 'history', label: '翻译历史', count: history.length },
]}
/>
@@ -82,7 +95,7 @@ export function TranslatePage() {
onChange={(e) => setSource(e.target.value)}
className="w-32"
/>
<Button variant="ghost" size="sm" disabled={!text} onClick={() => { setText(DEMO_TEXT); toast.success('已填入演示文本') }}>
<Button variant="ghost" size="sm" onClick={() => { setText(SAMPLE_TEXT); toast.success('已填入示例文本') }}>
示例文本
</Button>
</div>
@@ -132,8 +145,7 @@ export function TranslatePage() {
<Button size="sm" variant="ghost" disabled={!result} onClick={() => { navigator.clipboard.writeText(result); toast.success('已复制') }}>
<Copy className="h-3.5 w-3.5" /> 复制
</Button>
<Button size="sm" variant="ghost" disabled={!result} onClick={() => { toast.success('已收藏');
}}>
<Button size="sm" variant="ghost" disabled={!result || !currentRecordId || favoriteMut.isPending} onClick={() => favoriteMut.mutate()}>
<Save className="h-3.5 w-3.5" /> 收藏
</Button>
<Button size="sm" variant="ghost" onClick={() => translateMut.mutate()}>
@@ -147,17 +159,6 @@ export function TranslatePage() {
</div>
)}
{tab === 'file' && (
<Card className="p-6 text-center">
<Upload className="mx-auto h-10 w-10 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">上传文件翻译</p>
<p className="mt-1 text-xs text-muted-foreground">支持 Word / PDF / TXT / Excel / 图片文件,AI 自动解析并保留原格式。</p>
<Button className="mt-4" variant="outline" onClick={() => toast.info('文件翻译演示:选择文件后将模拟上传与翻译流程')}>
<Upload className="h-4 w-4" /> 选择文件
</Button>
</Card>
)}
{tab === 'history' && (
<Card>
<div className="divide-y divide-border">
@@ -166,10 +167,11 @@ export function TranslatePage() {
<div key={i} className="p-3 text-sm">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<Languages className="h-3 w-3" />
{LANGUAGES.find(l => l.code === h.source)?.name ?? '自动识别'} → {LANGUAGES.find(l => l.code === h.target)?.name}
{LANGUAGES.find(l => l.code === h.sourceLang)?.name ?? '自动识别'} → {LANGUAGES.find(l => l.code === h.targetLang)?.name}
{h.favorite && <Badge className="ml-auto bg-amber-50 text-amber-700">已收藏</Badge>}
</div>
<p className="text-xs truncate">{h.text.slice(0, 80)}</p>
<p className="text-xs text-muted-foreground mt-0.5 truncate">{h.result.slice(0, 80)}</p>
<p className="text-xs truncate">{h.originalText.slice(0, 80)}</p>
<p className="text-xs text-muted-foreground mt-0.5 truncate">{h.translatedText.slice(0, 80)}</p>
</div>
))}
</div>
+13 -6
View File
@@ -1,11 +1,19 @@
import { http } from './client'
import type { AiAnalysis, AiReplySuggestion, AiTextActionPayload, InterpretLine, TranslatePayload, TranslateResult } from '@/types'
import type { AiAnalysis, AiReplySuggestion, AiTextActionPayload, TranslatePayload, TranslateResult, TranslationHistoryItem } from '@/types'
/** POST /api/ai/translate */
export function translate(payload: TranslatePayload) {
return http.post<TranslateResult>('/api/ai/translate', payload)
}
export function getTranslationHistory() {
return http.get<TranslationHistoryItem[]>('/api/ai/translations')
}
export function favoriteTranslation(id: string, favorite: boolean) {
return http.patch<TranslationHistoryItem>(`/api/ai/translations/${id}`, { favorite })
}
/** POST /api/ai/analyze */
export function analyzeConversation(conversationId: string) {
return http.post<AiAnalysis>('/api/ai/analyze', { conversationId })
@@ -21,12 +29,11 @@ export function summarizeConversation(conversationId: string) {
return http.post<{ summary: string }>('/api/ai/summarize', { conversationId })
}
/** POST /api/ai/interpretation */
export function nextInterpretLine(index: number) {
return http.post<InterpretLine>('/api/ai/interpretation', { index })
}
/** POST /api/ai/text-action(润色/缩写/扩写/语气/翻译) */
export function textAction(payload: AiTextActionPayload) {
return http.post<{ result: string }>('/api/ai/text-action', payload)
}
export function recordAiAdoption(conversationId: string, content: string, suggestionId = '') {
return http.post<{ id: string }>('/api/ai/adoptions', { conversationId, content, suggestionId })
}
+32 -3
View File
@@ -16,7 +16,36 @@ export function me() {
return http.get<{ user: AuthSession['user']; tenant: AuthSession['tenant'] }>('/api/auth/me')
}
/** 发送验证码(演示:固定可收到 6 位码) */
export function sendCode(account: string) {
return http.post<{ sent: boolean }>('/api/auth/send-code', { account }).catch(() => ({ sent: true }))
export function getWebSocketTicket() {
return http.post<{ ticket: string; expiresAt: string }>('/api/auth/ws-ticket', {})
}
export function changePassword(payload: { currentPassword: string; newPassword: string }) {
return http.post<{ success: boolean; loginRequired: boolean }>('/api/auth/change-password', payload)
}
export function revokeAllSessions() {
return http.post<{ success: boolean }>('/api/auth/logout-all', {})
}
export interface ApiKeyRecord {
id: string
name: string
prefix: string
role: string
createdAt: string
revoked: boolean
key?: string
}
export function getApiKeys() {
return http.get<ApiKeyRecord[]>('/api/api-keys')
}
export function createApiKey(name: string, role = 'service') {
return http.post<ApiKeyRecord>('/api/api-keys', { name, role })
}
export function revokeApiKey(id: string) {
return http.del<{ success: boolean }>(`/api/api-keys/${id}`)
}
+39
View File
@@ -17,6 +17,45 @@ export function connectChannel(payload: {
return http.post<ChannelConnection>('/api/channels/connect', payload)
}
export function startTelegramLogin(phone: string) {
return http.post<ChannelConnection>('/api/channels/telegram/start', { phone })
}
export function connectEmailChannel(payload: {
name?: string
displayName?: string
account: string
password: string
imapHost: string
imapPort: number
imapSecure: boolean
imapFolder?: string
smtpHost: string
smtpPort: number
smtpSecure: boolean
syncDays?: number
}) {
return http.post<ChannelConnection>('/api/channels/email/connect', payload)
}
export function submitTelegramCode(channelId: string, code: string) {
return http.post<{ status: string; channel: ChannelConnection }>(`/api/channels/telegram/${channelId}/code`, { code })
}
export function submitTelegramPassword(channelId: string, password: string) {
return http.post<{ status: string; channel: ChannelConnection }>(`/api/channels/telegram/${channelId}/password`, { password })
}
export function getTelegramLoginState(channelId: string) {
return http.post<{ status: string; connectionState?: string; channel: ChannelConnection }>(
`/api/channels/telegram/${channelId}/state`,
)
}
export function syncTelegramChannel(channelId: string) {
return http.post<{ chats: number; messages: number }>(`/api/channels/${channelId}/sync`)
}
/** POST /api/channels/test */
export function testChannel(id: string) {
return http.post<{ success: boolean; latencyMs: number; message: string }>('/api/channels/test', { id })
+4 -11
View File
@@ -1,10 +1,5 @@
/**
* 统一 API 客户端
* - VITE_USE_MOCK=true :走内置 Mock 路由(本地模拟数据,含模拟网络延迟)
* - VITE_USE_MOCK=false :请求真实后端(VITE_API_BASE_URL),自动携带 Bearer Token
*/
/** 灵犀跨境统一 API 客户端:所有请求均访问真实后端。 */
import { env } from '@/config/env'
import { mockRequest } from '@/services/mock'
export class ApiError extends Error {
code: number
@@ -29,10 +24,6 @@ export function setTokenGetter(fn: () => string | null) {
export async function api<T = unknown>(path: string, options: RequestOptions = {}): Promise<T> {
const method = options.method ?? 'GET'
if (env.useMock) {
return mockRequest<T>(method, path, options.query ?? {}, options.body)
}
const base = env.apiBaseUrl.replace(/\/$/, '')
const url = new URL(`${base}${path}`, window.location.origin)
Object.entries(options.query ?? {}).forEach(([k, v]) => {
@@ -54,7 +45,9 @@ export async function api<T = unknown>(path: string, options: RequestOptions = {
}
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new ApiError(res.status, text || `请求失败(${res.status})`)
let message = text
try { message = (JSON.parse(text) as { message?: string }).message ?? text } catch { /* 保留原始响应 */ }
throw new ApiError(res.status, message || `请求失败(${res.status})`)
}
const json = (await res.json().catch(() => null)) as { code?: number; message?: string; data?: T } | T | null
// 兼容 {code,message,data} 与直接返回数据两种后端风格
+66 -1
View File
@@ -25,12 +25,77 @@ export function getMessages(id: string) {
return http.get<Message[]>(`/api/conversations/${id}/messages`)
}
export function updateMessage(id: string, messageId: string, payload: { isImportant?: boolean; internalNote?: string }) {
return http.patch<Message>(`/api/conversations/${id}/messages/${messageId}`, payload)
}
export function markConversationRead(id: string, messageIds?: string[]) {
return http.post<Conversation>(`/api/conversations/${id}/read`, { messageIds })
}
export function getMessageAttachment(id: string, messageId: string, attachmentId?: string) {
return http.get<{ dataUrl: string; mimeType: string; name: string }>(
`/api/conversations/${id}/messages/${messageId}/attachment`,
attachmentId ? { attachmentId } : undefined,
)
}
/** POST /api/conversations/:id/messages */
export function sendMessage(id: string, payload: SendMessagePayload & { translatedText?: string }) {
export function sendMessage(id: string, payload: SendMessagePayload & { translatedText?: string; autoTranslate?: boolean }) {
return http.post<Message>(`/api/conversations/${id}/messages`, payload)
}
export function sendMedia(
id: string,
payload: { name: string; mimeType: string; base64: string; kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker' },
) {
return http.post<unknown>(`/api/conversations/${id}/media`, payload)
}
export function sendAlbum(
id: string,
files: Array<{ name: string; mimeType: string; base64: string }>,
) {
return http.post<unknown>(`/api/conversations/${id}/album`, { files })
}
/** PATCH /api/conversations/:id(标记已读/分配/状态) */
export function updateConversation(id: string, payload: { assigneeId?: string | null; markRead?: boolean; status?: string }) {
return http.patch<Conversation>(`/api/conversations/${id}`, payload)
}
export type TelegramConversationAction =
| 'clear'
| 'delete'
| 'pin'
| 'unpin'
| 'archive'
| 'unarchive'
| 'mute'
| 'unmute'
| 'mark-unread'
| 'leave'
| 'block'
| 'unblock'
export function runTelegramConversationAction(
id: string,
action: TelegramConversationAction,
options: { seconds?: number } = {},
) {
return http.post<Conversation | { success: true; deleted: true }>(
`/api/conversations/${id}/actions`,
{ action, ...options },
)
}
export function openTelegramConversation(payload: {
identifier: string
targetType: 'user' | 'chat' | 'invite'
channelId?: string
}) {
return http.post<{ conversation: Conversation; customer: Customer }>(
'/api/conversations/telegram/open',
payload,
)
}
+1 -1
View File
@@ -22,7 +22,7 @@ export function deleteKnowledge(id: string) {
}
/** POST /api/knowledge/upload */
export function uploadKnowledgeFiles(files: { name: string; type: string }[]) {
export function uploadKnowledgeFiles(files: { name: string; type: string; base64: string }[]) {
return http.post<{ items: KnowledgeItem[] }>('/api/knowledge/upload', { files })
}
+1 -1
View File
@@ -51,7 +51,7 @@ export function getBilling() {
/** POST /api/billing/upgrade */
export function upgradePlan(planKey: string) {
return http.post<BillingInfo>('/api/billing/upgrade', { planKey })
return http.post<{ checkoutUrl: string; sessionId: string }>('/api/billing/upgrade', { planKey })
}
/* ===== 设置 ===== */
-331
View File
@@ -1,331 +0,0 @@
/** Mock 处理器:客户 / 商机 / 任务 / 风险 */
import { registerRoute, MockApiError } from '../router'
import { db, dbHelpers } from '@/mocks/db'
import { uid, toCsv } from '@/utils/misc'
import { countryMeta, intentLevelOf } from '@/config/constants'
import type { Customer, CustomerFilters, FollowTask, Opportunity, RiskAlert } from '@/types'
function filterCustomers(query: Record<string, unknown>): Customer[] {
const keyword = String(query.keyword ?? '').trim().toLowerCase()
let list = [...db.customers]
if (keyword) {
list = list.filter(
(c) =>
c.name.toLowerCase().includes(keyword) ||
c.company.toLowerCase().includes(keyword) ||
c.email.toLowerCase().includes(keyword) ||
c.phone.includes(keyword) ||
c.products.some((p) => p.toLowerCase().includes(keyword)) ||
countryMeta(c.country).zh.includes(keyword),
)
}
if (query.country) list = list.filter((c) => c.country === query.country)
if (query.channel) list = list.filter((c) => c.channel === query.channel)
if (query.stage) list = list.filter((c) => c.stage === query.stage)
if (query.ownerId === 'none') list = list.filter((c) => !c.ownerId)
else if (query.ownerId) list = list.filter((c) => c.ownerId === query.ownerId)
if (query.intentLevel) list = list.filter((c) => intentLevelOf(c.intentScore) === query.intentLevel)
if (query.tag) list = list.filter((c) => c.tags.includes(String(query.tag)))
if (query.riskOnly === 'true' || query.riskOnly === true) list = list.filter((c) => c.riskLevel !== 'none')
if (query.keyOnly === 'true' || query.keyOnly === true) list = list.filter((c) => c.isKey)
list.sort((a, b) => new Date(b.lastContactAt).getTime() - new Date(a.lastContactAt).getTime())
return list
}
function registerCustomers() {
registerRoute('GET', '/api/customers', ({ query }) => {
const list = filterCustomers(query)
return { list, total: list.length }
})
registerRoute('GET', '/api/customers/duplicates', () => {
const pairs = [
{
id: 'dup-1',
reason: '手机号尾号相同(345678)且同为孟加拉服装行业客户',
customers: [dbHelpers.customer('c-003'), dbHelpers.customer('c-029')].filter(Boolean),
},
{
id: 'dup-2',
reason: '邮箱域名相同(gmail 别名)且来源均为 WhatsApp',
customers: [dbHelpers.customer('c-009'), dbHelpers.customer('c-014')].filter(Boolean),
},
]
return pairs.filter((p) => p.customers.length === 2)
})
registerRoute('GET', '/api/customers/export', () => {
const header = ['客户名称', '公司', '国家', '邮箱', '电话', '来源渠道', '感兴趣产品', '意向分数', '阶段', '负责人']
const rows = db.customers.map((c) => [
c.name,
c.company,
countryMeta(c.country).zh,
c.email,
c.phone,
c.channel,
c.products.join('/'),
c.intentScore,
c.stage,
dbHelpers.member(c.ownerId)?.name ?? '未分配',
])
return { csv: toCsv([header, ...rows]) }
})
registerRoute('GET', '/api/customers/:id', ({ params }) => {
const customer = dbHelpers.customer(params.id)
if (!customer) throw new MockApiError(404, '客户不存在')
return {
customer,
conversations: db.conversations
.filter((c) => c.customerId === params.id)
.map((c) => ({ ...c, messageCount: db.messages.filter((m) => m.conversationId === c.id).length })),
tasks: db.tasks.filter((t) => t.customerId === params.id),
opportunities: db.opportunities.filter((o) => o.customerId === params.id),
risks: db.risks.filter((r) => r.customerId === params.id),
files: db.customerFiles.filter((f) => f.customerId === params.id),
logs: db.activityLogs
.filter((l) => l.customerId === params.id)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
}
})
registerRoute('POST', '/api/customers', ({ body }) => {
const name = String(body.name ?? '').trim()
if (!name) throw new MockApiError(400, '客户名称不能为空')
const customer: Customer = {
id: uid('c'),
name,
company: String(body.company ?? ''),
country: String(body.country ?? 'US'),
email: String(body.email ?? ''),
phone: String(body.phone ?? ''),
language: countryMeta(String(body.country ?? 'US')).lang,
channel: (body.channel as Customer['channel']) ?? 'email',
sourcePath: '手动录入',
products: (body.products as string[]) ?? [],
tags: (body.tags as string[]) ?? [],
intentScore: 50,
valueLevel: 'C',
stage: (body.stage as Customer['stage']) ?? 'initial',
sentiment: 'neutral',
riskLevel: 'none',
ownerId: (body.ownerId as string) ?? null,
lastContactAt: new Date().toISOString(),
nextFollowUpAt: null,
createdAt: new Date().toISOString(),
isKey: false,
aiProfile: { concerns: [], summary: '手动建档客户,AI 画像将在首次会话后生成。', nextActions: ['发起首次沟通'] },
}
db.customers.unshift(customer)
return customer
})
registerRoute('PATCH', '/api/customers/:id', ({ params, body }) => {
const customer = dbHelpers.customer(params.id)
if (!customer) throw new MockApiError(404, '客户不存在')
const allowed: (keyof Customer)[] = [
'name', 'company', 'country', 'email', 'phone', 'products', 'tags',
'stage', 'ownerId', 'intentScore', 'valueLevel', 'sentiment', 'riskLevel',
'nextFollowUpAt', 'isKey', 'channel', 'sourcePath', 'city', 'language',
]
for (const k of allowed) {
if (k in body) (customer as unknown as Record<string, unknown>)[k] = body[k as string]
}
return customer
})
registerRoute('DELETE', '/api/customers/:id', ({ params }) => {
const idx = db.customers.findIndex((c) => c.id === params.id)
if (idx === -1) throw new MockApiError(404, '客户不存在')
db.customers.splice(idx, 1)
db.conversations = db.conversations.filter((c) => c.customerId !== params.id)
db.tasks = db.tasks.filter((t) => t.customerId !== params.id)
return { success: true }
})
registerRoute('POST', '/api/customers/merge', ({ body }) => {
const keep = dbHelpers.customer(String(body.keepId))
const drop = dbHelpers.customer(String(body.mergeId))
if (!keep || !drop) throw new MockApiError(404, '客户不存在')
// 迁移关联数据
db.conversations.forEach((c) => {
if (c.customerId === drop.id) c.customerId = keep.id
})
db.tasks.forEach((t) => {
if (t.customerId === drop.id) t.customerId = keep.id
})
db.opportunities.forEach((o) => {
if (o.customerId === drop.id) o.customerId = keep.id
})
db.risks.forEach((r) => {
if (r.customerId === drop.id) r.customerId = keep.id
})
keep.tags = Array.from(new Set([...keep.tags, ...drop.tags]))
keep.products = Array.from(new Set([...keep.products, ...drop.products]))
keep.intentScore = Math.max(keep.intentScore, drop.intentScore)
db.customers = db.customers.filter((c) => c.id !== drop.id)
db.activityLogs.unshift({
id: uid('al'), customerId: keep.id, actorName: '当前用户', action: '合并客户',
detail: `将「${drop.name}」合并至「${keep.name}」`, createdAt: new Date().toISOString(),
})
return keep
})
registerRoute('POST', '/api/customers/import', ({ body }) => {
const rows = (body.rows as Record<string, string>[]) ?? []
let imported = 0
for (const r of rows.slice(0, 200)) {
if (!r.name) continue
db.customers.unshift({
id: uid('c'), name: r.name, company: r.company ?? '', country: r.country ?? 'US',
email: r.email ?? '', phone: r.phone ?? '', language: 'en',
channel: 'email', sourcePath: '批量导入', products: [], tags: ['批量导入'],
intentScore: 45, valueLevel: 'C', stage: 'initial', sentiment: 'neutral', riskLevel: 'none',
ownerId: null, lastContactAt: new Date().toISOString(), nextFollowUpAt: null,
createdAt: new Date().toISOString(), isKey: false,
aiProfile: { concerns: [], summary: '批量导入客户,待首次沟通后完善画像。', nextActions: ['分配负责人'] },
})
imported++
}
return { imported, skipped: rows.length - imported }
})
}
function registerOpportunities() {
registerRoute('GET', '/api/opportunities', ({ query }) => {
let list = [...db.opportunities]
if (query.stage) list = list.filter((o) => o.stage === query.stage)
if (query.ownerId) list = list.filter((o) => o.ownerId === query.ownerId)
if (query.keyword) {
const kw = String(query.keyword).toLowerCase()
list = list.filter((o) => o.name.toLowerCase().includes(kw) || o.customerName.toLowerCase().includes(kw))
}
return { list, total: list.length }
})
registerRoute('POST', '/api/opportunities', ({ body }) => {
const name = String(body.name ?? '').trim()
if (!name) throw new MockApiError(400, '商机名称不能为空')
const customer = dbHelpers.customer(String(body.customerId))
const opp: Opportunity = {
id: uid('o'), name,
customerId: String(body.customerId), customerName: customer?.name ?? String(body.customerName ?? ''),
country: customer?.country ?? 'US',
product: String(body.product ?? ''), quantity: String(body.quantity ?? ''),
amount: Number(body.amount ?? 0), probability: Number(body.probability ?? 30),
stage: 'new', ownerId: String(body.ownerId ?? 'u-003'),
expectedDealAt: String(body.expectedDealAt ?? new Date(Date.now() + 14 * 86400000).toISOString()),
lastSummary: '手动创建商机', nextAction: String(body.nextAction ?? '首次沟通'),
updatedAt: new Date().toISOString(),
}
db.opportunities.unshift(opp)
return opp
})
registerRoute('PATCH', '/api/opportunities/:id', ({ params, body }) => {
const opp = db.opportunities.find((o) => o.id === params.id)
if (!opp) throw new MockApiError(404, '商机不存在')
Object.assign(opp, body, { updatedAt: new Date().toISOString() })
return opp
})
registerRoute('DELETE', '/api/opportunities/:id', ({ params }) => {
db.opportunities = db.opportunities.filter((o) => o.id !== params.id)
return { success: true }
})
}
function registerTasks() {
registerRoute('GET', '/api/tasks', ({ query }) => {
const now = Date.now()
let list = db.tasks.map((t) => ({
...t,
status: (t.status === 'done' ? 'done' : new Date(t.dueAt).getTime() < now ? 'overdue' : 'pending') as FollowTask['status'],
}))
const filter = String(query.filter ?? 'all')
if (filter === 'mine' && query.ownerId) list = list.filter((t) => t.ownerId === query.ownerId)
if (filter === 'today') {
const d = new Date()
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`
list = list.filter((t) => {
const dd = new Date(t.dueAt)
return `${dd.getFullYear()}-${dd.getMonth()}-${dd.getDate()}` === key && t.status !== 'done'
})
}
if (filter === 'overdue') list = list.filter((t) => t.status === 'overdue')
if (filter === 'done') list = list.filter((t) => t.status === 'done')
if (filter === 'pending') list = list.filter((t) => t.status === 'pending')
if (query.customerId) list = list.filter((t) => t.customerId === query.customerId)
list.sort((a, b) => new Date(a.dueAt).getTime() - new Date(b.dueAt).getTime())
return { list, total: list.length }
})
registerRoute('POST', '/api/tasks', ({ body }) => {
const title = String(body.title ?? '').trim()
if (!title) throw new MockApiError(400, '任务标题不能为空')
const customer = dbHelpers.customer(String(body.customerId))
const task: FollowTask = {
id: uid('t'), title,
type: (body.type as FollowTask['type']) ?? 'custom-task',
priority: (body.priority as FollowTask['priority']) ?? 'medium',
status: 'pending',
customerId: String(body.customerId ?? ''), customerName: customer?.name ?? String(body.customerName ?? ''),
conversationId: body.conversationId as string | undefined,
ownerId: String(body.ownerId ?? 'u-003'),
dueAt: String(body.dueAt ?? new Date(Date.now() + 86400000).toISOString()),
note: body.note ? String(body.note) : undefined,
relatedSummary: body.relatedSummary ? String(body.relatedSummary) : undefined,
createdAt: new Date().toISOString(),
}
db.tasks.unshift(task)
return task
})
registerRoute('PATCH', '/api/tasks/:id', ({ params, body }) => {
const task = db.tasks.find((t) => t.id === params.id)
if (!task) throw new MockApiError(404, '任务不存在')
if (body.status === 'done') {
task.status = 'done'
task.completedAt = new Date().toISOString()
} else {
Object.assign(task, body)
}
return task
})
registerRoute('DELETE', '/api/tasks/:id', ({ params }) => {
db.tasks = db.tasks.filter((t) => t.id !== params.id)
return { success: true }
})
}
function registerRisks() {
registerRoute('GET', '/api/risks', ({ query }) => {
let list = [...db.risks]
if (query.level) list = list.filter((r) => r.level === query.level)
if (query.type) list = list.filter((r) => r.type === query.type)
if (query.status) list = list.filter((r) => r.status === query.status)
if (query.keyword) {
const kw = String(query.keyword).toLowerCase()
list = list.filter((r) => r.customerName.toLowerCase().includes(kw) || r.reason.toLowerCase().includes(kw))
}
list.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
return { list, total: list.length }
})
registerRoute('PATCH', '/api/risks/:id', ({ params, body }) => {
const risk = db.risks.find((r) => r.id === params.id)
if (!risk) throw new MockApiError(404, '风险不存在')
if (body.status) risk.status = body.status as RiskAlert['status']
if (body.ownerId !== undefined) risk.ownerId = body.ownerId as string
if (body.note !== undefined) risk.note = String(body.note)
return risk
})
}
export function registerCrmHandlers(): void {
registerCustomers()
registerOpportunities()
registerTasks()
registerRisks()
}
-320
View File
@@ -1,320 +0,0 @@
/** Mock 处理器:会话 / 消息 / AI 能力 */
import { registerRoute, MockApiError, type MockCtx } from '../router'
import { db, dbHelpers } from '@/mocks/db'
import { uid } from '@/utils/misc'
import { sleep } from '@/utils/misc'
import { countryMeta, intentLevelOf, sentimentMeta } from '@/config/constants'
import { languageName } from '@/config/languages'
import { mockTranslate, detectLang } from '../translateEngine'
import type { Conversation, Customer, Message } from '@/types'
import type { AiAnalysis, AiReplySuggestion, InterpretLine } from '@/types/ai'
export type ConversationListItem = Conversation & { customer: Customer }
function matchFilter(item: ConversationListItem, filter: string, userId?: string): boolean {
switch (filter) {
case 'unread':
return item.unreadCount > 0
case 'pending':
return item.status === 'pending'
case 'high-intent':
return item.intentLevel === 'high' || item.customer.intentScore >= 80
case 'risk':
return item.hasRisk || item.customer.riskLevel === 'high' || item.customer.riskLevel === 'urgent'
case 'unassigned':
return !item.assigneeId
case 'mine':
return !!userId && item.assigneeId === userId
default:
return true
}
}
function registerConversations() {
registerRoute('GET', '/api/conversations', ({ query }) => {
const filter = String(query.filter ?? 'all')
const channel = String(query.channel ?? '')
const keyword = String(query.keyword ?? '').trim().toLowerCase()
const userId = query.userId ? String(query.userId) : undefined
let list: ConversationListItem[] = db.conversations
.map((c) => ({ ...c, customer: dbHelpers.customer(c.customerId)! }))
.filter((c) => c.customer)
if (channel) list = list.filter((c) => c.channel === channel)
if (filter && filter !== 'all') list = list.filter((c) => matchFilter(c, filter, userId))
if (keyword) {
list = list.filter((c) => {
const cu = c.customer
const inMsgs = db.messages.some(
(m) => m.conversationId === c.id && m.originalText.toLowerCase().includes(keyword),
)
return (
cu.name.toLowerCase().includes(keyword) ||
cu.email.toLowerCase().includes(keyword) ||
cu.phone.toLowerCase().includes(keyword) ||
cu.company.toLowerCase().includes(keyword) ||
countryMeta(cu.country).zh.includes(keyword) ||
cu.products.some((p) => p.toLowerCase().includes(keyword)) ||
c.summary.toLowerCase().includes(keyword) ||
inMsgs
)
})
}
list.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
return { list, total: list.length }
})
registerRoute('GET', '/api/conversations/:id', ({ params }) => {
const conv = dbHelpers.conversation(params.id)
if (!conv) throw new MockApiError(404, '会话不存在')
return { conversation: conv, customer: dbHelpers.customer(conv.customerId) }
})
registerRoute('GET', '/api/conversations/:id/messages', ({ params }) => {
const conv = dbHelpers.conversation(params.id)
if (!conv) throw new MockApiError(404, '会话不存在')
conv.unreadCount = 0
return dbHelpers.messagesOf(params.id)
})
registerRoute('POST', '/api/conversations/:id/messages', ({ params, body }) => {
const conv = dbHelpers.conversation(params.id)
if (!conv) throw new MockApiError(404, '会话不存在')
const text = String(body.text ?? '').trim()
if (!text && !body.attachmentName) throw new MockApiError(400, '消息内容不能为空')
const lang = detectLang(text)
const msg: Message = {
id: uid('msg'),
conversationId: params.id,
sender: 'agent',
type: (body.type as Message['type']) ?? 'text',
originalText: text || String(body.attachmentName),
translatedText: body.translatedText ? String(body.translatedText) : lang === 'zh' ? mockTranslate(text, 'en').translated : mockTranslate(text, 'zh').translated,
originalLang: lang,
status: 'sent',
createdAt: new Date().toISOString(),
attachmentName: body.attachmentName ? String(body.attachmentName) : undefined,
}
db.messages.push(msg)
conv.status = 'replied'
conv.unreadCount = 0
dbHelpers.touchConversation(params.id, msg)
// 模拟送达回执
setTimeout(() => {
msg.status = 'delivered'
}, 1500)
return msg
})
registerRoute('PATCH', '/api/conversations/:id', ({ params, body }) => {
const conv = dbHelpers.conversation(params.id)
if (!conv) throw new MockApiError(404, '会话不存在')
if (body.assigneeId !== undefined) conv.assigneeId = body.assigneeId as string | null
if (body.markRead) conv.unreadCount = 0
if (body.status) conv.status = body.status as Conversation['status']
return conv
})
}
/* ================= AI ================= */
function buildAnalysis(customer: Customer): AiAnalysis {
const p = customer.aiProfile
const keyPoints = [
p.inquiryQuantity ? { label: '询价数量', value: p.inquiryQuantity } : null,
p.targetPrice ? { label: '目标价格', value: p.targetPrice } : null,
p.moqRequirement ? { label: 'MOQ需求', value: p.moqRequirement } : null,
p.sampleNeed ? { label: '样品需求', value: p.sampleNeed } : null,
p.customRequirement ? { label: '定制要求', value: p.customRequirement } : null,
p.deliveryTime ? { label: '交付时间', value: p.deliveryTime } : null,
p.logistics ? { label: '物流方式', value: p.logistics } : null,
p.payment ? { label: '付款方式', value: p.payment } : null,
p.aftersales ? { label: '售后诉求', value: p.aftersales } : null,
p.concerns.length ? { label: '主要顾虑', value: p.concerns.join('、') } : null,
].filter(Boolean) as { label: string; value: string }[]
return {
intentScore: customer.intentScore,
intentLevel: intentLevelOf(customer.intentScore),
stage: customer.stage,
sentiment: customer.sentiment,
riskLevel: customer.riskLevel,
riskNote:
customer.riskLevel === 'medium'
? '客户对交期比较敏感,请谨慎承诺时效'
: customer.riskLevel === 'high' || customer.riskLevel === 'urgent'
? '客户情绪消极,存在流失/纠纷风险,建议升级处理'
: undefined,
country: customer.country,
keyPoints,
nextActions: p.nextActions,
summary: p.summary,
}
}
function buildReplies(customer: Customer, conv: Conversation): AiReplySuggestion[] {
const c = countryMeta(customer.country)
const product = customer.products[0] ?? '产品'
const qty = customer.aiProfile.inquiryQuantity ?? '该数量'
const refs = ['玻璃杯阶梯报价规则(2026 Q3)', '定制Logo玻璃水杯工艺说明', '物流政策:中东/南亚线路']
const isGlass = product.includes('玻璃')
const knowledgeRefs = isGlass
? refs
: ['企业介绍(标准版)', '付款方式与条款', '外贸术语库(中英对照)']
return [
{
id: uid('reply'),
style: 'stable',
content:
`Dear ${customer.name.split(' ')[0]}, thank you for your patience. ` +
`For ${qty} of ${product}, our formal quotation with all details (specs, packaging, lead time) is attached. ` +
`We have reserved production capacity for you this week. Please let me know if you need any adjustments.`,
targetLang: customer.language === 'zh' ? 'zh' : 'en',
reason: '客户处于比价/确认阶段,正式、完整的回复能建立专业信任,避免过度承诺。',
knowledgeRefs,
expectedEffect: '建立专业形象,推进客户进入条款确认,预计回复率高。',
cultureTip: c.code === 'PK' || c.code === 'BD' ? '南亚客户重视关系,开头寒暄必不可少。' : undefined,
},
{
id: uid('reply'),
style: 'active',
content:
`Great news, ${customer.name.split(' ')[0]}! For ${qty}, I can offer you our tiered price: better unit price at 1000+, ` +
`plus free logo setup if confirmed this week. I'll also share photos of similar successful projects for ${c.en} market. ` +
`Shall we lock this in today?`,
targetLang: 'en',
reason: '客户意向分高且有明确节点(开业/销售季),用阶梯价+限时权益推动当日决策。',
knowledgeRefs: [...knowledgeRefs, '成功回复案例:交期敏感客户促单'],
expectedEffect: '制造紧迫感与价值感,适合快速促单;预计可提前 2—3 天成交。',
cultureTip:
c.code === 'SA' || c.code === 'AE'
? '中东客户避免过于强硬催促,保持尊重与热情并重。'
: undefined,
},
{
id: uid('reply'),
style: 'brief',
content:
`Hi ${customer.name.split(' ')[0]}, price confirmed for ${qty}. ` +
`I will send the formal quotation and customization cases within 1 hour. Any other questions?`,
targetLang: 'en',
reason: '客户问题已明确(价格/定制/交期),简洁回复+明确时限,适合即时沟通渠道。',
knowledgeRefs: knowledgeRefs.slice(0, 1),
expectedEffect: '快速响应提升好感,适合 WhatsApp/Telegram 场景。',
},
]
}
const INTERPRET_SCRIPT: { speaker: 'me' | 'peer'; original: string; translated: string; lang: string }[] = [
{ speaker: 'peer', original: 'السلام عليكم، شكراً لقبولكم هذا الاجتماع.', translated: '你好,感谢贵方接受这次会议。', lang: 'ar' },
{ speaker: 'me', original: '您好,很高兴认识您。我们今天想确认500个定制玻璃杯的报价细节。', translated: 'Hello, nice to meet you. Today we would like to confirm the quotation for 500 custom glass cups.', lang: 'zh' },
{ speaker: 'peer', original: 'نعم، نريد معرفة السعر النهائي ومدة التسليم.', translated: '好的,我们想了解最终价格和交付时间。', lang: 'ar' },
{ speaker: 'me', original: '500个双层杯单价1.85美元,含双色Logo,常规交期12到15天。', translated: 'The unit price for 500 double-wall cups is $1.85 including 2-color logo, standard lead time 12-15 days.', lang: 'zh' },
{ speaker: 'peer', original: 'نحتاج التسليم خلال 10 أيام بسبب افتتاح المتجر.', translated: '由于店铺开业,我们需要10天内交付。', lang: 'ar' },
{ speaker: 'me', original: '可以安排加急生产,10天交付,加急费80美元。', translated: 'We can arrange rush production for 10-day delivery with an $80 rush fee.', lang: 'zh' },
{ speaker: 'peer', original: 'موافق، أرسلوا العرض الرسمي وسأؤكد مع شريكي اليوم.', translated: '同意,请发正式报价,我今天和合伙人确认。', lang: 'ar' },
{ speaker: 'me', original: '好的,报价单和定制案例将在会议后立即发送。', translated: 'Sure, the quotation and customization cases will be sent right after this meeting.', lang: 'zh' },
]
function registerAi() {
registerRoute('POST', '/api/ai/translate', async ({ body }) => {
await sleep(400)
const text = String(body.text ?? '')
if (!text.trim()) throw new MockApiError(400, '请输入要翻译的内容')
return mockTranslate(text, String(body.targetLang ?? 'en'), body.sourceLang ? String(body.sourceLang) : undefined)
})
registerRoute('POST', '/api/ai/analyze', async ({ body }) => {
await sleep(700)
const conv = dbHelpers.conversation(String(body.conversationId))
if (!conv) throw new MockApiError(404, '会话不存在')
const customer = dbHelpers.customer(conv.customerId)
if (!customer) throw new MockApiError(404, '客户不存在')
return buildAnalysis(customer)
})
registerRoute('POST', '/api/ai/replies', async ({ body }) => {
await sleep(1200)
const conv = dbHelpers.conversation(String(body.conversationId))
if (!conv) throw new MockApiError(404, '会话不存在')
const customer = dbHelpers.customer(conv.customerId)
if (!customer) throw new MockApiError(404, '客户不存在')
return buildReplies(customer, conv)
})
registerRoute('POST', '/api/ai/summarize', async ({ body }) => {
await sleep(600)
const conv = dbHelpers.conversation(String(body.conversationId))
if (!conv) throw new MockApiError(404, '会话不存在')
const customer = dbHelpers.customer(conv.customerId)
const msgs = dbHelpers.messagesOf(conv.id)
return {
summary: `本会话共 ${msgs.length} 条消息。${customer?.aiProfile.summary ?? conv.summary} 情绪状态:${sentimentMeta(conv.sentiment).label}。建议下一步:${customer?.aiProfile.nextActions[0] ?? '继续跟进'}。`,
}
})
registerRoute('POST', '/api/ai/interpretation', async ({ body }) => {
await sleep(500)
const index = Number(body.index ?? 0)
const line = INTERPRET_SCRIPT[index % INTERPRET_SCRIPT.length]
const out: InterpretLine = {
id: uid('line'),
speaker: line.speaker,
original: line.original,
translated: line.translated,
lang: line.lang,
at: new Date().toISOString(),
}
return out
})
registerRoute('POST', '/api/ai/text-action', async ({ body }) => {
await sleep(700)
const text = String(body.text ?? '').trim()
if (!text) throw new MockApiError(400, '请输入内容')
const action = String(body.action)
const tone = String(body.tone ?? 'professional')
switch (action) {
case 'polish':
return {
result: text
.replace(/你好[!!。]?/, 'Dear valued customer,')
.replace(/谢谢[!!。]?/, 'Thank you very much.')
.replace(/。/g, '. ')
.replace(/,/g, ', ') || text,
}
case 'shorten': {
const first = text.split(/[。!?!?.]/)[0]
return { result: `${first}.` }
}
case 'expand':
return {
result: `${text}\n\nAdditionally, we can provide free samples for your evaluation, and our team is available 24/7 for any questions. Looking forward to your reply.`,
}
case 'tone': {
const openers: Record<string, string> = {
professional: 'Dear valued customer, ',
friendly: 'Hi there! ',
concise: '',
negotiation: 'Thank you for your offer. ',
comfort: 'We sincerely apologize for the inconvenience. ',
urge: 'Friendly reminder: ',
}
return { result: `${openers[tone] ?? ''}${text}` }
}
case 'translate':
return { result: mockTranslate(text, String(body.targetLang ?? 'en')).translated }
default:
return { result: text }
}
})
}
export function registerInboxHandlers(): void {
registerConversations()
registerAi()
}
-396
View File
@@ -1,396 +0,0 @@
/** Mock 处理器:认证 / 工作台 / 渠道 / 知识库 / 分析 / 团队 / 账单 / 设置 / 通知 / 搜索 */
import { registerRoute, MockApiError } from '../router'
import { db, dbHelpers } from '@/mocks/db'
import { uid } from '@/utils/misc'
import { sleep } from '@/utils/misc'
import { dashboardOverview, analyticsData } from '@/mocks/seed/analytics'
import { industryPacks } from '@/mocks/seed/knowledge'
import { countryMeta } from '@/config/constants'
import { getPlan } from '@/config/pricingPlans'
import type { RoleKey } from '@/config/roles'
import type { AuthSession, KnowledgeItem, Tenant, User } from '@/types'
function tenant(): Tenant {
return {
id: 't-001',
name: '徐州欧汇科技有限公司',
industry: '玻璃制品',
size: '21—50人',
plan: db.billing.planKey,
planExpireAt: db.billing.expireAt,
country: 'CN',
}
}
function userOfRole(role: RoleKey): User {
const map: Record<RoleKey, string> = { admin: 'u-001', manager: 'u-002', sales: 'u-003', service: 'u-005', viewer: 'u-007' }
const m = dbHelpers.member(map[role]) ?? db.team[0]
return {
id: m.id, name: m.name, email: m.email, phone: m.phone, role: m.role,
department: m.department, title: m.department === '总经办' ? '总经理' : m.department + '成员',
status: m.status, lastLoginAt: new Date().toISOString(),
}
}
function registerAuth() {
registerRoute('POST', '/api/auth/login', ({ body }) => {
const account = String(body.account ?? '').trim()
if (!account) throw new MockApiError(400, '请输入手机号或邮箱')
const mode = String(body.mode ?? 'password')
if (mode === 'password' && !String(body.password ?? '')) throw new MockApiError(400, '请输入密码')
if (mode === 'code' && String(body.code ?? '').length !== 6) throw new MockApiError(400, '请输入6位验证码')
if (String(body.password ?? '') === 'error') throw new MockApiError(401, '账号或密码错误')
let role = (body.role as RoleKey | undefined) ?? 'admin'
if (!body.role) {
const hit = db.team.find((m) => account.includes(m.email.split('@')[0]) || account.includes(m.name))
if (hit) role = hit.role
}
const session: AuthSession = {
token: `mock-token-${uid('tk')}`,
user: userOfRole(role),
tenant: tenant(),
onboarded: true,
}
return session
})
registerRoute('POST', '/api/auth/register', ({ body }) => {
const name = String(body.name ?? '').trim()
const account = String(body.account ?? '').trim()
if (!name) throw new MockApiError(400, '请输入姓名')
if (!account) throw new MockApiError(400, '请输入手机号或邮箱')
if (String(body.password ?? '').length < 6) throw new MockApiError(400, '密码至少6位')
if (!String(body.company ?? '').trim()) throw new MockApiError(400, '请输入企业名称')
const session: AuthSession = {
token: `mock-token-${uid('tk')}`,
user: { ...userOfRole('admin'), name, email: account.includes('@') ? account : `${uid('user')}@example.com` },
tenant: { ...tenant(), name: String(body.company), industry: String(body.industry ?? '通用外贸') },
onboarded: false,
}
return session
})
registerRoute('GET', '/api/auth/me', () => ({ user: userOfRole('admin'), tenant: tenant() }))
}
function registerDashboard() {
registerRoute('GET', '/api/dashboard/overview', () => {
const openRiskCount = db.risks.filter((r) => r.status === 'open' || r.status === 'confirmed').length
return {
...dashboardOverview,
stats: { ...dashboardOverview.stats, openRisks: { value: openRiskCount, delta: 0.22 } },
todos: db.tasks.filter((t) => t.status !== 'done').slice(0, 7),
highValueCustomers: [...db.customers].sort((a, b) => b.intentScore - a.intentScore).slice(0, 6),
recentRisks: [...db.risks]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 5),
updatedAt: new Date().toISOString(),
}
})
}
function registerChannels() {
registerRoute('GET', '/api/channels', () => db.channels)
registerRoute('GET', '/api/channels/:id/logs', ({ params }) =>
db.channelSyncLogs.filter((l) => l.channelId === params.id),
)
registerRoute('POST', '/api/channels/connect', async ({ body }) => {
await sleep(800)
const type = String(body.type)
const ch = db.channels.find((c) => c.type === type)
if (!ch) throw new MockApiError(404, '渠道不存在')
ch.status = 'connected'
ch.account = String(body.account ?? ch.account ?? '已授权账号')
ch.lastSyncAt = new Date().toISOString()
ch.autoReply = Boolean(body.autoReply)
ch.defaultOwnerId = body.defaultOwnerId ? String(body.defaultOwnerId) : ch.defaultOwnerId
ch.errorMessage = undefined
db.channelSyncLogs.unshift({
id: uid('sl'), channelId: ch.id, action: '渠道连接',
detail: `完成授权连接,账号:${ch.account}`, status: 'success', createdAt: new Date().toISOString(),
})
return ch
})
registerRoute('POST', '/api/channels/test', async ({ body }) => {
await sleep(900)
const id = String(body.id ?? '')
const ch = db.channels.find((c) => c.id === id || c.type === id)
if (!ch) throw new MockApiError(404, '渠道不存在')
return { success: true, latencyMs: 80 + Math.round(Math.random() * 120), message: '连接测试通过,消息通道正常' }
})
registerRoute('PATCH', '/api/channels/:id', ({ params, body }) => {
const ch = db.channels.find((c) => c.id === params.id)
if (!ch) throw new MockApiError(404, '渠道不存在')
Object.assign(ch, body)
return ch
})
registerRoute('DELETE', '/api/channels/:id', ({ params }) => {
const ch = db.channels.find((c) => c.id === params.id)
if (!ch) throw new MockApiError(404, '渠道不存在')
ch.status = 'unconnected'
ch.account = undefined
ch.todayMessages = 0
ch.errorMessage = undefined
return { success: true }
})
}
function registerKnowledge() {
registerRoute('GET', '/api/knowledge', ({ query }) => {
let list = [...db.knowledge]
if (query.category) list = list.filter((k) => k.category === query.category)
if (query.keyword) {
const kw = String(query.keyword).toLowerCase()
list = list.filter(
(k) => k.title.toLowerCase().includes(kw) || k.content.toLowerCase().includes(kw) || k.tags.some((t) => t.includes(kw)),
)
}
list.sort((a, b) => b.citedCount - a.citedCount)
return { list, total: list.length }
})
registerRoute('GET', '/api/knowledge/packs', () => industryPacks)
registerRoute('GET', '/api/knowledge/stats', () => {
const total = db.knowledge.reduce((s, k) => s + k.citedCount, 0)
return {
totalCitations: total,
aiReplyCitationRate: 0.684,
topUsed: [...db.knowledge]
.sort((a, b) => b.citedCount - a.citedCount)
.slice(0, 5)
.map((k) => ({ id: k.id, title: k.title, citedCount: k.citedCount })),
lowQuality: db.knowledge
.filter((k) => k.quality === 'low' || (k.enabled && k.citedCount < 20))
.slice(0, 5)
.map((k) => ({ id: k.id, title: k.title, reason: k.quality === 'low' ? '内容质量低,命中率差' : '近30天引用次数过低' })),
outdated: [...db.knowledge]
.sort((a, b) => new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime())
.slice(0, 5)
.map((k) => ({ id: k.id, title: k.title, updatedAt: k.updatedAt })),
}
})
registerRoute('POST', '/api/knowledge', ({ body }) => {
const title = String(body.title ?? '').trim()
if (!title) throw new MockApiError(400, '知识标题不能为空')
const item: KnowledgeItem = {
id: uid('k'), title,
category: (body.category as KnowledgeItem['category']) ?? 'faq',
content: String(body.content ?? ''),
tags: (body.tags as string[]) ?? [],
enabled: true, version: 1,
updatedAt: new Date().toISOString(),
updatedBy: '当前用户',
source: 'manual', status: 'ready', citedCount: 0,
languages: ['zh'], quality: 'normal',
}
db.knowledge.unshift(item)
return item
})
registerRoute('POST', '/api/knowledge/upload', ({ body }) => {
const files = (body.files as { name: string; type: string }[]) ?? []
const items: KnowledgeItem[] = files.map((f) => {
const item: KnowledgeItem = {
id: uid('k'), title: f.name.replace(/\.[^.]+$/, ''),
category: 'product', content: '',
tags: ['文件导入'], enabled: true, version: 1,
updatedAt: new Date().toISOString(), updatedBy: '当前用户',
source: 'file', fileName: f.name, fileType: f.type,
status: 'parsing', citedCount: 0, languages: ['zh'], quality: 'normal',
}
db.knowledge.unshift(item)
// 模拟异步解析
setTimeout(() => {
item.status = 'ready'
item.content = `【模拟解析结果】已从文件「${f.name}」中提取文本内容,包含产品参数、价格与政策说明,可用于 AI 回复引用。`
}, 3000 + Math.random() * 3000)
return item
})
return { items }
})
registerRoute('PATCH', '/api/knowledge/:id', ({ params, body }) => {
const item = db.knowledge.find((k) => k.id === params.id)
if (!item) throw new MockApiError(404, '知识不存在')
const contentChanged = typeof body.content === 'string' && body.content !== item.content
Object.assign(item, body)
if (contentChanged) item.version += 1
item.updatedAt = new Date().toISOString()
return item
})
registerRoute('DELETE', '/api/knowledge/:id', ({ params }) => {
db.knowledge = db.knowledge.filter((k) => k.id !== params.id)
return { success: true }
})
registerRoute('POST', '/api/knowledge/search-test', async ({ body }) => {
await sleep(900)
const question = String(body.question ?? '').trim()
if (!question) throw new MockApiError(400, '请输入测试问题')
const words = question.split(/[\s,,。??!!]+/).filter((w) => w.length >= 2)
const scored = db.knowledge
.filter((k) => k.enabled && k.status === 'ready')
.map((k) => {
let score = 0.3
const hay = `${k.title} ${k.content} ${k.tags.join(' ')}`.toLowerCase()
for (const w of words) if (hay.includes(w.toLowerCase())) score += 0.18
score += Math.min(0.15, k.citedCount / 2000)
return { item: k, score: Math.min(0.98, score) }
})
.sort((a, b) => b.score - a.score)
.slice(0, 3)
.map((h) => ({
item: h.item,
score: h.score,
snippet: h.item.content.slice(0, 90) + (h.item.content.length > 90 ? '…' : ''),
}))
const top = scored[0]
const answer = top
? `根据企业知识库「${top.item.title}」:${top.item.content.slice(0, 120)}。如需更详细信息(报价/政策原文),可查看引用来源。`
: '当前知识库未检索到足够匹配的内容,建议补充相关知识条目。'
return { answer, hits: scored }
})
}
function registerAnalytics() {
registerRoute('GET', '/api/analytics', () => analyticsData)
}
function registerTeam() {
registerRoute('GET', '/api/team', () => db.team)
registerRoute('GET', '/api/team/operation-logs', () => db.operationLogs)
registerRoute('GET', '/api/team/login-records', () => db.loginRecords)
registerRoute('POST', '/api/team/invite', ({ body }) => {
const name = String(body.name ?? '').trim()
if (!name) throw new MockApiError(400, '请输入成员姓名')
const email = String(body.email ?? '').trim()
if (!email.includes('@')) throw new MockApiError(400, '请输入有效邮箱')
const member = {
id: uid('u'), name, email,
phone: String(body.phone ?? ''),
department: String(body.department ?? '外贸一部'),
role: (body.role as RoleKey) ?? 'sales',
online: false, customerCount: 0, todoCount: 0,
lastLoginAt: new Date().toISOString(), status: 'active' as const,
joinedAt: new Date().toISOString(),
}
db.team.push(member)
return member
})
registerRoute('PATCH', '/api/team/:id', ({ params, body }) => {
const m = dbHelpers.member(params.id)
if (!m) throw new MockApiError(404, '成员不存在')
Object.assign(m, body)
return m
})
registerRoute('DELETE', '/api/team/:id', ({ params }) => {
db.team = db.team.filter((m) => m.id !== params.id)
return { success: true }
})
}
function registerBilling() {
registerRoute('GET', '/api/billing', () => ({
info: db.billing,
orders: db.orders,
invoices: db.invoices,
}))
registerRoute('POST', '/api/billing/upgrade', async ({ body }) => {
await sleep(1000)
const plan = getPlan(String(body.planKey ?? 'pro'))
db.billing.planKey = plan.key
db.billing.planName = plan.name
db.billing.expireAt = new Date(Date.now() + 365 * 86400000).toISOString()
db.billing.usage.members.total = plan.limits.members
db.billing.usage.messages.total = plan.limits.messagesPerMonth
db.billing.usage.translations.total = plan.limits.translationsPerMonth
db.billing.usage.knowledgeStorage.total = plan.limits.knowledgeStorageMb
db.orders.unshift({
id: uid('bo'),
orderNo: `LX${new Date().toISOString().slice(0, 10).replace(/-/g, '')}${String(db.orders.length + 1).padStart(3, '0')}`,
planName: `${plan.name}(年付)`,
period: `${new Date().toISOString().slice(0, 10)} 至 ${new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10)}`,
amount: plan.yearlyPrice ?? 0,
status: 'paid',
createdAt: new Date().toISOString(),
})
return db.billing
})
}
function registerSettings() {
registerRoute('GET', '/api/settings', () => db.settings)
registerRoute('PATCH', '/api/settings', ({ body }) => {
for (const [k, v] of Object.entries(body)) {
const key = k as keyof typeof db.settings
const cur = db.settings[key]
if (cur && typeof cur === 'object' && !Array.isArray(cur) && v && typeof v === 'object') {
Object.assign(cur as Record<string, unknown>, v as Record<string, unknown>)
} else {
;(db.settings as unknown as Record<string, unknown>)[k] = v
}
}
return db.settings
})
}
function registerNotifications() {
registerRoute('GET', '/api/notifications', () => db.notifications)
registerRoute('POST', '/api/notifications/read-all', () => {
db.notifications.forEach((n) => (n.read = true))
return { success: true }
})
}
function registerSearch() {
registerRoute('GET', '/api/search', ({ query }) => {
const q = String(query.q ?? '').trim().toLowerCase()
if (!q) return { customers: [], conversations: [], knowledge: [], tasks: [] }
const customers = db.customers
.filter((c) => c.name.toLowerCase().includes(q) || c.company.toLowerCase().includes(q) || c.email.toLowerCase().includes(q) || countryMeta(c.country).zh.includes(q))
.slice(0, 5)
.map((c) => ({ id: c.id, title: c.name, sub: `${countryMeta(c.country).zh} · ${c.company}` }))
const conversations = db.conversations
.filter((c) => {
const cu = dbHelpers.customer(c.customerId)
return cu?.name.toLowerCase().includes(q) || c.summary.toLowerCase().includes(q)
})
.slice(0, 5)
.map((c) => ({ id: c.id, title: dbHelpers.customer(c.customerId)?.name ?? '', sub: c.summary }))
const knowledge = db.knowledge
.filter((k) => k.title.toLowerCase().includes(q))
.slice(0, 5)
.map((k) => ({ id: k.id, title: k.title, sub: k.content.slice(0, 40) }))
const tasks = db.tasks
.filter((t) => t.title.toLowerCase().includes(q) || t.customerName.toLowerCase().includes(q))
.slice(0, 5)
.map((t) => ({ id: t.id, title: t.title, sub: t.customerName }))
return { customers, conversations, knowledge, tasks }
})
}
export function registerMiscHandlers(): void {
registerAuth()
registerDashboard()
registerChannels()
registerKnowledge()
registerAnalytics()
registerTeam()
registerBilling()
registerSettings()
registerNotifications()
registerSearch()
}
-19
View File
@@ -1,19 +0,0 @@
/** Mock 服务入口:注册全部 Mock 路由并暴露请求方法 */
import { mockRequest } from './router'
import { registerInboxHandlers } from './handlers/inbox'
import { registerCrmHandlers } from './handlers/crm'
import { registerMiscHandlers } from './handlers/misc'
let registered = false
function ensureRegistered() {
if (registered) return
registered = true
registerInboxHandlers()
registerCrmHandlers()
registerMiscHandlers()
}
ensureRegistered()
export { mockRequest }
export { MockApiError } from './router'
-69
View File
@@ -1,69 +0,0 @@
/** Mock 路由内核:注册模式路由并按路径匹配(支持 :param) */
import { sleep } from '@/utils/misc'
export class MockApiError extends Error {
code: number
constructor(code: number, message: string) {
super(message)
this.code = code
}
}
export interface MockCtx {
params: Record<string, string>
query: Record<string, unknown>
body: Record<string, unknown>
}
type Handler = (ctx: MockCtx) => unknown | Promise<unknown>
interface Route {
method: string
pattern: string
regex: RegExp
keys: string[]
specificity: number
handler: Handler
}
const routes: Route[] = []
export function registerRoute(method: string, pattern: string, handler: Handler): void {
const keys: string[] = []
const regexStr = pattern.replace(/:[^/]+/g, (seg) => {
keys.push(seg.slice(1))
return '([^/]+)'
})
routes.push({
method,
pattern,
regex: new RegExp(`^${regexStr}$`),
keys,
specificity: pattern.replace(/:[^/]+/g, '').length,
handler,
})
routes.sort((a, b) => b.specificity - a.specificity)
}
export async function mockRequest<T>(
method: string,
path: string,
query: Record<string, unknown>,
body: unknown,
): Promise<T> {
// 模拟网络延迟
await sleep(120 + Math.random() * 300)
for (const r of routes) {
if (r.method !== method) continue
const match = r.regex.exec(path)
if (!match) continue
const params: Record<string, string> = {}
r.keys.forEach((k, i) => {
params[k] = decodeURIComponent(match[i + 1])
})
const result = await r.handler({ params, query, body: (body ?? {}) as Record<string, unknown> })
return result as T
}
throw new MockApiError(404, `Mock 接口未实现:${method} ${path}`)
}
-141
View File
@@ -1,141 +0,0 @@
/** Mock 翻译引擎:常用商务句库 + 词条替换(真实环境替换为 AI 翻译服务) */
import { languageName } from '@/config/languages'
import type { TranslateResult } from '@/types/ai'
/** 常用商务句库(源句 → 各目标语言译文) */
const PHRASEBOOK: Record<string, Record<string, string>> = {
'你好,请问500个定制玻璃杯什么价格?': {
en: 'Hello, what is the price for 500 custom glass cups?',
ar: 'مرحباً، ما سعر 500 كوب زجاجي مخصص؟',
ur: 'ہیلو، 500 کسٹم گلاس کپس کی قیمت کیا ہے؟',
es: 'Hola, ¿cuál es el precio de 500 vasos de vidrio personalizados?',
},
'请发送你们的最新产品目录和报价单。': {
en: 'Please send your latest product catalog and quotation.',
ar: 'يرجى إرسال أحدث كتالوج وقائمة أسعار.',
ur: 'براہ کرم اپنی تازہ ترین کیٹلاگ اور قیمتوں کی فہرست بھیجیں۔',
es: 'Por favor envíe su catálogo más reciente y lista de precios.',
},
'我们可以在10天内完成生产,但需要收取加急费用。': {
en: 'We can finish production within 10 days, with a rush fee applied.',
ar: 'يمكننا إنهاء الإنتاج خلال 10 أيام مع رسوم إضافية للاستعجال.',
ur: 'ہم 10 دنوں میں پیداوار مکمل کر سکتے ہیں، لیکن جلد بازی کی فیس لگے گی۔',
es: 'Podemos terminar la producción en 10 días, con un cargo urgente.',
},
'感谢您的询盘,我们将在24小时内回复详细报价。': {
en: 'Thank you for your inquiry. We will reply with a detailed quotation within 24 hours.',
ar: 'شكراً لاستفساركم، سنرد بعرض أسعار مفصل خلال 24 ساعة.',
ur: 'آپ کی انکوائری کا شکریہ، ہم 24 گھنٹوں میں تفصیلی قیمت جواب دیں گے۔',
es: 'Gracias por su consulta, responderemos con una cotización detallada en 24 horas.',
},
'样品已通过国际快递寄出,单号稍后发给您。': {
en: 'The sample has been shipped via international express. Tracking number will follow.',
ar: 'تم إرسال العينة عبر البريد السريع الدولي، وسنرسل رقم التتبع لاحقاً.',
ur: 'نمونہ بین الاقوامی کورئیر سے بھیج دیا گیا ہے، ٹریکنگ نمبر بعد میں بھیجیں گے۔',
es: 'La muestra ha sido enviada por mensajería internacional. El número de seguimiento se enviará después.',
},
'Hello, I would like to inquire about your glass cups.': {
zh: '你好,我想咨询一下你们的玻璃杯。',
},
'What is your MOQ and best price?': {
zh: '你们的最低起订量和最优价格是多少?',
},
'Please send me the quotation including shipping.': {
zh: '请发给我包含运费的报价单。',
},
'السلام عليكم': { zh: '愿平安归于你(阿拉伯语问候)', en: 'Peace be upon you' },
'ہیلو': { zh: '你好', en: 'Hello' },
}
/** 词条级词典(中→英) */
const ZH_EN_WORDS: [string, string][] = [
['玻璃杯', 'glass cup'], ['定制', 'custom'], ['价格', 'price'], ['报价', 'quotation'],
['样品', 'sample'], ['起订量', 'MOQ'], ['交货', 'delivery'], ['交期', 'lead time'],
['物流', 'logistics'], ['运费', 'shipping cost'], ['付款', 'payment'], ['定金', 'deposit'],
['合同', 'contract'], ['发票', 'invoice'], ['质量', 'quality'], ['包装', 'packaging'],
['破损', 'breakage'], ['补发', 'replacement'], ['折扣', 'discount'], ['订单', 'order'],
['个', ' pcs'], ['件', ' pcs'], ['套', ' sets'], ['台', ' units'],
['谢谢', 'thank you'], ['你好', 'hello'], ['请', 'please'], ['我们', 'we'], ['你们', 'you'],
['可以', 'can'], ['需要', 'need'], ['发送', 'send'], ['确认', 'confirm'], ['收到', 'received'],
['今天', 'today'], ['明天', 'tomorrow'], ['下周', 'next week'], ['生产', 'production'],
['海运', 'sea freight'], ['空运', 'air freight'], ['仓库', 'warehouse'], ['库存', 'stock'],
]
const TERMINOLOGY: [string, string][] = [
['MOQ', '最低起订量'], ['FOB', '离岸价'], ['CIF', '到岸价'], ['DDP', '完税后交货'],
['OEM', '贴牌代工'], ['ODM', '原创设计制造'], ['PI', '形式发票'], ['B/L', '提单'],
['lead time', '交期'], ['deposit', '定金'], ['玻璃杯', 'glass cup'], ['高硼硅', 'borosilicate'],
]
/** 识别文本语言(启发式) */
export function detectLang(text: string): string {
if (/[؀-ۿݐ-ݿ࣠-ࣿ]/.test(text)) {
// 阿拉伯系文字:含乌尔都语特征字符则判 ur
if (/[ٹڈڑںہھےۓ]/.test(text)) return 'ur'
return 'ar'
}
if (/[ঀ-৿]/.test(text)) return 'bn'
if (/[ऀ-ॿ]/.test(text)) return 'hi'
if (/[Ѐ-ӿ]/.test(text)) return 'ru'
if (/[가-힯]/.test(text)) return 'ko'
if (/[぀-ヿ]/.test(text)) return 'ja'
if (/[一-鿿]/.test(text)) return 'zh'
if (/[áéíóúñ¿¡]/i.test(text)) return 'es'
return 'en'
}
/** Mock 翻译 */
export function mockTranslate(text: string, targetLang: string, sourceLang?: string): TranslateResult {
const detected = sourceLang && sourceLang !== 'auto' ? sourceLang : detectLang(text)
const trimmed = text.trim()
// 1. 句库精确匹配
const phrase = PHRASEBOOK[trimmed]
if (phrase?.[targetLang]) {
return { translated: phrase[targetLang], detectedLang: detected, terms: extractTerms(trimmed) }
}
// 2. 词条替换(中→英 / 英→中)
if (detected === 'zh' && targetLang === 'en') {
let out = trimmed
let replaced = 0
for (const [zh, en] of ZH_EN_WORDS) {
if (out.includes(zh)) {
out = out.split(zh).join(en)
replaced++
}
}
if (replaced > 0) {
return { translated: out.replace(/\s+/g, ' ').trim(), detectedLang: detected, terms: extractTerms(trimmed) }
}
}
if (detected === 'en' && targetLang === 'zh') {
const lower = trimmed.toLowerCase()
const hits = ZH_EN_WORDS.filter(([, en]) => lower.includes(en.trim().toLowerCase()))
if (hits.length > 0 || trimmed.length < 120) {
// 对英文输入返回可读的模拟中文释义
const note = hits.slice(0, 3).map(([zh, en]) => `${en.trim()}→${zh}`).join(';')
return {
translated: `【中文释义】${note ? `识别到:${note}。` : ''}原文大意为:“${trimmed.slice(0, 80)}${trimmed.length > 80 ? '…' : ''}”`,
detectedLang: detected,
terms: extractTerms(trimmed),
}
}
}
// 3. 兜底:标注目标语言的模拟译文
return {
translated: `【${languageName(targetLang)}译文】${trimmed}`,
detectedLang: detected,
terms: extractTerms(trimmed),
}
}
function extractTerms(text: string): { source: string; target: string }[] {
const terms: { source: string; target: string }[] = []
for (const [a, b] of TERMINOLOGY) {
if (text.includes(a)) terms.push({ source: a, target: b })
if (text.includes(b)) terms.push({ source: b, target: a })
}
return terms.slice(0, 5)
}
+181 -12
View File
@@ -4,26 +4,26 @@
@layer base {
:root {
--background: 220 14% 96%;
--foreground: 215 25% 16%;
--background: 210 20% 98%;
--foreground: 215 28% 13%;
--card: 0 0% 100%;
--card-foreground: 215 25% 16%;
--popover: 0 0% 100%;
--popover-foreground: 215 25% 16%;
--primary: 172 59% 30%;
--primary: 221 83% 53%;
--primary-foreground: 0 0% 100%;
--secondary: 210 16% 93%;
--secondary: 210 20% 96%;
--secondary-foreground: 215 20% 24%;
--muted: 210 16% 93%;
--muted-foreground: 215 12% 46%;
--accent: 170 40% 93%;
--accent-foreground: 172 60% 22%;
--muted: 210 20% 96%;
--muted-foreground: 215 12% 43%;
--accent: 214 100% 97%;
--accent-foreground: 221 83% 40%;
--destructive: 0 70% 50%;
--destructive-foreground: 0 0% 100%;
--border: 214 15% 88%;
--input: 214 15% 84%;
--ring: 172 59% 34%;
--radius: 0.5rem;
--border: 214 22% 91%;
--input: 214 18% 86%;
--ring: 221 83% 53%;
--radius: 0.75rem;
}
* {
@@ -40,6 +40,10 @@
@apply bg-background text-foreground antialiased;
font-size: 14px;
line-height: 1.6;
letter-spacing: -0.006em;
background-image:
radial-gradient(circle at 75% -10%, rgba(37, 99, 235, 0.05), transparent 26rem),
linear-gradient(180deg, #ffffff 0, hsl(var(--background)) 22rem);
}
::selection {
@@ -65,6 +69,171 @@
.text-balance {
text-wrap: balance;
}
.surface-panel {
@apply rounded-2xl border border-slate-200/80 bg-white shadow-[0_1px_2px_rgba(15,23,42,0.03),0_10px_30px_rgba(15,23,42,0.025)];
}
.eyebrow {
@apply text-[11px] font-semibold uppercase tracking-[0.14em] text-primary-700;
}
}
.chat-wallpaper {
transition: background-color .28s ease, background-image .28s ease;
background-position: center, center, center, center, 0 0, center;
background-size: 100% 100%, 100% 100%, 100% 100%, 100% 100%, 320px 320px, 100% 100%;
}
.chat-wallpaper-ocean {
background-color: #eaf2f9;
background-image:
radial-gradient(ellipse at 8% 10%, rgba(125, 211, 252, .42), transparent 45%),
radial-gradient(ellipse at 92% 8%, rgba(96, 165, 250, .34), transparent 44%),
radial-gradient(ellipse at 12% 92%, rgba(45, 212, 191, .24), transparent 43%),
radial-gradient(ellipse at 90% 90%, rgba(129, 140, 248, .25), transparent 46%),
url('/chat-pattern.svg'),
linear-gradient(145deg, rgba(245, 251, 255, .88), rgba(224, 236, 250, .94));
}
.chat-wallpaper-aurora {
background-color: #eff0ff;
background-image:
radial-gradient(ellipse at 8% 8%, rgba(167, 139, 250, .38), transparent 46%),
radial-gradient(ellipse at 92% 12%, rgba(244, 114, 182, .3), transparent 43%),
radial-gradient(ellipse at 10% 92%, rgba(34, 211, 238, .3), transparent 45%),
radial-gradient(ellipse at 90% 90%, rgba(99, 102, 241, .32), transparent 47%),
url('/chat-pattern.svg'),
linear-gradient(145deg, rgba(249, 244, 255, .9), rgba(228, 246, 255, .88));
}
.chat-wallpaper-sunset {
background-color: #fff3ed;
background-image:
radial-gradient(ellipse at 8% 10%, rgba(251, 191, 36, .34), transparent 44%),
radial-gradient(ellipse at 92% 8%, rgba(251, 113, 133, .33), transparent 45%),
radial-gradient(ellipse at 9% 92%, rgba(251, 146, 60, .28), transparent 43%),
radial-gradient(ellipse at 90% 90%, rgba(192, 132, 252, .28), transparent 47%),
url('/chat-pattern.svg'),
linear-gradient(145deg, rgba(255, 249, 232, .92), rgba(250, 235, 255, .9));
}
.chat-wallpaper-mint {
background-color: #eaf8f4;
background-image:
radial-gradient(ellipse at 8% 9%, rgba(52, 211, 153, .34), transparent 46%),
radial-gradient(ellipse at 92% 10%, rgba(34, 211, 238, .28), transparent 44%),
radial-gradient(ellipse at 10% 92%, rgba(163, 230, 53, .22), transparent 43%),
radial-gradient(ellipse at 90% 90%, rgba(96, 165, 250, .3), transparent 47%),
url('/chat-pattern.svg'),
linear-gradient(145deg, rgba(237, 253, 247, .92), rgba(229, 244, 255, .9));
}
.telegram-bubble-in {
border: 1px solid rgba(226, 232, 240, .96);
background: rgba(255, 255, 255, .97);
box-shadow: 0 2px 5px rgba(15, 23, 42, .06), 0 10px 24px -18px rgba(15, 23, 42, .22);
}
.telegram-bubble-in::before {
content: '';
position: absolute;
bottom: -1px;
left: -7px;
width: 12px;
height: 14px;
background: rgba(255, 255, 255, .97);
clip-path: polygon(100% 0, 0 100%, 100% 100%);
}
.telegram-bubble-out {
border: 1px solid rgba(147, 197, 253, .75);
background: linear-gradient(135deg, #dbeafe 0%, #e0f2fe 52%, #eff6ff 100%);
box-shadow: 0 3px 7px rgba(37, 99, 235, .1), 0 12px 28px -18px rgba(37, 99, 235, .38);
}
.telegram-bubble-out::before {
content: '';
position: absolute;
right: -7px;
bottom: -1px;
width: 12px;
height: 14px;
background: #e8f3ff;
clip-path: polygon(0 0, 0 100%, 100% 100%);
}
.group:hover .telegram-bubble-in {
box-shadow: 0 3px 8px rgba(15, 23, 42, .08), 0 14px 30px -20px rgba(15, 23, 42, .28);
}
.group:hover .telegram-bubble-out {
box-shadow: 0 4px 10px rgba(37, 99, 235, .13), 0 16px 32px -20px rgba(37, 99, 235, .45);
}
@keyframes message-in-left {
from { opacity: 0; transform: translate3d(-18px, 8px, 0) scale(.96); }
65% { opacity: 1; transform: translate3d(2px, -1px, 0) scale(1.01); }
to { opacity: 1; transform: translate3d(0, 0, 0) scale(1); }
}
@keyframes message-in-right {
from { opacity: 0; transform: translate3d(18px, 8px, 0) scale(.96); }
65% { opacity: 1; transform: translate3d(-2px, -1px, 0) scale(1.01); }
to { opacity: 1; transform: translate3d(0, 0, 0) scale(1); }
}
@keyframes incoming-notice {
from { opacity: 0; transform: translate3d(24px, -12px, 0) scale(.94); }
70% { opacity: 1; transform: translate3d(-3px, 1px, 0) scale(1.01); }
to { opacity: 1; transform: translate3d(0, 0, 0) scale(1); }
}
@keyframes conversation-select {
0% {
transform: translate3d(0, 2px, 0) scale(.988);
box-shadow: 0 0 0 rgba(37, 99, 235, 0);
}
58% {
transform: translate3d(0, -1px, 0) scale(1.003);
box-shadow: 0 12px 28px -15px rgba(37, 99, 235, .48);
}
100% {
transform: translate3d(0, 0, 0) scale(1);
box-shadow: 0 9px 24px -15px rgba(37, 99, 235, .52), 0 0 0 1px rgba(96, 165, 250, .1);
}
}
@keyframes conversation-sheen {
from { transform: translate3d(-170%, 0, 0) skewX(-18deg); opacity: 0; }
18% { opacity: .7; }
to { transform: translate3d(470%, 0, 0) skewX(-18deg); opacity: 0; }
}
.conversation-selected {
animation: conversation-select .42s cubic-bezier(.2, .82, .2, 1) both;
}
.conversation-selected::after {
content: '';
pointer-events: none;
position: absolute;
inset-block: 0;
left: 0;
width: 24%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .78), transparent);
animation: conversation-sheen .62s cubic-bezier(.2, .7, .2, 1) .04s both;
}
@layer utilities {
.animate-message-in-left { animation: message-in-left .34s cubic-bezier(.2,.8,.2,1) both; }
.animate-message-in-right { animation: message-in-right .34s cubic-bezier(.2,.8,.2,1) both; }
.animate-incoming-notice { animation: incoming-notice .42s cubic-bezier(.2,.9,.2,1) both; }
}
@media (prefers-reduced-motion: reduce) {
.conversation-selected,
.conversation-selected::after {
animation: none;
}
}
/* RTL 微调 */
+13
View File
@@ -8,6 +8,7 @@ export interface AiReplySuggestion {
id: string
style: ReplyStyle
content: string
translationCn: string
targetLang: string
reason: string
knowledgeRefs: string[]
@@ -40,6 +41,18 @@ export interface TranslateResult {
translated: string
detectedLang: string
terms: { source: string; target: string }[]
historyItem?: TranslationHistoryItem
}
export interface TranslationHistoryItem {
id: string
sourceLang: string
targetLang: string
originalText: string
translatedText: string
detectedLang: string
favorite: boolean
createdAt: string
}
export type AiTextAction = 'polish' | 'translate' | 'shorten' | 'expand' | 'tone'

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