Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9804341463 | |||
| 7f09132fad |
@@ -15,6 +15,12 @@ TELEGRAM_GATEWAY_SERVICE_EMAIL=replace-with-gateway-service-email
|
|||||||
TELEGRAM_GATEWAY_SERVICE_PASSWORD=replace-with-random-service-password
|
TELEGRAM_GATEWAY_SERVICE_PASSWORD=replace-with-random-service-password
|
||||||
TELEGRAM_GATEWAY_ACCOUNT_ID=replace-with-default-account-id
|
TELEGRAM_GATEWAY_ACCOUNT_ID=replace-with-default-account-id
|
||||||
TELEGRAM_WEBHOOK_SECRET=replace-with-random-webhook-secret
|
TELEGRAM_WEBHOOK_SECRET=replace-with-random-webhook-secret
|
||||||
|
|
||||||
|
# 普通 WhatsApp 账户通过“关联设备”接入。两个值均至少 32 位且必须彼此不同。
|
||||||
|
WHATSAPP_GATEWAY_TOKEN=replace-with-at-least-32-random-characters
|
||||||
|
WHATSAPP_WEBHOOK_SECRET=replace-with-another-32-random-secret
|
||||||
|
WHATSAPP_LOG_LEVEL=info
|
||||||
|
|
||||||
PUBLIC_WEBHOOK_BASE_URL=http://host.docker.internal:8088
|
PUBLIC_WEBHOOK_BASE_URL=http://host.docker.internal:8088
|
||||||
APP_PUBLIC_URL=http://127.0.0.1:8088
|
APP_PUBLIC_URL=http://127.0.0.1:8088
|
||||||
|
|
||||||
@@ -26,6 +32,11 @@ SMTP_USER=
|
|||||||
SMTP_PASSWORD=
|
SMTP_PASSWORD=
|
||||||
SMTP_FROM=
|
SMTP_FROM=
|
||||||
|
|
||||||
|
# 企业邮箱渠道(IMAP 收信 + SMTP 发信)
|
||||||
|
# 建议使用 32 字节以上独立随机值;未配置时回退到 LX_JWT_SECRET 派生密钥。
|
||||||
|
EMAIL_CREDENTIAL_KEY=
|
||||||
|
EMAIL_POLL_MS=30000
|
||||||
|
|
||||||
# 真实 Stripe Checkout
|
# 真实 Stripe Checkout
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
STRIPE_WEBHOOK_SECRET=
|
STRIPE_WEBHOOK_SECRET=
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -16,6 +16,21 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
whatsapp-gateway:
|
||||||
|
build:
|
||||||
|
context: ./whatsapp-gateway
|
||||||
|
environment:
|
||||||
|
PORT: 3200
|
||||||
|
DATA_DIR: /data
|
||||||
|
WHATSAPP_GATEWAY_TOKEN: ${WHATSAPP_GATEWAY_TOKEN:?WHATSAPP_GATEWAY_TOKEN is required}
|
||||||
|
LOG_LEVEL: ${WHATSAPP_LOG_LEVEL:-info}
|
||||||
|
volumes:
|
||||||
|
- lingxi_whatsapp_data:/data
|
||||||
|
dns:
|
||||||
|
- 1.1.1.1
|
||||||
|
- 8.8.8.8
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
context: ./server
|
context: ./server
|
||||||
@@ -33,6 +48,10 @@ services:
|
|||||||
TELEGRAM_GATEWAY_SERVICE_PASSWORD: ${TELEGRAM_GATEWAY_SERVICE_PASSWORD:?TELEGRAM_GATEWAY_SERVICE_PASSWORD is required}
|
TELEGRAM_GATEWAY_SERVICE_PASSWORD: ${TELEGRAM_GATEWAY_SERVICE_PASSWORD:?TELEGRAM_GATEWAY_SERVICE_PASSWORD is required}
|
||||||
TELEGRAM_GATEWAY_ACCOUNT_ID: ${TELEGRAM_GATEWAY_ACCOUNT_ID:-}
|
TELEGRAM_GATEWAY_ACCOUNT_ID: ${TELEGRAM_GATEWAY_ACCOUNT_ID:-}
|
||||||
TELEGRAM_WEBHOOK_SECRET: ${TELEGRAM_WEBHOOK_SECRET:?TELEGRAM_WEBHOOK_SECRET is required}
|
TELEGRAM_WEBHOOK_SECRET: ${TELEGRAM_WEBHOOK_SECRET:?TELEGRAM_WEBHOOK_SECRET is required}
|
||||||
|
WHATSAPP_GATEWAY_URL: http://whatsapp-gateway:3200
|
||||||
|
WHATSAPP_GATEWAY_TOKEN: ${WHATSAPP_GATEWAY_TOKEN:?WHATSAPP_GATEWAY_TOKEN is required}
|
||||||
|
WHATSAPP_WEBHOOK_SECRET: ${WHATSAPP_WEBHOOK_SECRET:?WHATSAPP_WEBHOOK_SECRET is required}
|
||||||
|
WHATSAPP_WEBHOOK_BASE_URL: ${WHATSAPP_WEBHOOK_BASE_URL:-http://api:3100}
|
||||||
PUBLIC_WEBHOOK_BASE_URL: ${PUBLIC_WEBHOOK_BASE_URL:-http://host.docker.internal:8088}
|
PUBLIC_WEBHOOK_BASE_URL: ${PUBLIC_WEBHOOK_BASE_URL:-http://host.docker.internal:8088}
|
||||||
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://127.0.0.1:8088}
|
APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://127.0.0.1:8088}
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
@@ -41,6 +60,8 @@ services:
|
|||||||
SMTP_USER: ${SMTP_USER:-}
|
SMTP_USER: ${SMTP_USER:-}
|
||||||
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
|
||||||
SMTP_FROM: ${SMTP_FROM:-}
|
SMTP_FROM: ${SMTP_FROM:-}
|
||||||
|
EMAIL_CREDENTIAL_KEY: ${EMAIL_CREDENTIAL_KEY:-}
|
||||||
|
EMAIL_POLL_MS: ${EMAIL_POLL_MS:-30000}
|
||||||
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
||||||
STRIPE_PRICE_LITE: ${STRIPE_PRICE_LITE:-}
|
STRIPE_PRICE_LITE: ${STRIPE_PRICE_LITE:-}
|
||||||
STRIPE_PRICE_STANDARD: ${STRIPE_PRICE_STANDARD:-}
|
STRIPE_PRICE_STANDARD: ${STRIPE_PRICE_STANDARD:-}
|
||||||
@@ -54,6 +75,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
whatsapp-gateway:
|
||||||
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3100/health/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3100/health/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -75,3 +98,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
lingxi_pgdata:
|
lingxi_pgdata:
|
||||||
|
lingxi_whatsapp_data:
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
FROM lingxi-greenmail-test:2.1.8
|
||||||
|
|
||||||
|
ENTRYPOINT ["java"]
|
||||||
|
CMD ["-Dgreenmail.setup.test.smtp", "-Dgreenmail.setup.test.imap", "-Dgreenmail.hostname=0.0.0.0", "-Dgreenmail.users.login=email", "-Dgreenmail.users=sender:pwd@local.test,receiver:pwd@local.test", "-jar", "/tmp/greenmail.jar"]
|
||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "灵犀跨境 - Telegram 跨境 AI 客户经营平台",
|
"description": "灵犀跨境 - Telegram、WhatsApp 与企业邮箱 AI 客户经营平台",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
|
|||||||
@@ -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 |
Generated
+487
@@ -12,7 +12,9 @@
|
|||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
|
"imapflow": "^1.6.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"mailparser": "^3.9.14",
|
||||||
"mammoth": "^1.9.0",
|
"mammoth": "^1.9.0",
|
||||||
"nodemailer": "^7.0.5",
|
"nodemailer": "^7.0.5",
|
||||||
"pdf-parse": "^1.1.1",
|
"pdf-parse": "^1.1.1",
|
||||||
@@ -20,6 +22,28 @@
|
|||||||
"ws": "^8.18.3"
|
"ws": "^8.18.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@pinojs/redact": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@selderee/plugin-htmlparser2": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "~2.3.0",
|
||||||
|
"domhandler": "~5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/KillyMXI"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"selderee": "~0.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@xmldom/xmldom": {
|
"node_modules/@xmldom/xmldom": {
|
||||||
"version": "0.8.13",
|
"version": "0.8.13",
|
||||||
"resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
|
"resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
|
||||||
@@ -29,6 +53,17 @@
|
|||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@zone-eu/mailsplit": {
|
||||||
|
"version": "5.4.14",
|
||||||
|
"resolved": "https://registry.npmmirror.com/@zone-eu/mailsplit/-/mailsplit-5.4.14.tgz",
|
||||||
|
"integrity": "sha512-rz0FQOhN3Vq1XrSeSSa9+dPcaFbBxmQPjiZm6zS9oxdVHV7rOWIAYX3yP2YAUf0qBncY8CI+NogzPCmMVrMXcw==",
|
||||||
|
"license": "(MIT OR EUPL-1.1+)",
|
||||||
|
"dependencies": {
|
||||||
|
"libbase64": "1.3.0",
|
||||||
|
"libmime": "5.4.1",
|
||||||
|
"libqp": "2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/accepts": {
|
"node_modules/accepts": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/accepts/-/accepts-2.0.0.tgz",
|
||||||
@@ -51,6 +86,15 @@
|
|||||||
"sprintf-js": "~1.0.2"
|
"sprintf-js": "~1.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/atomic-sleep": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/base64-js": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
@@ -244,6 +288,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/deepmerge-ts": {
|
||||||
|
"version": "7.1.5",
|
||||||
|
"resolved": "https://registry.npmmirror.com/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
|
||||||
|
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -259,6 +312,61 @@
|
|||||||
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
|
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
|
||||||
"license": "BSD-2-Clause"
|
"license": "BSD-2-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-serializer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"entities": "^4.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domelementtype": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/domhandler": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/domhandler/-/domhandler-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domutils": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/domutils/-/domutils-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/duck": {
|
"node_modules/duck": {
|
||||||
"version": "0.1.12",
|
"version": "0.1.12",
|
||||||
"resolved": "https://registry.npmmirror.com/duck/-/duck-0.1.12.tgz",
|
"resolved": "https://registry.npmmirror.com/duck/-/duck-0.1.12.tgz",
|
||||||
@@ -306,6 +414,27 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/encoding-japanese": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/encoding-japanese/-/encoding-japanese-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-define-property": {
|
"node_modules/es-define-property": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
@@ -515,6 +644,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/he": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"he": "bin/he"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/helmet": {
|
"node_modules/helmet": {
|
||||||
"version": "8.3.0",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/helmet/-/helmet-8.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/helmet/-/helmet-8.3.0.tgz",
|
||||||
@@ -527,6 +665,56 @@
|
|||||||
"url": "https://github.com/sponsors/EvanHahn"
|
"url": "https://github.com/sponsors/EvanHahn"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-to-text": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/html-to-text/-/html-to-text-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-2OH59Gtprdczel+7Rxgpz9hGVJREaf8Lt1H4kZwWHpEn70VQKRuMNGsb2eDbwaTzrYzb0hheiOG1P7Dim0B4dQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@selderee/plugin-htmlparser2": "~0.12.0",
|
||||||
|
"deepmerge-ts": "^7.1.5",
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"htmlparser2": "^10.1.0",
|
||||||
|
"selderee": "~0.12.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/KillyMXI"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/htmlparser2": {
|
||||||
|
"version": "10.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/htmlparser2/-/htmlparser2-10.1.0.tgz",
|
||||||
|
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.2.2",
|
||||||
|
"entities": "^7.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/htmlparser2/node_modules/entities": {
|
||||||
|
"version": "7.0.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
|
||||||
|
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
@@ -563,6 +751,22 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/imapflow": {
|
||||||
|
"version": "1.6.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/imapflow/-/imapflow-1.6.1.tgz",
|
||||||
|
"integrity": "sha512-lBkh0ZcKeYHDS3sWfxEcD7j24frrrn4wtbe0UUtLqdLwMSvm9Mygx8j7ZtzWgnvqI7V1DkNfcsidJ/qasb7FBA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@zone-eu/mailsplit": "5.4.14",
|
||||||
|
"encoding-japanese": "2.2.0",
|
||||||
|
"iconv-lite": "0.7.3",
|
||||||
|
"libbase64": "1.3.0",
|
||||||
|
"libmime": "5.4.1",
|
||||||
|
"libqp": "2.1.1",
|
||||||
|
"pino": "10.3.1",
|
||||||
|
"socks": "2.8.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/immediate": {
|
"node_modules/immediate": {
|
||||||
"version": "3.0.6",
|
"version": "3.0.6",
|
||||||
"resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz",
|
"resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz",
|
||||||
@@ -575,6 +779,15 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ip-address": {
|
||||||
|
"version": "10.3.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/ip-address/-/ip-address-10.3.1.tgz",
|
||||||
|
"integrity": "sha512-1e9d3kb97NHJTIJDZW9rKqW2h6+dFa50Dy0fpPSMQp2ADje5gvKsXmdiK6dwY5t76TaTt5+P5N1Y/LoToIxP6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -651,6 +864,39 @@
|
|||||||
"safe-buffer": "^5.0.1"
|
"safe-buffer": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leac": {
|
||||||
|
"version": "0.7.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/leac/-/leac-0.7.0.tgz",
|
||||||
|
"integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/KillyMXI"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/libbase64": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/libbase64/-/libbase64-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/libmime": {
|
||||||
|
"version": "5.4.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/libmime/-/libmime-5.4.1.tgz",
|
||||||
|
"integrity": "sha512-0wHGhsofo9IdQPenr3BBHXuxcwMq4atFUTsZ9Ogc1OvI5h4rUdDIrBQEN9JHjCXfDMrE59LUMJWsTD82wTYk8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"encoding-japanese": "2.2.0",
|
||||||
|
"iconv-lite": "0.7.3",
|
||||||
|
"libbase64": "1.3.0",
|
||||||
|
"libqp": "2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/libqp": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/libqp/-/libqp-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lie": {
|
"node_modules/lie": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",
|
||||||
@@ -660,6 +906,25 @@
|
|||||||
"immediate": "~3.0.5"
|
"immediate": "~3.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/linkify-it": {
|
||||||
|
"version": "5.0.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/linkify-it/-/linkify-it-5.0.2.tgz",
|
||||||
|
"integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/markdown-it"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"uc.micro": "^2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lodash.includes": {
|
"node_modules/lodash.includes": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/lodash.includes/-/lodash.includes-4.3.0.tgz",
|
||||||
@@ -713,6 +978,33 @@
|
|||||||
"underscore": "^1.13.1"
|
"underscore": "^1.13.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mailparser": {
|
||||||
|
"version": "3.9.14",
|
||||||
|
"resolved": "https://registry.npmmirror.com/mailparser/-/mailparser-3.9.14.tgz",
|
||||||
|
"integrity": "sha512-3QD6TRXcyXtq2NCuyA2AEjqmallQkyxYmZI9GMCIvQDCaB9Uc034WUI1x8RUBYFnk5+p7h14JEz4O/lrxQmttw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@zone-eu/mailsplit": "5.4.14",
|
||||||
|
"encoding-japanese": "2.2.0",
|
||||||
|
"he": "1.2.0",
|
||||||
|
"html-to-text": "10.0.0",
|
||||||
|
"iconv-lite": "0.7.3",
|
||||||
|
"libmime": "5.4.1",
|
||||||
|
"linkify-it": "5.0.2",
|
||||||
|
"nodemailer": "9.0.3",
|
||||||
|
"punycode.js": "2.3.1",
|
||||||
|
"tlds": "1.261.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mailparser/node_modules/nodemailer": {
|
||||||
|
"version": "9.0.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/nodemailer/-/nodemailer-9.0.3.tgz",
|
||||||
|
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mammoth": {
|
"node_modules/mammoth": {
|
||||||
"version": "1.12.0",
|
"version": "1.12.0",
|
||||||
"resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.0.tgz",
|
"resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.0.tgz",
|
||||||
@@ -847,6 +1139,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/on-exit-leak-free": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/on-finished": {
|
"node_modules/on-finished": {
|
||||||
"version": "2.4.1",
|
"version": "2.4.1",
|
||||||
"resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz",
|
"resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz",
|
||||||
@@ -880,6 +1181,19 @@
|
|||||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||||
"license": "(MIT AND Zlib)"
|
"license": "(MIT AND Zlib)"
|
||||||
},
|
},
|
||||||
|
"node_modules/parseley": {
|
||||||
|
"version": "0.13.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/parseley/-/parseley-0.13.1.tgz",
|
||||||
|
"integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"leac": "^0.7.0",
|
||||||
|
"peberminta": "^0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/KillyMXI"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parseurl": {
|
"node_modules/parseurl": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
|
"resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
|
||||||
@@ -924,6 +1238,15 @@
|
|||||||
"url": "https://github.com/sponsors/mehmet-kozan"
|
"url": "https://github.com/sponsors/mehmet-kozan"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/peberminta": {
|
||||||
|
"version": "0.10.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/peberminta/-/peberminta-0.10.0.tgz",
|
||||||
|
"integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/KillyMXI"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pg": {
|
"node_modules/pg": {
|
||||||
"version": "8.22.0",
|
"version": "8.22.0",
|
||||||
"resolved": "https://registry.npmmirror.com/pg/-/pg-8.22.0.tgz",
|
"resolved": "https://registry.npmmirror.com/pg/-/pg-8.22.0.tgz",
|
||||||
@@ -1013,6 +1336,43 @@
|
|||||||
"split2": "^4.1.0"
|
"split2": "^4.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pino": {
|
||||||
|
"version": "10.3.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pino/-/pino-10.3.1.tgz",
|
||||||
|
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@pinojs/redact": "^0.4.0",
|
||||||
|
"atomic-sleep": "^1.0.0",
|
||||||
|
"on-exit-leak-free": "^2.1.0",
|
||||||
|
"pino-abstract-transport": "^3.0.0",
|
||||||
|
"pino-std-serializers": "^7.0.0",
|
||||||
|
"process-warning": "^5.0.0",
|
||||||
|
"quick-format-unescaped": "^4.0.3",
|
||||||
|
"real-require": "^0.2.0",
|
||||||
|
"safe-stable-stringify": "^2.3.1",
|
||||||
|
"sonic-boom": "^4.0.1",
|
||||||
|
"thread-stream": "^4.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pino": "bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pino-abstract-transport": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pino-std-serializers": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/postgres-array": {
|
"node_modules/postgres-array": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/postgres-array/-/postgres-array-2.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
@@ -1058,6 +1418,22 @@
|
|||||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/process-warning": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/process-warning/-/process-warning-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
@@ -1071,6 +1447,15 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/punycode.js": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.3",
|
"version": "6.15.3",
|
||||||
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz",
|
"resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.3.tgz",
|
||||||
@@ -1087,6 +1472,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/quick-format-unescaped": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/range-parser": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.3.0.tgz",
|
||||||
@@ -1136,6 +1527,15 @@
|
|||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/real-require": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/real-require/-/real-require-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/router": {
|
"node_modules/router": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/router/-/router-2.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/router/-/router-2.2.0.tgz",
|
||||||
@@ -1172,12 +1572,33 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-stable-stringify": {
|
||||||
|
"version": "2.5.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||||
|
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/safer-buffer": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/selderee": {
|
||||||
|
"version": "0.12.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/selderee/-/selderee-0.12.0.tgz",
|
||||||
|
"integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"parseley": "~0.13.1"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/KillyMXI"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "7.8.5",
|
"version": "7.8.5",
|
||||||
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
|
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.8.5.tgz",
|
||||||
@@ -1319,6 +1740,39 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/smart-buffer": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/smart-buffer/-/smart-buffer-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0",
|
||||||
|
"npm": ">= 3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socks": {
|
||||||
|
"version": "2.8.9",
|
||||||
|
"resolved": "https://registry.npmmirror.com/socks/-/socks-2.8.9.tgz",
|
||||||
|
"integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ip-address": "^10.1.1",
|
||||||
|
"smart-buffer": "^4.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0",
|
||||||
|
"npm": ">= 3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sonic-boom": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"atomic-sleep": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/split2": {
|
"node_modules/split2": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz",
|
"resolved": "https://registry.npmmirror.com/split2/-/split2-4.2.0.tgz",
|
||||||
@@ -1358,6 +1812,33 @@
|
|||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/thread-stream": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"real-require": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/thread-stream/node_modules/real-require": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/real-require/-/real-require-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/tlds": {
|
||||||
|
"version": "1.261.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/tlds/-/tlds-1.261.0.tgz",
|
||||||
|
"integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"tlds": "bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/toidentifier": {
|
"node_modules/toidentifier": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||||
@@ -1398,6 +1879,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uc.micro": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/underscore": {
|
"node_modules/underscore": {
|
||||||
"version": "1.13.8",
|
"version": "1.13.8",
|
||||||
"resolved": "https://registry.npmmirror.com/underscore/-/underscore-1.13.8.tgz",
|
"resolved": "https://registry.npmmirror.com/underscore/-/underscore-1.13.8.tgz",
|
||||||
|
|||||||
+7
-3
@@ -5,18 +5,22 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/index.js",
|
"start": "node src/index.js",
|
||||||
"test": "node --test test/business.test.js"
|
"test": "node --test test/business.test.js",
|
||||||
|
"test:email-protocol": "node --test test/email-protocol.test.js",
|
||||||
|
"test:email-api": "node --test test/email-api-e2e.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
|
"imapflow": "^1.6.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"mailparser": "^3.9.14",
|
||||||
"mammoth": "^1.9.0",
|
"mammoth": "^1.9.0",
|
||||||
"nodemailer": "^7.0.5",
|
"nodemailer": "^7.0.5",
|
||||||
"pdf-parse": "^1.1.1",
|
"pdf-parse": "^1.1.1",
|
||||||
"pg": "^8.13.1"
|
"pg": "^8.13.1",
|
||||||
,"ws": "^8.18.3"
|
"ws": "^8.18.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-25
@@ -10,32 +10,48 @@ function parseJson(text) {
|
|||||||
export async function deepseek(system, user, json = false) {
|
export async function deepseek(system, user, json = false) {
|
||||||
const apiKey = process.env.DEEPSEEK_API_KEY
|
const apiKey = process.env.DEEPSEEK_API_KEY
|
||||||
if (!apiKey) throw Object.assign(new Error('DEEPSEEK_API_KEY 尚未配置'), { status: 503 })
|
if (!apiKey) throw Object.assign(new Error('DEEPSEEK_API_KEY 尚未配置'), { status: 503 })
|
||||||
const controller = new AbortController()
|
const endpoint = `${(process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/anthropic').replace(/\/$/, '')}/v1/messages`
|
||||||
const timeout = setTimeout(() => controller.abort(), Number(process.env.AI_TIMEOUT_MS || 45000))
|
const attempts = json ? 2 : 1
|
||||||
try {
|
let prompt = user
|
||||||
const response = await fetch(`${(process.env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com/anthropic').replace(/\/$/, '')}/v1/messages`, {
|
let lastError
|
||||||
method: 'POST',
|
|
||||||
signal: controller.signal,
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||||
headers: {
|
const controller = new AbortController()
|
||||||
'content-type': 'application/json',
|
const timeout = setTimeout(() => controller.abort(), Number(process.env.AI_TIMEOUT_MS || 45000))
|
||||||
'x-api-key': apiKey,
|
try {
|
||||||
'anthropic-version': '2023-06-01',
|
const response = await fetch(endpoint, {
|
||||||
},
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
signal: controller.signal,
|
||||||
model: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash',
|
headers: {
|
||||||
max_tokens: Number(process.env.AI_MAX_TOKENS || 4096),
|
'content-type': 'application/json',
|
||||||
temperature: json ? 0.1 : 0.3,
|
'x-api-key': apiKey,
|
||||||
system,
|
'anthropic-version': '2023-06-01',
|
||||||
messages: [{ role: 'user', content: [{ type: 'text', text: user }] }],
|
},
|
||||||
}),
|
body: JSON.stringify({
|
||||||
})
|
model: process.env.DEEPSEEK_MODEL || 'deepseek-v4-flash',
|
||||||
const body = await response.json()
|
max_tokens: Number(process.env.AI_MAX_TOKENS || 4096),
|
||||||
if (!response.ok) throw Object.assign(new Error(body?.error?.message || `AI 请求失败 (${response.status})`), { status: 502 })
|
temperature: json ? 0 : 0.3,
|
||||||
const text = contentText(body)
|
system,
|
||||||
return json ? parseJson(text) : text
|
messages: [{ role: 'user', content: [{ type: 'text', text: prompt }] }],
|
||||||
} finally {
|
}),
|
||||||
clearTimeout(timeout)
|
})
|
||||||
|
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) {
|
export function conversationContext(messages) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2370
-72
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
|||||||
|
const base = () => (process.env.WHATSAPP_GATEWAY_URL || 'http://whatsapp-gateway:3200').replace(/\/$/, '')
|
||||||
|
|
||||||
|
export async function whatsappRequest(path, options = {}) {
|
||||||
|
const token = String(process.env.WHATSAPP_GATEWAY_TOKEN || '')
|
||||||
|
if (token.length < 24) {
|
||||||
|
throw Object.assign(new Error('WhatsApp 网关令牌尚未配置'), { status: 503 })
|
||||||
|
}
|
||||||
|
const response = await fetch(`${base()}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
...(options.body instanceof FormData ? {} : { 'content-type': 'application/json' }),
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const raw = await response.text()
|
||||||
|
let result
|
||||||
|
try { result = raw ? JSON.parse(raw) : null } catch { result = raw }
|
||||||
|
if (!response.ok) {
|
||||||
|
throw Object.assign(
|
||||||
|
new Error(result?.message || `WhatsApp 网关请求失败 (${response.status})`),
|
||||||
|
{ status: response.status, data: result?.data },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return result?.data ?? result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function postWhatsApp(path, body) {
|
||||||
|
return whatsappRequest(path, { method: 'POST', body: JSON.stringify(body) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function putWhatsApp(path, body) {
|
||||||
|
return whatsappRequest(path, { method: 'PUT', body: JSON.stringify(body) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadWhatsApp(path, formData) {
|
||||||
|
return whatsappRequest(path, { method: 'POST', body: formData })
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import assert from 'node:assert/strict'
|
import assert from 'node:assert/strict'
|
||||||
import { countBy, dateKey, percent, telegramAttachment } from '../src/business.js'
|
import { countBy, dateKey, percent, telegramAttachment } from '../src/business.js'
|
||||||
|
import { decryptEmailPassword, encryptEmailPassword } from '../src/email.js'
|
||||||
|
|
||||||
test('Telegram 图片、文档、语音和贴纸映射为可下载文件', () => {
|
test('Telegram 图片、文档、语音和贴纸映射为可下载文件', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -42,3 +43,14 @@ test('业务指标工具处理百分比、分组和无效日期', () => {
|
|||||||
{ new: 2, won: 1 },
|
{ new: 2, won: 1 },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Email 渠道凭据使用认证加密且可正确解密', () => {
|
||||||
|
process.env.EMAIL_CREDENTIAL_KEY = 'lingxi-email-test-key-with-more-than-32-bytes'
|
||||||
|
const encrypted = encryptEmailPassword('app-password-123')
|
||||||
|
assert.notEqual(encrypted, 'app-password-123')
|
||||||
|
assert.match(encrypted, /^v1:/)
|
||||||
|
assert.equal(decryptEmailPassword(encrypted), 'app-password-123')
|
||||||
|
const tampered = encrypted.split(':')
|
||||||
|
tampered[2] = `${tampered[2][0] === 'A' ? 'B' : 'A'}${tampered[2].slice(1)}`
|
||||||
|
assert.throws(() => decryptEmailPassword(tampered.join(':')))
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import {
|
||||||
|
encryptEmailPassword,
|
||||||
|
fetchEmailMessages,
|
||||||
|
sendEmailMessage,
|
||||||
|
} from '../src/email.js'
|
||||||
|
|
||||||
|
const enabled = process.env.RUN_EMAIL_API_E2E === '1'
|
||||||
|
const baseUrl = process.env.EMAIL_API_E2E_BASE_URL || 'http://127.0.0.1:8088'
|
||||||
|
|
||||||
|
function localChannel(account) {
|
||||||
|
return {
|
||||||
|
account,
|
||||||
|
encryptedPassword: encryptEmailPassword('pwd'),
|
||||||
|
imapHost: '127.0.0.1',
|
||||||
|
imapPort: 3143,
|
||||||
|
imapSecure: false,
|
||||||
|
imapFolder: 'INBOX',
|
||||||
|
smtpHost: '127.0.0.1',
|
||||||
|
smtpPort: 3025,
|
||||||
|
smtpSecure: false,
|
||||||
|
syncDays: 7,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrap(payload) {
|
||||||
|
return payload && typeof payload === 'object' && 'data' in payload ? payload.data : payload
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path, { token, method = 'GET', body } = {}) {
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
})
|
||||||
|
const text = await response.text()
|
||||||
|
const payload = text ? JSON.parse(text) : null
|
||||||
|
if (!response.ok) throw new Error(`${method} ${path} -> ${response.status}: ${payload?.message || text}`)
|
||||||
|
return unwrap(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForSenderMessage(predicate) {
|
||||||
|
const sender = localChannel('sender@local.test')
|
||||||
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||||
|
const result = await fetchEmailMessages(sender, { full: true })
|
||||||
|
const message = result.messages.find(predicate)
|
||||||
|
if (message) return message
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||||
|
}
|
||||||
|
throw new Error('等待网关发出的邮件进入 sender IMAP 超时')
|
||||||
|
}
|
||||||
|
|
||||||
|
test('真实 HTTP 网关完成 Email 连接、全量同步、附件读取、回复与已读', { skip: !enabled }, async () => {
|
||||||
|
process.env.JWT_SECRET ||= 'lingxi-email-api-e2e-secret'
|
||||||
|
const suffix = Date.now()
|
||||||
|
const senderChannel = localChannel('sender@local.test')
|
||||||
|
const olderSubject = `Lingxi sorting older ${suffix}`
|
||||||
|
const newerSubject = `Lingxi sorting newer ${suffix}`
|
||||||
|
await sendEmailMessage(senderChannel, {
|
||||||
|
to: 'receiver@local.test',
|
||||||
|
subject: olderSubject,
|
||||||
|
text: 'Older conversation used for stable ordering',
|
||||||
|
})
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 30))
|
||||||
|
await sendEmailMessage(senderChannel, {
|
||||||
|
to: 'receiver@local.test',
|
||||||
|
subject: newerSubject,
|
||||||
|
text: 'Newer conversation must remain above the opened older conversation',
|
||||||
|
})
|
||||||
|
|
||||||
|
const session = await request('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
name: 'Email API E2E',
|
||||||
|
account: `email-api-${suffix}@local.test`,
|
||||||
|
password: 'Lingxi-Email-Api-2026!',
|
||||||
|
company: 'Lingxi Protocol Lab',
|
||||||
|
industry: '跨境电商',
|
||||||
|
teamSize: '1-10人',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.ok(session.token)
|
||||||
|
|
||||||
|
const connected = await request('/api/channels/email/connect', {
|
||||||
|
token: session.token,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
name: '本地协议邮箱',
|
||||||
|
displayName: 'Lingxi Receiver',
|
||||||
|
account: 'receiver@local.test',
|
||||||
|
password: 'pwd',
|
||||||
|
imapHost: 'host.docker.internal',
|
||||||
|
imapPort: 3143,
|
||||||
|
imapSecure: false,
|
||||||
|
imapFolder: 'INBOX',
|
||||||
|
smtpHost: 'host.docker.internal',
|
||||||
|
smtpPort: 3025,
|
||||||
|
smtpSecure: false,
|
||||||
|
syncDays: 7,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.equal(connected.status, 'connected')
|
||||||
|
assert.equal(connected.encryptedPassword, undefined)
|
||||||
|
|
||||||
|
const conversations = await request('/api/conversations?channel=email', { token: session.token })
|
||||||
|
assert.ok(conversations.list.length >= 2)
|
||||||
|
const newerConversation = conversations.list.find((item) => item.emailSubject === newerSubject)
|
||||||
|
const olderConversation = conversations.list.find((item) => item.emailSubject === olderSubject)
|
||||||
|
assert.ok(newerConversation)
|
||||||
|
assert.ok(olderConversation)
|
||||||
|
assert.ok(conversations.list.indexOf(newerConversation) < conversations.list.indexOf(olderConversation))
|
||||||
|
|
||||||
|
const olderMessages = await request(`/api/conversations/${olderConversation.id}/messages`, { token: session.token })
|
||||||
|
await request(`/api/conversations/${olderConversation.id}/read`, {
|
||||||
|
token: session.token,
|
||||||
|
method: 'POST',
|
||||||
|
body: { messageIds: olderMessages.map((message) => message.id) },
|
||||||
|
})
|
||||||
|
const afterRead = await request('/api/conversations?channel=email', { token: session.token })
|
||||||
|
const relevantOrder = afterRead.list
|
||||||
|
.filter((item) => [newerConversation.id, olderConversation.id].includes(item.id))
|
||||||
|
.map((item) => item.id)
|
||||||
|
assert.deepEqual(relevantOrder, [newerConversation.id, olderConversation.id])
|
||||||
|
|
||||||
|
let conversation
|
||||||
|
let incoming
|
||||||
|
for (const candidate of conversations.list.filter((item) => item.customer?.email === 'sender@local.test')) {
|
||||||
|
const candidateMessages = await request(`/api/conversations/${candidate.id}/messages`, { token: session.token })
|
||||||
|
const candidateIncoming = candidateMessages.find((message) => message.sender === 'customer' && message.emailAttachments?.length)
|
||||||
|
if (candidateIncoming) {
|
||||||
|
conversation = candidate
|
||||||
|
incoming = candidateIncoming
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.ok(conversation)
|
||||||
|
assert.ok(incoming)
|
||||||
|
const attachment = await request(
|
||||||
|
`/api/conversations/${conversation.id}/messages/${incoming.id}/attachment?attachmentId=${encodeURIComponent(incoming.emailAttachments[0].id)}`,
|
||||||
|
{ token: session.token },
|
||||||
|
)
|
||||||
|
assert.equal(attachment.name, 'lingxi-proof.txt')
|
||||||
|
assert.ok(attachment.dataUrl.startsWith('data:text/plain;base64,'))
|
||||||
|
|
||||||
|
const replyText = `Lingxi gateway API reply ${suffix}`
|
||||||
|
const sent = await request(`/api/conversations/${conversation.id}/messages`, {
|
||||||
|
token: session.token,
|
||||||
|
method: 'POST',
|
||||||
|
body: { text: replyText, autoTranslate: false },
|
||||||
|
})
|
||||||
|
assert.equal(sent.status, 'sent')
|
||||||
|
assert.ok(sent.emailMessageId)
|
||||||
|
|
||||||
|
const receivedBySender = await waitForSenderMessage((message) => message.messageId === sent.emailMessageId)
|
||||||
|
assert.equal(receivedBySender.text, replyText)
|
||||||
|
assert.ok(receivedBySender.inReplyTo)
|
||||||
|
|
||||||
|
await request(`/api/conversations/${conversation.id}/read`, {
|
||||||
|
token: session.token,
|
||||||
|
method: 'POST',
|
||||||
|
body: { messageIds: [incoming.id] },
|
||||||
|
})
|
||||||
|
const receiver = await fetchEmailMessages(localChannel('receiver@local.test'), { full: true })
|
||||||
|
assert.equal(receiver.messages.find((message) => message.uid === incoming.emailUid)?.seen, true)
|
||||||
|
})
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
import {
|
||||||
|
encryptEmailPassword,
|
||||||
|
fetchEmailMessages,
|
||||||
|
markEmailUidsSeen,
|
||||||
|
sendEmailMessage,
|
||||||
|
verifyEmailConnection,
|
||||||
|
} from '../src/email.js'
|
||||||
|
|
||||||
|
const enabled = process.env.RUN_EMAIL_PROTOCOL_TEST === '1'
|
||||||
|
|
||||||
|
function channel(account) {
|
||||||
|
return {
|
||||||
|
id: `protocol-${account}`,
|
||||||
|
type: 'email',
|
||||||
|
account,
|
||||||
|
displayName: account.startsWith('sender') ? 'Lingxi Sender' : 'Lingxi Receiver',
|
||||||
|
encryptedPassword: encryptEmailPassword('pwd'),
|
||||||
|
imapHost: process.env.EMAIL_TEST_HOST || '127.0.0.1',
|
||||||
|
imapPort: Number(process.env.EMAIL_TEST_IMAP_PORT || 3143),
|
||||||
|
imapSecure: false,
|
||||||
|
imapFolder: 'INBOX',
|
||||||
|
smtpHost: process.env.EMAIL_TEST_HOST || '127.0.0.1',
|
||||||
|
smtpPort: Number(process.env.EMAIL_TEST_SMTP_PORT || 3025),
|
||||||
|
smtpSecure: false,
|
||||||
|
syncDays: 7,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForMessage(target, predicate) {
|
||||||
|
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||||
|
const result = await fetchEmailMessages(target, { full: true })
|
||||||
|
const message = result.messages.find(predicate)
|
||||||
|
if (message) return { message, result }
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||||
|
}
|
||||||
|
throw new Error('等待邮件进入 IMAP INBOX 超时')
|
||||||
|
}
|
||||||
|
|
||||||
|
test('真实 SMTP 发件、IMAP 收件、附件解析、已读与回复头', { skip: !enabled }, async () => {
|
||||||
|
process.env.JWT_SECRET ||= 'lingxi-email-protocol-test-secret'
|
||||||
|
const sender = channel('sender@local.test')
|
||||||
|
const receiver = channel('receiver@local.test')
|
||||||
|
const subject = `Lingxi protocol ${Date.now()}`
|
||||||
|
|
||||||
|
const senderCheck = await verifyEmailConnection(sender, 'pwd')
|
||||||
|
const receiverCheck = await verifyEmailConnection(receiver, 'pwd')
|
||||||
|
assert.ok(senderCheck.latencyMs >= 0)
|
||||||
|
assert.ok(receiverCheck.latencyMs >= 0)
|
||||||
|
assert.ok(!sender.encryptedPassword.includes('pwd'))
|
||||||
|
|
||||||
|
const sent = await sendEmailMessage(sender, {
|
||||||
|
to: receiver.account,
|
||||||
|
subject,
|
||||||
|
text: 'Lingxi IMAP/SMTP real protocol message',
|
||||||
|
attachments: [{
|
||||||
|
filename: 'lingxi-proof.txt',
|
||||||
|
content: Buffer.from('real email attachment'),
|
||||||
|
contentType: 'text/plain',
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
assert.ok(sent.messageId)
|
||||||
|
|
||||||
|
const received = await waitForMessage(receiver, (message) => message.subject === subject)
|
||||||
|
assert.equal(received.message.text, 'Lingxi IMAP/SMTP real protocol message')
|
||||||
|
assert.equal(received.message.from.email, sender.account)
|
||||||
|
assert.equal(received.message.attachments.length, 1)
|
||||||
|
assert.equal(received.message.attachments[0].name, 'lingxi-proof.txt')
|
||||||
|
assert.equal(Buffer.from(received.message.attachments[0].base64, 'base64').toString(), 'real email attachment')
|
||||||
|
|
||||||
|
await markEmailUidsSeen(receiver, [received.message.uid])
|
||||||
|
const marked = await fetchEmailMessages(receiver, { full: true })
|
||||||
|
assert.equal(marked.messages.find((message) => message.uid === received.message.uid)?.seen, true)
|
||||||
|
|
||||||
|
const reply = await sendEmailMessage(receiver, {
|
||||||
|
to: sender.account,
|
||||||
|
subject: `Re: ${subject}`,
|
||||||
|
text: 'Reply received by Lingxi',
|
||||||
|
inReplyTo: received.message.messageId,
|
||||||
|
references: [received.message.messageId],
|
||||||
|
})
|
||||||
|
const replyReceived = await waitForMessage(sender, (message) => message.messageId === reply.messageId)
|
||||||
|
assert.equal(replyReceived.message.inReplyTo, received.message.messageId)
|
||||||
|
assert.ok(replyReceived.message.references.includes(received.message.messageId))
|
||||||
|
})
|
||||||
+70
-37
@@ -1,43 +1,76 @@
|
|||||||
import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom'
|
import { createBrowserRouter, Navigate, RouterProvider, useRouteError } from 'react-router-dom'
|
||||||
import { useAuthStore } from '@/app/store/auth'
|
import { isSessionTokenExpired, useAuthStore } from '@/app/store/auth'
|
||||||
import { AppLayout } from '@/components/layout/AppLayout'
|
import { AppLayout } from '@/components/layout/AppLayout'
|
||||||
import type { ReactNode } from 'react'
|
import { useEffect, type ReactNode } from 'react'
|
||||||
|
|
||||||
import { LoginPage } from '@/pages/auth/LoginPage'
|
import { LoginPage } from '@/pages/auth/LoginPage'
|
||||||
import { RegisterPage } from '@/pages/auth/RegisterPage'
|
import { RegisterPage } from '@/pages/auth/RegisterPage'
|
||||||
import { DashboardPage } from '@/pages/dashboard/DashboardPage'
|
|
||||||
import { InboxPage } from '@/pages/inbox/InboxPage'
|
|
||||||
import { CustomersPage } from '@/pages/customers/CustomersPage'
|
|
||||||
import { CustomerDetailPage } from '@/pages/customers/CustomerDetailPage'
|
|
||||||
import { OpportunitiesPage } from '@/pages/opportunities/OpportunitiesPage'
|
|
||||||
import { TranslatePage } from '@/pages/translate/TranslatePage'
|
|
||||||
import { RisksPage } from '@/pages/risks/RisksPage'
|
|
||||||
import { KnowledgePage } from '@/pages/knowledge/KnowledgePage'
|
|
||||||
import { AnalyticsPage } from '@/pages/analytics/AnalyticsPage'
|
|
||||||
import { ChannelsPage } from '@/pages/channels/ChannelsPage'
|
|
||||||
import { TeamPage } from '@/pages/team/TeamPage'
|
|
||||||
import { BillingPage } from '@/pages/billing/BillingPage'
|
|
||||||
import { SettingsPage } from '@/pages/settings/SettingsPage'
|
|
||||||
import { HelpPage } from '@/pages/help/HelpPage'
|
|
||||||
import { NotFoundPage } from '@/pages/NotFoundPage'
|
|
||||||
|
|
||||||
function RequireAuth({ children }: { children: ReactNode }) {
|
function RequireAuth({ children }: { children: ReactNode }) {
|
||||||
const token = useAuthStore((s) => s.token)
|
const token = useAuthStore((s) => s.token)
|
||||||
if (!token) return <Navigate to="/login" replace />
|
const logout = useAuthStore((s) => s.logout)
|
||||||
|
const expired = isSessionTokenExpired(token)
|
||||||
|
useEffect(() => {
|
||||||
|
if (token && expired) logout()
|
||||||
|
}, [expired, logout, token])
|
||||||
|
if (expired) return <Navigate to="/login?reason=session-expired" replace />
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
function GuestOnly({ children }: { children: ReactNode }) {
|
function GuestOnly({ children }: { children: ReactNode }) {
|
||||||
const token = useAuthStore((s) => s.token)
|
const token = useAuthStore((s) => s.token)
|
||||||
if (token) return <Navigate to="/dashboard" replace />
|
const logout = useAuthStore((s) => s.logout)
|
||||||
|
const expired = Boolean(token) && isSessionTokenExpired(token)
|
||||||
|
useEffect(() => {
|
||||||
|
if (expired) logout()
|
||||||
|
}, [expired, logout])
|
||||||
|
if (token && !expired) return <Navigate to="/dashboard" replace />
|
||||||
return <>{children}</>
|
return <>{children}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RouteErrorFallback() {
|
||||||
|
const error = useRouteError()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.error('Route render failed', error)
|
||||||
|
}, [error])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex min-h-screen items-center justify-center bg-slate-50 px-6">
|
||||||
|
<section className="w-full max-w-md rounded-3xl border border-slate-200 bg-white p-8 text-center shadow-xl shadow-slate-200/60">
|
||||||
|
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-blue-50 text-xl text-blue-600">
|
||||||
|
!
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-5 text-xl font-semibold tracking-tight text-slate-950">页面暂时无法显示</h1>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||||
|
当前页面遇到异常。你可以重新加载;如果问题仍然存在,请返回工作台后重试。
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="rounded-xl bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
重新加载
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
href="/dashboard"
|
||||||
|
className="rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
返回工作台
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{ path: '/login', element: <GuestOnly><LoginPage /></GuestOnly> },
|
{ path: '/login', element: <GuestOnly><LoginPage /></GuestOnly>, errorElement: <RouteErrorFallback /> },
|
||||||
{ path: '/register', element: <GuestOnly><RegisterPage /></GuestOnly> },
|
{ path: '/register', element: <GuestOnly><RegisterPage /></GuestOnly>, errorElement: <RouteErrorFallback /> },
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
|
errorElement: <RouteErrorFallback />,
|
||||||
element: (
|
element: (
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
<AppLayout />
|
<AppLayout />
|
||||||
@@ -45,21 +78,21 @@ const router = createBrowserRouter([
|
|||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
||||||
{ path: 'dashboard', element: <DashboardPage /> },
|
{ path: 'dashboard', lazy: async () => ({ Component: (await import('@/pages/dashboard/DashboardPage')).DashboardPage }) },
|
||||||
{ path: 'inbox', element: <InboxPage /> },
|
{ path: 'inbox', lazy: async () => ({ Component: (await import('@/pages/inbox/InboxPage')).InboxPage }) },
|
||||||
{ path: 'customers', element: <CustomersPage /> },
|
{ path: 'customers', lazy: async () => ({ Component: (await import('@/pages/customers/CustomersPage')).CustomersPage }) },
|
||||||
{ path: 'customers/:id', element: <CustomerDetailPage /> },
|
{ path: 'customers/:id', lazy: async () => ({ Component: (await import('@/pages/customers/CustomerDetailPage')).CustomerDetailPage }) },
|
||||||
{ path: 'opportunities', element: <OpportunitiesPage /> },
|
{ path: 'opportunities', lazy: async () => ({ Component: (await import('@/pages/opportunities/OpportunitiesPage')).OpportunitiesPage }) },
|
||||||
{ path: 'translate', element: <TranslatePage /> },
|
{ path: 'translate', lazy: async () => ({ Component: (await import('@/pages/translate/TranslatePage')).TranslatePage }) },
|
||||||
{ path: 'risks', element: <RisksPage /> },
|
{ path: 'risks', lazy: async () => ({ Component: (await import('@/pages/risks/RisksPage')).RisksPage }) },
|
||||||
{ path: 'knowledge', element: <KnowledgePage /> },
|
{ path: 'knowledge', lazy: async () => ({ Component: (await import('@/pages/knowledge/KnowledgePage')).KnowledgePage }) },
|
||||||
{ path: 'analytics', element: <AnalyticsPage /> },
|
{ path: 'analytics', lazy: async () => ({ Component: (await import('@/pages/analytics/AnalyticsPage')).AnalyticsPage }) },
|
||||||
{ path: 'channels', element: <ChannelsPage /> },
|
{ path: 'channels', lazy: async () => ({ Component: (await import('@/pages/channels/ChannelsPage')).ChannelsPage }) },
|
||||||
{ path: 'team', element: <TeamPage /> },
|
{ path: 'team', lazy: async () => ({ Component: (await import('@/pages/team/TeamPage')).TeamPage }) },
|
||||||
{ path: 'billing', element: <BillingPage /> },
|
{ path: 'billing', lazy: async () => ({ Component: (await import('@/pages/billing/BillingPage')).BillingPage }) },
|
||||||
{ path: 'settings', element: <SettingsPage /> },
|
{ path: 'settings', lazy: async () => ({ Component: (await import('@/pages/settings/SettingsPage')).SettingsPage }) },
|
||||||
{ path: 'help', element: <HelpPage /> },
|
{ path: 'help', lazy: async () => ({ Component: (await import('@/pages/help/HelpPage')).HelpPage }) },
|
||||||
{ path: '*', element: <NotFoundPage /> },
|
{ path: '*', lazy: async () => ({ Component: (await import('@/pages/NotFoundPage')).NotFoundPage }) },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
+21
-1
@@ -2,7 +2,7 @@ import { create } from 'zustand'
|
|||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
import type { AuthSession, Tenant, User } from '@/types'
|
import type { AuthSession, Tenant, User } from '@/types'
|
||||||
import type { RoleKey } from '@/config/roles'
|
import type { RoleKey } from '@/config/roles'
|
||||||
import { setTokenGetter } from '@/services/api/client'
|
import { setTokenGetter, setUnauthorizedHandler } from '@/services/api/client'
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
token: string | null
|
token: string | null
|
||||||
@@ -32,6 +32,26 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
|
|
||||||
// 让 API client 能读取 token(真实后端模式使用)
|
// 让 API client 能读取 token(真实后端模式使用)
|
||||||
setTokenGetter(() => useAuthStore.getState().token)
|
setTokenGetter(() => useAuthStore.getState().token)
|
||||||
|
setUnauthorizedHandler(() => {
|
||||||
|
useAuthStore.getState().logout()
|
||||||
|
if (!window.location.pathname.startsWith('/login')) {
|
||||||
|
window.location.replace('/login?reason=session-expired')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export function isSessionTokenExpired(token: string | null): boolean {
|
||||||
|
if (!token) return true
|
||||||
|
try {
|
||||||
|
const encoded = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||||
|
const padded = encoded.padEnd(Math.ceil(encoded.length / 4) * 4, '=')
|
||||||
|
const payload = JSON.parse(window.atob(padded)) as {
|
||||||
|
exp?: number
|
||||||
|
}
|
||||||
|
return !payload.exp || payload.exp * 1_000 <= Date.now()
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useCurrentRole(): RoleKey {
|
export function useCurrentRole(): RoleKey {
|
||||||
return useAuthStore((s) => s.user?.role ?? 'viewer')
|
return useAuthStore((s) => s.user?.role ?? 'viewer')
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { Card, CardHeader } from '@/components/ui/card'
|
|||||||
import { LiveDataBadge } from '@/components/common/LiveDataBadge'
|
import { LiveDataBadge } from '@/components/common/LiveDataBadge'
|
||||||
import { cn } from '@/utils/cn'
|
import { cn } from '@/utils/cn'
|
||||||
|
|
||||||
export const CHART_COLORS = ['#247b71', '#32998c', '#52b3a5', '#83cfc2', '#f59e0b', '#3b82f6', '#8b5cf6', '#94a3b8']
|
export const CHART_COLORS = ['#2563eb', '#3b82f6', '#60a5fa', '#8b5cf6', '#06b6d4', '#10b981', '#f59e0b', '#94a3b8']
|
||||||
|
|
||||||
export function ChartCard({
|
export function ChartCard({
|
||||||
title,
|
title,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function LiveDataBadge({ className, text = '实时数据' }: { className?
|
|||||||
'inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700',
|
'inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
title="指标由当前企业真实业务数据实时计算"
|
title="指标实时更新"
|
||||||
>
|
>
|
||||||
<Radio className="h-3 w-3" />
|
<Radio className="h-3 w-3" />
|
||||||
{text}
|
{text}
|
||||||
|
|||||||
@@ -27,17 +27,20 @@ export function StatCard({
|
|||||||
const DeltaIcon = neutral ? Minus : (delta ?? 0) > 0 ? TrendingUp : TrendingDown
|
const DeltaIcon = neutral ? Minus : (delta ?? 0) > 0 ? TrendingUp : TrendingDown
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={cn('p-4', onClick && 'cursor-pointer transition-shadow hover:shadow-md')}
|
className={cn(
|
||||||
|
'group p-5 transition-all duration-200',
|
||||||
|
onClick && 'cursor-pointer hover:-translate-y-0.5 hover:border-primary-200 hover:shadow-[0_14px_35px_rgba(15,23,42,.07)]',
|
||||||
|
)}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs text-muted-foreground">{title}</span>
|
<span className="text-xs font-medium text-slate-500">{title}</span>
|
||||||
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-primary-50">
|
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary-50 transition-colors group-hover:bg-primary-100">
|
||||||
<Icon className="h-4 w-4 text-primary-600" />
|
<Icon className="h-4 w-4 text-primary-600" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-baseline gap-1">
|
<div className="mt-3 flex items-baseline gap-1">
|
||||||
<span className="text-2xl font-semibold tracking-tight text-foreground">{value}</span>
|
<span className="text-[28px] font-semibold tracking-[-0.04em] text-slate-950">{value}</span>
|
||||||
{suffix && <span className="text-xs text-muted-foreground">{suffix}</span>}
|
{suffix && <span className="text-xs text-muted-foreground">{suffix}</span>}
|
||||||
</div>
|
</div>
|
||||||
{showDelta && (
|
{showDelta && (
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { useId } from 'react'
|
||||||
|
import {
|
||||||
|
PolarAngleAxis,
|
||||||
|
PolarGrid,
|
||||||
|
PolarRadiusAxis,
|
||||||
|
Radar,
|
||||||
|
RadarChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
} from 'recharts'
|
||||||
|
import { Sparkles, TrendingUp } from 'lucide-react'
|
||||||
|
import type { Customer } from '@/types'
|
||||||
|
import { cn } from '@/utils/cn'
|
||||||
|
|
||||||
|
function qualitativeScore(value?: string, fallback = 50) {
|
||||||
|
const text = String(value || '').toLowerCase()
|
||||||
|
if (!text) return fallback
|
||||||
|
if (/高|强|紧急|决策|老板|owner|director|high|urgent/.test(text)) return 86
|
||||||
|
if (/中|一般|参与|medium|normal/.test(text)) return 62
|
||||||
|
if (/低|弱|未知|助理|low|unknown/.test(text)) return 36
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function profileRadarData(customer: Customer) {
|
||||||
|
const profile = customer.aiProfile || {}
|
||||||
|
const extracted = [
|
||||||
|
profile.companyName,
|
||||||
|
profile.industry,
|
||||||
|
profile.businessModel,
|
||||||
|
profile.inquiryQuantity,
|
||||||
|
profile.targetPrice,
|
||||||
|
profile.budget,
|
||||||
|
profile.purchaseTimeline,
|
||||||
|
profile.payment,
|
||||||
|
].filter(Boolean).length
|
||||||
|
const fitScore = Math.min(96, 42 + extracted * 6 + (profile.keyNeeds?.length || 0) * 4)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ dimension: '购买意向', score: Math.max(0, Math.min(100, Number(customer.intentScore || 0))), hint: '基于询盘强度与购买信号' },
|
||||||
|
{ dimension: '成交可能', score: Number(profile.dealProbability || 0), hint: '根据当前客户资料和会话信号估算' },
|
||||||
|
{ dimension: '决策能力', score: qualitativeScore(profile.decisionPower), hint: profile.decisionPower || '尚待确认采购决策权' },
|
||||||
|
{ dimension: '需求紧迫', score: qualitativeScore(profile.urgency), hint: profile.urgency || '尚待确认采购时间表' },
|
||||||
|
{ dimension: '客户匹配', score: fitScore, hint: '画像字段、需求与产品匹配度' },
|
||||||
|
{ dimension: '关系健康', score: Math.max(0, 100 - Number(profile.churnProbability || 0)), hint: '流失风险的反向健康度' },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomerProfileRadar({
|
||||||
|
customer,
|
||||||
|
compact = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
customer: Customer
|
||||||
|
compact?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const data = profileRadarData(customer)
|
||||||
|
const average = Math.round(data.reduce((sum, item) => sum + item.score, 0) / data.length)
|
||||||
|
const chartId = useId().replace(/:/g, '')
|
||||||
|
const gradientId = `radar-fill-${chartId}`
|
||||||
|
const glowId = `radar-glow-${chartId}`
|
||||||
|
const level = average >= 75 ? '高潜客户' : average >= 55 ? '重点培育' : '持续观察'
|
||||||
|
const levelTone = average >= 75
|
||||||
|
? 'text-blue-700 bg-blue-50 border-blue-100'
|
||||||
|
: average >= 55
|
||||||
|
? 'text-violet-700 bg-violet-50 border-violet-100'
|
||||||
|
: 'text-amber-700 bg-amber-50 border-amber-100'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn(
|
||||||
|
'group relative isolate overflow-hidden rounded-2xl border border-blue-100/90 bg-white shadow-[0_18px_55px_-32px_rgba(37,99,235,.32)]',
|
||||||
|
className,
|
||||||
|
)}>
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-40 bg-[radial-gradient(circle_at_75%_0%,rgba(96,165,250,.16),transparent_58%)]" />
|
||||||
|
<div className="flex items-start justify-between gap-3 px-5 pt-5">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex h-8 w-8 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500 to-violet-600 text-white shadow-[0_8px_20px_-8px_rgba(37,99,235,.7)]">
|
||||||
|
<Sparkles className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold tracking-tight text-slate-950">客户价值雷达</p>
|
||||||
|
<p className="mt-0.5 text-[11px] text-slate-500">基于当前客户资料和会话信号</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={cn('mt-3 inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-[11px] font-semibold', levelTone)}>
|
||||||
|
<TrendingUp className="h-3 w-3" />
|
||||||
|
{level}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="relative flex h-[70px] w-[70px] shrink-0 items-center justify-center rounded-full p-[5px] shadow-[0_10px_30px_-16px_rgba(37,99,235,.7)]"
|
||||||
|
style={{ background: `conic-gradient(#2563eb ${average * 3.6}deg, #dbeafe 0deg)` }}
|
||||||
|
aria-label={`综合指数 ${average} 分`}
|
||||||
|
>
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center rounded-full border border-white bg-white shadow-inner">
|
||||||
|
<p className="text-[9px] font-semibold tracking-wider text-blue-600">综合指数</p>
|
||||||
|
<p className="text-2xl font-bold leading-6 tracking-tight text-slate-950">{average}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={cn(
|
||||||
|
'customer-radar-stage relative mx-3 mt-1 overflow-hidden rounded-2xl',
|
||||||
|
'bg-[radial-gradient(circle_at_center,rgba(239,246,255,.96)_0%,rgba(255,255,255,.45)_62%,transparent_72%)]',
|
||||||
|
compact ? 'h-[240px]' : 'h-[310px]',
|
||||||
|
)}>
|
||||||
|
<div className="pointer-events-none absolute left-1/2 top-1/2 h-44 w-44 -translate-x-1/2 -translate-y-1/2 rounded-full border border-blue-100/80 shadow-[0_0_60px_rgba(37,99,235,.10)]" />
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<RadarChart data={data} cx="50%" cy="52%" outerRadius={compact ? '66%' : '72%'}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stopColor="#60a5fa" stopOpacity="0.58" />
|
||||||
|
<stop offset="55%" stopColor="#3b82f6" stopOpacity="0.34" />
|
||||||
|
<stop offset="100%" stopColor="#8b5cf6" stopOpacity="0.22" />
|
||||||
|
</linearGradient>
|
||||||
|
<filter id={glowId} x="-40%" y="-40%" width="180%" height="180%">
|
||||||
|
<feGaussianBlur stdDeviation="4" result="blur" />
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur" />
|
||||||
|
<feMergeNode in="SourceGraphic" />
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<PolarGrid stroke="#dbeafe" strokeOpacity={0.95} gridType="polygon" />
|
||||||
|
<PolarAngleAxis dataKey="dimension" tick={{ fill: '#334155', fontSize: compact ? 10 : 12, fontWeight: 650 }} />
|
||||||
|
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={false} axisLine={false} />
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => [`${value} / 100`, '画像指数']}
|
||||||
|
labelFormatter={(label) => `${label} · 参考值`}
|
||||||
|
contentStyle={{
|
||||||
|
borderRadius: 14,
|
||||||
|
border: '1px solid #dbeafe',
|
||||||
|
boxShadow: '0 18px 40px rgba(37,99,235,.14)',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Radar
|
||||||
|
name="客户画像"
|
||||||
|
dataKey="score"
|
||||||
|
isAnimationActive={false}
|
||||||
|
stroke="#60a5fa"
|
||||||
|
strokeWidth={8}
|
||||||
|
strokeOpacity={0.16}
|
||||||
|
fill="none"
|
||||||
|
dot={false}
|
||||||
|
filter={`url(#${glowId})`}
|
||||||
|
/>
|
||||||
|
<Radar
|
||||||
|
name="客户画像"
|
||||||
|
dataKey="score"
|
||||||
|
isAnimationActive={false}
|
||||||
|
stroke="#2563eb"
|
||||||
|
strokeWidth={3}
|
||||||
|
fill={`url(#${gradientId})`}
|
||||||
|
fillOpacity={1}
|
||||||
|
dot={{ r: compact ? 3.5 : 4.5, fill: '#2563eb', strokeWidth: 3, stroke: '#fff' }}
|
||||||
|
/>
|
||||||
|
</RadarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
{!compact && (
|
||||||
|
<div className="grid grid-cols-2 gap-2 border-t border-slate-100 bg-slate-50/40 px-4 py-4 sm:grid-cols-3">
|
||||||
|
{data.map((item) => (
|
||||||
|
<div key={item.dimension} className="min-w-0 rounded-xl border border-slate-100 bg-white px-3 py-2.5 shadow-sm">
|
||||||
|
<div className="flex items-end justify-between gap-2">
|
||||||
|
<span className="text-[11px] font-medium text-slate-500">{item.dimension}</span>
|
||||||
|
<span className="text-base font-bold leading-none text-slate-900">{item.score}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-slate-100">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-gradient-to-r from-blue-400 via-blue-600 to-violet-500 shadow-[0_0_8px_rgba(37,99,235,.28)]"
|
||||||
|
style={{ width: `${item.score}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { Tag } from 'lucide-react'
|
||||||
|
import { cn } from '@/utils/cn'
|
||||||
|
|
||||||
|
type TagCategory = 'positive' | 'emotion' | 'risk' | 'aftersales' | 'quality' | 'service' | 'profile'
|
||||||
|
|
||||||
|
const categoryConfig: Record<TagCategory, { label: string; className: string; dot: string }> = {
|
||||||
|
positive: {
|
||||||
|
label: '积极信号',
|
||||||
|
className: 'border-emerald-200/90 bg-emerald-50 text-emerald-700',
|
||||||
|
dot: 'bg-emerald-500 shadow-[0_0_0_2px_rgba(16,185,129,.12)]',
|
||||||
|
},
|
||||||
|
emotion: {
|
||||||
|
label: '客户情绪',
|
||||||
|
className: 'border-amber-200/90 bg-amber-50 text-amber-700',
|
||||||
|
dot: 'bg-amber-500 shadow-[0_0_0_2px_rgba(245,158,11,.12)]',
|
||||||
|
},
|
||||||
|
risk: {
|
||||||
|
label: '投诉风险',
|
||||||
|
className: 'border-rose-200/90 bg-rose-50 text-rose-700',
|
||||||
|
dot: 'bg-rose-500 shadow-[0_0_0_2px_rgba(244,63,94,.12)]',
|
||||||
|
},
|
||||||
|
aftersales: {
|
||||||
|
label: '售后处理',
|
||||||
|
className: 'border-orange-200/90 bg-orange-50 text-orange-700',
|
||||||
|
dot: 'bg-orange-500 shadow-[0_0_0_2px_rgba(249,115,22,.12)]',
|
||||||
|
},
|
||||||
|
quality: {
|
||||||
|
label: '产品质量',
|
||||||
|
className: 'border-sky-200/90 bg-sky-50 text-sky-700',
|
||||||
|
dot: 'bg-sky-500 shadow-[0_0_0_2px_rgba(14,165,233,.12)]',
|
||||||
|
},
|
||||||
|
service: {
|
||||||
|
label: '服务沟通',
|
||||||
|
className: 'border-violet-200/90 bg-violet-50 text-violet-700',
|
||||||
|
dot: 'bg-violet-500 shadow-[0_0_0_2px_rgba(139,92,246,.12)]',
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
label: '客户属性',
|
||||||
|
className: 'border-slate-200 bg-slate-50 text-slate-600',
|
||||||
|
dot: 'bg-slate-400 shadow-[0_0_0_2px_rgba(100,116,139,.1)]',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function customerTagCategory(tag: string): TagCategory {
|
||||||
|
const value = tag.toLowerCase().replace(/[_-]+/g, ' ')
|
||||||
|
|
||||||
|
// Positive outcomes take priority for phrases such as “投诉处理成功”.
|
||||||
|
if (/success|satisfaction|repurchase|positive|resolved|回升|缓和|修复|改进|成功|复购|满意|转正/.test(value)) return 'positive'
|
||||||
|
if (/emotion|anger|angry|insult|情绪|愤怒|高情绪|冲突/.test(value)) return 'emotion'
|
||||||
|
if (/urgent|escalation|crisis|complaint|投诉|危机|辱骂|欺诈|风险/.test(value)) return 'risk'
|
||||||
|
if (/after.?sales|refund|return|replace|售后|退款|退货|补偿|换货|更换/.test(value)) return 'aftersales'
|
||||||
|
if (/quality|defect|product|holes?|smell|品质|质量|瑕疵|破损|异味|制造|尺寸|材质/.test(value)) return 'quality'
|
||||||
|
if (/agent|service|communication|feedback|客服|坐席|沟通|销售反馈/.test(value)) return 'service'
|
||||||
|
return 'profile'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomerSemanticTag({
|
||||||
|
tag,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
tag: string
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const category = customerTagCategory(tag)
|
||||||
|
const config = categoryConfig[category]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-tag-category={category}
|
||||||
|
title={`${config.label} · ${tag}`}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-medium leading-none transition-transform hover:-translate-y-px',
|
||||||
|
config.className,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', config.dot)} />
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomerTagLegend({ tags }: { tags: string[] }) {
|
||||||
|
const counts = tags.reduce<Partial<Record<TagCategory, number>>>((result, tag) => {
|
||||||
|
const category = customerTagCategory(tag)
|
||||||
|
result[category] = (result[category] || 0) + 1
|
||||||
|
return result
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[10px] text-slate-500">
|
||||||
|
<span className="inline-flex items-center gap-1 font-semibold uppercase tracking-[0.12em] text-slate-400">
|
||||||
|
<Tag className="h-3 w-3" />
|
||||||
|
标签画像
|
||||||
|
</span>
|
||||||
|
{(Object.keys(categoryConfig) as TagCategory[])
|
||||||
|
.filter((category) => counts[category])
|
||||||
|
.map((category) => (
|
||||||
|
<span key={category} className="inline-flex items-center gap-1">
|
||||||
|
<span className={cn('h-1.5 w-1.5 rounded-full', categoryConfig[category].dot)} />
|
||||||
|
{categoryConfig[category].label}
|
||||||
|
<span className="font-semibold text-slate-700">{counts[category]}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation } from '@tanstack/react-query'
|
||||||
import { Sparkles, Copy, Edit, Check, Lightbulb, Globe, AlertTriangle, BookOpen, X } from 'lucide-react'
|
import { Sparkles, Copy, Edit, Check, Lightbulb, Globe, AlertTriangle, BookOpen, Languages, X } from 'lucide-react'
|
||||||
import { generateReplies } from '@/services/api/ai'
|
import { generateReplies } from '@/services/api/ai'
|
||||||
import type { AiReplySuggestion } from '@/types/ai'
|
import type { AiReplySuggestion } from '@/types/ai'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -22,8 +22,8 @@ export function AiReplies({
|
|||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
conversationId: string
|
conversationId: string
|
||||||
onAdopt: (content: string) => void
|
onAdopt: (content: string, translationCn: string) => void
|
||||||
onEdit: (content: string) => void
|
onEdit: (content: string, translationCn: string) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const [items, setItems] = useState<AiReplySuggestion[] | null>(null)
|
const [items, setItems] = useState<AiReplySuggestion[] | null>(null)
|
||||||
@@ -33,17 +33,17 @@ export function AiReplies({
|
|||||||
mutationFn: () => generateReplies(conversationId),
|
mutationFn: () => generateReplies(conversationId),
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setItems(Array.isArray(data) ? data : [])
|
setItems(Array.isArray(data) ? data : [])
|
||||||
toast.success('AI 回复建议已生成', '根据客户画像、知识库和历史成功回复综合生成')
|
toast.success('回复建议已生成', '已参考客户资料、知识库和历史回复')
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(errorMessage(e)),
|
onError: (e) => toast.error(errorMessage(e)),
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-border p-4">
|
<div className="border-t border-slate-100 bg-white px-4 py-3">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<h4 className="flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
|
<h4 className="flex items-center gap-2 text-xs font-semibold text-slate-700">
|
||||||
<Sparkles className="h-3.5 w-3.5 text-primary" />
|
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary-50"><Sparkles className="h-3.5 w-3.5 text-primary" /></span>
|
||||||
AI 智能回复
|
回复建议
|
||||||
</h4>
|
</h4>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
@@ -61,7 +61,7 @@ export function AiReplies({
|
|||||||
'生成回复建议'
|
'生成回复建议'
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon-sm" onClick={onClose} title="关闭 AI 智能回复">
|
<Button variant="ghost" size="icon-sm" onClick={onClose} title="关闭回复建议">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,7 +94,29 @@ export function AiReplies({
|
|||||||
{languageName(s.targetLang)}
|
{languageName(s.targetLang)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm leading-relaxed whitespace-pre-wrap">{s.content}</p>
|
<div className="space-y-2">
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 flex items-center gap-1.5 text-[10px] font-semibold uppercase tracking-wide text-primary">
|
||||||
|
<Globe className="h-3 w-3" />
|
||||||
|
发送给客户 · 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">
|
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
{(s.knowledgeRefs || []).slice(0, 2).map((ref) => (
|
{(s.knowledgeRefs || []).slice(0, 2).map((ref) => (
|
||||||
@@ -111,7 +133,7 @@ export function AiReplies({
|
|||||||
<span className="text-muted-foreground">{s.reason}</span>
|
<span className="text-muted-foreground">{s.reason}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-1">
|
<div className="flex items-start gap-1">
|
||||||
<Globe className="mt-0.5 h-3 w-3 shrink-0 text-blue-500" />
|
<Sparkles className="mt-0.5 h-3 w-3 shrink-0 text-blue-500" />
|
||||||
<span className="text-muted-foreground">{s.expectedEffect}</span>
|
<span className="text-muted-foreground">{s.expectedEffect}</span>
|
||||||
</div>
|
</div>
|
||||||
{s.cultureTip && (
|
{s.cultureTip && (
|
||||||
@@ -123,11 +145,11 @@ export function AiReplies({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 flex gap-1.5">
|
<div className="mt-2 flex gap-1.5">
|
||||||
<Button size="sm" variant="default" onClick={() => onAdopt(s.content)}>
|
<Button size="sm" variant="default" onClick={() => onAdopt(s.content, s.translationCn)}>
|
||||||
<Check className="h-3.5 w-3.5" />
|
<Check className="h-3.5 w-3.5" />
|
||||||
采用
|
采用
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="outline" onClick={() => onEdit(s.content)}>
|
<Button size="sm" variant="outline" onClick={() => onEdit(s.content, s.translationCn)}>
|
||||||
<Edit className="h-3.5 w-3.5" />
|
<Edit className="h-3.5 w-3.5" />
|
||||||
编辑后采用
|
编辑后采用
|
||||||
</Button>
|
</Button>
|
||||||
@@ -146,9 +168,10 @@ export function AiReplies({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!items && !mutation.isPending && (
|
{!items && !mutation.isPending && (
|
||||||
<p className="py-3 text-center text-xs text-muted-foreground">
|
<div className="flex items-center justify-between rounded-xl bg-slate-50 px-3 py-2.5">
|
||||||
点击「生成回复建议」让 AI 根据客户上下文、知识库与历史成功回复为您生成回复
|
<p className="text-xs text-slate-500">生成三种回复建议</p>
|
||||||
</p>
|
<span className="hidden text-[10px] font-medium text-primary sm:inline">不会自动发送</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { Dropdown } from '@/components/ui/dropdown'
|
|||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { toast } from '@/app/store/toast'
|
import { toast } from '@/app/store/toast'
|
||||||
import { cn } from '@/utils/cn'
|
import type { ChannelType } from '@/types'
|
||||||
|
|
||||||
const QUICK_PHRASES = [
|
const QUICK_PHRASES = [
|
||||||
{ label: '报价催促', text: 'Friendly reminder: the quotation is valid until this Friday. Please confirm at your earliest convenience.' },
|
{ label: '报价催促', text: 'Friendly reminder: the quotation is valid until this Friday. Please confirm at your earliest convenience.' },
|
||||||
@@ -47,6 +47,7 @@ export function Composer({
|
|||||||
disabled,
|
disabled,
|
||||||
replyingTo,
|
replyingTo,
|
||||||
onCancelReply,
|
onCancelReply,
|
||||||
|
channel = 'telegram',
|
||||||
}: {
|
}: {
|
||||||
onSend: (text: string, autoTranslate?: boolean) => Promise<void>
|
onSend: (text: string, autoTranslate?: boolean) => Promise<void>
|
||||||
onMedia?: (files: File[], kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker') => Promise<void>
|
onMedia?: (files: File[], kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker') => Promise<void>
|
||||||
@@ -55,6 +56,7 @@ export function Composer({
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
replyingTo?: { id: string; text: string } | null
|
replyingTo?: { id: string; text: string } | null
|
||||||
onCancelReply?: () => void
|
onCancelReply?: () => void
|
||||||
|
channel?: ChannelType
|
||||||
}) {
|
}) {
|
||||||
const [internalText, setInternalText] = useState('')
|
const [internalText, setInternalText] = useState('')
|
||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
@@ -73,7 +75,12 @@ export function Composer({
|
|||||||
setSending(true)
|
setSending(true)
|
||||||
try {
|
try {
|
||||||
await onMedia(Array.from(files), kind)
|
await onMedia(Array.from(files), kind)
|
||||||
toast.success(`${files.length} 个文件已发送到 Telegram`)
|
const destination = channel === 'email'
|
||||||
|
? '客户邮箱'
|
||||||
|
: channel === 'whatsapp'
|
||||||
|
? ' WhatsApp'
|
||||||
|
: ' Telegram'
|
||||||
|
toast.success(`${files.length} 个文件已发送到${destination}`)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('媒体发送失败')
|
toast.error('媒体发送失败')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -120,11 +127,11 @@ export function Composer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const toolbar = (
|
const toolbar = (
|
||||||
<div className="flex flex-wrap items-center gap-0.5 border-b border-border px-2 py-1.5">
|
<div className="flex items-center gap-1 overflow-x-auto border-b border-slate-100 px-3 py-2 scrollbar-thin">
|
||||||
{/* 话术 */}
|
{/* 话术 */}
|
||||||
<Dropdown
|
<Dropdown
|
||||||
trigger={
|
trigger={
|
||||||
<button className="flex items-center gap-0.5 rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground">
|
<button className="flex shrink-0 items-center gap-1 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50 hover:text-slate-900">
|
||||||
<FileText className="h-3.5 w-3.5" />
|
<FileText className="h-3.5 w-3.5" />
|
||||||
常用话术
|
常用话术
|
||||||
<ChevronDown className="h-3 w-3" />
|
<ChevronDown className="h-3 w-3" />
|
||||||
@@ -134,7 +141,7 @@ export function Composer({
|
|||||||
/>
|
/>
|
||||||
<span className="mx-1 h-4 w-px bg-border" />
|
<span className="mx-1 h-4 w-px bg-border" />
|
||||||
<button
|
<button
|
||||||
className="rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
|
className="shrink-0 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50 hover:text-slate-900"
|
||||||
onClick={() => documentInput.current?.click()}
|
onClick={() => documentInput.current?.click()}
|
||||||
title="上传文件"
|
title="上传文件"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -142,29 +149,33 @@ export function Composer({
|
|||||||
<Paperclip className="inline h-3.5 w-3.5" /> 文件
|
<Paperclip className="inline h-3.5 w-3.5" /> 文件
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
|
className="shrink-0 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50 hover:text-slate-900"
|
||||||
onClick={() => imageInput.current?.click()}
|
onClick={() => imageInput.current?.click()}
|
||||||
title="上传图片"
|
title="上传图片"
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<Image className="inline h-3.5 w-3.5" /> 图片
|
<Image className="inline h-3.5 w-3.5" /> 图片
|
||||||
</button>
|
</button>
|
||||||
<button
|
{channel !== 'email' && (
|
||||||
className="rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
|
<>
|
||||||
onClick={() => audioInput.current?.click()}
|
<button
|
||||||
title="语音输入"
|
className="shrink-0 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50 hover:text-slate-900"
|
||||||
type="button"
|
onClick={() => audioInput.current?.click()}
|
||||||
>
|
title="语音输入"
|
||||||
<Mic className="inline h-3.5 w-3.5" /> 语音
|
type="button"
|
||||||
</button>
|
>
|
||||||
<button
|
<Mic className="inline h-3.5 w-3.5" /> 语音
|
||||||
className="rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
|
</button>
|
||||||
onClick={() => stickerInput.current?.click()}
|
<button
|
||||||
title="发送贴纸"
|
className="shrink-0 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50 hover:text-slate-900"
|
||||||
type="button"
|
onClick={() => stickerInput.current?.click()}
|
||||||
>
|
title="发送贴纸"
|
||||||
贴纸
|
type="button"
|
||||||
</button>
|
>
|
||||||
|
贴纸
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<input ref={documentInput} type="file" className="hidden" onChange={(e) => uploadFiles(e.target.files, 'document')} />
|
<input ref={documentInput} type="file" className="hidden" onChange={(e) => uploadFiles(e.target.files, 'document')} />
|
||||||
<input ref={imageInput} type="file" accept="image/*" multiple className="hidden" onChange={(e) => uploadFiles(e.target.files, 'photo')} />
|
<input ref={imageInput} type="file" accept="image/*" multiple className="hidden" onChange={(e) => uploadFiles(e.target.files, 'photo')} />
|
||||||
<input ref={audioInput} type="file" accept="audio/*" className="hidden" onChange={(e) => uploadFiles(e.target.files, 'voice')} />
|
<input ref={audioInput} type="file" accept="audio/*" className="hidden" onChange={(e) => uploadFiles(e.target.files, 'voice')} />
|
||||||
@@ -182,7 +193,7 @@ export function Composer({
|
|||||||
type="button"
|
type="button"
|
||||||
disabled={aiLoading || !text.trim()}
|
disabled={aiLoading || !text.trim()}
|
||||||
onClick={() => aiAction(t.action)}
|
onClick={() => aiAction(t.action)}
|
||||||
className="rounded px-1.5 py-1 text-xs text-teal-600 hover:bg-teal-50 disabled:opacity-50"
|
className="shrink-0 rounded-lg px-2 py-1.5 text-xs font-medium text-primary-700 hover:bg-primary-50 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<t.icon className="inline h-3.5 w-3.5" /> {t.label}
|
<t.icon className="inline h-3.5 w-3.5" /> {t.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -190,7 +201,7 @@ export function Composer({
|
|||||||
<span className="mx-1 h-4 w-px bg-border" />
|
<span className="mx-1 h-4 w-px bg-border" />
|
||||||
<Dropdown
|
<Dropdown
|
||||||
trigger={
|
trigger={
|
||||||
<button className="flex items-center gap-0.5 rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted">
|
<button className="flex shrink-0 items-center gap-0.5 rounded-lg px-2 py-1.5 text-xs text-slate-500 hover:bg-slate-50">
|
||||||
修改语气 <ChevronDown className="h-3 w-3" />
|
修改语气 <ChevronDown className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -200,9 +211,9 @@ export function Composer({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-border bg-card">
|
<div className="border-t border-slate-100 bg-white">
|
||||||
{toolbar}
|
{toolbar}
|
||||||
<div className="p-3">
|
<div className="p-3.5">
|
||||||
{replyingTo && (
|
{replyingTo && (
|
||||||
<div className="mb-2 flex items-center gap-2 rounded-lg border-s-2 border-primary bg-primary-50/70 px-3 py-2">
|
<div className="mb-2 flex items-center gap-2 rounded-lg border-s-2 border-primary bg-primary-50/70 px-3 py-2">
|
||||||
<Reply className="h-3.5 w-3.5 shrink-0 text-primary" />
|
<Reply className="h-3.5 w-3.5 shrink-0 text-primary" />
|
||||||
@@ -220,9 +231,9 @@ export function Composer({
|
|||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => setText(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder="输入回复内容,Enter 发送,Shift+Enter 换行"
|
placeholder={channel === 'email' ? '撰写邮件回复,Enter 发送,Shift+Enter 换行' : '输入回复内容,Enter 发送,Shift+Enter 换行'}
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground outline-none focus:border-primary focus:ring-1 focus:ring-ring scrollbar-thin"
|
className="min-h-[86px] w-full resize-none rounded-2xl border border-slate-200 bg-slate-50/60 px-4 py-3.5 text-sm leading-6 placeholder:text-slate-400 outline-none transition-all focus:border-primary-300 focus:bg-white focus:ring-4 focus:ring-primary-50 scrollbar-thin"
|
||||||
disabled={sending || disabled}
|
disabled={sending || disabled}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -230,7 +241,7 @@ export function Composer({
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<Switch checked={autoTranslate} onCheckedChange={setAutoTranslate} />
|
<Switch checked={autoTranslate} onCheckedChange={setAutoTranslate} />
|
||||||
中文自动译为英文发送
|
中文自动译为英文{channel === 'email' ? '邮件' : '发送'}
|
||||||
</label>
|
</label>
|
||||||
{aiLoading && (
|
{aiLoading && (
|
||||||
<Badge variant="primary" className="animate-pulse">
|
<Badge variant="primary" className="animate-pulse">
|
||||||
@@ -240,7 +251,7 @@ export function Composer({
|
|||||||
</div>
|
</div>
|
||||||
<Button onClick={send} size="sm" disabled={!text.trim() || sending || disabled} loading={sending}>
|
<Button onClick={send} size="sm" disabled={!text.trim() || sending || disabled} loading={sending}>
|
||||||
<Send className="h-4 w-4" />
|
<Send className="h-4 w-4" />
|
||||||
发 送 (↵)
|
{channel === 'email' ? '发送邮件' : '发 送'} (↵)
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Brain, Building2, ChevronRight } from 'lucide-react'
|
import { Brain, ChevronRight } from 'lucide-react'
|
||||||
import type { Conversation, Customer, CustomerStage } from '@/types'
|
import type { Conversation, Customer, CustomerStage } from '@/types'
|
||||||
import { Avatar } from '@/components/ui/avatar'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { CountryFlag } from '@/components/common/CountryFlag'
|
|
||||||
import {
|
import {
|
||||||
CustomerStageBadge,
|
CustomerStageBadge,
|
||||||
IntentScoreBadge,
|
IntentScoreBadge,
|
||||||
@@ -25,33 +23,24 @@ export function ConversationInsightBar({
|
|||||||
onOpenProfile: () => void
|
onOpenProfile: () => void
|
||||||
}) {
|
}) {
|
||||||
const stage = validStages.has(customer.stage) ? customer.stage : 'initial'
|
const stage = validStages.has(customer.stage) ? customer.stage : 'initial'
|
||||||
const summary = customer.aiProfile?.summary || conversation.summary || 'AI 正在根据会话完善客户画像'
|
const summary = customer.aiProfile?.summary || conversation.summary || '收到更多客户消息后将补充客户画像'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div data-testid="conversation-insight-bar" className="border-b border-border bg-gradient-to-r from-primary-50/80 via-card to-card px-3 py-2">
|
<div
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
data-testid="conversation-insight-bar"
|
||||||
<Avatar name={customer.name} src={customer.avatarUrl} size="sm" />
|
className="shrink-0 border-b border-slate-100 bg-white px-3 py-2 shadow-[0_1px_0_rgba(15,23,42,.02)] sm:px-4"
|
||||||
<div className="min-w-0">
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span className="max-w-40 truncate text-sm font-semibold">{customer.name}</span>
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border border-violet-100 bg-violet-50 text-violet-600">
|
||||||
{customer.telegramUsername && (
|
<Brain className="h-4 w-4" />
|
||||||
<span className="max-w-32 truncate text-[11px] text-muted-foreground">
|
</span>
|
||||||
@{customer.telegramUsername}
|
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||||
</span>
|
<span className="hidden shrink-0 text-[10px] font-semibold uppercase tracking-[0.12em] text-violet-600 sm:inline">
|
||||||
)}
|
客户洞察
|
||||||
<CountryFlag code={customer.country} withName={false} />
|
</span>
|
||||||
{customer.company && (
|
<p className="truncate text-xs leading-5 text-slate-600">{summary}</p>
|
||||||
<span className="hidden items-center gap-1 truncate text-xs text-muted-foreground md:flex">
|
|
||||||
<Building2 className="h-3 w-3" /> {customer.company}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="mt-0.5 max-w-[60vw] truncate text-[11px] text-muted-foreground">
|
|
||||||
<Brain className="mr-1 inline h-3 w-3 text-primary" />
|
|
||||||
{summary}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="ms-auto hidden items-center gap-2 sm:flex">
|
<div className="hidden shrink-0 items-center gap-2 md:flex">
|
||||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
意向 <IntentScoreBadge score={Number(customer.intentScore || 0)} />
|
意向 <IntentScoreBadge score={Number(customer.intentScore || 0)} />
|
||||||
</span>
|
</span>
|
||||||
@@ -59,13 +48,13 @@ export function ConversationInsightBar({
|
|||||||
<CustomerStageBadge stage={stage as CustomerStage} />
|
<CustomerStageBadge stage={stage as CustomerStage} />
|
||||||
<RiskLevelBadge level={customer.riskLevel || 'none'} />
|
<RiskLevelBadge level={customer.riskLevel || 'none'} />
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" onClick={onOpenProfile}>
|
<Button variant="ghost" size="sm" onClick={onOpenProfile} title="打开客户画像">
|
||||||
<span className="hidden lg:inline">客户画像</span>
|
<span className="hidden lg:inline">客户画像</span>
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 flex items-center gap-3 overflow-x-auto sm:hidden">
|
<div className="mt-1.5 flex items-center gap-2 overflow-x-auto pb-0.5 md:hidden">
|
||||||
<span className="flex shrink-0 items-center gap-1 text-xs">意向 <IntentScoreBadge score={Number(customer.intentScore || 0)} /></span>
|
<span className="flex shrink-0 items-center gap-1 text-[11px]">意向 <IntentScoreBadge score={Number(customer.intentScore || 0)} /></span>
|
||||||
<SentimentBadge sentiment={customer.sentiment || 'neutral'} />
|
<SentimentBadge sentiment={customer.sentiment || 'neutral'} />
|
||||||
<CustomerStageBadge stage={stage as CustomerStage} />
|
<CustomerStageBadge stage={stage as CustomerStage} />
|
||||||
<RiskLevelBadge level={customer.riskLevel || 'none'} />
|
<RiskLevelBadge level={customer.riskLevel || 'none'} />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Search, ShieldAlert } from 'lucide-react'
|
import { useRef, useState } from 'react'
|
||||||
|
import { Eraser, Mail, MessageCircle, Pin, Plus, Search, Send, ShieldAlert, Trash2 } from 'lucide-react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { getTeam } from '@/services/api/org'
|
import { getTeam } from '@/services/api/org'
|
||||||
import type { ConversationListItem } from '@/services/api/conversations'
|
import type { ConversationListItem } from '@/services/api/conversations'
|
||||||
@@ -21,10 +22,11 @@ const FILTERS = [
|
|||||||
{ value: 'risk', label: '有风险' },
|
{ value: 'risk', label: '有风险' },
|
||||||
{ value: 'unassigned', label: '无负责人' },
|
{ value: 'unassigned', label: '无负责人' },
|
||||||
{ value: 'mine', label: '我的客户' },
|
{ value: 'mine', label: '我的客户' },
|
||||||
|
{ value: 'archived', label: '已归档' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function ConversationList({
|
export function ConversationList({
|
||||||
items,
|
items = [],
|
||||||
loading,
|
loading,
|
||||||
activeId,
|
activeId,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -32,6 +34,11 @@ export function ConversationList({
|
|||||||
onFilterChange,
|
onFilterChange,
|
||||||
keyword,
|
keyword,
|
||||||
onKeywordChange,
|
onKeywordChange,
|
||||||
|
channel,
|
||||||
|
onChannelChange,
|
||||||
|
onAdd,
|
||||||
|
onClear,
|
||||||
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
items: ConversationListItem[]
|
items: ConversationListItem[]
|
||||||
loading: boolean
|
loading: boolean
|
||||||
@@ -41,34 +48,83 @@ export function ConversationList({
|
|||||||
onFilterChange: (f: string) => void
|
onFilterChange: (f: string) => void
|
||||||
keyword: string
|
keyword: string
|
||||||
onKeywordChange: (k: string) => void
|
onKeywordChange: (k: string) => void
|
||||||
|
channel: string
|
||||||
|
onChannelChange: (channel: string) => void
|
||||||
|
onAdd: () => void
|
||||||
|
onClear: (item: ConversationListItem) => void
|
||||||
|
onDelete: (item: ConversationListItem) => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { data: team } = useQuery({ queryKey: ['team'], queryFn: getTeam, staleTime: 300_000 })
|
const { data: team } = useQuery({ queryKey: ['team'], queryFn: getTeam, staleTime: 300_000 })
|
||||||
const memberName = (id: string | null) => (id ? (team?.find((m) => m.id === id)?.name ?? '—') : null)
|
const memberName = (id: string | null) => (id ? (team?.find((m) => m.id === id)?.name ?? '—') : null)
|
||||||
|
const [swipedId, setSwipedId] = useState<string | null>(null)
|
||||||
|
const [dragOffset, setDragOffset] = useState(0)
|
||||||
|
const touchStart = useRef<{ x: number; y: number; id: string } | null>(null)
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{/* 搜索 */}
|
<div className="border-b border-slate-200 bg-white px-3 pb-3 pt-3">
|
||||||
<div className="border-b border-border p-3">
|
<div className="mb-3 flex items-center justify-between px-1">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold tracking-[-0.03em] text-slate-950">消息</h1>
|
||||||
|
<p className="mt-0.5 text-[11px] text-slate-400">{items.length} 个活跃会话</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="rounded-full bg-emerald-50 px-2.5 py-1 text-[10px] font-semibold text-emerald-700">
|
||||||
|
实时连接
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAdd}
|
||||||
|
title="添加联系人或群组"
|
||||||
|
aria-label="添加联系人或群组"
|
||||||
|
className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-white shadow-sm shadow-primary-200 transition hover:-translate-y-0.5 hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground rtl:left-auto rtl:right-2.5" />
|
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground rtl:left-auto rtl:right-2.5" />
|
||||||
<input
|
<input
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChange={(e) => onKeywordChange(e.target.value)}
|
onChange={(e) => onKeywordChange(e.target.value)}
|
||||||
placeholder="搜索客户/国家/产品/消息内容"
|
placeholder="搜索客户/国家/产品/消息内容"
|
||||||
className="h-9 w-full rounded-md border border-input bg-muted/40 pl-8 pr-2 text-sm placeholder:text-muted-foreground focus:border-primary focus:bg-card focus:outline-none rtl:pl-2 rtl:pr-8"
|
className="h-10 w-full rounded-full border border-transparent bg-slate-100/80 pl-9 pr-3 text-sm placeholder:text-slate-400 focus:border-sky-200 focus:bg-white focus:outline-none focus:ring-4 focus:ring-sky-50 rtl:pl-2 rtl:pr-8"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* 筛选 */}
|
<div className="mt-2.5 flex items-center gap-1 rounded-xl bg-slate-50 p-1">
|
||||||
<div className="mt-2.5 flex flex-wrap gap-1">
|
{[
|
||||||
|
{ value: '', label: '全部', icon: null },
|
||||||
|
{ value: 'telegram', label: 'Telegram', icon: Send },
|
||||||
|
{ value: 'whatsapp', label: 'WhatsApp', icon: MessageCircle },
|
||||||
|
{ value: 'email', label: 'Email', icon: Mail },
|
||||||
|
].map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChannelChange(item.value)}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-1 items-center justify-center gap-1 rounded-lg px-2 py-1.5 text-[11px] font-semibold transition-all',
|
||||||
|
channel === item.value
|
||||||
|
? 'bg-white text-slate-900 shadow-sm ring-1 ring-slate-200/70'
|
||||||
|
: 'text-slate-400 hover:text-slate-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.icon && <item.icon className="h-3 w-3" />}
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex gap-1 overflow-x-auto pb-0.5 scrollbar-none">
|
||||||
{FILTERS.map((f) => (
|
{FILTERS.map((f) => (
|
||||||
<button
|
<button
|
||||||
key={f.value}
|
key={f.value}
|
||||||
onClick={() => onFilterChange(f.value)}
|
onClick={() => onFilterChange(f.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-full px-2.5 py-1 text-xs transition-colors',
|
'shrink-0 rounded-full px-3 py-1.5 text-[11px] font-medium transition-colors',
|
||||||
filter === f.value
|
filter === f.value
|
||||||
? 'bg-primary font-medium text-primary-foreground'
|
? 'bg-primary font-semibold text-white shadow-sm shadow-primary-200'
|
||||||
: 'bg-muted text-muted-foreground hover:bg-border',
|
: 'text-slate-500 hover:bg-slate-100 hover:text-slate-800',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{f.label}
|
{f.label}
|
||||||
@@ -78,10 +134,10 @@ export function ConversationList({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 列表 */}
|
{/* 列表 */}
|
||||||
<div className="flex-1 overflow-y-auto scrollbar-thin">
|
<div className="flex-1 overflow-y-auto bg-white px-1.5 py-1.5 scrollbar-thin">
|
||||||
{loading &&
|
{loading &&
|
||||||
Array.from({ length: 8 }).map((_, i) => (
|
Array.from({ length: 8 }).map((_, i) => (
|
||||||
<div key={i} className="flex gap-3 border-b border-border px-3 py-3">
|
<div key={i} className="flex gap-3 rounded-xl px-3 py-3">
|
||||||
<Skeleton className="h-10 w-10 rounded-full" />
|
<Skeleton className="h-10 w-10 rounded-full" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<Skeleton className="h-3.5 w-2/3" />
|
<Skeleton className="h-3.5 w-2/3" />
|
||||||
@@ -97,17 +153,82 @@ export function ConversationList({
|
|||||||
const c = item.customer
|
const c = item.customer
|
||||||
const active = item.id === activeId
|
const active = item.id === activeId
|
||||||
const intent = intentLevelMeta(item.intentLevel)
|
const intent = intentLevelMeta(item.intentLevel)
|
||||||
|
const isTelegramChannel = String(item.telegramChatType || '').toLowerCase() === 'channel'
|
||||||
|
const supportsSwipeActions = item.channel === 'telegram' || item.channel === 'whatsapp'
|
||||||
return (
|
return (
|
||||||
<button
|
<div key={item.id} className="relative mb-0.5 overflow-hidden rounded-xl">
|
||||||
key={item.id}
|
{supportsSwipeActions && (
|
||||||
data-testid="conversation-item"
|
<div
|
||||||
data-conversation-id={item.id}
|
data-testid="conversation-swipe-actions"
|
||||||
onClick={() => onSelect(item.id)}
|
aria-hidden={swipedId !== item.id && touchStart.current?.id !== item.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex w-full gap-2.5 border-b border-border px-3 py-3 text-start transition-colors',
|
'absolute inset-y-0 right-0 flex w-[144px] overflow-hidden rounded-xl transition-opacity duration-150',
|
||||||
active ? 'bg-primary-50/70' : 'hover:bg-muted/50',
|
swipedId === item.id || touchStart.current?.id === item.id
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'pointer-events-none opacity-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!isTelegramChannel && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onClear(item)}
|
||||||
|
className="flex w-[72px] flex-col items-center justify-center gap-1 bg-amber-500 text-[11px] font-semibold text-white"
|
||||||
|
>
|
||||||
|
<Eraser className="h-4 w-4" />清空
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onDelete(item)}
|
||||||
|
className="flex flex-1 flex-col items-center justify-center gap-1 bg-red-500 text-[11px] font-semibold text-white"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />{isTelegramChannel ? '退出并删除' : '删除'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
<button
|
||||||
|
data-testid="conversation-item"
|
||||||
|
data-conversation-id={item.id}
|
||||||
|
data-channel={item.channel}
|
||||||
|
data-telegram-chat-type={item.telegramChatType || ''}
|
||||||
|
data-last-sender={item.lastMessage?.sender || ''}
|
||||||
|
onTouchStart={(event) => {
|
||||||
|
if (!supportsSwipeActions) return
|
||||||
|
const touch = event.touches[0]
|
||||||
|
touchStart.current = { x: touch.clientX, y: touch.clientY, id: item.id }
|
||||||
|
if (swipedId !== item.id) setDragOffset(0)
|
||||||
|
}}
|
||||||
|
onTouchMove={(event) => {
|
||||||
|
const start = touchStart.current
|
||||||
|
if (!start || start.id !== item.id) return
|
||||||
|
const touch = event.touches[0]
|
||||||
|
const dx = touch.clientX - start.x
|
||||||
|
const dy = touch.clientY - start.y
|
||||||
|
if (Math.abs(dx) <= Math.abs(dy)) return
|
||||||
|
setDragOffset(Math.max(-144, Math.min(0, (swipedId === item.id ? -144 : 0) + dx)))
|
||||||
|
}}
|
||||||
|
onTouchEnd={() => {
|
||||||
|
setSwipedId(dragOffset < -54 ? item.id : null)
|
||||||
|
setDragOffset(0)
|
||||||
|
touchStart.current = null
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (swipedId === item.id) {
|
||||||
|
setSwipedId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onSelect(item.id)
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
transform: `translateX(${swipedId === item.id ? -144 : touchStart.current?.id === item.id ? dragOffset : 0}px)`,
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full gap-3 overflow-hidden rounded-xl px-3 py-3 text-start transition-[transform,background-color,box-shadow] duration-200',
|
||||||
|
active
|
||||||
|
? 'conversation-selected bg-gradient-to-br from-primary-200/80 via-primary-100/90 to-white text-foreground ring-1 ring-inset ring-primary-300'
|
||||||
|
: 'bg-white hover:bg-slate-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
<Avatar name={c.name} src={c.avatarUrl} size="md" />
|
<Avatar name={c.name} src={c.avatarUrl} size="md" />
|
||||||
<span className="absolute -bottom-1 -right-1 rounded-full border border-card bg-card">
|
<span className="absolute -bottom-1 -right-1 rounded-full border border-card bg-card">
|
||||||
@@ -116,7 +237,8 @@ export function ConversationList({
|
|||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="truncate text-sm font-medium">{c.name}</span>
|
<span className={cn('truncate text-sm font-semibold', active && 'text-primary-900')}>{c.name}</span>
|
||||||
|
{item.isPinned && <Pin className="h-3 w-3 shrink-0 fill-primary-100 text-primary" />}
|
||||||
{c.telegramUsername && (
|
{c.telegramUsername && (
|
||||||
<span className="hidden max-w-24 truncate text-[10px] text-muted-foreground xl:inline">
|
<span className="hidden max-w-24 truncate text-[10px] text-muted-foreground xl:inline">
|
||||||
@{c.telegramUsername}
|
@{c.telegramUsername}
|
||||||
@@ -124,30 +246,34 @@ export function ConversationList({
|
|||||||
)}
|
)}
|
||||||
<CountryFlag code={c.country} withName={false} />
|
<CountryFlag code={c.country} withName={false} />
|
||||||
{item.hasRisk && <ShieldAlert className="h-3.5 w-3.5 shrink-0 text-red-500" />}
|
{item.hasRisk && <ShieldAlert className="h-3.5 w-3.5 shrink-0 text-red-500" />}
|
||||||
<span className="ms-auto shrink-0 text-[11px] text-muted-foreground">
|
<span className={cn('ms-auto shrink-0 text-[11px] text-muted-foreground', active && 'text-primary-600')}>
|
||||||
{formatRelative(item.updatedAt)}
|
{formatRelative(item.lastMessage?.createdAt || item.updatedAt)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
<p className={cn('mt-0.5 truncate text-xs text-muted-foreground', active && 'text-slate-700')}>
|
||||||
{item.lastMessage?.sender === 'agent' ? '我:' : ''}
|
{item.lastMessage?.sender === 'agent' ? '我:' : ''}
|
||||||
{item.lastMessage?.translatedText ?? item.lastMessage?.originalText ?? item.summary}
|
{item.lastMessage?.translatedText ?? item.lastMessage?.originalText ?? item.summary}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-1.5 flex items-center gap-1.5">
|
<div className="mt-2 flex items-center gap-1.5">
|
||||||
<Badge className={cn('text-[10px] px-1 py-0', intent.color)}>{intent.label}</Badge>
|
{!active && <Badge className={cn('rounded-md px-1.5 py-0 text-[10px]', intent.color)}>{intent.label}</Badge>}
|
||||||
<SentimentBadge sentiment={item.sentiment} emojiOnly />
|
<SentimentBadge sentiment={item.sentiment} emojiOnly />
|
||||||
{item.assigneeId ? (
|
{item.assigneeId ? (
|
||||||
<span className="text-[10px] text-muted-foreground">负责人:{memberName(item.assigneeId)}</span>
|
<span className={cn('text-[10px] text-muted-foreground', active && 'text-slate-500')}>负责人:{memberName(item.assigneeId)}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[10px] text-amber-600">未分配</span>
|
<span className="text-[10px] text-amber-700">未分配</span>
|
||||||
)}
|
)}
|
||||||
{item.unreadCount > 0 && (
|
{item.unreadCount > 0 && (
|
||||||
<span className="ms-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-white">
|
<span className={cn(
|
||||||
|
'ms-auto flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-semibold',
|
||||||
|
'bg-primary text-white',
|
||||||
|
)}>
|
||||||
{item.unreadCount}
|
{item.unreadCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { updateCustomer } from '@/services/api/customers'
|
import { updateCustomer } from '@/services/api/customers'
|
||||||
|
import { createTask } from '@/services/api/crm'
|
||||||
import { analyzeConversation } from '@/services/api/ai'
|
import { analyzeConversation } from '@/services/api/ai'
|
||||||
|
import { useAuthStore } from '@/app/store/auth'
|
||||||
import type { Customer, Conversation } from '@/types'
|
import type { Customer, Conversation } from '@/types'
|
||||||
import { Avatar } from '@/components/ui/avatar'
|
import { Avatar } from '@/components/ui/avatar'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -29,6 +31,8 @@ import { IntentScoreBadge, CustomerStageBadge, SentimentBadge, RiskLevelBadge }
|
|||||||
import { CUSTOMER_STAGES, NEXT_ACTIONS, countryMeta, INTENT_LEVELS } from '@/config/constants'
|
import { CUSTOMER_STAGES, NEXT_ACTIONS, countryMeta, INTENT_LEVELS } from '@/config/constants'
|
||||||
import { toast, errorMessage } from '@/app/store/toast'
|
import { toast, errorMessage } from '@/app/store/toast'
|
||||||
import { cn } from '@/utils/cn'
|
import { cn } from '@/utils/cn'
|
||||||
|
import { CustomerProfileRadar } from '@/components/customer/CustomerProfileRadar'
|
||||||
|
import { CustomerSemanticTag } from '@/components/customer/CustomerSemanticTag'
|
||||||
|
|
||||||
export function InsightPanel({
|
export function InsightPanel({
|
||||||
customer,
|
customer,
|
||||||
@@ -38,6 +42,7 @@ export function InsightPanel({
|
|||||||
conversation: Conversation
|
conversation: Conversation
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const user = useAuthStore((state) => state.user)
|
||||||
const [analyzing, setAnalyzing] = useState(false)
|
const [analyzing, setAnalyzing] = useState(false)
|
||||||
const [analysis, setAnalysis] = useState(customer.aiProfile.summary)
|
const [analysis, setAnalysis] = useState(customer.aiProfile.summary)
|
||||||
const c = countryMeta(customer.country)
|
const c = countryMeta(customer.country)
|
||||||
@@ -54,22 +59,41 @@ export function InsightPanel({
|
|||||||
},
|
},
|
||||||
onError: (e) => toast.error(errorMessage(e)),
|
onError: (e) => toast.error(errorMessage(e)),
|
||||||
})
|
})
|
||||||
|
const taskMut = useMutation({
|
||||||
|
mutationFn: (title: string) => createTask({
|
||||||
|
title,
|
||||||
|
type: 'revisit',
|
||||||
|
priority: customer.riskLevel === 'urgent' || customer.riskLevel === 'high' ? 'high' : 'medium',
|
||||||
|
status: 'pending',
|
||||||
|
customerId: customer.id,
|
||||||
|
customerName: customer.name,
|
||||||
|
conversationId: conversation.id,
|
||||||
|
ownerId: user?.id || '',
|
||||||
|
dueAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
note: `由客户洞察建议创建:${title}`,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||||
|
toast.success('已创建跟进任务', '默认明天到期')
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(errorMessage(error)),
|
||||||
|
})
|
||||||
|
|
||||||
const handleAnalyze = async () => {
|
const handleAnalyze = async () => {
|
||||||
setAnalyzing(true)
|
setAnalyzing(true)
|
||||||
try {
|
try {
|
||||||
const result = await analyzeConversation(conversation.id)
|
const result = await analyzeConversation(conversation.id)
|
||||||
setAnalysis(result.summary)
|
setAnalysis(result.summary)
|
||||||
toast.success('AI 分析完成', `意向评分 ${result.intentScore} · ${INTENT_LEVELS.find(l => l.value === result.intentLevel)?.label}`)
|
toast.success('会话分析完成', `意向评分 ${result.intentScore} · ${INTENT_LEVELS.find(l => l.value === result.intentLevel)?.label}`)
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('分析失败,请稍后重试')
|
toast.error('分析失败,请稍后重试')
|
||||||
} finally { setAnalyzing(false) }
|
} finally { setAnalyzing(false) }
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-y-auto scrollbar-thin">
|
<div className="flex h-full flex-col overflow-y-auto bg-slate-50/40 scrollbar-thin">
|
||||||
{/* 客户基本信息 */}
|
{/* 客户基本信息 */}
|
||||||
<div className="border-b border-border p-4">
|
<div className="border-b border-slate-100 bg-white p-5">
|
||||||
<div className="mb-3 flex items-center gap-3">
|
<div className="mb-3 flex items-center gap-3">
|
||||||
<Avatar name={customer.name} src={customer.avatarUrl} size="lg" />
|
<Avatar name={customer.name} src={customer.avatarUrl} size="lg" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -120,7 +144,7 @@ export function InsightPanel({
|
|||||||
<Badge key={p} variant="primary">{p}</Badge>
|
<Badge key={p} variant="primary">{p}</Badge>
|
||||||
))}
|
))}
|
||||||
{customer.tags?.map((t) => (
|
{customer.tags?.map((t) => (
|
||||||
<Badge key={t} variant="outline">{t}</Badge>
|
<CustomerSemanticTag key={t} tag={t} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -151,9 +175,14 @@ export function InsightPanel({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AI 分析 */}
|
{/* AI 分析 */}
|
||||||
<div className="border-b border-border p-4">
|
<div className="space-y-4 border-b border-slate-100 p-3">
|
||||||
|
<CustomerProfileRadar customer={customer} compact />
|
||||||
|
<div className="rounded-2xl border border-slate-200/80 bg-white p-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="text-xs font-semibold text-muted-foreground">AI 分析摘要</h4>
|
<div>
|
||||||
|
<p className="text-sm font-semibold tracking-tight text-slate-900">会话摘要</p>
|
||||||
|
<p className="text-[11px] text-slate-400">根据已同步的会话内容生成</p>
|
||||||
|
</div>
|
||||||
<Button variant="ghost" size="sm" onClick={handleAnalyze} disabled={analyzing}>
|
<Button variant="ghost" size="sm" onClick={handleAnalyze} disabled={analyzing}>
|
||||||
{analyzing ? <Spinner className="h-3 w-3" /> : '重新分析'}
|
{analyzing ? <Spinner className="h-3 w-3" /> : '重新分析'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -164,41 +193,24 @@ export function InsightPanel({
|
|||||||
<span className="ml-2 text-sm text-muted-foreground">正在分析会话上下文…</span>
|
<span className="ml-2 text-sm text-muted-foreground">正在分析会话上下文…</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">{analysis}</p>
|
<p className="mt-3 text-xs leading-6 text-slate-600">{analysis || '收到更多客户消息后将补充客户画像。'}</p>
|
||||||
)}
|
)}
|
||||||
{/* AI 提取关键信息 */}
|
{/* AI 提取关键信息 */}
|
||||||
{Object.values(customer.aiProfile || {}).some(Boolean) && (
|
{Object.values(customer.aiProfile || {}).some(Boolean) && (
|
||||||
<div className="mt-3 space-y-1 border-t border-border pt-3">
|
<div className="mt-4 grid grid-cols-2 gap-2 border-t border-slate-100 pt-4">
|
||||||
{Object.entries({
|
{Object.entries({
|
||||||
公司: customer.aiProfile.companyName,
|
|
||||||
行业: customer.aiProfile.industry,
|
行业: customer.aiProfile.industry,
|
||||||
商业模式: customer.aiProfile.businessModel,
|
|
||||||
采购角色: customer.aiProfile.purchasingRole,
|
采购角色: customer.aiProfile.purchasingRole,
|
||||||
决策权: customer.aiProfile.decisionPower,
|
|
||||||
询价数量: customer.aiProfile.inquiryQuantity,
|
询价数量: customer.aiProfile.inquiryQuantity,
|
||||||
目标价格: customer.aiProfile.targetPrice,
|
目标价格: customer.aiProfile.targetPrice,
|
||||||
预算: customer.aiProfile.budget,
|
预算: customer.aiProfile.budget,
|
||||||
MOQ: customer.aiProfile.moqRequirement,
|
MOQ: customer.aiProfile.moqRequirement,
|
||||||
样品: customer.aiProfile.sampleNeed,
|
样品: customer.aiProfile.sampleNeed,
|
||||||
定制: customer.aiProfile.customRequirement,
|
|
||||||
交付: customer.aiProfile.deliveryTime,
|
交付: customer.aiProfile.deliveryTime,
|
||||||
紧迫程度: customer.aiProfile.urgency,
|
|
||||||
采购周期: customer.aiProfile.purchaseTimeline,
|
|
||||||
物流: customer.aiProfile.logistics,
|
|
||||||
付款: customer.aiProfile.payment,
|
|
||||||
核心需求: customer.aiProfile.keyNeeds?.join('、'),
|
|
||||||
痛点: customer.aiProfile.painPoints?.join('、'),
|
|
||||||
顾虑: customer.aiProfile.concerns?.join('、'),
|
|
||||||
异议: customer.aiProfile.objections?.join('、'),
|
|
||||||
购买信号: customer.aiProfile.buyingSignals?.join('、'),
|
|
||||||
竞品: customer.aiProfile.competitorMentions?.join('、'),
|
|
||||||
沟通风格: customer.aiProfile.communicationStyle,
|
|
||||||
建议语气: customer.aiProfile.recommendedTone,
|
|
||||||
跟进时机: customer.aiProfile.followUpTiming,
|
|
||||||
}).filter(([, v]) => v).map(([k, v]) => (
|
}).filter(([, v]) => v).map(([k, v]) => (
|
||||||
<div key={k} className="flex gap-2 text-xs">
|
<div key={k} className="min-w-0 rounded-xl bg-slate-50 px-3 py-2">
|
||||||
<span className="shrink-0 text-muted-foreground">{k}</span>
|
<p className="text-[10px] font-medium text-slate-400">{k}</p>
|
||||||
<span className="text-foreground">{v as string}</span>
|
<p className="mt-0.5 truncate text-xs font-medium text-slate-700">{v as string}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -209,7 +221,7 @@ export function InsightPanel({
|
|||||||
['流失概率', customer.aiProfile.churnProbability],
|
['流失概率', customer.aiProfile.churnProbability],
|
||||||
['分析可信度', customer.aiProfile.confidenceScore],
|
['分析可信度', customer.aiProfile.confidenceScore],
|
||||||
].map(([label, value]) => (
|
].map(([label, value]) => (
|
||||||
<div key={String(label)} className="rounded-lg bg-muted/70 px-2 py-2 text-center">
|
<div key={String(label)} className="rounded-xl bg-slate-50 px-2 py-2 text-center">
|
||||||
<p className="text-[10px] text-muted-foreground">{label}</p>
|
<p className="text-[10px] text-muted-foreground">{label}</p>
|
||||||
<p className="text-sm font-semibold">{Number(value || 0)}%</p>
|
<p className="text-sm font-semibold">{Number(value || 0)}%</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -223,17 +235,19 @@ export function InsightPanel({
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 推荐动作 */}
|
{/* 推荐动作 */}
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<h4 className="text-xs font-semibold text-muted-foreground">AI 推荐下一步</h4>
|
<h4 className="text-xs font-semibold text-slate-500">下一步建议</h4>
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
<div className="mt-2 space-y-2">
|
||||||
{customer.aiProfile.nextActions.map((a) => (
|
{customer.aiProfile.nextActions.map((a) => (
|
||||||
<button
|
<button
|
||||||
key={a}
|
key={a}
|
||||||
onClick={() => toast.info(`建议操作:${a}`)}
|
onClick={() => taskMut.mutate(a)}
|
||||||
className="rounded-full border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs text-primary-700 hover:bg-primary-100"
|
disabled={taskMut.isPending}
|
||||||
|
className="w-full rounded-xl border border-slate-200 bg-white px-3 py-2 text-start text-xs text-slate-700 hover:border-primary-200 hover:bg-primary-50"
|
||||||
>
|
>
|
||||||
{a}
|
{a}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -13,14 +13,17 @@ import {
|
|||||||
BookOpen,
|
BookOpen,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Languages,
|
Languages,
|
||||||
|
Globe,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Download,
|
Download,
|
||||||
FileText,
|
FileText,
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
|
SmilePlus,
|
||||||
|
Trash2,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import type { Message } from '@/types'
|
import type { ChannelType, Message } from '@/types'
|
||||||
import { cn } from '@/utils/cn'
|
import { cn } from '@/utils/cn'
|
||||||
import { toast } from '@/app/store/toast'
|
import { toast } from '@/app/store/toast'
|
||||||
import { formatTime } from '@/utils/format'
|
import { formatTime } from '@/utils/format'
|
||||||
@@ -31,6 +34,48 @@ import { getMessageAttachment } from '@/services/api/conversations'
|
|||||||
|
|
||||||
export type ViewMode = 'original' | 'translated' | 'bilingual'
|
export type ViewMode = 'original' | 'translated' | 'bilingual'
|
||||||
|
|
||||||
|
function EmailAttachmentLink({
|
||||||
|
conversationId,
|
||||||
|
messageId,
|
||||||
|
attachment,
|
||||||
|
}: {
|
||||||
|
conversationId: string
|
||||||
|
messageId: string
|
||||||
|
attachment: NonNullable<Message['emailAttachments']>[number]
|
||||||
|
}) {
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['email-attachment', conversationId, messageId, attachment.id],
|
||||||
|
queryFn: () => getMessageAttachment(conversationId, messageId, attachment.id),
|
||||||
|
enabled: attachment.available,
|
||||||
|
staleTime: Infinity,
|
||||||
|
retry: 1,
|
||||||
|
})
|
||||||
|
const size = attachment.size >= 1024 * 1024
|
||||||
|
? `${(attachment.size / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
: `${Math.max(1, Math.round(attachment.size / 1024))} KB`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={data?.dataUrl}
|
||||||
|
download={data?.name || attachment.name}
|
||||||
|
onClick={(event) => {
|
||||||
|
if (!data?.dataUrl) event.preventDefault()
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'mt-2 flex items-center gap-2 rounded-xl border border-slate-200/80 bg-white/75 px-3 py-2 transition-colors',
|
||||||
|
data?.dataUrl && 'hover:border-sky-200 hover:bg-sky-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isLoading ? <LoaderCircle className="h-5 w-5 animate-spin text-sky-500" /> : <FileText className="h-5 w-5 text-sky-500" />}
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-xs font-medium text-slate-700">{attachment.name}</span>
|
||||||
|
<span className="text-[10px] text-slate-400">{error ? '附件暂不可用' : size}</span>
|
||||||
|
</span>
|
||||||
|
{data?.dataUrl && <Download className="h-4 w-4 text-slate-400" />}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function MessageBubble({
|
export function MessageBubble({
|
||||||
msg,
|
msg,
|
||||||
viewMode,
|
viewMode,
|
||||||
@@ -39,8 +84,11 @@ export function MessageBubble({
|
|||||||
onConvertToTask,
|
onConvertToTask,
|
||||||
onAddToKnowledge,
|
onAddToKnowledge,
|
||||||
onAddNote,
|
onAddNote,
|
||||||
|
onReact,
|
||||||
|
onDelete,
|
||||||
customerName,
|
customerName,
|
||||||
customerAvatar,
|
customerAvatar,
|
||||||
|
channel,
|
||||||
}: {
|
}: {
|
||||||
msg: Message
|
msg: Message
|
||||||
viewMode: ViewMode
|
viewMode: ViewMode
|
||||||
@@ -49,24 +97,43 @@ export function MessageBubble({
|
|||||||
onConvertToTask: () => void
|
onConvertToTask: () => void
|
||||||
onAddToKnowledge: () => void
|
onAddToKnowledge: () => void
|
||||||
onAddNote: () => void
|
onAddNote: () => void
|
||||||
|
onReact?: (emoji: string) => void
|
||||||
|
onDelete?: () => void
|
||||||
customerName?: string
|
customerName?: string
|
||||||
customerAvatar?: string
|
customerAvatar?: string
|
||||||
|
channel?: ChannelType
|
||||||
}) {
|
}) {
|
||||||
const [showActions, setShowActions] = useState(false)
|
const [showActions, setShowActions] = useState(false)
|
||||||
const [translationExpanded, setTranslationExpanded] = useState(false)
|
const [translationExpanded, setTranslationExpanded] = useState(false)
|
||||||
const isMe = msg.sender === 'agent'
|
const isMe = msg.sender === 'agent'
|
||||||
const isSystem = msg.sender === 'system'
|
const isSystem = msg.sender === 'system'
|
||||||
|
const originalText = String(msg.originalText || '')
|
||||||
const rawTranslation = String(msg.translatedText || '')
|
const rawTranslation = String(msg.translatedText || '')
|
||||||
const translationCorrupted =
|
const translationCorrupted =
|
||||||
/(?:^|\n)\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:[\d.]+Z\s+(?:客户|坐席):/m.test(rawTranslation) ||
|
/(?:^|\n)\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:[\d.]+Z\s+(?:客户|坐席):/m.test(rawTranslation) ||
|
||||||
rawTranslation.length > (msg.originalText.length <= 20 ? 160 : Math.max(500, msg.originalText.length * 8))
|
rawTranslation.length > (originalText.length <= 20 ? 160 : Math.max(500, originalText.length * 8))
|
||||||
const translatedText = translationCorrupted ? '' : rawTranslation
|
const translatedText = translationCorrupted ? '' : rawTranslation
|
||||||
const hasTranslation = translatedText && translatedText !== msg.originalText
|
const hasTranslation = translatedText && translatedText !== originalText
|
||||||
|
const outgoingChineseNote = isMe
|
||||||
|
? String(
|
||||||
|
msg.translationCn ||
|
||||||
|
(hasTranslation && /[\u3400-\u9fff]/u.test(originalText) ? originalText : ''),
|
||||||
|
).trim()
|
||||||
|
: ''
|
||||||
|
const outgoingSentText = isMe && translatedText ? translatedText : originalText
|
||||||
const longTranslation = translatedText.length > 260 || translatedText.split('\n').length > 6
|
const longTranslation = translatedText.length > 260 || translatedText.split('\n').length > 6
|
||||||
|
const hasKnownLanguage = Boolean(msg.originalLang && !['und', 'auto', 'unknown'].includes(msg.originalLang))
|
||||||
|
const sourceLanguage = hasKnownLanguage ? languageName(msg.originalLang) : '原文'
|
||||||
|
const providerName = channel === 'whatsapp' ? 'WhatsApp' : channel === 'email' ? 'Email' : 'Telegram'
|
||||||
|
const isMediaMessage = ['image', 'audio', 'file'].includes(msg.type)
|
||||||
|
const hasProviderAttachment = Boolean(
|
||||||
|
msg.telegramFileId
|
||||||
|
|| (msg.whatsappMessageId && isMediaMessage),
|
||||||
|
)
|
||||||
const { data: attachment, isLoading: attachmentLoading, error: attachmentError } = useQuery({
|
const { data: attachment, isLoading: attachmentLoading, error: attachmentError } = useQuery({
|
||||||
queryKey: ['message-attachment', msg.conversationId, msg.id, msg.telegramFileId],
|
queryKey: ['message-attachment', msg.conversationId, msg.id, msg.telegramFileId, msg.whatsappMessageId],
|
||||||
queryFn: () => getMessageAttachment(msg.conversationId, msg.id),
|
queryFn: () => getMessageAttachment(msg.conversationId, msg.id),
|
||||||
enabled: Boolean(msg.telegramFileId),
|
enabled: hasProviderAttachment,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
})
|
})
|
||||||
@@ -74,7 +141,7 @@ export function MessageBubble({
|
|||||||
if (isSystem) {
|
if (isSystem) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center py-2">
|
<div className="flex justify-center py-2">
|
||||||
<span className="rounded-full bg-amber-50 px-3 py-1 text-xs text-amber-700">{msg.originalText}</span>
|
<span className="rounded-full bg-amber-50 px-3 py-1 text-xs text-amber-700">{originalText}</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -104,6 +171,28 @@ export function MessageBubble({
|
|||||||
)
|
)
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
|
if (msg.isDeleted) {
|
||||||
|
return (
|
||||||
|
<p className="flex items-center gap-1.5 text-xs italic text-slate-400">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5" />
|
||||||
|
该消息已撤回
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (msg.type !== 'text') {
|
||||||
|
const labels = { image: '图片', audio: '语音', file: '文件', product: '产品卡片' }
|
||||||
|
return originalText ? (
|
||||||
|
<p className="mb-2 whitespace-pre-wrap break-words text-sm leading-6 text-slate-700">{originalText}</p>
|
||||||
|
) : (
|
||||||
|
<div className="mb-1 flex items-center gap-2 text-xs font-medium text-slate-500">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-primary-400" />
|
||||||
|
{labels[msg.type]}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!originalText.trim()) {
|
||||||
|
return <p className="text-xs text-slate-500">{providerName} 服务通知</p>
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
(viewMode === 'bilingual' || viewMode === 'translated') &&
|
(viewMode === 'bilingual' || viewMode === 'translated') &&
|
||||||
msg.sender === 'customer' &&
|
msg.sender === 'customer' &&
|
||||||
@@ -112,7 +201,7 @@ export function MessageBubble({
|
|||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
<p className="whitespace-pre-wrap break-words text-sm leading-6">{msg.originalText}</p>
|
<p className="whitespace-pre-wrap break-words text-sm leading-6">{originalText}</p>
|
||||||
<div className="flex items-center gap-1.5 border-t border-border/60 pt-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-1.5 border-t border-border/60 pt-2 text-xs text-muted-foreground">
|
||||||
<Languages className="h-3.5 w-3.5 text-primary" />
|
<Languages className="h-3.5 w-3.5 text-primary" />
|
||||||
{translationCorrupted
|
{translationCorrupted
|
||||||
@@ -124,14 +213,56 @@ export function MessageBubble({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (isMe && viewMode === 'bilingual') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
<div>
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100/80 px-2 py-0.5 text-[10px] font-medium text-blue-700">
|
||||||
|
<Globe className="h-3 w-3" />
|
||||||
|
客户收到 · {translatedText ? '英文译文' : '原文'}
|
||||||
|
</span>
|
||||||
|
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{outgoingSentText}</p>
|
||||||
|
</div>
|
||||||
|
{outgoingChineseNote ? (
|
||||||
|
<div data-testid="outgoing-chinese-note" className="border-t border-blue-200/60 pt-2">
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-white/65 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
||||||
|
<Languages className="h-3 w-3 text-primary" />
|
||||||
|
中文备注 · 仅内部可见
|
||||||
|
</span>
|
||||||
|
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6 text-slate-600">
|
||||||
|
{outgoingChineseNote}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : msg.translationStatus === 'pending' || msg.translationStatus === 'failed' ? (
|
||||||
|
<div className="flex items-center gap-1.5 border-t border-blue-200/60 pt-2 text-xs text-slate-500">
|
||||||
|
<Languages className="h-3.5 w-3.5 text-primary" />
|
||||||
|
{msg.translationStatus === 'failed' ? '中文备注生成失败,将自动重试' : 'AI 正在生成中文备注…'}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (isMe && viewMode === 'translated' && !translatedText && outgoingChineseNote) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-white/65 px-2 py-0.5 text-[10px] font-medium text-slate-600">
|
||||||
|
<Languages className="h-3 w-3 text-primary" />
|
||||||
|
中文备注 · 仅内部可见
|
||||||
|
</span>
|
||||||
|
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6 text-slate-700">
|
||||||
|
{outgoingChineseNote}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
if (viewMode === 'bilingual' && hasTranslation) {
|
if (viewMode === 'bilingual' && hasTranslation) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
<div>
|
<div>
|
||||||
<span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
<span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||||
原文 · {languageName(msg.originalLang)}
|
{hasKnownLanguage ? `原文 · ${sourceLanguage}` : '原文'}
|
||||||
</span>
|
</span>
|
||||||
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{msg.originalText}</p>
|
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{originalText}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-t border-border/60 pt-2">
|
<div className="border-t border-border/60 pt-2">
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-0.5 text-[10px] font-medium text-primary">
|
<span className="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-0.5 text-[10px] font-medium text-primary">
|
||||||
@@ -148,7 +279,7 @@ export function MessageBubble({
|
|||||||
<div>
|
<div>
|
||||||
<span className="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-0.5 text-[10px] font-medium text-primary">
|
<span className="inline-flex items-center gap-1 rounded-full bg-primary-50 px-2 py-0.5 text-[10px] font-medium text-primary">
|
||||||
<Languages className="h-3 w-3" />
|
<Languages className="h-3 w-3" />
|
||||||
{isMe ? `${languageName(msg.originalLang)} → 英文发送内容` : `${languageName(msg.originalLang)} → 中文`}
|
{isMe ? `${sourceLanguage} → 英文发送内容` : `${sourceLanguage} → 中文`}
|
||||||
</span>
|
</span>
|
||||||
<div className="mt-1.5">{translationContent(false)}</div>
|
<div className="mt-1.5">{translationContent(false)}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -156,10 +287,14 @@ export function MessageBubble({
|
|||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
{hasKnownLanguage && (
|
||||||
{languageName(msg.originalLang)}
|
<span className="inline-flex rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
|
||||||
</span>
|
{sourceLanguage}
|
||||||
<p className="mt-1.5 whitespace-pre-wrap break-words text-sm leading-6">{msg.originalText}</p>
|
</span>
|
||||||
|
)}
|
||||||
|
<p className={cn('whitespace-pre-wrap break-words text-sm leading-6', hasKnownLanguage && 'mt-1.5')}>
|
||||||
|
{originalText}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -182,12 +317,18 @@ export function MessageBubble({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const moreActions = [
|
const moreActions = [
|
||||||
{ key: 'copy', label: '复制', icon: Copy, onSelect: () => { navigator.clipboard.writeText(msg.originalText); toast.success('已复制') } },
|
{ key: 'copy', label: '复制', icon: Copy, onSelect: () => { void navigator.clipboard.writeText(originalText); toast.success('已复制') } },
|
||||||
{ key: 'reply', label: '引用回复', icon: Reply, onSelect: onQuote },
|
{ key: 'reply', label: '引用回复', icon: Reply, onSelect: onQuote },
|
||||||
|
...(channel === 'whatsapp' && onReact
|
||||||
|
? [{ key: 'react', label: '👍 回应', icon: SmilePlus, onSelect: () => onReact('👍') }]
|
||||||
|
: []),
|
||||||
{ key: 'note', label: '添加内部备注', icon: StickyNote, onSelect: onAddNote },
|
{ key: 'note', label: '添加内部备注', icon: StickyNote, onSelect: onAddNote },
|
||||||
{ key: 'important', label: msg.isImportant ? '取消重要' : '标记重要', icon: Star, onSelect: onMarkImportant },
|
{ key: 'important', label: msg.isImportant ? '取消重要' : '标记重要', icon: Star, onSelect: onMarkImportant },
|
||||||
{ key: 'task', label: '转为待办', icon: ListTodo, onSelect: onConvertToTask },
|
{ key: 'task', label: '转为待办', icon: ListTodo, onSelect: onConvertToTask },
|
||||||
{ key: 'knowledge', label: '添加到知识库', icon: BookOpen, onSelect: onAddToKnowledge },
|
{ key: 'knowledge', label: '添加到知识库', icon: BookOpen, onSelect: onAddToKnowledge },
|
||||||
|
...(channel === 'whatsapp' && isMe && onDelete
|
||||||
|
? [{ key: 'delete', label: '撤回消息', icon: Trash2, danger: true, onSelect: onDelete }]
|
||||||
|
: []),
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -195,7 +336,7 @@ export function MessageBubble({
|
|||||||
data-testid="message-bubble"
|
data-testid="message-bubble"
|
||||||
data-sender={msg.sender}
|
data-sender={msg.sender}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group flex gap-2 px-4 py-2.5 will-change-transform',
|
'group flex gap-2 px-3 py-2 will-change-transform sm:px-4 sm:py-2.5',
|
||||||
isMe ? 'justify-end motion-safe:animate-message-in-right' : 'justify-start motion-safe:animate-message-in-left',
|
isMe ? 'justify-end motion-safe:animate-message-in-right' : 'justify-start motion-safe:animate-message-in-left',
|
||||||
)}
|
)}
|
||||||
onMouseEnter={() => setShowActions(true)}
|
onMouseEnter={() => setShowActions(true)}
|
||||||
@@ -203,7 +344,7 @@ export function MessageBubble({
|
|||||||
>
|
>
|
||||||
{!isMe && (
|
{!isMe && (
|
||||||
<Avatar
|
<Avatar
|
||||||
name={customerName || msg.senderName || 'Telegram 用户'}
|
name={customerName || msg.senderName || `${providerName} 用户`}
|
||||||
src={customerAvatar}
|
src={customerAvatar}
|
||||||
size="xs"
|
size="xs"
|
||||||
className="order-1 mt-1"
|
className="order-1 mt-1"
|
||||||
@@ -217,11 +358,13 @@ export function MessageBubble({
|
|||||||
)}
|
)}
|
||||||
{/* 消息气泡 */}
|
{/* 消息气泡 */}
|
||||||
<div
|
<div
|
||||||
|
data-testid="message-surface"
|
||||||
|
data-direction={isMe ? 'outgoing' : 'incoming'}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative rounded-2xl px-3.5 py-3 shadow-sm',
|
'relative isolate rounded-2xl px-3.5 py-2.5 transition-shadow',
|
||||||
isMe
|
isMe
|
||||||
? 'rounded-br-md border border-primary/15 bg-gradient-to-br from-primary-50 to-emerald-50/80'
|
? 'telegram-bubble-out rounded-br-[5px]'
|
||||||
: 'rounded-bl-md border border-border/90 bg-card',
|
: 'telegram-bubble-in rounded-bl-[5px]',
|
||||||
msg.isImportant && 'ring-1 ring-amber-400',
|
msg.isImportant && 'ring-1 ring-amber-400',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -234,9 +377,9 @@ export function MessageBubble({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
{msg.telegramFileId && attachmentLoading && (
|
{hasProviderAttachment && attachmentLoading && (
|
||||||
<div className="mt-2 flex items-center gap-2 rounded-lg bg-muted/70 px-3 py-2 text-xs text-muted-foreground">
|
<div className="mt-2 flex items-center gap-2 rounded-lg bg-muted/70 px-3 py-2 text-xs text-muted-foreground">
|
||||||
<LoaderCircle className="h-3.5 w-3.5 animate-spin" /> 正在读取 Telegram 文件…
|
<LoaderCircle className="h-3.5 w-3.5 animate-spin" /> 正在读取 {providerName} 文件…
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{attachment && msg.type === 'image' && (
|
{attachment && msg.type === 'image' && (
|
||||||
@@ -264,14 +407,36 @@ export function MessageBubble({
|
|||||||
<Download className="h-4 w-4 text-muted-foreground" />
|
<Download className="h-4 w-4 text-muted-foreground" />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{msg.telegramFileId && attachmentError && (
|
{hasProviderAttachment && attachmentError && (
|
||||||
<p className="mt-2 rounded-lg bg-red-50 px-2.5 py-1.5 text-xs text-red-600">文件暂时无法下载,请稍后重试</p>
|
<p className="mt-2 rounded-lg bg-red-50 px-2.5 py-1.5 text-xs text-red-600">文件暂时无法下载,请稍后重试</p>
|
||||||
)}
|
)}
|
||||||
|
{msg.emailAttachments?.map((emailAttachment) => (
|
||||||
|
<EmailAttachmentLink
|
||||||
|
key={emailAttachment.id}
|
||||||
|
conversationId={msg.conversationId}
|
||||||
|
messageId={msg.id}
|
||||||
|
attachment={emailAttachment}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{Boolean(msg.reactions?.length) && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{msg.reactions?.map((reaction, index) => (
|
||||||
|
<span
|
||||||
|
key={`${reaction.actor}-${reaction.emoji}-${index}`}
|
||||||
|
title={reaction.fromMe ? '我' : reaction.actor}
|
||||||
|
className="inline-flex min-w-7 items-center justify-center rounded-full border border-blue-200 bg-blue-50/90 px-2 py-0.5 text-sm shadow-sm"
|
||||||
|
>
|
||||||
|
{reaction.emoji}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 底栏:时间 + 状态 */}
|
{/* 底栏:时间 + 状态 */}
|
||||||
<div className={cn('mt-0.5 flex items-center gap-1.5 text-[10px] text-muted-foreground', isMe ? 'justify-end' : 'justify-start')}>
|
<div className={cn('mt-0.5 flex items-center gap-1.5 text-[10px] text-muted-foreground', isMe ? 'justify-end' : 'justify-start')}>
|
||||||
<span>{formatTime(msg.createdAt)}</span>
|
<span>{formatTime(msg.createdAt)}</span>
|
||||||
|
{msg.editedAt && !msg.isDeleted && <span>已编辑</span>}
|
||||||
{isMe && statusIcon()}
|
{isMe && statusIcon()}
|
||||||
{msg.isImportant && <Star className="h-2.5 w-2.5 fill-amber-400 text-amber-400" />}
|
{msg.isImportant && <Star className="h-2.5 w-2.5 fill-amber-400 text-amber-400" />}
|
||||||
{msg.internalNote && <StickyNote className="h-2.5 w-2.5 text-blue-500" />}
|
{msg.internalNote && <StickyNote className="h-2.5 w-2.5 text-blue-500" />}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function AppLayout() {
|
|||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<Topbar />
|
<Topbar />
|
||||||
<main className="flex-1 overflow-y-auto scrollbar-thin" id="main-scroll">
|
<main className="flex-1 overflow-y-auto bg-white/35 scrollbar-thin" id="main-scroll">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ export function GlobalRealtime() {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token) return
|
// Inbox owns the richer message socket so it can update message and
|
||||||
|
// conversation caches atomically. Keep only one live socket per page.
|
||||||
|
if (!token || location.pathname.startsWith('/inbox')) return
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||||
let socket: WebSocket | undefined
|
let socket: WebSocket | undefined
|
||||||
let reconnectTimer: ReturnType<typeof setTimeout> | undefined
|
let reconnectTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
@@ -34,12 +36,12 @@ export function GlobalRealtime() {
|
|||||||
socket.onmessage = (raw) => {
|
socket.onmessage = (raw) => {
|
||||||
let event: { type?: string; message?: Message; customer?: Customer; conversationId?: string }
|
let event: { type?: string; message?: Message; customer?: Customer; conversationId?: string }
|
||||||
try { event = JSON.parse(String(raw.data)) } catch { return }
|
try { event = JSON.parse(String(raw.data)) } catch { return }
|
||||||
if (event.type !== 'message.created' || event.message?.sender !== 'customer') return
|
if (!['message.created', 'message.received'].includes(event.type || '') || event.message?.sender !== 'customer') return
|
||||||
void queryClient.invalidateQueries({ queryKey: ['notifications'] })
|
void queryClient.invalidateQueries({ queryKey: ['notifications'] })
|
||||||
void queryClient.invalidateQueries({ queryKey: ['conversations'] })
|
void queryClient.invalidateQueries({ queryKey: ['conversations'] })
|
||||||
if (!location.pathname.startsWith('/inbox')) {
|
if (!location.pathname.startsWith('/inbox')) {
|
||||||
const preview = event.message.translatedText || event.message.originalText || `[${event.message.type}]`
|
const preview = event.message.translatedText || event.message.originalText || `[${event.message.type}]`
|
||||||
toast.info(`${event.customer?.name || event.message.senderName || 'Telegram 客户'} 发来新消息`, preview.slice(0, 120))
|
toast.info(`${event.customer?.name || event.message.senderName || '客户'} 发来新消息`, preview.slice(0, 120))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
socket.onerror = () => socket?.close()
|
socket.onerror = () => socket?.close()
|
||||||
|
|||||||
@@ -26,33 +26,49 @@ export function Sidebar() {
|
|||||||
const usagePct = msgUsage ? Math.round((msgUsage.used / msgUsage.total) * 100) : 0
|
const usagePct = msgUsage ? Math.round((msgUsage.used / msgUsage.total) * 100) : 0
|
||||||
|
|
||||||
const items = NAV_ITEMS.filter((item) => !item.permission || can(item.permission))
|
const items = NAV_ITEMS.filter((item) => !item.permission || can(item.permission))
|
||||||
|
const groups = [
|
||||||
|
{ key: 'core', label: '核心业务' },
|
||||||
|
{ key: 'intelligence', label: '知识与洞察' },
|
||||||
|
{ key: 'admin', label: '企业管理' },
|
||||||
|
] as const
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-full flex-col border-e border-border bg-card transition-[width] duration-200',
|
'relative z-30 flex h-full flex-col border-e border-slate-300/80 bg-[#f8fafc] shadow-[4px_0_16px_-14px_rgba(15,23,42,.32)] transition-[width] duration-200',
|
||||||
sidebarCollapsed ? 'w-16' : 'w-60',
|
sidebarCollapsed ? 'w-[68px]' : 'w-[228px]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div
|
<div
|
||||||
className="flex h-14 cursor-pointer items-center gap-2.5 border-b border-border px-4"
|
className="flex h-16 cursor-pointer items-center gap-2.5 px-4"
|
||||||
onClick={() => navigate('/dashboard')}
|
onClick={() => navigate('/dashboard')}
|
||||||
>
|
>
|
||||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
<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]" />
|
<Sparkles className="h-4.5 w-4.5 h-[18px] w-[18px]" />
|
||||||
</span>
|
</span>
|
||||||
{!sidebarCollapsed && (
|
{!sidebarCollapsed && (
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-semibold leading-tight">{t('app.name')}</p>
|
<p className="truncate text-[15px] font-semibold tracking-tight leading-tight">{t('app.name')}</p>
|
||||||
<p className="truncate text-[11px] text-muted-foreground leading-tight">{t('app.slogan')}</p>
|
<p className="truncate text-[11px] text-muted-foreground leading-tight">{t('app.slogan')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 导航 */}
|
{/* 导航 */}
|
||||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 py-3 scrollbar-thin">
|
<nav className="flex-1 overflow-y-auto px-2.5 py-2 scrollbar-thin">
|
||||||
{items.map((item) => (
|
{groups.map((group) => {
|
||||||
|
const groupItems = items.filter((item) => item.group === group.key)
|
||||||
|
if (!groupItems.length) return null
|
||||||
|
return (
|
||||||
|
<div key={group.key} className="mb-3">
|
||||||
|
{!sidebarCollapsed && (
|
||||||
|
<p className="mb-1.5 px-3 pt-2 text-[10px] font-semibold uppercase tracking-[0.14em] text-slate-400">
|
||||||
|
{group.label}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-1">
|
||||||
|
{groupItems.map((item) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={item.key}
|
key={item.key}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
@@ -60,10 +76,10 @@ export function Sidebar() {
|
|||||||
title={sidebarCollapsed ? t(item.labelKey as never) : undefined}
|
title={sidebarCollapsed ? t(item.labelKey as never) : undefined}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm transition-colors',
|
'flex items-center gap-3 rounded-xl px-3 py-2.5 text-[13px] transition-all duration-150',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-primary-50 font-medium text-primary-700'
|
? 'bg-primary-50/90 font-semibold text-primary-800 shadow-[inset_0_0_0_1px_rgba(37,99,235,.08)]'
|
||||||
: 'text-secondary-foreground hover:bg-muted',
|
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900',
|
||||||
sidebarCollapsed && 'justify-center px-0',
|
sidebarCollapsed && 'justify-center px-0',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -71,7 +87,11 @@ export function Sidebar() {
|
|||||||
<item.icon className="h-[18px] w-[18px] shrink-0" />
|
<item.icon className="h-[18px] w-[18px] shrink-0" />
|
||||||
{!sidebarCollapsed && <span className="truncate">{t(item.labelKey as never)}</span>}
|
{!sidebarCollapsed && <span className="truncate">{t(item.labelKey as never)}</span>}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* 底部:套餐与企业信息 */}
|
{/* 底部:套餐与企业信息 */}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { cn } from '@/utils/cn'
|
|||||||
|
|
||||||
const NOTIF_TYPE_COLOR: Record<string, string> = {
|
const NOTIF_TYPE_COLOR: Record<string, string> = {
|
||||||
inquiry: 'bg-emerald-100 text-emerald-700',
|
inquiry: 'bg-emerald-100 text-emerald-700',
|
||||||
|
message: 'bg-sky-100 text-sky-700',
|
||||||
risk: 'bg-red-100 text-red-700',
|
risk: 'bg-red-100 text-red-700',
|
||||||
task: 'bg-amber-100 text-amber-700',
|
task: 'bg-amber-100 text-amber-700',
|
||||||
channel: 'bg-blue-100 text-blue-700',
|
channel: 'bg-blue-100 text-blue-700',
|
||||||
@@ -61,7 +62,7 @@ export function Topbar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-border bg-card px-3 md:px-4">
|
<header className="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
|
<button
|
||||||
className="rounded-md p-2 text-muted-foreground hover:bg-muted lg:hidden"
|
className="rounded-md p-2 text-muted-foreground hover:bg-muted lg:hidden"
|
||||||
@@ -155,7 +156,7 @@ export function Topbar() {
|
|||||||
label: (
|
label: (
|
||||||
<span className="flex flex-col gap-0.5 py-0.5">
|
<span className="flex flex-col gap-0.5 py-0.5">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<Badge className={NOTIF_TYPE_COLOR[n.type]}>{n.type === 'inquiry' ? '询盘' : n.type === 'risk' ? '风险' : n.type === 'task' ? '任务' : n.type === 'channel' ? '渠道' : '系统'}</Badge>
|
<Badge className={NOTIF_TYPE_COLOR[n.type]}>{n.type === 'inquiry' ? '询盘' : n.type === 'message' ? '消息' : n.type === 'risk' ? '风险' : n.type === 'task' ? '任务' : n.type === 'channel' ? '渠道' : '系统'}</Badge>
|
||||||
{!n.read && <span className="h-1.5 w-1.5 rounded-full bg-red-500" />}
|
{!n.read && <span className="h-1.5 w-1.5 rounded-full bg-red-500" />}
|
||||||
<span className="text-[11px] text-muted-foreground">{formatRelative(n.createdAt)}</span>
|
<span className="text-[11px] text-muted-foreground">{formatRelative(n.createdAt)}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { cn } from '@/utils/cn'
|
|||||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn('rounded-lg border border-border bg-card shadow-[0_1px_2px_rgba(16,24,40,0.04)]', className)}
|
className={cn('rounded-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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -19,9 +19,9 @@ export function CardHeader({
|
|||||||
...props
|
...props
|
||||||
}: HTMLAttributes<HTMLDivElement> & { title?: ReactNode; description?: ReactNode; action?: ReactNode }) {
|
}: HTMLAttributes<HTMLDivElement> & { title?: ReactNode; description?: ReactNode; action?: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex items-start justify-between gap-3 border-b border-border px-4 py-3', className)} {...props}>
|
<div className={cn('flex items-start justify-between gap-3 border-b border-slate-100 px-5 py-4', className)} {...props}>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
{title && <h3 className="text-sm font-semibold text-foreground">{title}</h3>}
|
{title && <h3 className="text-sm font-semibold tracking-tight text-foreground">{title}</h3>}
|
||||||
{description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
|
{description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function Dropdown({
|
|||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute z-40 mt-1 min-w-[160px] overflow-hidden rounded-md border border-border bg-popover py-1 shadow-lg animate-slide-in-top',
|
'absolute z-[80] mt-1 min-w-[160px] overflow-hidden rounded-md border border-border bg-popover py-1 shadow-lg animate-slide-in-top',
|
||||||
align === 'end' ? 'right-0 rtl:right-auto rtl:left-0' : 'left-0 rtl:left-auto rtl:right-0',
|
align === 'end' ? 'right-0 rtl:right-auto rtl:left-0' : 'left-0 rtl:left-auto rtl:right-0',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -57,23 +57,35 @@ export function Segmented<T extends string>({
|
|||||||
options,
|
options,
|
||||||
size = 'default',
|
size = 'default',
|
||||||
className,
|
className,
|
||||||
|
ariaLabel = '切换选项',
|
||||||
}: {
|
}: {
|
||||||
value: T
|
value: T
|
||||||
onChange: (v: T) => void
|
onChange: (v: T) => void
|
||||||
options: { value: T; label: ReactNode }[]
|
options: { value: T; label: ReactNode }[]
|
||||||
size?: 'sm' | 'default'
|
size?: 'sm' | 'default'
|
||||||
className?: string
|
className?: string
|
||||||
|
ariaLabel?: string
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('inline-flex items-center rounded-md bg-muted p-0.5', className)}>
|
<div
|
||||||
|
role="radiogroup"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className={cn('inline-flex items-center rounded-lg border border-slate-200 bg-slate-100/90 p-0.5 shadow-inner', className)}
|
||||||
|
>
|
||||||
{options.map((o) => (
|
{options.map((o) => (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={o.value === value}
|
||||||
|
data-state={o.value === value ? 'active' : 'inactive'}
|
||||||
key={o.value}
|
key={o.value}
|
||||||
onClick={() => onChange(o.value)}
|
onClick={() => onChange(o.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded px-2.5 text-xs font-medium transition-colors',
|
'relative rounded-md px-2.5 text-xs font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40',
|
||||||
size === 'sm' ? 'h-6' : 'h-7',
|
size === 'sm' ? 'h-6' : 'h-7',
|
||||||
o.value === value ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
|
o.value === value
|
||||||
|
? 'bg-primary text-white shadow-sm shadow-primary/20'
|
||||||
|
: 'text-slate-500 hover:bg-white/70 hover:text-slate-900',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{o.label}
|
{o.label}
|
||||||
|
|||||||
+11
-16
@@ -3,15 +3,12 @@ import {
|
|||||||
MessagesSquare,
|
MessagesSquare,
|
||||||
Users,
|
Users,
|
||||||
KanbanSquare,
|
KanbanSquare,
|
||||||
Languages,
|
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
Cable,
|
Cable,
|
||||||
UsersRound,
|
UsersRound,
|
||||||
CreditCard,
|
|
||||||
Settings,
|
Settings,
|
||||||
GraduationCap,
|
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { Permission } from './roles'
|
import type { Permission } from './roles'
|
||||||
@@ -23,20 +20,18 @@ export interface NavItem {
|
|||||||
labelKey: string
|
labelKey: string
|
||||||
icon: LucideIcon
|
icon: LucideIcon
|
||||||
permission?: Permission
|
permission?: Permission
|
||||||
|
group: 'core' | 'intelligence' | 'admin'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NAV_ITEMS: NavItem[] = [
|
export const NAV_ITEMS: NavItem[] = [
|
||||||
{ key: 'dashboard', path: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard },
|
{ key: 'dashboard', path: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard, group: 'core' },
|
||||||
{ key: 'inbox', path: '/inbox', labelKey: 'nav.inbox', icon: MessagesSquare },
|
{ key: 'inbox', path: '/inbox', labelKey: 'nav.inbox', icon: MessagesSquare, group: 'core' },
|
||||||
{ key: 'customers', path: '/customers', labelKey: 'nav.customers', icon: Users, permission: 'customer:view' },
|
{ key: 'customers', path: '/customers', labelKey: 'nav.customers', icon: Users, permission: 'customer:view', group: 'core' },
|
||||||
{ key: 'opportunities', path: '/opportunities', labelKey: 'nav.opportunities', icon: KanbanSquare },
|
{ key: 'opportunities', path: '/opportunities', labelKey: 'nav.opportunities', icon: KanbanSquare, permission: 'opportunity:manage', group: 'core' },
|
||||||
{ key: 'translate', path: '/translate', labelKey: 'nav.translate', icon: Languages },
|
{ key: 'risks', path: '/risks', labelKey: 'nav.risks', icon: ShieldAlert, permission: 'risk:handle', group: 'core' },
|
||||||
{ key: 'risks', path: '/risks', labelKey: 'nav.risks', icon: ShieldAlert },
|
{ key: 'knowledge', path: '/knowledge', labelKey: 'nav.knowledge', icon: BookOpen, permission: 'knowledge:manage', group: 'intelligence' },
|
||||||
{ key: 'knowledge', path: '/knowledge', labelKey: 'nav.knowledge', icon: BookOpen },
|
{ key: 'analytics', path: '/analytics', labelKey: 'nav.analytics', icon: BarChart3, permission: 'analytics:view', group: 'intelligence' },
|
||||||
{ key: 'analytics', path: '/analytics', labelKey: 'nav.analytics', icon: BarChart3, permission: 'analytics:view' },
|
{ key: 'channels', path: '/channels', labelKey: 'nav.channels', icon: Cable, permission: 'channel:manage', group: 'admin' },
|
||||||
{ key: 'channels', path: '/channels', labelKey: 'nav.channels', icon: Cable },
|
{ key: 'team', path: '/team', labelKey: 'nav.team', icon: UsersRound, permission: 'team:manage', group: 'admin' },
|
||||||
{ key: 'team', path: '/team', labelKey: 'nav.team', icon: UsersRound },
|
{ key: 'settings', path: '/settings', labelKey: 'nav.settings', icon: Settings, permission: 'settings:manage', group: 'admin' },
|
||||||
{ key: 'billing', path: '/billing', labelKey: 'nav.billing', icon: CreditCard },
|
|
||||||
{ key: 'settings', path: '/settings', labelKey: 'nav.settings', icon: Settings },
|
|
||||||
{ key: 'help', path: '/help', labelKey: 'nav.help', icon: GraduationCap },
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export const PRICING_PLANS: PricingPlan[] = [
|
|||||||
{
|
{
|
||||||
key: 'pro',
|
key: 'pro',
|
||||||
name: '专业版',
|
name: '专业版',
|
||||||
tagline: '5—20 人团队的 AI 经营中枢',
|
tagline: '多渠道客户沟通与团队协作',
|
||||||
priceNote: '¥49,800/年',
|
priceNote: '¥49,800/年',
|
||||||
monthlyPrice: null,
|
monthlyPrice: null,
|
||||||
yearlyPrice: 49800,
|
yearlyPrice: 49800,
|
||||||
@@ -66,9 +66,9 @@ export const PRICING_PLANS: PricingPlan[] = [
|
|||||||
features: [
|
features: [
|
||||||
'包含标准版全部能力',
|
'包含标准版全部能力',
|
||||||
'客户情感分析与意图预测',
|
'客户情感分析与意图预测',
|
||||||
'AI 智能回复建议(多策略)',
|
'多策略回复建议',
|
||||||
'风险预警与文化敏感检测',
|
'风险预警与文化敏感检测',
|
||||||
'老板经营看板',
|
'业务数据看板',
|
||||||
],
|
],
|
||||||
limits: { members: 20, messagesPerMonth: 100000, translationsPerMonth: 200000, knowledgeStorageMb: 5120 },
|
limits: { members: 20, messagesPerMonth: 100000, translationsPerMonth: 200000, knowledgeStorageMb: 5120 },
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ export const ROLE_META: Record<RoleKey, { label: string; description: string; co
|
|||||||
admin: { label: '企业管理员', description: '管理企业、成员、权限、渠道、套餐与系统设置', color: 'bg-violet-100 text-violet-700' },
|
admin: { label: '企业管理员', description: '管理企业、成员、权限、渠道、套餐与系统设置', color: 'bg-violet-100 text-violet-700' },
|
||||||
manager: { label: '业务主管', description: '查看全部客户、团队数据、经营看板与客户分配', color: 'bg-blue-100 text-blue-700' },
|
manager: { label: '业务主管', description: '查看全部客户、团队数据、经营看板与客户分配', color: 'bg-blue-100 text-blue-700' },
|
||||||
sales: { label: '外贸业务员', description: '查看分配给自己的客户、消息与跟进任务', color: 'bg-teal-100 text-teal-700' },
|
sales: { label: '外贸业务员', description: '查看分配给自己的客户、消息与跟进任务', color: 'bg-teal-100 text-teal-700' },
|
||||||
service: { label: '客服人员', description: '处理跨渠道消息、翻译、AI回复与售后咨询', color: 'bg-amber-100 text-amber-700' },
|
service: { label: '客服人员', description: '处理跨渠道消息、翻译、回复建议与售后咨询', color: 'bg-amber-100 text-amber-700' },
|
||||||
viewer: { label: '只读成员', description: '仅可查看被授权的数据,不能编辑或发送消息', color: 'bg-gray-100 text-gray-600' },
|
viewer: { label: '只读成员', description: '仅可查看被授权的数据,不能编辑或发送消息', color: 'bg-gray-100 text-gray-600' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const en: LocaleDict = {
|
|||||||
'app.company': 'Xuzhou Ouhui Technology Co., Ltd.',
|
'app.company': 'Xuzhou Ouhui Technology Co., Ltd.',
|
||||||
|
|
||||||
'nav.dashboard': 'Workspace',
|
'nav.dashboard': 'Workspace',
|
||||||
'nav.inbox': 'Unified Inbox',
|
'nav.inbox': 'Messages',
|
||||||
'nav.customers': 'Customers',
|
'nav.customers': 'Customers',
|
||||||
'nav.opportunities': 'Deals & Follow-ups',
|
'nav.opportunities': 'Deals & Follow-ups',
|
||||||
'nav.translate': 'AI Translation',
|
'nav.translate': 'AI Translation',
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ export const zh = {
|
|||||||
'app.company': '徐州欧汇科技有限公司',
|
'app.company': '徐州欧汇科技有限公司',
|
||||||
|
|
||||||
'nav.dashboard': '工作台',
|
'nav.dashboard': '工作台',
|
||||||
'nav.inbox': '统一消息',
|
'nav.inbox': '消息中心',
|
||||||
'nav.customers': '客户管理',
|
'nav.customers': '客户管理',
|
||||||
'nav.opportunities': '商机与跟进',
|
'nav.opportunities': '商机与跟进',
|
||||||
'nav.translate': 'AI翻译',
|
'nav.translate': 'AI翻译',
|
||||||
'nav.interpret': '同声传译',
|
'nav.interpret': '同声传译',
|
||||||
'nav.risks': '风险预警',
|
'nav.risks': '风险工单',
|
||||||
'nav.knowledge': '企业知识库',
|
'nav.knowledge': '企业知识库',
|
||||||
'nav.analytics': '数据分析',
|
'nav.analytics': '数据分析',
|
||||||
'nav.channels': '渠道中心',
|
'nav.channels': '渠道与账号',
|
||||||
'nav.team': '团队协作',
|
'nav.team': '团队协作',
|
||||||
'nav.billing': '套餐与账单',
|
'nav.billing': '套餐与账单',
|
||||||
'nav.settings': '系统设置',
|
'nav.settings': '系统设置',
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export function AnalyticsPage() {
|
|||||||
<div className="p-4 md:p-6">
|
<div className="p-4 md:p-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="数据分析中心"
|
title="数据分析中心"
|
||||||
description="企业经营数据全景 · 基于当前企业实时数据"
|
description="查看客户、消息、跟进与转化数据"
|
||||||
actions={<LiveDataBadge />}
|
actions={<LiveDataBadge />}
|
||||||
extra={
|
extra={
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
@@ -46,7 +46,7 @@ export function AnalyticsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<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}><SimpleBarChart data={data.channelInquiries} xKey="name" barKey="count" name="询盘数" /></ChartCard>
|
||||||
<ChartCard title="客户国家/地区分布" height={260}><DonutChart data={data.countryDistribution} /></ChartCard>
|
<ChartCard title="客户国家/地区分布" height={260}><DonutChart data={data.countryDistribution} /></ChartCard>
|
||||||
<ChartCard title="客户阶段转化漏斗" height={260}><FunnelChart data={data.funnel} /></ChartCard>
|
<ChartCard title="客户阶段转化漏斗" height={260}><FunnelChart data={data.funnel} /></ChartCard>
|
||||||
@@ -56,15 +56,15 @@ export function AnalyticsPage() {
|
|||||||
<ChartCard title="团队客户转化排行" height={240}><RankBars data={data.teamConversionRank.map((r) => ({ name: r.name, value: r.rate }))} unit="%" /></ChartCard>
|
<ChartCard title="团队客户转化排行" height={240}><RankBars data={data.teamConversionRank.map((r) => ({ name: r.name, value: r.rate }))} unit="%" /></ChartCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 老板看板 */}
|
{/* 业务概览 */}
|
||||||
<h2 className="mt-6 mb-3 text-lg font-semibold">老板看板 <LiveDataBadge 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">
|
<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.urgentHighValue.map((c) => (<div key={c.id} className="flex items-center justify-between px-4 py-2.5 text-sm"><span>{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="text-xs text-muted-foreground">{c.reason.slice(0, 20)}…</span></div>))}</div></Card>
|
||||||
<Card><CardHeader title="⚠️ 存在逾期跟进的业务员" /><div className="divide-y divide-border">{data.bossBoard.overdueMembers.map((m) => (<div key={m.id} className="flex items-center justify-between px-4 py-2.5 text-sm"><span>{m.name}</span><Badge variant="danger">{m.overdueCount} 项逾期</Badge></div>))}</div></Card>
|
<Card><CardHeader title="逾期跟进" /><div className="divide-y divide-border">{data.bossBoard.overdueMembers.map((m) => (<div key={m.id} className="flex items-center justify-between px-4 py-2.5 text-sm"><span>{m.name}</span><Badge variant="danger">{m.overdueCount} 项逾期</Badge></div>))}</div></Card>
|
||||||
<Card><CardHeader title="📈 客户增长最快的国家" /><div className="divide-y divide-border">{data.bossBoard.fastestCountries.map((c) => (<div key={c.country} className="flex items-center justify-between px-4 py-2.5 text-sm"><CountryFlag code={c.country} /><Badge variant="success">+{(c.growth * 100).toFixed(0)}%</Badge></div>))}</div></Card>
|
<Card><CardHeader title="客户增长较快的国家" /><div className="divide-y divide-border">{data.bossBoard.fastestCountries.map((c) => (<div key={c.country} className="flex items-center justify-between px-4 py-2.5 text-sm"><CountryFlag code={c.country} /><Badge variant="success">+{(c.growth * 100).toFixed(0)}%</Badge></div>))}</div></Card>
|
||||||
<Card><CardHeader title="🏅 最优渠道" /><div className="p-4 text-center"><p className="text-2xl font-semibold text-primary-700">{data.bossBoard.bestChannel.name}</p><p className="text-sm text-muted-foreground">转化率 {data.bossBoard.bestChannel.conversion}%</p></div></Card>
|
<Card><CardHeader title="渠道转化" /><div className="p-4 text-center"><p className="text-2xl font-semibold text-primary-700">{data.bossBoard.bestChannel.name}</p><p className="text-sm text-muted-foreground">转化率 {data.bossBoard.bestChannel.conversion}%</p></div></Card>
|
||||||
<Card><CardHeader title="📊 最热门产品" /><div className="space-y-2 p-4">{data.bossBoard.topProducts.map((p) => (<div key={p.product} className="flex items-center justify-between text-sm"><span>{p.product}</span><span className="text-muted-foreground">{p.inquiries} 次询盘</span></div>))}</div></Card>
|
<Card><CardHeader title="产品询盘" /><div className="space-y-2 p-4">{data.bossBoard.topProducts.map((p) => (<div key={p.product} className="flex items-center justify-between text-sm"><span>{p.product}</span><span className="text-muted-foreground">{p.inquiries} 次询盘</span></div>))}</div></Card>
|
||||||
<Card><CardHeader title="🚨 正在流失的高意向客户" /><div className="divide-y divide-border">{data.bossBoard.churningHighIntent.map((c) => (<div key={c.id} className="px-4 py-2.5 text-sm"><span>{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="text-xs text-muted-foreground ml-2">{c.daysSilent}天未联系 · 意向{c.intentScore}分</span></div>))}</div></Card>
|
<Card><CardHeader title="久未联系的高意向客户" /><div className="divide-y divide-border">{data.bossBoard.churningHighIntent.map((c) => (<div key={c.id} className="px-4 py-2.5 text-sm"><span>{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="text-xs text-muted-foreground ml-2">{c.daysSilent}天未联系 · 意向{c.intentScore}分</span></div>))}</div></Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
@@ -28,6 +28,7 @@ type FormValues = z.infer<typeof schema>
|
|||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { t, locale, setLocale } = useI18n()
|
const { t, locale, setLocale } = useI18n()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const setSession = useAuthStore((s) => s.setSession)
|
const setSession = useAuthStore((s) => s.setSession)
|
||||||
const [mode, setMode] = useState<'code' | 'password'>('password')
|
const [mode, setMode] = useState<'code' | 'password'>('password')
|
||||||
const [remember, setRemember] = useState(true)
|
const [remember, setRemember] = useState(true)
|
||||||
@@ -42,6 +43,12 @@ export function LoginPage() {
|
|||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { account: '', password: '', code: '' } })
|
} = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { account: '', password: '', code: '' } })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchParams.get('reason') !== 'session-expired') return
|
||||||
|
toast.warning('登录状态已过期,请重新登录')
|
||||||
|
setSearchParams({}, { replace: true })
|
||||||
|
}, [searchParams, setSearchParams])
|
||||||
|
|
||||||
const doLogin = async (values: FormValues) => {
|
const doLogin = async (values: FormValues) => {
|
||||||
if (!agreed) {
|
if (!agreed) {
|
||||||
toast.warning('请先勾选并同意《隐私政策》和《服务协议》')
|
toast.warning('请先勾选并同意《隐私政策》和《服务协议》')
|
||||||
@@ -94,16 +101,16 @@ export function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<h1 className="text-2xl font-semibold leading-snug">
|
<h1 className="text-2xl font-semibold leading-snug">
|
||||||
把分散在全球渠道的客户对话,
|
集中处理客户消息,
|
||||||
<br />
|
<br />
|
||||||
沉淀为可成交的企业资产。
|
更快回复和跟进。
|
||||||
</h1>
|
</h1>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{[
|
{[
|
||||||
{ icon: MessageCircle, text: 'WhatsApp / Telegram / 邮件 / 独立站,统一收件箱' },
|
{ icon: MessageCircle, text: '集中处理 WhatsApp、Telegram、邮件和独立站消息' },
|
||||||
{ icon: Languages, text: 'AI 翻译与行业术语,跨语种沟通专业自然' },
|
{ icon: Languages, text: '消息翻译与双语对照,支持行业术语' },
|
||||||
{ icon: ShieldCheck, text: '情感分析、风险预警、文化敏感检测' },
|
{ icon: ShieldCheck, text: '识别客户情绪、沟通意图和风险信号' },
|
||||||
{ icon: UsersRound, text: '客户自动建档、意向评分与团队协同跟进' },
|
{ icon: UsersRound, text: '管理客户资料、意向评分和团队跟进' },
|
||||||
].map((f) => (
|
].map((f) => (
|
||||||
<div key={f.text} className="flex items-center gap-3 text-sm text-primary-100">
|
<div key={f.text} className="flex items-center gap-3 text-sm text-primary-100">
|
||||||
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-white/10">
|
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-white/10">
|
||||||
@@ -209,9 +216,9 @@ export function LoginPage() {
|
|||||||
<div className="space-y-3 text-sm leading-relaxed text-muted-foreground">
|
<div className="space-y-3 text-sm leading-relaxed text-muted-foreground">
|
||||||
<p>使用本服务前,请阅读并确认以下数据处理与服务条款。</p>
|
<p>使用本服务前,请阅读并确认以下数据处理与服务条款。</p>
|
||||||
<p>1. 我们仅收集为您提供跨境沟通与客户管理服务所必需的信息,包括账号信息、企业信息与您主动录入的客户数据。</p>
|
<p>1. 我们仅收集为您提供跨境沟通与客户管理服务所必需的信息,包括账号信息、企业信息与您主动录入的客户数据。</p>
|
||||||
<p>2. 您的会话数据采用全链路加密存储,未经您授权,我们不会向任何第三方披露。</p>
|
<p>2. 我们按照已公布的安全措施处理会话数据;涉及第三方服务时,处理范围以隐私政策及相关服务条款为准。</p>
|
||||||
<p>3. 您可以随时导出或删除您的数据;企业注销后,相关数据将在 30 天内完成清理。</p>
|
<p>3. 数据导出、删除和账号注销申请按照正式发布的隐私政策处理。</p>
|
||||||
<p>4. 使用 AI 功能时,相关内容仅用于为您生成翻译与回复建议,不会用于其他用途。</p>
|
<p>4. 使用翻译和回复建议时,相关内容可能发送至已配置的模型服务商处理,具体范围以隐私政策为准。</p>
|
||||||
</div>
|
</div>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { INDUSTRIES } from '@/config/constants'
|
|||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z.string().min(2, '请输入您的姓名'),
|
name: z.string().min(2, '请输入您的姓名'),
|
||||||
account: z.string().min(5, '请输入手机号或邮箱'),
|
account: z.string().min(5, '请输入手机号或邮箱'),
|
||||||
password: z.string().min(6, '密码至少6位').max(32, '密码最长32位'),
|
password: z.string().min(8, '密码至少8位').max(32, '密码最长32位'),
|
||||||
company: z.string().min(2, '请输入企业名称'),
|
company: z.string().min(2, '请输入企业名称'),
|
||||||
industry: z.string().min(1, '请选择所属行业'),
|
industry: z.string().min(1, '请选择所属行业'),
|
||||||
teamSize: z.string().min(1, '请选择团队规模'),
|
teamSize: z.string().min(1, '请选择团队规模'),
|
||||||
@@ -51,7 +51,7 @@ export function RegisterPage() {
|
|||||||
const session = await registerApi(values)
|
const session = await registerApi(values)
|
||||||
setSession(session)
|
setSession(session)
|
||||||
toast.success('企业创建成功', '接下来请完成企业初始化配置')
|
toast.success('企业创建成功', '接下来请完成企业初始化配置')
|
||||||
navigate('/channels')
|
navigate('/channels?setup=choose')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(errorMessage(e))
|
toast.error(errorMessage(e))
|
||||||
} finally {
|
} finally {
|
||||||
@@ -79,7 +79,7 @@ export function RegisterPage() {
|
|||||||
<Field label="手机号或邮箱" required error={errors.account?.message}>
|
<Field label="手机号或邮箱" required error={errors.account?.message}>
|
||||||
<Input placeholder="用于登录与接收通知" {...register('account')} />
|
<Input placeholder="用于登录与接收通知" {...register('account')} />
|
||||||
</Field>
|
</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')} />
|
<Input type="password" placeholder="请设置登录密码" {...register('password')} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="企业名称" required error={errors.company?.message}>
|
<Field label="企业名称" required error={errors.company?.message}>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function BillingPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-6">
|
<div className="p-4 md:p-6">
|
||||||
<PageHeader title="套餐与账单" description="通过 Stripe Checkout 管理真实订阅与支付" />
|
<PageHeader title="套餐与账单" description="管理套餐、订阅和支付记录" />
|
||||||
|
|
||||||
{info && (
|
{info && (
|
||||||
<Card className="mb-4 p-4">
|
<Card className="mb-4 p-4">
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { Cable, Plus, Pause, Play, RefreshCw, Trash2, History, Settings } from 'lucide-react'
|
import {
|
||||||
|
Activity,
|
||||||
|
ArrowUpRight,
|
||||||
|
AlertTriangle,
|
||||||
|
Cable,
|
||||||
|
CheckCircle2,
|
||||||
|
History,
|
||||||
|
Mail,
|
||||||
|
MessageCircle,
|
||||||
|
Plus,
|
||||||
|
MessageSquareText,
|
||||||
|
Pause,
|
||||||
|
Play,
|
||||||
|
RefreshCw,
|
||||||
|
Settings,
|
||||||
|
ShieldCheck,
|
||||||
|
Star,
|
||||||
|
Trash2,
|
||||||
|
Workflow,
|
||||||
|
} from 'lucide-react'
|
||||||
import { toast, errorMessage } from '@/app/store/toast'
|
import { toast, errorMessage } from '@/app/store/toast'
|
||||||
import { confirmDialog } from '@/app/store/confirm'
|
import { confirmDialog } from '@/app/store/confirm'
|
||||||
import { PageHeader } from '@/components/common/PageHeader'
|
import { PageHeader } from '@/components/common/PageHeader'
|
||||||
@@ -15,12 +35,13 @@ import { Input, Field } from '@/components/ui/input'
|
|||||||
import { PermissionGate } from '@/components/common/PermissionGate'
|
import { PermissionGate } from '@/components/common/PermissionGate'
|
||||||
import { ChannelIcon } from '@/components/common/ChannelIcon'
|
import { ChannelIcon } from '@/components/common/ChannelIcon'
|
||||||
import {
|
import {
|
||||||
getChannels, connectChannel, testChannel, updateChannel, deleteChannel, getChannelLogs,
|
getChannels, testChannel, updateChannel, deleteChannel, getChannelLogs,
|
||||||
startTelegramLogin, submitTelegramCode, submitTelegramPassword, syncTelegramChannel,
|
connectEmailChannel, startTelegramLogin, submitTelegramCode, submitTelegramPassword,
|
||||||
|
getTelegramLoginState, syncChannel, startWhatsAppLogin, getWhatsAppLoginState,
|
||||||
|
setDefaultChannel,
|
||||||
} from '@/services/api/channels'
|
} from '@/services/api/channels'
|
||||||
import { CHANNEL_META } from '@/config/constants'
|
import { CHANNEL_META } from '@/config/constants'
|
||||||
import { formatRelative } from '@/utils/format'
|
import { formatRelative } from '@/utils/format'
|
||||||
import { cn } from '@/utils/cn'
|
|
||||||
import type { ChannelType, ChannelConnection, ChannelSyncLog } from '@/types'
|
import type { ChannelType, ChannelConnection, ChannelSyncLog } from '@/types'
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
||||||
@@ -31,15 +52,48 @@ const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
|||||||
unconnected: { label: '未连接', color: 'bg-gray-100 text-gray-400' },
|
unconnected: { label: '未连接', color: 'bg-gray-100 text-gray-400' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EMAIL_PRESETS = {
|
||||||
|
gmail: { label: 'Gmail', imapHost: 'imap.gmail.com', imapPort: 993, imapSecure: true, smtpHost: 'smtp.gmail.com', smtpPort: 465, smtpSecure: true },
|
||||||
|
outlook: { label: 'Outlook / Microsoft 365', imapHost: 'outlook.office365.com', imapPort: 993, imapSecure: true, smtpHost: 'smtp.office365.com', smtpPort: 587, smtpSecure: false },
|
||||||
|
qq: { label: 'QQ 企业邮箱', imapHost: 'imap.exmail.qq.com', imapPort: 993, imapSecure: true, smtpHost: 'smtp.exmail.qq.com', smtpPort: 465, smtpSecure: true },
|
||||||
|
netease: { label: '网易企业邮箱', imapHost: 'imap.qiye.163.com', imapPort: 993, imapSecure: true, smtpHost: 'smtp.qiye.163.com', smtpPort: 465, smtpSecure: true },
|
||||||
|
custom: { label: '自定义服务器', imapHost: '', imapPort: 993, imapSecure: true, smtpHost: '', smtpPort: 465, smtpSecure: true },
|
||||||
|
} as const
|
||||||
|
|
||||||
export function ChannelsPage() {
|
export function ChannelsPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { data: channels } = useQuery({ queryKey: ['channels'], queryFn: getChannels })
|
const { data: channels } = useQuery({
|
||||||
|
queryKey: ['channels'],
|
||||||
|
queryFn: getChannels,
|
||||||
|
refetchInterval: 15_000,
|
||||||
|
})
|
||||||
const [wizard, setWizard] = useState<{ open: boolean; type: ChannelType | null; step: number }>({ open: false, type: null, step: 0 })
|
const [wizard, setWizard] = useState<{ open: boolean; type: ChannelType | null; step: number }>({ open: false, type: null, step: 0 })
|
||||||
const [telegramAccount, setTelegramAccount] = useState('')
|
const [telegramAccount, setTelegramAccount] = useState('')
|
||||||
const [telegramCode, setTelegramCode] = useState('')
|
const [telegramCode, setTelegramCode] = useState('')
|
||||||
const [telegramPassword, setTelegramPassword] = useState('')
|
const [telegramPassword, setTelegramPassword] = useState('')
|
||||||
const [loginChannelId, setLoginChannelId] = useState('')
|
const [loginChannelId, setLoginChannelId] = useState('')
|
||||||
const [authState, setAuthState] = useState('')
|
const [authState, setAuthState] = useState('')
|
||||||
|
const [whatsappAccount, setWhatsAppAccount] = useState('')
|
||||||
|
const [whatsappChannelId, setWhatsAppChannelId] = useState('')
|
||||||
|
const [whatsappPairingCode, setWhatsAppPairingCode] = useState('')
|
||||||
|
const [whatsappQr, setWhatsAppQr] = useState('')
|
||||||
|
const [connecting, setConnecting] = useState(false)
|
||||||
|
const [emailPreset, setEmailPreset] = useState<keyof typeof EMAIL_PRESETS>('custom')
|
||||||
|
const [emailForm, setEmailForm] = useState({
|
||||||
|
displayName: '',
|
||||||
|
account: '',
|
||||||
|
password: '',
|
||||||
|
imapHost: '',
|
||||||
|
imapPort: 993,
|
||||||
|
imapSecure: true,
|
||||||
|
imapFolder: 'INBOX',
|
||||||
|
smtpHost: '',
|
||||||
|
smtpPort: 465,
|
||||||
|
smtpSecure: true,
|
||||||
|
syncDays: 30,
|
||||||
|
})
|
||||||
const [logDrawer, setLogDrawer] = useState<string | null>(null)
|
const [logDrawer, setLogDrawer] = useState<string | null>(null)
|
||||||
const [settingsDrawer, setSettingsDrawer] = useState<ChannelConnection | null>(null)
|
const [settingsDrawer, setSettingsDrawer] = useState<ChannelConnection | null>(null)
|
||||||
const [logItems, setLogItems] = useState<ChannelSyncLog[]>([])
|
const [logItems, setLogItems] = useState<ChannelSyncLog[]>([])
|
||||||
@@ -58,116 +112,718 @@ export function ChannelsPage() {
|
|||||||
|
|
||||||
const list = channels ?? []
|
const list = channels ?? []
|
||||||
|
|
||||||
return (
|
const resetTelegramWizard = () => {
|
||||||
<div className="p-4 md:p-6">
|
setTelegramAccount('')
|
||||||
<PageHeader title="渠道中心" description={`${list.filter((c) => c.status === 'connected').length} 个渠道已连接 · 共 ${list.length} 个可连接渠道`} />
|
setTelegramCode('')
|
||||||
|
setTelegramPassword('')
|
||||||
|
setLoginChannelId('')
|
||||||
|
setAuthState('')
|
||||||
|
}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
const setTelegramStepFromState = (state: string) => {
|
||||||
|
if (state === 'waiting_password') {
|
||||||
|
setAuthState('waiting_password')
|
||||||
|
setWizard({ open: true, type: 'telegram', step: 2 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAuthState('waiting_code')
|
||||||
|
setWizard({ open: true, type: 'telegram', step: 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const completeTelegramBinding = async (channelId: string) => {
|
||||||
|
let syncSummary = ''
|
||||||
|
try {
|
||||||
|
const result = await syncChannel(channelId)
|
||||||
|
syncSummary = `,已同步 ${result.chats} 个会话和 ${result.messages} 条消息`
|
||||||
|
} catch (error) {
|
||||||
|
syncSummary = `;账号已连接,但首次同步失败:${errorMessage(error)}`
|
||||||
|
}
|
||||||
|
toast.success(`Telegram 绑定成功${syncSummary}`)
|
||||||
|
resetTelegramWizard()
|
||||||
|
setWizard({ open: false, type: null, step: 0 })
|
||||||
|
await queryClient.invalidateQueries()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openTelegramWizard = async (channel?: ChannelConnection) => {
|
||||||
|
resetTelegramWizard()
|
||||||
|
setTelegramAccount(channel?.account || '')
|
||||||
|
setWizard({ open: true, type: 'telegram', step: 0 })
|
||||||
|
if (!channel || channel.id === 'telegram-available') return
|
||||||
|
setConnecting(true)
|
||||||
|
try {
|
||||||
|
const result = await getTelegramLoginState(channel.id)
|
||||||
|
setLoginChannelId(channel.id)
|
||||||
|
if (result.status === 'ready' && result.connectionState === 'ready') {
|
||||||
|
await completeTelegramBinding(channel.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTelegramStepFromState(result.status)
|
||||||
|
toast.info(result.status === 'waiting_password' ? '请继续输入二步验证密码' : '请继续输入 Telegram 验证码')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(errorMessage(error))
|
||||||
|
} finally {
|
||||||
|
setConnecting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openWhatsAppWizard = async (channel?: ChannelConnection) => {
|
||||||
|
setWhatsAppAccount(channel?.account || '')
|
||||||
|
setWhatsAppChannelId(channel && channel.id !== 'whatsapp-available' ? channel.id : '')
|
||||||
|
setWhatsAppPairingCode(channel?.pairingCode || '')
|
||||||
|
setWhatsAppQr(channel?.qrDataUrl || '')
|
||||||
|
setWizard({ open: true, type: 'whatsapp', step: channel?.pairingCode ? 1 : 0 })
|
||||||
|
if (!channel || channel.id === 'whatsapp-available') return
|
||||||
|
setConnecting(true)
|
||||||
|
try {
|
||||||
|
const current = await getWhatsAppLoginState(channel.id)
|
||||||
|
if (current.status === 'connected') {
|
||||||
|
await syncChannel(channel.id, { full: true })
|
||||||
|
toast.success('WhatsApp 已连接并完成首次会话同步')
|
||||||
|
setWizard({ open: false, type: null, step: 0 })
|
||||||
|
await queryClient.invalidateQueries()
|
||||||
|
} else {
|
||||||
|
const restarted = await startWhatsAppLogin(channel.account || '')
|
||||||
|
setWhatsAppChannelId(restarted.id)
|
||||||
|
setWhatsAppPairingCode(restarted.pairingCode || '')
|
||||||
|
setWhatsAppQr(restarted.qrDataUrl || '')
|
||||||
|
setWizard({ open: true, type: 'whatsapp', step: 1 })
|
||||||
|
toast.success('已生成新的配对码,请在手机 WhatsApp 中完成关联设备')
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(errorMessage(error))
|
||||||
|
} finally {
|
||||||
|
setConnecting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openChannelWizard = (type: 'telegram' | 'whatsapp' | 'email', channel?: ChannelConnection) => {
|
||||||
|
setSearchParams({}, { replace: true })
|
||||||
|
if (type === 'telegram') {
|
||||||
|
void openTelegramWizard(channel)
|
||||||
|
} else if (type === 'whatsapp') {
|
||||||
|
void openWhatsAppWizard(channel)
|
||||||
|
} else {
|
||||||
|
applyEmailPreset('custom')
|
||||||
|
setWizard({ open: true, type: 'email', step: 0 })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyEmailPreset = (value: keyof typeof EMAIL_PRESETS) => {
|
||||||
|
setEmailPreset(value)
|
||||||
|
const preset = EMAIL_PRESETS[value]
|
||||||
|
setEmailForm((current) => ({
|
||||||
|
...current,
|
||||||
|
imapHost: preset.imapHost,
|
||||||
|
imapPort: preset.imapPort,
|
||||||
|
imapSecure: preset.imapSecure,
|
||||||
|
smtpHost: preset.smtpHost,
|
||||||
|
smtpPort: preset.smtpPort,
|
||||||
|
smtpSecure: preset.smtpSecure,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectedCount = list.filter((channel) => channel.status === 'connected').length
|
||||||
|
const setupChoice = searchParams.get('setup') === 'choose'
|
||||||
|
const todayMessages = list.reduce((total, channel) => total + Number(channel.todayMessages || 0), 0)
|
||||||
|
const latestSync = list
|
||||||
|
.map((channel) => channel.lastSyncAt)
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort()
|
||||||
|
.at(-1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="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>
|
||||||
|
渠道连接
|
||||||
|
</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">
|
||||||
|
在一个工作台管理 WhatsApp、Telegram 与企业邮箱消息。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2 sm:min-w-[460px]">
|
||||||
|
{[
|
||||||
|
{ label: '在线账号', value: `${connectedCount}/${list.length}`, icon: CheckCircle2 },
|
||||||
|
{ label: '今日消息', value: String(todayMessages), icon: MessageSquareText },
|
||||||
|
{ label: '最近同步', value: latestSync ? formatRelative(latestSync) : '等待首次同步', icon: Activity },
|
||||||
|
].map((stat) => (
|
||||||
|
<div key={stat.label} className="rounded-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="连接状态、同步进度与安全设置"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{setupChoice && (
|
||||||
|
<Card
|
||||||
|
data-testid="channel-onboarding"
|
||||||
|
className="mb-5 overflow-hidden border-primary-200/80 bg-gradient-to-r from-primary-50/90 via-white to-sky-50 shadow-[0_16px_45px_rgba(37,99,235,.10)]"
|
||||||
|
>
|
||||||
|
<div className="p-5 lg:p-6">
|
||||||
|
<Badge variant="primary">企业创建成功 · 渠道可稍后配置</Badge>
|
||||||
|
<h2 className="mt-3 text-xl font-bold tracking-tight text-slate-950">选择第一个客户沟通渠道</h2>
|
||||||
|
<p className="mt-1.5 text-sm leading-6 text-slate-600">
|
||||||
|
不强制绑定 Telegram。你可以连接 WhatsApp、Telegram 或企业邮箱,也可以先进入系统,之后再添加多个账号。
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 grid gap-3 md:grid-cols-3">
|
||||||
|
{[
|
||||||
|
{ type: 'telegram' as const, icon: Cable, title: 'Telegram', text: '手机号、验证码与可选二步密码' },
|
||||||
|
{ type: 'whatsapp' as const, icon: MessageCircle, title: 'WhatsApp', text: '普通账号通过关联设备配对' },
|
||||||
|
{ type: 'email' as const, icon: Mail, title: '企业邮箱', text: 'IMAP 收信与 SMTP 发信' },
|
||||||
|
].map((item) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={item.type}
|
||||||
|
data-testid={`choose-${item.type}`}
|
||||||
|
onClick={() => openChannelWizard(item.type)}
|
||||||
|
className="group rounded-2xl border border-slate-200 bg-white p-4 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-primary-300 hover:shadow-lg"
|
||||||
|
>
|
||||||
|
<span className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary-50 text-primary transition group-hover:bg-primary group-hover:text-white">
|
||||||
|
<item.icon className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<p className="mt-3 text-sm font-bold text-slate-900">{item.title}</p>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-slate-500">{item.text}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<Button variant="ghost" onClick={() => setSearchParams({}, { replace: true })}>
|
||||||
|
暂不配置,先进入系统
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mb-5 flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200 bg-white p-3 shadow-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-slate-900">连接更多工作账号</p>
|
||||||
|
<p className="mt-0.5 text-xs text-slate-500">每种渠道可绑定多个账号,并指定一个默认账号用于发起新会话。</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button size="sm" variant="outline" onClick={() => openChannelWizard('telegram')}>
|
||||||
|
<Plus className="h-3.5 w-3.5" />Telegram
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => openChannelWizard('whatsapp')}>
|
||||||
|
<Plus className="h-3.5 w-3.5" />WhatsApp
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => openChannelWizard('email')}>
|
||||||
|
<Plus className="h-3.5 w-3.5" />企业邮箱
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-3">
|
||||||
{list.map((ch) => {
|
{list.map((ch) => {
|
||||||
const meta = CHANNEL_META[ch.type]
|
const meta = CHANNEL_META[ch.type]
|
||||||
const st = STATUS_MAP[ch.status]
|
const st = STATUS_MAP[ch.status]
|
||||||
return (
|
return (
|
||||||
<Card key={ch.id} className="p-4">
|
<Card data-testid={`channel-card-${ch.type}`} key={ch.id} className="group overflow-hidden 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'
|
||||||
|
: ch.type === 'whatsapp'
|
||||||
|
? 'h-1 bg-gradient-to-r from-blue-500 via-cyan-400 to-violet-400'
|
||||||
|
: 'h-1 bg-gradient-to-r from-slate-500 to-slate-300'
|
||||||
|
} />
|
||||||
|
<div className="p-5">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<ChannelIcon channel={ch.type} size="lg" withBg />
|
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-slate-50 ring-1 ring-slate-100 transition-transform duration-300 group-hover:scale-105">
|
||||||
<Badge className={st.color}>{st.label}</Badge>
|
<ChannelIcon channel={ch.type} size="lg" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap justify-end gap-1.5">
|
||||||
|
{ch.isDefault && <Badge variant="primary"><Star className="mr-1 h-3 w-3 fill-current" />默认账号</Badge>}
|
||||||
|
<Badge className={st.color}>{st.label}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-base font-semibold tracking-tight text-slate-950">{meta.name}</p>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-slate-500">{meta.desc}</p>
|
||||||
|
{ch.account && <p className="mt-2 truncate text-xs font-medium text-slate-700">{ch.account}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||||
|
<div className="rounded-xl bg-slate-50 px-3 py-2.5">
|
||||||
|
<p className="text-[10px] font-medium text-slate-400">今日消息</p>
|
||||||
|
<p className="mt-0.5 text-sm font-semibold text-slate-800">{Number(ch.todayMessages || 0)} 条</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-slate-50 px-3 py-2.5">
|
||||||
|
<p className="text-[10px] font-medium text-slate-400">消息连接</p>
|
||||||
|
<p className="mt-0.5 truncate text-sm font-semibold text-slate-800">
|
||||||
|
{ch.status === 'connected'
|
||||||
|
? '实时同步中'
|
||||||
|
: ch.lastSyncAt
|
||||||
|
? formatRelative(ch.lastSyncAt)
|
||||||
|
: '等待连接'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
{(ch.type === 'email'
|
||||||
|
? ['IMAP 收信', 'SMTP 发信', '附件同步']
|
||||||
|
: ['实时收发', '历史同步', '媒体消息']
|
||||||
|
).map((capability) => (
|
||||||
|
<span key={capability} className="rounded-full border border-slate-200 bg-white px-2.5 py-1 text-[10px] font-medium text-slate-500">
|
||||||
|
{capability}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-sm font-medium">{meta.name}</p>
|
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">{meta.desc}</p>
|
|
||||||
{ch.account && <p className="mt-1 text-xs text-muted-foreground">账号:{ch.account}</p>}
|
|
||||||
{ch.todayMessages > 0 && <p className="mt-1 text-xs">今日消息:{ch.todayMessages} 条</p>}
|
|
||||||
{ch.lastSyncAt && <p className="mt-0.5 text-xs text-muted-foreground">最近同步:{formatRelative(ch.lastSyncAt)}</p>}
|
|
||||||
{ch.errorMessage && <p className="mt-1 text-xs text-red-600">{ch.errorMessage}</p>}
|
{ch.errorMessage && <p className="mt-1 text-xs text-red-600">{ch.errorMessage}</p>}
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap gap-1">
|
<div className="mt-5 flex flex-wrap items-center gap-1 border-t border-slate-100 pt-4">
|
||||||
{ch.status === 'connected' ? (
|
{ch.status === 'connected' ? (
|
||||||
<>
|
<>
|
||||||
<Button size="sm" variant="outline" onClick={async () => {
|
<Button size="sm" variant="outline" onClick={async () => {
|
||||||
const result = await syncTelegramChannel(ch.id)
|
try {
|
||||||
toast.success(`同步完成:${result.chats} 个会话,${result.messages} 条消息`)
|
await updateChannel(ch.id, { status: 'paused' })
|
||||||
queryClient.invalidateQueries()
|
toast.success('渠道已暂停接收实时消息')
|
||||||
}}><RefreshCw className="h-3 w-3" />同步</Button>
|
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||||
<Button size="sm" variant="outline" onClick={() => updateChannel(ch.id, { status: 'paused' }).then(() => queryClient.invalidateQueries({ queryKey: ['channels'] }))}><Pause className="h-3 w-3" />暂停</Button>
|
} catch (error) {
|
||||||
|
toast.error(errorMessage(error))
|
||||||
|
}
|
||||||
|
}}><Pause className="h-3 w-3" />暂停</Button>
|
||||||
|
{!ch.isDefault && (
|
||||||
|
<Button size="sm" variant="outline" onClick={async () => {
|
||||||
|
try {
|
||||||
|
await setDefaultChannel(ch.id)
|
||||||
|
toast.success(`${ch.account || meta.name} 已设为默认账号`)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(errorMessage(error))
|
||||||
|
}
|
||||||
|
}}><Star className="h-3 w-3" />设为默认</Button>
|
||||||
|
)}
|
||||||
<Button size="sm" variant="outline" onClick={() => delMut.mutate(ch.id)}><Trash2 className="h-3 w-3" /></Button>
|
<Button size="sm" variant="outline" onClick={() => delMut.mutate(ch.id)}><Trash2 className="h-3 w-3" /></Button>
|
||||||
</>
|
</>
|
||||||
) : ch.status === 'paused' ? (
|
) : ch.status === 'paused' ? (
|
||||||
<Button size="sm" variant="outline" onClick={() => updateChannel(ch.id, { status: 'connected' }).then(() => queryClient.invalidateQueries({ queryKey: ['channels'] }))}><Play className="h-3 w-3" />恢复</Button>
|
<Button size="sm" variant="outline" onClick={async () => {
|
||||||
|
try {
|
||||||
|
await updateChannel(ch.id, { status: 'connected' })
|
||||||
|
toast.success('渠道已恢复接收实时消息')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(errorMessage(error))
|
||||||
|
}
|
||||||
|
}}><Play className="h-3 w-3" />恢复</Button>
|
||||||
) : ch.status === 'error' ? (
|
) : ch.status === 'error' ? (
|
||||||
<Button size="sm" variant="outline" onClick={() => { setWizard({ open: true, type: ch.type, step: 0 }); toast.info('重新授权') }}><RefreshCw className="h-3 w-3" />重新授权</Button>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
if (ch.type === 'email') {
|
||||||
|
applyEmailPreset('custom')
|
||||||
|
setWizard({ open: true, type: ch.type, step: 0 })
|
||||||
|
} else if (ch.type === 'whatsapp') {
|
||||||
|
void openWhatsAppWizard(ch)
|
||||||
|
} else {
|
||||||
|
void openTelegramWizard(ch)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
><RefreshCw className="h-3 w-3" />重新授权</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button size="sm" variant="default" onClick={() => setWizard({ open: true, type: ch.type, step: 1 })}><Cable className="h-3 w-3" />连接</Button>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
onClick={() => {
|
||||||
|
if (ch.type === 'email') {
|
||||||
|
applyEmailPreset('custom')
|
||||||
|
setWizard({ open: true, type: ch.type, step: 0 })
|
||||||
|
} else if (ch.type === 'whatsapp') {
|
||||||
|
void openWhatsAppWizard(ch)
|
||||||
|
} else {
|
||||||
|
void openTelegramWizard(ch)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ch.type === 'email'
|
||||||
|
? <Mail className="h-3 w-3" />
|
||||||
|
: ch.type === 'whatsapp'
|
||||||
|
? <MessageCircle className="h-3 w-3" />
|
||||||
|
: <Cable className="h-3 w-3" />}
|
||||||
|
{['telegram', 'whatsapp'].includes(ch.type) && !ch.id.endsWith('-available') ? '继续授权' : '连接'}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button size="sm" variant="ghost" onClick={() => loadLogs(ch.id)}><History className="h-3 w-3" /></Button>
|
{!ch.id.endsWith('-available') && (
|
||||||
<Button size="sm" variant="ghost" onClick={() => setSettingsDrawer(ch)}><Settings className="h-3 w-3" /></Button>
|
<>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => loadLogs(ch.id)}><History className="h-3 w-3" /></Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setSettingsDrawer(ch)}><Settings className="h-3 w-3" /></Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
<Card className="relative overflow-hidden border-dashed border-slate-300 bg-slate-50/60 p-5">
|
||||||
|
<div className="absolute -right-10 -top-10 h-32 w-32 rounded-full bg-primary/5" />
|
||||||
|
<div className="relative">
|
||||||
|
<span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-primary text-white shadow-lg shadow-primary/20">
|
||||||
|
<Workflow className="h-5 w-5" />
|
||||||
|
</span>
|
||||||
|
<h3 className="mt-4 text-base font-semibold text-slate-900">消息处理</h3>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-slate-500">渠道消息进入收件箱后,可分配负责人、翻译并跟进。</p>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{[
|
||||||
|
['01', '接收并同步渠道消息'],
|
||||||
|
['02', '分配负责人并处理消息'],
|
||||||
|
['03', '记录客户信息与跟进事项'],
|
||||||
|
].map(([step, label]) => (
|
||||||
|
<div key={step} className="flex items-center gap-3 rounded-xl bg-white px-3 py-2.5 shadow-sm ring-1 ring-slate-100">
|
||||||
|
<span className="text-[10px] font-bold text-primary">{step}</span>
|
||||||
|
<span className="text-xs font-medium text-slate-700">{label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button className="mt-4 w-full" variant="outline" onClick={() => navigate('/inbox')}>
|
||||||
|
进入消息中心 <ArrowUpRight className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 连接向导 */}
|
{/* 连接向导 */}
|
||||||
<Dialog
|
<Dialog
|
||||||
open={wizard.open}
|
open={wizard.open}
|
||||||
onOpenChange={(v) => setWizard({ ...wizard, open: v })}
|
onOpenChange={(open) => {
|
||||||
|
setWizard({ ...wizard, open })
|
||||||
|
if (!open) {
|
||||||
|
setEmailForm((current) => ({ ...current, password: '' }))
|
||||||
|
resetTelegramWizard()
|
||||||
|
setWhatsAppChannelId('')
|
||||||
|
setWhatsAppPairingCode('')
|
||||||
|
setWhatsAppQr('')
|
||||||
|
}
|
||||||
|
}}
|
||||||
title={`连接 ${wizard.type ? CHANNEL_META[wizard.type].name : ''}`}
|
title={`连接 ${wizard.type ? CHANNEL_META[wizard.type].name : ''}`}
|
||||||
footer={
|
footer={
|
||||||
<Button
|
<Button
|
||||||
|
loading={connecting}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!loginChannelId) {
|
setConnecting(true)
|
||||||
if (telegramAccount.trim()) {
|
try {
|
||||||
const channel = await startTelegramLogin(telegramAccount.trim())
|
if (wizard.type === 'email') {
|
||||||
|
await connectEmailChannel(emailForm)
|
||||||
|
toast.success('企业邮箱连接成功,历史邮件已开始同步')
|
||||||
|
setWizard({ open: false, type: null, step: 0 })
|
||||||
|
setEmailForm((current) => ({ ...current, password: '' }))
|
||||||
|
queryClient.invalidateQueries()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (wizard.type === 'whatsapp') {
|
||||||
|
if (!whatsappChannelId) {
|
||||||
|
const phone = whatsappAccount.replace(/\D/g, '')
|
||||||
|
if (!/^\d{7,15}$/.test(phone)) {
|
||||||
|
toast.warning('请输入包含国家/地区代码的 WhatsApp 手机号')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const channel = await startWhatsAppLogin(phone)
|
||||||
|
setWhatsAppChannelId(channel.id)
|
||||||
|
setWhatsAppPairingCode(channel.pairingCode || '')
|
||||||
|
setWhatsAppQr(channel.qrDataUrl || '')
|
||||||
|
setWizard({ open: true, type: 'whatsapp', step: 1 })
|
||||||
|
toast.success('配对码已生成,请在手机 WhatsApp 中完成关联设备')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const state = await getWhatsAppLoginState(whatsappChannelId)
|
||||||
|
setWhatsAppPairingCode(state.pairingCode || state.channel.pairingCode || '')
|
||||||
|
setWhatsAppQr(state.qrDataUrl || state.channel.qrDataUrl || '')
|
||||||
|
if (state.status !== 'connected') {
|
||||||
|
toast.info('尚未检测到手机确认,请完成关联设备后再次检查')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const sync = await syncChannel(whatsappChannelId, { full: true })
|
||||||
|
toast.success(`WhatsApp 绑定成功,已同步 ${sync.chats} 个会话和 ${sync.messages} 条消息`)
|
||||||
|
setWizard({ open: false, type: null, step: 0 })
|
||||||
|
await queryClient.invalidateQueries()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loginChannelId) {
|
||||||
|
const phone = telegramAccount.replace(/\s+/g, '')
|
||||||
|
if (!/^\+\d{7,15}$/.test(phone)) {
|
||||||
|
toast.warning('请输入国际格式手机号,例如 +8613800138000')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const channel = await startTelegramLogin(phone)
|
||||||
|
setTelegramAccount(phone)
|
||||||
setLoginChannelId(channel.id)
|
setLoginChannelId(channel.id)
|
||||||
setAuthState('waiting_code')
|
if (channel.status === 'connected' || channel.authState === 'ready') {
|
||||||
toast.success('验证码已发送到 Telegram')
|
await completeTelegramBinding(channel.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setTelegramStepFromState(channel.authState || 'waiting_code')
|
||||||
|
toast.success(channel.authState === 'waiting_password'
|
||||||
|
? '请继续输入 Telegram 二步验证密码'
|
||||||
|
: '验证码已发送到 Telegram')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const channel = await connectChannel({ type: 'telegram', account: '现有网关账号', autoReply: false })
|
if (authState === 'waiting_code') {
|
||||||
toast.success(channel.status === 'connected' ? '现有 Telegram 账号已连接' : '账号尚未完成授权')
|
const code = telegramCode.replace(/\s+/g, '')
|
||||||
setWizard({ open: false, type: null, step: 0 })
|
if (!/^\d{4,8}$/.test(code)) {
|
||||||
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
toast.warning('请输入 4—8 位 Telegram 验证码')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (authState === 'waiting_code') {
|
const result = await submitTelegramCode(loginChannelId, code)
|
||||||
const result = await submitTelegramCode(loginChannelId, telegramCode)
|
if (result.status === 'waiting_password') {
|
||||||
if (result.status === 'waiting_password') {
|
setTelegramStepFromState('waiting_password')
|
||||||
setAuthState('waiting_password')
|
toast.info('该账号启用了二步验证,请输入密码')
|
||||||
toast.info('该账号启用了二步验证,请输入密码')
|
return
|
||||||
return
|
}
|
||||||
|
if (result.status !== 'ready') {
|
||||||
|
setTelegramStepFromState(result.status)
|
||||||
|
toast.info('Telegram 仍在等待验证,请检查验证码后重试')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if (authState === 'waiting_password') {
|
||||||
|
if (!telegramPassword) {
|
||||||
|
toast.warning('请输入 Telegram 二步验证密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const result = await submitTelegramPassword(loginChannelId, telegramPassword)
|
||||||
|
if (result.status !== 'ready') {
|
||||||
|
setTelegramStepFromState(result.status)
|
||||||
|
toast.info('Telegram 授权尚未完成,请重试')
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (authState === 'waiting_password') {
|
await completeTelegramBinding(loginChannelId)
|
||||||
await submitTelegramPassword(loginChannelId, telegramPassword)
|
} catch (error) {
|
||||||
|
toast.error(errorMessage(error))
|
||||||
|
} finally {
|
||||||
|
setConnecting(false)
|
||||||
}
|
}
|
||||||
toast.success('Telegram 授权完成')
|
|
||||||
setWizard({ open: false, type: null, step: 0 })
|
|
||||||
setLoginChannelId('')
|
|
||||||
setAuthState('')
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
|
||||||
}}
|
}}
|
||||||
>{!loginChannelId ? '连接' : authState === 'waiting_password' ? '提交二步密码' : '提交验证码'}</Button>
|
>
|
||||||
|
{wizard.type === 'email'
|
||||||
|
? '验证并连接邮箱'
|
||||||
|
: wizard.type === 'whatsapp'
|
||||||
|
? !whatsappChannelId
|
||||||
|
? '生成 WhatsApp 配对码'
|
||||||
|
: '我已在手机确认,检查连接'
|
||||||
|
: !loginChannelId
|
||||||
|
? '发送 Telegram 验证码'
|
||||||
|
: authState === 'waiting_password'
|
||||||
|
? '验证二步密码并完成绑定'
|
||||||
|
: '验证并完成绑定'}
|
||||||
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-3">
|
{wizard.type === 'email' ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="space-y-4">
|
||||||
<Badge variant="primary">步骤 {wizard.step + 1}/7</Badge>
|
<div className="rounded-xl border border-sky-100 bg-sky-50/70 p-3">
|
||||||
<span className="text-sm font-medium">连接现有 Telegram 网关账号</span>
|
<div className="flex items-center gap-2 text-sm font-semibold text-sky-900">
|
||||||
|
<Mail className="h-4 w-4" />
|
||||||
|
IMAP 收信 + SMTP 发信
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-sky-700">
|
||||||
|
Gmail、Outlook 等邮箱通常需要使用应用专用密码。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Field label="邮箱服务商">
|
||||||
|
<Select
|
||||||
|
value={emailPreset}
|
||||||
|
onChange={(event) => applyEmailPreset(event.target.value as keyof typeof EMAIL_PRESETS)}
|
||||||
|
options={Object.entries(EMAIL_PRESETS).map(([value, preset]) => ({ value, label: preset.label }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="发件人名称">
|
||||||
|
<Input
|
||||||
|
value={emailForm.displayName}
|
||||||
|
onChange={(event) => setEmailForm({ ...emailForm, displayName: event.target.value })}
|
||||||
|
placeholder="例如:Lingxi Sales"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="邮箱地址">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={emailForm.account}
|
||||||
|
onChange={(event) => setEmailForm({ ...emailForm, account: event.target.value })}
|
||||||
|
placeholder="sales@company.com"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="邮箱密码 / 应用专用密码">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={emailForm.password}
|
||||||
|
onChange={(event) => setEmailForm({ ...emailForm, password: event.target.value })}
|
||||||
|
placeholder="邮箱密码或应用专用密码"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="grid grid-cols-[1fr_96px] gap-3">
|
||||||
|
<Field label="IMAP 服务器">
|
||||||
|
<Input value={emailForm.imapHost} onChange={(event) => setEmailForm({ ...emailForm, imapHost: event.target.value })} placeholder="imap.company.com" />
|
||||||
|
</Field>
|
||||||
|
<Field label="端口">
|
||||||
|
<Input type="number" value={emailForm.imapPort} onChange={(event) => setEmailForm({ ...emailForm, imapPort: Number(event.target.value) })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="SMTP 服务器">
|
||||||
|
<Input value={emailForm.smtpHost} onChange={(event) => setEmailForm({ ...emailForm, smtpHost: event.target.value })} placeholder="smtp.company.com" />
|
||||||
|
</Field>
|
||||||
|
<Field label="端口">
|
||||||
|
<Input type="number" value={emailForm.smtpPort} onChange={(event) => setEmailForm({ ...emailForm, smtpPort: Number(event.target.value) })} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="同步文件夹">
|
||||||
|
<Input value={emailForm.imapFolder} onChange={(event) => setEmailForm({ ...emailForm, imapFolder: event.target.value })} />
|
||||||
|
</Field>
|
||||||
|
<Field label="首次同步范围">
|
||||||
|
<Select
|
||||||
|
value={String(emailForm.syncDays)}
|
||||||
|
onChange={(event) => setEmailForm({ ...emailForm, syncDays: Number(event.target.value) })}
|
||||||
|
options={[7, 30, 90, 180, 365].map((days) => ({ value: String(days), label: `最近 ${days} 天` }))}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-5">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-slate-600">
|
||||||
|
<Switch checked={emailForm.imapSecure} onCheckedChange={(checked) => setEmailForm({ ...emailForm, imapSecure: checked })} />
|
||||||
|
IMAP SSL/TLS
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-slate-600">
|
||||||
|
<Switch checked={emailForm.smtpSecure} onCheckedChange={(checked) => setEmailForm({ ...emailForm, smtpSecure: checked })} />
|
||||||
|
SMTP 直连 SSL
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!loginChannelId && (
|
) : wizard.type === 'whatsapp' ? (
|
||||||
<Field label="Telegram 手机号(留空则连接服务器现有账号)">
|
<div className="space-y-4">
|
||||||
<Input value={telegramAccount} onChange={(e) => setTelegramAccount(e.target.value)} placeholder="+8613800138000" />
|
<div className="rounded-xl border border-amber-200 bg-amber-50 p-3.5">
|
||||||
</Field>
|
<div className="flex items-center gap-2 text-sm font-semibold text-amber-900">
|
||||||
)}
|
<AlertTriangle className="h-4 w-4" />
|
||||||
{authState === 'waiting_code' && (
|
普通 WhatsApp 关联设备接入
|
||||||
<Field label="Telegram 验证码">
|
</div>
|
||||||
<Input value={telegramCode} onChange={(e) => setTelegramCode(e.target.value)} placeholder="请输入验证码" />
|
<p className="mt-1 text-xs leading-5 text-amber-800">
|
||||||
</Field>
|
该方式通过 WhatsApp 的“关联设备”连接普通个人或 Business App 账号,并非 Meta 官方 Business Cloud API。
|
||||||
)}
|
请勿用于垃圾营销或违反 WhatsApp 条款的自动化行为。
|
||||||
{authState === 'waiting_password' && (
|
</p>
|
||||||
<Field label="Telegram 二步验证密码">
|
</div>
|
||||||
<Input type="password" value={telegramPassword} onChange={(e) => setTelegramPassword(e.target.value)} />
|
{!whatsappChannelId ? (
|
||||||
</Field>
|
<Field label="WhatsApp 手机号" required hint="包含国家/地区代码,例如中国号码输入 +8613800138000">
|
||||||
)}
|
<Input
|
||||||
<p className="text-sm text-muted-foreground">手机号、验证码和二步密码只发送到服务端 Telegram Gateway,不在浏览器持久化。</p>
|
type="tel"
|
||||||
</div>
|
autoComplete="tel"
|
||||||
|
value={whatsappAccount}
|
||||||
|
onChange={(event) => setWhatsAppAccount(event.target.value)}
|
||||||
|
placeholder="+8613800138000"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="rounded-2xl border border-primary-200 bg-primary-50/70 p-5 text-center">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary-600">关联设备配对码</p>
|
||||||
|
<p className="mt-3 font-mono text-3xl font-black tracking-[0.2em] text-primary-950">
|
||||||
|
{whatsappPairingCode || '等待生成'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-xs leading-5 text-primary-700">
|
||||||
|
手机 WhatsApp → 设置 → 关联设备 → 关联设备 → 使用手机号关联,然后输入上方配对码。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{whatsappQr && (
|
||||||
|
<div className="flex justify-center rounded-2xl border border-slate-200 bg-white p-4">
|
||||||
|
<img src={whatsappQr} alt="WhatsApp 关联设备二维码" className="h-48 w-48 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 text-xs leading-5 text-slate-600">
|
||||||
|
手机确认后将自动同步历史会话、联系人和媒体消息。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-xl border border-primary-100 bg-primary-50/70 p-3.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ShieldCheck className="h-4 w-4 text-primary" />
|
||||||
|
<span className="text-sm font-semibold text-primary-900">绑定你自己的 Telegram 工作账号</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs leading-5 text-primary-700">
|
||||||
|
可以继续添加其他工作账号,并选择一个默认账号发起新会话。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div aria-label="Telegram 绑定进度" className="grid grid-cols-3 gap-2">
|
||||||
|
{[
|
||||||
|
{ step: 0, label: '手机号' },
|
||||||
|
{ step: 1, label: '验证码' },
|
||||||
|
{ step: 2, label: '二步密码' },
|
||||||
|
].map((item) => {
|
||||||
|
const active = wizard.step === item.step
|
||||||
|
const complete = wizard.step > item.step
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.step}
|
||||||
|
className={`rounded-lg border px-2 py-2 text-center text-[11px] font-semibold transition-colors ${
|
||||||
|
active
|
||||||
|
? 'border-primary bg-primary text-white'
|
||||||
|
: complete
|
||||||
|
? 'border-primary-200 bg-primary-50 text-primary'
|
||||||
|
: 'border-slate-200 bg-white text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{complete ? <CheckCircle2 className="mx-auto mb-1 h-3.5 w-3.5" /> : <span className="mb-1 block">{item.step + 1}</span>}
|
||||||
|
{item.label}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{!loginChannelId && (
|
||||||
|
<Field label="Telegram 手机号" required hint="必须包含国家/地区代码,不要输入空格或短横线">
|
||||||
|
<Input
|
||||||
|
type="tel"
|
||||||
|
autoComplete="tel"
|
||||||
|
value={telegramAccount}
|
||||||
|
onChange={(e) => setTelegramAccount(e.target.value)}
|
||||||
|
placeholder="+8613800138000"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{authState === 'waiting_code' && (
|
||||||
|
<Field label="Telegram 验证码" required hint="验证码由 Telegram 官方发送,请勿转发给他人">
|
||||||
|
<Input
|
||||||
|
inputMode="numeric"
|
||||||
|
autoComplete="one-time-code"
|
||||||
|
value={telegramCode}
|
||||||
|
onChange={(e) => setTelegramCode(e.target.value.replace(/\D/g, '').slice(0, 8))}
|
||||||
|
placeholder="请输入 4—8 位验证码"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{authState === 'waiting_password' && (
|
||||||
|
<Field label="Telegram 二步验证密码" required hint="仅当该 Telegram 账号已启用两步验证时需要">
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={telegramPassword}
|
||||||
|
onChange={(e) => setTelegramPassword(e.target.value)}
|
||||||
|
placeholder="输入 Telegram 二步验证密码"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<p className="text-xs leading-5 text-muted-foreground">
|
||||||
|
完成验证后将自动同步历史会话、联系人和媒体消息。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* 同步日志 */}
|
{/* 同步日志 */}
|
||||||
@@ -180,6 +836,73 @@ export function ChannelsPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
open={!!settingsDrawer}
|
||||||
|
onOpenChange={() => setSettingsDrawer(null)}
|
||||||
|
title={settingsDrawer ? `${CHANNEL_META[settingsDrawer.type].name} 连接详情` : '连接详情'}
|
||||||
|
width="w-[420px]"
|
||||||
|
>
|
||||||
|
{settingsDrawer && (
|
||||||
|
<div className="space-y-5 p-4">
|
||||||
|
<div className="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 p-3">
|
||||||
|
<ChannelIcon channel={settingsDrawer.type} size="lg" withBg />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-semibold">{settingsDrawer.account || settingsDrawer.name}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
|
{STATUS_MAP[settingsDrawer.status]?.label} · 最近同步 {settingsDrawer.lastSyncAt ? formatRelative(settingsDrawer.lastSyncAt) : '尚未同步'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{settingsDrawer.type === 'email' && (
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
{[
|
||||||
|
['IMAP', `${settingsDrawer.imapHost}:${settingsDrawer.imapPort}`],
|
||||||
|
['SMTP', `${settingsDrawer.smtpHost}:${settingsDrawer.smtpPort}`],
|
||||||
|
['文件夹', settingsDrawer.imapFolder || 'INBOX'],
|
||||||
|
['同步范围', `最近 ${settingsDrawer.syncDays || 30} 天`],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<div key={label} className="rounded-lg border border-slate-100 p-3">
|
||||||
|
<p className="text-slate-400">{label}</p>
|
||||||
|
<p className="mt-1 break-all font-medium text-slate-700">{value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center justify-between rounded-xl border border-slate-100 px-3 py-2.5 text-sm">
|
||||||
|
自动回复
|
||||||
|
<Switch
|
||||||
|
checked={settingsDrawer.autoReply}
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
const updated = await updateChannel(settingsDrawer.id, { autoReply: checked })
|
||||||
|
setSettingsDrawer(updated)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['channels'] })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
const result = await testChannel(settingsDrawer.id)
|
||||||
|
toast.success(`${result.message} · ${result.latencyMs}ms`)
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(errorMessage(error))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
测试收发连接
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { useNavigate, useParams } from 'react-router-dom'
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { ArrowLeft, Edit, Send, UserRoundPlus, Tag, GitMerge, Download, ExternalLink } from 'lucide-react'
|
import { ArrowLeft, Edit, Send, UserRoundPlus, Tag, GitMerge, Download, ExternalLink } from 'lucide-react'
|
||||||
import { getCustomer, updateCustomer } from '@/services/api/customers'
|
import { getCustomer, updateCustomer } from '@/services/api/customers'
|
||||||
|
import { createTask } from '@/services/api/crm'
|
||||||
|
import { useAuthStore } from '@/app/store/auth'
|
||||||
import { useI18n } from '@/i18n'
|
import { useI18n } from '@/i18n'
|
||||||
import { toast, errorMessage } from '@/app/store/toast'
|
import { toast, errorMessage } from '@/app/store/toast'
|
||||||
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback'
|
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback'
|
||||||
@@ -22,6 +24,8 @@ import { CountryFlag } from '@/components/common/CountryFlag'
|
|||||||
import { ChannelIcon } from '@/components/common/ChannelIcon'
|
import { ChannelIcon } from '@/components/common/ChannelIcon'
|
||||||
import { IntentScoreBadge, CustomerStageBadge, SentimentBadge, RiskLevelBadge } from '@/components/common/StatusBadges'
|
import { IntentScoreBadge, CustomerStageBadge, SentimentBadge, RiskLevelBadge } from '@/components/common/StatusBadges'
|
||||||
import { ChipPicker } from '@/components/customer/CustomerFormDialog'
|
import { ChipPicker } from '@/components/customer/CustomerFormDialog'
|
||||||
|
import { CustomerProfileRadar } from '@/components/customer/CustomerProfileRadar'
|
||||||
|
import { CustomerSemanticTag, CustomerTagLegend, customerTagCategory } from '@/components/customer/CustomerSemanticTag'
|
||||||
import { CUSTOMER_STAGES, CUSTOMER_TAGS, NEXT_ACTIONS } from '@/config/constants'
|
import { CUSTOMER_STAGES, CUSTOMER_TAGS, NEXT_ACTIONS } from '@/config/constants'
|
||||||
import { formatRelative, formatDateTime } from '@/utils/format'
|
import { formatRelative, formatDateTime } from '@/utils/format'
|
||||||
import { cn } from '@/utils/cn'
|
import { cn } from '@/utils/cn'
|
||||||
@@ -31,6 +35,7 @@ export function CustomerDetailPage() {
|
|||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const user = useAuthStore((state) => state.user)
|
||||||
const [tab, setTab] = useState('overview')
|
const [tab, setTab] = useState('overview')
|
||||||
const [tagDialog, setTagDialog] = useState(false)
|
const [tagDialog, setTagDialog] = useState(false)
|
||||||
|
|
||||||
@@ -52,22 +57,42 @@ export function CustomerDetailPage() {
|
|||||||
if (isError || !data) return <ErrorState onRetry={() => refetch()} />
|
if (isError || !data) return <ErrorState onRetry={() => refetch()} />
|
||||||
|
|
||||||
const c = data.customer
|
const c = data.customer
|
||||||
|
const createSuggestedTask = async (title: string) => {
|
||||||
|
try {
|
||||||
|
await createTask({
|
||||||
|
title,
|
||||||
|
type: 'revisit',
|
||||||
|
priority: c.riskLevel === 'urgent' || c.riskLevel === 'high' ? 'high' : 'medium',
|
||||||
|
status: 'pending',
|
||||||
|
customerId: c.id,
|
||||||
|
customerName: c.name,
|
||||||
|
ownerId: user?.id || '',
|
||||||
|
dueAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
note: `由客户画像建议创建:${title}`,
|
||||||
|
})
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['tasks'] })
|
||||||
|
toast.success('已创建跟进任务', '默认明天到期,可在商机与跟进中调整')
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(errorMessage(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const FILE_ICONS: Record<string, string> = { quote: '📄', contract: '📝', image: '🖼️', doc: '📁', other: '📎' }
|
const FILE_ICONS: Record<string, string> = { quote: '📄', contract: '📝', image: '🖼️', doc: '📁', other: '📎' }
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-6">
|
<div className="mx-auto max-w-[1500px] p-4 md:p-7">
|
||||||
<button onClick={() => navigate(-1)} className="mb-3 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
|
<button onClick={() => navigate(-1)} className="mb-3 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
|
||||||
<ArrowLeft className="h-4 w-4 rtl-flip" /> 返回
|
<ArrowLeft className="h-4 w-4 rtl-flip" /> 返回
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 顶部客户画像卡片 */}
|
{/* 顶部客户画像卡片 */}
|
||||||
<Card className="p-4 md:p-5">
|
<Card className="overflow-hidden border-slate-200/80 p-5 md:p-6">
|
||||||
<div className="flex flex-wrap gap-4">
|
<div className="flex flex-wrap gap-4">
|
||||||
<Avatar name={c.name} src={c.avatarUrl} size="xl" />
|
<Avatar name={c.name} src={c.avatarUrl} size="xl" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="eyebrow mb-1">Customer intelligence</p>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h1 className="text-lg font-semibold">{c.name}</h1>
|
<h1 className="text-2xl font-semibold tracking-[-0.035em] text-slate-950">{c.name}</h1>
|
||||||
<CountryFlag code={c.country} />
|
<CountryFlag code={c.country} />
|
||||||
<ChannelIcon channel={c.channel} size="xs" />
|
<ChannelIcon channel={c.channel} size="xs" />
|
||||||
{c.isKey && <Badge variant="warning">⭐ 重点客户</Badge>}
|
{c.isKey && <Badge variant="warning">⭐ 重点客户</Badge>}
|
||||||
@@ -80,8 +105,19 @@ export function CustomerDetailPage() {
|
|||||||
{c.riskLevel !== 'none' && <RiskLevelBadge level={c.riskLevel} />}
|
{c.riskLevel !== 'none' && <RiskLevelBadge level={c.riskLevel} />}
|
||||||
<span className="text-muted-foreground">负责人:{c.ownerId === 'u-003' ? '李婷' : c.ownerId === 'u-004' ? '赵磊' : c.ownerId === 'u-005' ? '周敏' : c.ownerId === 'u-008' ? '郑楠' : '未分配'}</span>
|
<span className="text-muted-foreground">负责人:{c.ownerId === 'u-003' ? '李婷' : c.ownerId === 'u-004' ? '赵磊' : c.ownerId === 'u-005' ? '周敏' : c.ownerId === 'u-008' ? '郑楠' : '未分配'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex flex-wrap gap-1">
|
<div className="mt-3 rounded-xl border border-slate-100 bg-slate-50/50 p-3">
|
||||||
{c.tags.map((t) => <Badge key={t} variant="outline">{t}</Badge>)}
|
<CustomerTagLegend tags={c.tags} />
|
||||||
|
{c.tags.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{[...c.tags]
|
||||||
|
.sort((a, b) => customerTagCategory(a).localeCompare(customerTagCategory(b)))
|
||||||
|
.map((tag) => <CustomerSemanticTag key={tag} tag={tag} />)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs leading-5 text-slate-400">
|
||||||
|
暂无标签,收到更多客户消息后将自动补充。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-start gap-2">
|
<div className="flex flex-wrap items-start gap-2">
|
||||||
@@ -119,43 +155,46 @@ export function CustomerDetailPage() {
|
|||||||
|
|
||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{tab === 'overview' && (
|
{tab === 'overview' && (
|
||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-12">
|
||||||
<Card>
|
<CustomerProfileRadar customer={c} className="xl:col-span-5" />
|
||||||
<CardHeader title="自动建档流程" description="客户从渠道识别到完整建档案的自动化历程" />
|
|
||||||
<div className="space-y-3 px-4 pb-4">
|
<Card className="xl:col-span-7">
|
||||||
{[
|
<CardHeader title="客户洞察" description="参考已同步的会话、购买信号和风险变化" />
|
||||||
'✅ 识别来源渠道(WhatsApp)',
|
<div className="p-5">
|
||||||
'✅ 记录来源参数(官网按钮带入)',
|
<div className="grid grid-cols-3 gap-2">
|
||||||
'✅ 匹配客户身份(手机号 + 姓名字段)',
|
{[
|
||||||
'✅ 自动发现重复客户(未匹配到重复)',
|
['成交概率', c.aiProfile.dealProbability || 0, 'text-emerald-700 bg-emerald-50'],
|
||||||
'✅ 人工确认建档(管理员 / 业务主管确认)',
|
['流失风险', c.aiProfile.churnProbability || 0, 'text-amber-700 bg-amber-50'],
|
||||||
'✅ 完整客户档案已建立',
|
['分析可信度', c.aiProfile.confidenceScore || 0, 'text-blue-700 bg-blue-50'],
|
||||||
].map((step, i) => (
|
].map(([label, value, color]) => (
|
||||||
<div key={i} className="flex items-center gap-2 text-sm">
|
<div key={String(label)} className={cn('rounded-2xl px-3 py-3', String(color))}>
|
||||||
<span className={cn('flex h-5 w-5 items-center justify-center rounded-full text-xs', step.startsWith('✅') ? 'bg-emerald-100 text-emerald-700' : 'bg-muted text-muted-foreground')}>{i + 1}</span>
|
<p className="text-[11px] opacity-75">{label}</p>
|
||||||
{step.replace('✅ ', '')}
|
<p className="mt-1 text-xl font-semibold tracking-tight">{Number(value)}%</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-5 text-sm leading-7 text-slate-600">
|
||||||
|
{c.aiProfile.summary || '收到更多客户消息后将补充客户画像。'}
|
||||||
|
</p>
|
||||||
|
{c.aiProfile.evidence?.length ? (
|
||||||
|
<div className="mt-5 border-t border-slate-100 pt-4">
|
||||||
|
<p className="text-xs font-semibold text-slate-900">关键判断依据</p>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{c.aiProfile.evidence.slice(0, 4).map((item) => (
|
||||||
|
<div key={item} className="flex gap-2 text-xs leading-5 text-slate-500">
|
||||||
|
<span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-primary-400" />
|
||||||
|
{item}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
|
||||||
<CardHeader title="AI 客户画像摘要" />
|
<Card className="xl:col-span-8">
|
||||||
<p className="px-4 pb-4 text-sm text-muted-foreground leading-relaxed">{c.aiProfile.summary}</p>
|
<CardHeader title="采购信息" description="根据已同步的会话内容整理" />
|
||||||
{c.aiProfile.nextActions.length > 0 && (
|
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-b-2xl bg-slate-100 md:grid-cols-4">
|
||||||
<div className="px-4 pb-4">
|
|
||||||
<h4 className="text-xs font-semibold mb-2">推荐动作</h4>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{c.aiProfile.nextActions.map((a) => (
|
|
||||||
<button key={a} onClick={() => toast.info(a)} className="rounded-full border border-primary-200 bg-primary-50 px-2 py-1 text-xs text-primary-700">{a}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
{/* AI 提取信息 */}
|
|
||||||
<Card className="lg:col-span-2">
|
|
||||||
<CardHeader title="AI 自动提取信息" />
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 px-4 pb-4 text-sm">
|
|
||||||
{Object.entries({
|
{Object.entries({
|
||||||
询价数量: c.aiProfile.inquiryQuantity,
|
询价数量: c.aiProfile.inquiryQuantity,
|
||||||
目标价格: c.aiProfile.targetPrice,
|
目标价格: c.aiProfile.targetPrice,
|
||||||
@@ -165,16 +204,32 @@ export function CustomerDetailPage() {
|
|||||||
交付时间: c.aiProfile.deliveryTime,
|
交付时间: c.aiProfile.deliveryTime,
|
||||||
物流方式: c.aiProfile.logistics,
|
物流方式: c.aiProfile.logistics,
|
||||||
付款方式: c.aiProfile.payment,
|
付款方式: c.aiProfile.payment,
|
||||||
售后诉求: c.aiProfile.aftersales,
|
}).map(([k, v]) => (
|
||||||
主要顾虑: c.aiProfile.concerns?.join('、'),
|
<div key={k} className="min-h-24 bg-white p-4">
|
||||||
}).filter(([, v]) => v).map(([k, v]) => (
|
<span className="text-[11px] font-medium text-slate-400">{k}</span>
|
||||||
<div key={k} className="rounded-md bg-muted/60 p-2">
|
<p className={cn('mt-2 text-sm font-medium leading-5', v ? 'text-slate-800' : 'text-slate-300')}>
|
||||||
<span className="text-xs text-muted-foreground">{k}</span>
|
{(v as string) || '待识别'}
|
||||||
<p className="text-sm font-medium mt-0.5">{v as string}</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="xl:col-span-4">
|
||||||
|
<CardHeader title="建议下一步" description={c.aiProfile.followUpTiming || '按当前客户热度排序'} />
|
||||||
|
<div className="space-y-2 p-4">
|
||||||
|
{(c.aiProfile.nextActions.length ? c.aiProfile.nextActions : ['补充确认采购数量', '确认目标交期']).slice(0, 5).map((action, index) => (
|
||||||
|
<button
|
||||||
|
key={action}
|
||||||
|
onClick={() => void createSuggestedTask(action)}
|
||||||
|
className="flex w-full items-center gap-3 rounded-xl border border-slate-100 px-3 py-2.5 text-start text-sm text-slate-700 transition-colors hover:border-primary-200 hover:bg-primary-50"
|
||||||
|
>
|
||||||
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-slate-100 text-[11px] font-semibold text-slate-500">{index + 1}</span>
|
||||||
|
{action}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ export function CustomersPage() {
|
|||||||
<div className="p-4 md:p-6">
|
<div className="p-4 md:p-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="客户管理"
|
title="客户管理"
|
||||||
description={`共 ${total} 个客户 · 统一管理与跟进`}
|
description={`共 ${total} 个客户`}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<PermissionGate permission="customer:export">
|
<PermissionGate permission="customer:export">
|
||||||
@@ -270,7 +270,7 @@ export function CustomersPage() {
|
|||||||
<Tr><Td colSpan={13}><EmptyState title="暂无匹配客户" description="尝试调整筛选条件" /></Td></Tr>
|
<Tr><Td colSpan={13}><EmptyState title="暂无匹配客户" description="尝试调整筛选条件" /></Td></Tr>
|
||||||
)}
|
)}
|
||||||
{list.map((customer: Customer) => (
|
{list.map((customer: Customer) => (
|
||||||
<Tr key={customer.id} className="cursor-pointer">
|
<Tr key={customer.id} data-testid="customer-row" className="cursor-pointer">
|
||||||
<Td onClick={(e) => e.stopPropagation()}><Checkbox checked={selected.has(customer.id)} onCheckedChange={(v) => setSelected((s) => { const ns = new Set(s); v ? ns.add(customer.id) : ns.delete(customer.id); return ns })} /></Td>
|
<Td onClick={(e) => e.stopPropagation()}><Checkbox checked={selected.has(customer.id)} onCheckedChange={(v) => setSelected((s) => { const ns = new Set(s); v ? ns.add(customer.id) : ns.delete(customer.id); return ns })} /></Td>
|
||||||
<Td onClick={() => navigate(`/customers/${customer.id}`)}>
|
<Td onClick={() => navigate(`/customers/${customer.id}`)}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -92,43 +92,53 @@ export function DashboardPage() {
|
|||||||
const s = data.stats
|
const s = data.stats
|
||||||
|
|
||||||
return (
|
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 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>
|
<div className="flex flex-wrap items-start justify-between gap-5">
|
||||||
<h1 className="text-xl font-semibold">
|
<div>
|
||||||
{greeting},{user?.name}
|
<p className="eyebrow">Business command center</p>
|
||||||
</h1>
|
<h1 className="mt-2 text-2xl font-semibold tracking-[-0.035em] text-slate-950 md:text-[28px]">
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
{greeting},{user?.name}
|
||||||
{dateStr} · {tenant?.name} · {t('dashboard.updatedAt')} {formatDateTime(data.updatedAt).slice(11)}
|
</h1>
|
||||||
</p>
|
<p className="mt-1.5 text-sm text-slate-500">
|
||||||
|
{dateStr} · {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>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="mt-5 flex gap-2 overflow-x-auto border-t border-slate-100 pt-4 scrollbar-thin">
|
||||||
<Badge variant="primary" className="h-7 px-2.5">{plan.name}</Badge>
|
{quickActions.slice(0, 5).map((a) => (
|
||||||
<LiveDataBadge />
|
<Button key={a.label} variant="ghost" size="sm" className="shrink-0 rounded-xl bg-slate-50 text-slate-600 hover:bg-primary-50 hover:text-primary-800" onClick={a.onClick}>
|
||||||
<Button size="sm" onClick={() => navigate('/channels')}>
|
<a.icon className="h-3.5 w-3.5" />
|
||||||
<Cable className="h-4 w-4" />
|
{a.label}
|
||||||
快速连接渠道
|
</Button>
|
||||||
</Button>
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 快捷操作 */}
|
|
||||||
<div className="mb-5 flex flex-wrap gap-2">
|
|
||||||
{quickActions.map((a) => (
|
|
||||||
<Button key={a.label} variant="outline" size="sm" onClick={a.onClick}>
|
|
||||||
<a.icon className="h-3.5 w-3.5" />
|
|
||||||
{a.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 核心指标 */}
|
{/* 核心指标 */}
|
||||||
|
<div className="mb-3 flex items-end justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">工作台</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">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<StatCard title="今日新增询盘" value={s.newInquiriesToday.value} delta={s.newInquiriesToday.delta} icon={MessageSquarePlus} onClick={() => navigate('/inbox')} />
|
<StatCard title="今日新增询盘" value={s.newInquiriesToday.value} delta={s.newInquiriesToday.delta} icon={MessageSquarePlus} onClick={() => navigate('/inbox')} />
|
||||||
<StatCard title="待回复消息" value={s.pendingReply.value} delta={s.pendingReply.delta} icon={MessageSquareWarning} onClick={() => navigate('/inbox?filter=pending')} />
|
<StatCard title="待回复消息" value={s.pendingReply.value} delta={s.pendingReply.delta} icon={MessageSquareWarning} onClick={() => navigate('/inbox?filter=pending')} />
|
||||||
<StatCard title="高意向客户" value={s.highIntentCustomers.value} delta={s.highIntentCustomers.delta} icon={Target} onClick={() => navigate('/customers?intentLevel=high')} />
|
<StatCard title="高意向客户" value={s.highIntentCustomers.value} delta={s.highIntentCustomers.delta} icon={Target} onClick={() => navigate('/customers?intentLevel=high')} />
|
||||||
<StatCard title="今日待跟进" value={s.todayFollowUps.value} delta={s.todayFollowUps.delta} icon={CalendarClock} onClick={() => navigate('/opportunities?tab=tasks')} />
|
<StatCard title="今日待跟进" value={s.todayFollowUps.value} delta={s.todayFollowUps.delta} icon={CalendarClock} onClick={() => navigate('/opportunities?tab=tasks')} />
|
||||||
|
</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.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.avgFirstResponseMin.value} suffix="分钟" delta={s.avgFirstResponseMin.delta} icon={Timer} invertDelta />
|
||||||
<StatCard title="本月客户转化率" value={`${s.monthConversionRate.value}%`} delta={s.monthConversionRate.delta} icon={BadgePercent} />
|
<StatCard title="本月客户转化率" value={`${s.monthConversionRate.value}%`} delta={s.monthConversionRate.delta} icon={BadgePercent} />
|
||||||
@@ -242,7 +252,7 @@ export function DashboardPage() {
|
|||||||
<div className="border-t border-border p-3">
|
<div className="border-t border-border p-3">
|
||||||
<Button variant="outline" className="w-full" onClick={() => navigate('/inbox')}>
|
<Button variant="outline" className="w-full" onClick={() => navigate('/inbox')}>
|
||||||
<Send className="h-4 w-4" />
|
<Send className="h-4 w-4" />
|
||||||
进入统一消息中心
|
查看全部消息
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ const HELP_ITEMS = [
|
|||||||
{ icon: Cable, title: '连接 Telegram', desc: '手机号、验证码和二步密码登录,并同步历史会话', path: '/channels' },
|
{ icon: Cable, title: '连接 Telegram', desc: '手机号、验证码和二步密码登录,并同步历史会话', path: '/channels' },
|
||||||
{ icon: BookOpen, title: '建设知识库', desc: '上传 TXT、Markdown、PDF 或 DOCX 并解析为企业知识', path: '/knowledge' },
|
{ icon: BookOpen, title: '建设知识库', desc: '上传 TXT、Markdown、PDF 或 DOCX 并解析为企业知识', path: '/knowledge' },
|
||||||
{ icon: FileUp, title: '导入与管理客户', desc: '维护客户资料、标签、负责人和跟进状态', path: '/customers' },
|
{ icon: FileUp, title: '导入与管理客户', desc: '维护客户资料、标签、负责人和跟进状态', path: '/customers' },
|
||||||
{ icon: Languages, title: 'AI 上下文翻译', desc: '结合当前会话上下文进行跨境商务翻译', path: '/translate' },
|
{ icon: Languages, title: '消息翻译', desc: '翻译当前消息并保留双语对照', path: '/translate' },
|
||||||
{ icon: UsersRound, title: '邀请团队成员', desc: '通过已配置的企业 SMTP 发送真实邀请邮件', path: '/team' },
|
{ icon: UsersRound, title: '邀请团队成员', desc: '向同事发送团队邀请邮件', path: '/team' },
|
||||||
{ icon: CreditCard, title: '订阅与支付', desc: '通过 Stripe Checkout 管理套餐订阅', path: '/billing' },
|
{ icon: CreditCard, title: '订阅与支付', desc: '通过 Stripe Checkout 管理套餐订阅', path: '/billing' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+1009
-104
File diff suppressed because it is too large
Load Diff
@@ -65,7 +65,7 @@ export function KnowledgePage() {
|
|||||||
<div className="p-4 md:p-6">
|
<div className="p-4 md:p-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="企业知识库"
|
title="企业知识库"
|
||||||
description={`${list.length} 条知识 · AI 回复与翻译的精准来源`}
|
description={`${list.length} 条知识 · 用于回复建议与翻译参考`}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<Button size="sm" variant="outline" onClick={() => setUploadDialog(true)}><Upload className="h-4 w-4" />上传文件</Button>
|
<Button size="sm" variant="outline" onClick={() => setUploadDialog(true)}><Upload className="h-4 w-4" />上传文件</Button>
|
||||||
@@ -129,7 +129,7 @@ export function KnowledgePage() {
|
|||||||
{tab === 'search' && (
|
{tab === 'search' && (
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input placeholder="输入跨境问题,测试知识库检索与 AI 答案生成" value={searchQ} onChange={(e) => setSearchQ(e.target.value)} className="flex-1" />
|
<Input placeholder="输入问题,测试知识库检索和答案生成" value={searchQ} onChange={(e) => setSearchQ(e.target.value)} className="flex-1" />
|
||||||
<Button loading={searchLoading} onClick={async () => {
|
<Button loading={searchLoading} onClick={async () => {
|
||||||
if (!searchQ.trim()) return
|
if (!searchQ.trim()) return
|
||||||
setSearchLoading(true)
|
setSearchLoading(true)
|
||||||
@@ -142,7 +142,7 @@ export function KnowledgePage() {
|
|||||||
{searchResult && (
|
{searchResult && (
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
<Card className="p-3 bg-primary-50/30">
|
<Card className="p-3 bg-primary-50/30">
|
||||||
<p className="text-xs font-semibold text-muted-foreground mb-1">AI 答案</p>
|
<p className="text-xs font-semibold text-muted-foreground mb-1">参考答案</p>
|
||||||
<p className="text-sm">{searchResult.answer}</p>
|
<p className="text-sm">{searchResult.answer}</p>
|
||||||
</Card>
|
</Card>
|
||||||
<p className="text-xs font-semibold text-muted-foreground">命中知识 ({searchResult.hits.length})</p>
|
<p className="text-xs font-semibold text-muted-foreground">命中知识 ({searchResult.hits.length})</p>
|
||||||
@@ -211,7 +211,7 @@ export function KnowledgePage() {
|
|||||||
onChange={(e) => setUploadFiles(Array.from(e.target.files || []))}
|
onChange={(e) => setUploadFiles(Array.from(e.target.files || []))}
|
||||||
className="block w-full rounded-md border border-input p-2 text-sm"
|
className="block w-full rounded-md border border-input p-2 text-sm"
|
||||||
/>
|
/>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">支持 PDF、DOCX、TXT、Markdown、CSV、JSON 和 HTML,文件内容由服务端真实解析并写入知识库。</p>
|
<p className="mt-2 text-sm text-muted-foreground">支持 PDF、DOCX、TXT、Markdown、CSV、JSON 和 HTML,上传后自动解析并写入知识库。</p>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { toast, errorMessage } from '@/app/store/toast'
|
import { toast, errorMessage } from '@/app/store/toast'
|
||||||
import { PageLoading, ErrorState } from '@/components/ui/feedback'
|
import { PageLoading, ErrorState } from '@/components/ui/feedback'
|
||||||
@@ -20,10 +21,12 @@ import { AlertTriangle, Settings } from 'lucide-react'
|
|||||||
|
|
||||||
export function RisksPage() {
|
export function RisksPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const navigate = useNavigate()
|
||||||
const [level, setLevel] = useState('')
|
const [level, setLevel] = useState('')
|
||||||
const [type, setType] = useState('')
|
const [type, setType] = useState('')
|
||||||
const [status, setStatus] = useState('')
|
const [status, setStatus] = useState('')
|
||||||
const [detailId, setDetailId] = useState<string | null>(null)
|
const [detailId, setDetailId] = useState<string | null>(null)
|
||||||
|
const [noteDraft, setNoteDraft] = useState('')
|
||||||
|
|
||||||
const { data, isLoading, isError, refetch } = useQuery({
|
const { data, isLoading, isError, refetch } = useQuery({
|
||||||
queryKey: ['risks', level, type, status],
|
queryKey: ['risks', level, type, status],
|
||||||
@@ -52,10 +55,10 @@ export function RisksPage() {
|
|||||||
<div className="p-4 md:p-6">
|
<div className="p-4 md:p-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="风险预警中心"
|
title="风险预警中心"
|
||||||
description="智能识别交易风险、客户流失信号与敏感表达,帮助团队主动应对"
|
description="查看交易风险、客户流失信号和敏感表达"
|
||||||
actions={
|
actions={
|
||||||
<PermissionGate permission="risk:handle">
|
<PermissionGate permission="settings:manage">
|
||||||
<Button size="sm" onClick={() => toast.info('风险处理操作已可用')}><Settings className="h-4 w-4" />规则设置</Button>
|
<Button size="sm" variant="outline" onClick={() => navigate('/settings')}><Settings className="h-4 w-4" />风险识别设置</Button>
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -77,7 +80,10 @@ export function RisksPage() {
|
|||||||
{list.map((r) => {
|
{list.map((r) => {
|
||||||
const meta = riskLevelMeta(r.level)
|
const meta = riskLevelMeta(r.level)
|
||||||
return (
|
return (
|
||||||
<Card key={r.id} className="cursor-pointer p-4 hover:bg-muted/40 transition-colors" onClick={() => setDetailId(r.id)}>
|
<Card key={r.id} className="cursor-pointer p-4 hover:bg-muted/40 transition-colors" onClick={() => {
|
||||||
|
setDetailId(r.id)
|
||||||
|
setNoteDraft(r.note || '')
|
||||||
|
}}>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-start gap-2 min-w-0">
|
<div className="flex items-start gap-2 min-w-0">
|
||||||
<span className={cn('mt-0.5 h-2 w-2 shrink-0 rounded-full', meta.dot)} />
|
<span className={cn('mt-0.5 h-2 w-2 shrink-0 rounded-full', meta.dot)} />
|
||||||
@@ -110,7 +116,7 @@ export function RisksPage() {
|
|||||||
<span className="text-xs text-muted-foreground">{formatRelative(detail.createdAt)}</span>
|
<span className="text-xs text-muted-foreground">{formatRelative(detail.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold mb-1">AI 判断说明</h4>
|
<h4 className="text-sm font-semibold mb-1">判断依据</h4>
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">{detail.aiExplanation}</p>
|
<p className="text-sm text-muted-foreground leading-relaxed">{detail.aiExplanation}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -119,8 +125,15 @@ export function RisksPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-semibold mb-1">添加备注</h4>
|
<h4 className="text-sm font-semibold mb-1">添加备注</h4>
|
||||||
<Textarea rows={3} placeholder="添加处理备注…" />
|
<Textarea rows={3} placeholder="添加处理备注…" value={noteDraft} onChange={(event) => setNoteDraft(event.target.value)} />
|
||||||
<Button size="sm" className="mt-2" onClick={() => { updateMut.mutate({ id: detail.id, data: { note: '已备注' } }); toast.success('备注已保存') }}>保存备注</Button>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="mt-2"
|
||||||
|
disabled={!noteDraft.trim()}
|
||||||
|
onClick={() => updateMut.mutate({ id: detail.id, data: { note: noteDraft.trim() } })}
|
||||||
|
>
|
||||||
|
保存备注
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export function SettingsPage() {
|
|||||||
<Field label="当前密码"><Input type="password" value={passwords.currentPassword} onChange={(e) => setPasswords((v) => ({ ...v, currentPassword: e.target.value }))} /></Field>
|
<Field label="当前密码"><Input type="password" value={passwords.currentPassword} onChange={(e) => setPasswords((v) => ({ ...v, currentPassword: e.target.value }))} /></Field>
|
||||||
<Field label="新密码"><Input type="password" placeholder="至少10位,包含字母和数字" value={passwords.newPassword} onChange={(e) => setPasswords((v) => ({ ...v, newPassword: e.target.value }))} /></Field>
|
<Field label="新密码"><Input type="password" placeholder="至少10位,包含字母和数字" value={passwords.newPassword} onChange={(e) => setPasswords((v) => ({ ...v, newPassword: e.target.value }))} /></Field>
|
||||||
<Field label="确认新密码"><Input type="password" value={passwords.confirmPassword} onChange={(e) => setPasswords((v) => ({ ...v, confirmPassword: e.target.value }))} /></Field>
|
<Field label="确认新密码"><Input type="password" value={passwords.confirmPassword} onChange={(e) => setPasswords((v) => ({ ...v, confirmPassword: e.target.value }))} /></Field>
|
||||||
<div className="rounded bg-emerald-50 p-3 text-sm text-emerald-700"><ShieldCheck className="inline h-4 w-4 mr-1" />密码使用 bcrypt 加密;修改后所有旧登录 Token 会立即失效。</div>
|
<div className="rounded bg-emerald-50 p-3 text-sm text-emerald-700"><ShieldCheck className="inline h-4 w-4 mr-1" />修改密码后,其他已登录设备需要重新登录。</div>
|
||||||
<Button onClick={async () => {
|
<Button onClick={async () => {
|
||||||
if (passwords.newPassword !== passwords.confirmPassword) return toast.error('两次输入的新密码不一致')
|
if (passwords.newPassword !== passwords.confirmPassword) return toast.error('两次输入的新密码不一致')
|
||||||
try {
|
try {
|
||||||
@@ -148,7 +148,7 @@ export function SettingsPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader title="API 与 Webhook" />
|
<CardHeader title="API 与 Webhook" />
|
||||||
<div className="space-y-3 p-4">
|
<div className="space-y-3 p-4">
|
||||||
<p className="text-sm text-muted-foreground">密钥只在创建时完整显示一次,数据库仅保存 SHA-256 摘要。</p>
|
<p className="text-sm text-muted-foreground">密钥只在创建时完整显示一次,请立即复制并妥善保存。</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Input value={apiName} onChange={(e) => setApiName(e.target.value)} placeholder="密钥名称" />
|
<Input value={apiName} onChange={(e) => setApiName(e.target.value)} placeholder="密钥名称" />
|
||||||
<Button variant="outline" onClick={async () => {
|
<Button variant="outline" onClick={async () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useMutation } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { ArrowLeftRight, Copy, FileText, History, Languages, RefreshCw, Save } from 'lucide-react'
|
import { ArrowLeftRight, Copy, Languages, RefreshCw, Save } from 'lucide-react'
|
||||||
import { translate } from '@/services/api/ai'
|
import { favoriteTranslation, getTranslationHistory, translate } from '@/services/api/ai'
|
||||||
import { toast, errorMessage } from '@/app/store/toast'
|
import { toast, errorMessage } from '@/app/store/toast'
|
||||||
import { PageHeader } from '@/components/common/PageHeader'
|
import { PageHeader } from '@/components/common/PageHeader'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -19,6 +19,7 @@ import { cn } from '@/utils/cn'
|
|||||||
const SAMPLE_TEXT = '你好,非常感谢贵司对500个定制双层玻璃杯的询盘。我们很高兴为您提供报价:单价$1.85/个(含双色Logo印刷),标准交期12-15天,加急10天可交付。如有任何问题或需要调整,请随时告知。期待与贵司建立长期合作。'
|
const SAMPLE_TEXT = '你好,非常感谢贵司对500个定制双层玻璃杯的询盘。我们很高兴为您提供报价:单价$1.85/个(含双色Logo印刷),标准交期12-15天,加急10天可交付。如有任何问题或需要调整,请随时告知。期待与贵司建立长期合作。'
|
||||||
|
|
||||||
export function TranslatePage() {
|
export function TranslatePage() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const [tab, setTab] = useState('text')
|
const [tab, setTab] = useState('text')
|
||||||
const [source, setSource] = useState('')
|
const [source, setSource] = useState('')
|
||||||
const [target, setTarget] = useState('en')
|
const [target, setTarget] = useState('en')
|
||||||
@@ -26,7 +27,11 @@ export function TranslatePage() {
|
|||||||
const [result, setResult] = useState('')
|
const [result, setResult] = useState('')
|
||||||
const [detected, setDetected] = useState('')
|
const [detected, setDetected] = useState('')
|
||||||
const [terms, setTerms] = useState<any[]>([])
|
const [terms, setTerms] = useState<any[]>([])
|
||||||
const [history, setHistory] = useState<{ source: string; target: string; text: string; result: string }[]>([])
|
const [currentRecordId, setCurrentRecordId] = useState('')
|
||||||
|
const { data: history = [] } = useQuery({
|
||||||
|
queryKey: ['translation-history'],
|
||||||
|
queryFn: getTranslationHistory,
|
||||||
|
})
|
||||||
|
|
||||||
const translateMut = useMutation({
|
const translateMut = useMutation({
|
||||||
mutationFn: () => translate({ text, sourceLang: source || undefined, targetLang: target }),
|
mutationFn: () => translate({ text, sourceLang: source || undefined, targetLang: target }),
|
||||||
@@ -34,10 +39,19 @@ export function TranslatePage() {
|
|||||||
setResult(r.translated)
|
setResult(r.translated)
|
||||||
setDetected(r.detectedLang)
|
setDetected(r.detectedLang)
|
||||||
setTerms(r.terms ?? [])
|
setTerms(r.terms ?? [])
|
||||||
setHistory((h) => [{ source: source || r.detectedLang, target, text, result: r.translated }, ...h].slice(0, 20))
|
setCurrentRecordId(r.historyItem?.id || '')
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['translation-history'] })
|
||||||
},
|
},
|
||||||
onError: (e) => toast.error(errorMessage(e)),
|
onError: (e) => toast.error(errorMessage(e)),
|
||||||
})
|
})
|
||||||
|
const favoriteMut = useMutation({
|
||||||
|
mutationFn: () => favoriteTranslation(currentRecordId, true),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['translation-history'] })
|
||||||
|
toast.success('已收藏到翻译历史')
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(errorMessage(error)),
|
||||||
|
})
|
||||||
|
|
||||||
const swap = () => {
|
const swap = () => {
|
||||||
if (detected && !source) setSource(detected)
|
if (detected && !source) setSource(detected)
|
||||||
@@ -52,8 +66,8 @@ export function TranslatePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="p-4 md:p-6">
|
<div className="p-4 md:p-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="AI 翻译中心"
|
title="翻译中心"
|
||||||
description={`跨境商务场景专用翻译引擎,支持全球主流贸易国家和地区语言(可配置 ${supportedLanguageCount} 种)。`}
|
description={`支持 ${supportedLanguageCount} 种已配置语言,可用于消息翻译与回复。`}
|
||||||
actions={<LiveDataBadge />}
|
actions={<LiveDataBadge />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -131,8 +145,7 @@ export function TranslatePage() {
|
|||||||
<Button size="sm" variant="ghost" disabled={!result} onClick={() => { navigator.clipboard.writeText(result); toast.success('已复制') }}>
|
<Button size="sm" variant="ghost" disabled={!result} onClick={() => { navigator.clipboard.writeText(result); toast.success('已复制') }}>
|
||||||
<Copy className="h-3.5 w-3.5" /> 复制
|
<Copy className="h-3.5 w-3.5" /> 复制
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" disabled={!result} onClick={() => { toast.success('已收藏');
|
<Button size="sm" variant="ghost" disabled={!result || !currentRecordId || favoriteMut.isPending} onClick={() => favoriteMut.mutate()}>
|
||||||
}}>
|
|
||||||
<Save className="h-3.5 w-3.5" /> 收藏
|
<Save className="h-3.5 w-3.5" /> 收藏
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" onClick={() => translateMut.mutate()}>
|
<Button size="sm" variant="ghost" onClick={() => translateMut.mutate()}>
|
||||||
@@ -154,10 +167,11 @@ export function TranslatePage() {
|
|||||||
<div key={i} className="p-3 text-sm">
|
<div key={i} className="p-3 text-sm">
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
|
||||||
<Languages className="h-3 w-3" />
|
<Languages className="h-3 w-3" />
|
||||||
{LANGUAGES.find(l => l.code === h.source)?.name ?? '自动识别'} → {LANGUAGES.find(l => l.code === h.target)?.name}
|
{LANGUAGES.find(l => l.code === h.sourceLang)?.name ?? '自动识别'} → {LANGUAGES.find(l => l.code === h.targetLang)?.name}
|
||||||
|
{h.favorite && <Badge className="ml-auto bg-amber-50 text-amber-700">已收藏</Badge>}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs truncate">{h.text.slice(0, 80)}</p>
|
<p className="text-xs truncate">{h.originalText.slice(0, 80)}</p>
|
||||||
<p className="text-xs text-muted-foreground mt-0.5 truncate">{h.result.slice(0, 80)}</p>
|
<p className="text-xs text-muted-foreground mt-0.5 truncate">{h.translatedText.slice(0, 80)}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { http } from './client'
|
import { http } from './client'
|
||||||
import type { AiAnalysis, AiReplySuggestion, AiTextActionPayload, TranslatePayload, TranslateResult } from '@/types'
|
import type { AiAnalysis, AiReplySuggestion, AiTextActionPayload, TranslatePayload, TranslateResult, TranslationHistoryItem } from '@/types'
|
||||||
|
|
||||||
/** POST /api/ai/translate */
|
/** POST /api/ai/translate */
|
||||||
export function translate(payload: TranslatePayload) {
|
export function translate(payload: TranslatePayload) {
|
||||||
return http.post<TranslateResult>('/api/ai/translate', payload)
|
return http.post<TranslateResult>('/api/ai/translate', payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getTranslationHistory() {
|
||||||
|
return http.get<TranslationHistoryItem[]>('/api/ai/translations')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function favoriteTranslation(id: string, favorite: boolean) {
|
||||||
|
return http.patch<TranslationHistoryItem>(`/api/ai/translations/${id}`, { favorite })
|
||||||
|
}
|
||||||
|
|
||||||
/** POST /api/ai/analyze */
|
/** POST /api/ai/analyze */
|
||||||
export function analyzeConversation(conversationId: string) {
|
export function analyzeConversation(conversationId: string) {
|
||||||
return http.post<AiAnalysis>('/api/ai/analyze', { conversationId })
|
return http.post<AiAnalysis>('/api/ai/analyze', { conversationId })
|
||||||
|
|||||||
@@ -21,6 +21,23 @@ export function startTelegramLogin(phone: string) {
|
|||||||
return http.post<ChannelConnection>('/api/channels/telegram/start', { phone })
|
return http.post<ChannelConnection>('/api/channels/telegram/start', { phone })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function connectEmailChannel(payload: {
|
||||||
|
name?: string
|
||||||
|
displayName?: string
|
||||||
|
account: string
|
||||||
|
password: string
|
||||||
|
imapHost: string
|
||||||
|
imapPort: number
|
||||||
|
imapSecure: boolean
|
||||||
|
imapFolder?: string
|
||||||
|
smtpHost: string
|
||||||
|
smtpPort: number
|
||||||
|
smtpSecure: boolean
|
||||||
|
syncDays?: number
|
||||||
|
}) {
|
||||||
|
return http.post<ChannelConnection>('/api/channels/email/connect', payload)
|
||||||
|
}
|
||||||
|
|
||||||
export function submitTelegramCode(channelId: string, code: string) {
|
export function submitTelegramCode(channelId: string, code: string) {
|
||||||
return http.post<{ status: string; channel: ChannelConnection }>(`/api/channels/telegram/${channelId}/code`, { code })
|
return http.post<{ status: string; channel: ChannelConnection }>(`/api/channels/telegram/${channelId}/code`, { code })
|
||||||
}
|
}
|
||||||
@@ -29,8 +46,36 @@ export function submitTelegramPassword(channelId: string, password: string) {
|
|||||||
return http.post<{ status: string; channel: ChannelConnection }>(`/api/channels/telegram/${channelId}/password`, { password })
|
return http.post<{ status: string; channel: ChannelConnection }>(`/api/channels/telegram/${channelId}/password`, { password })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function syncTelegramChannel(channelId: string) {
|
export function getTelegramLoginState(channelId: string) {
|
||||||
return http.post<{ chats: number; messages: number }>(`/api/channels/${channelId}/sync`)
|
return http.post<{ status: string; connectionState?: string; channel: ChannelConnection }>(
|
||||||
|
`/api/channels/telegram/${channelId}/state`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startWhatsAppLogin(phone: string, name?: string) {
|
||||||
|
return http.post<ChannelConnection>('/api/channels/whatsapp/start', {
|
||||||
|
phone,
|
||||||
|
name,
|
||||||
|
acceptedUnofficialRisk: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWhatsAppLoginState(channelId: string) {
|
||||||
|
return http.post<{
|
||||||
|
status: string
|
||||||
|
connectionState?: string
|
||||||
|
pairingCode?: string
|
||||||
|
qrDataUrl?: string
|
||||||
|
channel: ChannelConnection
|
||||||
|
}>(`/api/channels/whatsapp/${channelId}/state`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setDefaultChannel(channelId: string) {
|
||||||
|
return http.post<ChannelConnection>(`/api/channels/${channelId}/default`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncChannel(channelId: string, options: { full?: boolean } = {}) {
|
||||||
|
return http.post<{ chats: number; messages: number }>(`/api/channels/${channelId}/sync`, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /api/channels/test */
|
/** POST /api/channels/test */
|
||||||
@@ -52,3 +97,81 @@ export function deleteChannel(id: string) {
|
|||||||
export function getChannelLogs(id: string) {
|
export function getChannelLogs(id: string) {
|
||||||
return http.get<ChannelSyncLog[]>(`/api/channels/${id}/logs`)
|
return http.get<ChannelSyncLog[]>(`/api/channels/${id}/logs`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getWhatsAppContacts(
|
||||||
|
channelId: string,
|
||||||
|
query: { query?: string; cursor?: string; limit?: number } = {},
|
||||||
|
) {
|
||||||
|
return http.get<{
|
||||||
|
items: Array<{ id: string; name: string; phone: string }>
|
||||||
|
nextCursor: string | null
|
||||||
|
total: number
|
||||||
|
}>(`/api/channels/${channelId}/whatsapp/contacts`, query)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateWhatsAppProxy(channelId: string, url: string) {
|
||||||
|
return http.put<ChannelConnection>(`/api/channels/${channelId}/whatsapp/proxy`, {
|
||||||
|
url,
|
||||||
|
test: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateWhatsAppProfile(
|
||||||
|
channelId: string,
|
||||||
|
payload: { name?: string; status?: string },
|
||||||
|
) {
|
||||||
|
return http.patch<ChannelConnection>(`/api/channels/${channelId}/whatsapp/profile`, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateWhatsAppProfilePicture(
|
||||||
|
channelId: string,
|
||||||
|
payload: { name: string; mimeType: string; base64: string },
|
||||||
|
) {
|
||||||
|
return http.post<ChannelConnection>(
|
||||||
|
`/api/channels/${channelId}/whatsapp/profile/picture`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reauthorizeWhatsApp(channelId: string) {
|
||||||
|
return http.post<ChannelConnection>(`/api/channels/${channelId}/whatsapp/reauthorize`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWhatsAppDevices(channelId: string) {
|
||||||
|
return http.get<Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
platform: string
|
||||||
|
current: boolean
|
||||||
|
status: string
|
||||||
|
connectedAt?: string
|
||||||
|
}>>(`/api/channels/${channelId}/whatsapp/devices`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function terminateWhatsAppDevice(channelId: string, deviceId: string) {
|
||||||
|
return http.del<{ success: boolean; deviceId: string }>(
|
||||||
|
`/api/channels/${channelId}/whatsapp/devices/${encodeURIComponent(deviceId)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWhatsAppGatewayMetrics() {
|
||||||
|
return http.get<{
|
||||||
|
accounts: { total: number; connected: number; disconnected: number }
|
||||||
|
outbound: { queued: number; dead: number; sent: number; riskBackoff: number; averageLatencyMs: number }
|
||||||
|
webhooks: { queued: number; dead: number; delivered: number }
|
||||||
|
alerts: Array<{ severity: string; code: string; accountId: string; message: string }>
|
||||||
|
}>('/api/channels/whatsapp/metrics')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWhatsAppBackup(channelId: string) {
|
||||||
|
return http.get<{ version: number; accountId: string; createdAt: string; blob: string }>(
|
||||||
|
`/api/channels/${channelId}/whatsapp/backup`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function restoreWhatsAppBackup(channelId: string, blob: string) {
|
||||||
|
return http.post<{ restored: boolean }>(
|
||||||
|
`/api/channels/${channelId}/whatsapp/backup/restore`,
|
||||||
|
{ blob },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,10 +17,17 @@ export interface RequestOptions {
|
|||||||
|
|
||||||
/** 由 auth store 注入,避免循环依赖 */
|
/** 由 auth store 注入,避免循环依赖 */
|
||||||
let tokenGetter: () => string | null = () => null
|
let tokenGetter: () => string | null = () => null
|
||||||
|
let unauthorizedHandler: (() => void) | null = null
|
||||||
|
let handlingUnauthorized = false
|
||||||
|
|
||||||
export function setTokenGetter(fn: () => string | null) {
|
export function setTokenGetter(fn: () => string | null) {
|
||||||
tokenGetter = fn
|
tokenGetter = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setUnauthorizedHandler(fn: () => void) {
|
||||||
|
unauthorizedHandler = fn
|
||||||
|
}
|
||||||
|
|
||||||
export async function api<T = unknown>(path: string, options: RequestOptions = {}): Promise<T> {
|
export async function api<T = unknown>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
const method = options.method ?? 'GET'
|
const method = options.method ?? 'GET'
|
||||||
|
|
||||||
@@ -41,6 +48,11 @@ export async function api<T = unknown>(path: string, options: RequestOptions = {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
|
if (token && unauthorizedHandler && !handlingUnauthorized) {
|
||||||
|
handlingUnauthorized = true
|
||||||
|
unauthorizedHandler()
|
||||||
|
window.setTimeout(() => { handlingUnauthorized = false }, 1_000)
|
||||||
|
}
|
||||||
throw new ApiError(401, '登录状态已失效,请重新登录')
|
throw new ApiError(401, '登录状态已失效,请重新登录')
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
@@ -10,6 +10,15 @@ export interface ConversationQuery {
|
|||||||
userId?: string
|
userId?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function normalizeMessage(message: Message): Message {
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
originalText: String(message?.originalText ?? ''),
|
||||||
|
originalLang: String(message?.originalLang || 'und'),
|
||||||
|
reactions: Array.isArray(message?.reactions) ? message.reactions : [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** GET /api/conversations */
|
/** GET /api/conversations */
|
||||||
export function getConversations(query: ConversationQuery = {}) {
|
export function getConversations(query: ConversationQuery = {}) {
|
||||||
return http.get<{ list: ConversationListItem[]; total: number }>('/api/conversations', { ...query })
|
return http.get<{ list: ConversationListItem[]; total: number }>('/api/conversations', { ...query })
|
||||||
@@ -21,8 +30,26 @@ export function getConversation(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** GET /api/conversations/:id/messages */
|
/** GET /api/conversations/:id/messages */
|
||||||
export function getMessages(id: string) {
|
export async function getMessages(id: string) {
|
||||||
return http.get<Message[]>(`/api/conversations/${id}/messages`)
|
const messages = await http.get<Message[]>(`/api/conversations/${id}/messages`)
|
||||||
|
return Array.isArray(messages) ? messages.map(normalizeMessage) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMessagePage(
|
||||||
|
id: string,
|
||||||
|
query: { before?: string; after?: string; cursor?: string; limit?: number } = {},
|
||||||
|
) {
|
||||||
|
const page = await http.get<{
|
||||||
|
items: Message[]
|
||||||
|
beforeCursor: string | null
|
||||||
|
afterCursor: string | null
|
||||||
|
hasMoreBefore: boolean
|
||||||
|
hasMoreAfter: boolean
|
||||||
|
}>(`/api/conversations/${id}/messages/page`, query)
|
||||||
|
return {
|
||||||
|
...page,
|
||||||
|
items: Array.isArray(page?.items) ? page.items.map(normalizeMessage) : [],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateMessage(id: string, messageId: string, payload: { isImportant?: boolean; internalNote?: string }) {
|
export function updateMessage(id: string, messageId: string, payload: { isImportant?: boolean; internalNote?: string }) {
|
||||||
@@ -33,22 +60,55 @@ export function markConversationRead(id: string, messageIds?: string[]) {
|
|||||||
return http.post<Conversation>(`/api/conversations/${id}/read`, { messageIds })
|
return http.post<Conversation>(`/api/conversations/${id}/read`, { messageIds })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMessageAttachment(id: string, messageId: string) {
|
export function setConversationPresence(
|
||||||
|
id: string,
|
||||||
|
presence: 'composing' | 'recording' | 'paused' | 'available' | 'unavailable',
|
||||||
|
) {
|
||||||
|
return http.post<{ success: boolean; presence: string }>(`/api/conversations/${id}/presence`, { presence })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMessageAttachment(id: string, messageId: string, attachmentId?: string) {
|
||||||
return http.get<{ dataUrl: string; mimeType: string; name: string }>(
|
return http.get<{ dataUrl: string; mimeType: string; name: string }>(
|
||||||
`/api/conversations/${id}/messages/${messageId}/attachment`,
|
`/api/conversations/${id}/messages/${messageId}/attachment`,
|
||||||
|
attachmentId ? { attachmentId } : undefined,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** POST /api/conversations/:id/messages */
|
/** POST /api/conversations/:id/messages */
|
||||||
export function sendMessage(id: string, payload: SendMessagePayload & { translatedText?: string; autoTranslate?: boolean }) {
|
export function sendMessage(
|
||||||
return http.post<Message>(`/api/conversations/${id}/messages`, payload)
|
id: string,
|
||||||
|
payload: SendMessagePayload & {
|
||||||
|
translatedText?: string
|
||||||
|
autoTranslate?: boolean
|
||||||
|
idempotencyKey?: string
|
||||||
|
clientMessageId?: string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const idempotencyKey = payload.idempotencyKey || crypto.randomUUID()
|
||||||
|
return http.post<Message>(`/api/conversations/${id}/messages`, {
|
||||||
|
...payload,
|
||||||
|
idempotencyKey,
|
||||||
|
clientMessageId: payload.clientMessageId || idempotencyKey.replace(/-/g, '').slice(0, 20).toUpperCase(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendMedia(
|
export function sendMedia(
|
||||||
id: string,
|
id: string,
|
||||||
payload: { name: string; mimeType: string; base64: string; kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker' },
|
payload: {
|
||||||
|
name: string
|
||||||
|
mimeType: string
|
||||||
|
base64: string
|
||||||
|
kind: 'document' | 'photo' | 'audio' | 'voice' | 'sticker'
|
||||||
|
idempotencyKey?: string
|
||||||
|
clientMessageId?: string
|
||||||
|
},
|
||||||
) {
|
) {
|
||||||
return http.post<unknown>(`/api/conversations/${id}/media`, payload)
|
const idempotencyKey = payload.idempotencyKey || crypto.randomUUID()
|
||||||
|
return http.post<unknown>(`/api/conversations/${id}/media`, {
|
||||||
|
...payload,
|
||||||
|
idempotencyKey,
|
||||||
|
clientMessageId: payload.clientMessageId || idempotencyKey.replace(/-/g, '').slice(0, 20).toUpperCase(),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendAlbum(
|
export function sendAlbum(
|
||||||
@@ -59,6 +119,98 @@ export function sendAlbum(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** PATCH /api/conversations/:id(标记已读/分配/状态) */
|
/** PATCH /api/conversations/:id(标记已读/分配/状态) */
|
||||||
|
export type WhatsAppMessageAction =
|
||||||
|
| { action: 'react'; messageId: string; emoji: string }
|
||||||
|
| { action: 'edit'; messageId: string; text: string }
|
||||||
|
| { action: 'delete'; messageId: string }
|
||||||
|
| { action: 'forward'; messageId: string; destinationConversationId: string }
|
||||||
|
| { action: 'poll'; name: string; values: string[]; selectableCount?: number }
|
||||||
|
| { action: 'location'; latitude: number; longitude: number; name?: string; address?: string }
|
||||||
|
| { action: 'contact'; displayName: string; phone: string }
|
||||||
|
| { action: 'disappearing'; seconds: false | 0 | 86400 | 604800 | 7776000 }
|
||||||
|
|
||||||
|
export function runWhatsAppMessageAction(id: string, payload: WhatsAppMessageAction) {
|
||||||
|
return http.post<Message | { success: true }>(
|
||||||
|
`/api/conversations/${id}/messages/action`,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function updateConversation(id: string, payload: { assigneeId?: string | null; markRead?: boolean; status?: string }) {
|
export function updateConversation(id: string, payload: { assigneeId?: string | null; markRead?: boolean; status?: string }) {
|
||||||
return http.patch<Conversation>(`/api/conversations/${id}`, payload)
|
return http.patch<Conversation>(`/api/conversations/${id}`, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ConversationAction =
|
||||||
|
| 'clear'
|
||||||
|
| 'delete'
|
||||||
|
| 'pin'
|
||||||
|
| 'unpin'
|
||||||
|
| 'archive'
|
||||||
|
| 'unarchive'
|
||||||
|
| 'mute'
|
||||||
|
| 'unmute'
|
||||||
|
| 'mark-unread'
|
||||||
|
| 'leave'
|
||||||
|
| 'block'
|
||||||
|
| 'unblock'
|
||||||
|
|
||||||
|
export function runConversationAction(
|
||||||
|
id: string,
|
||||||
|
action: ConversationAction,
|
||||||
|
options: { seconds?: number } = {},
|
||||||
|
) {
|
||||||
|
return http.post<Conversation | { success: true; deleted: true }>(
|
||||||
|
`/api/conversations/${id}/actions`,
|
||||||
|
{ action, ...options },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openTelegramConversation(payload: {
|
||||||
|
identifier: string
|
||||||
|
targetType: 'user' | 'chat' | 'invite'
|
||||||
|
channelId?: string
|
||||||
|
}) {
|
||||||
|
return http.post<{ conversation: Conversation; customer: Customer }>(
|
||||||
|
'/api/conversations/telegram/open',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openWhatsAppConversation(payload: {
|
||||||
|
identifier?: string
|
||||||
|
targetType: 'user' | 'group' | 'invite'
|
||||||
|
channelId?: string
|
||||||
|
subject?: string
|
||||||
|
participants?: string[]
|
||||||
|
}) {
|
||||||
|
return http.post<{ conversation: Conversation; customer: Customer }>(
|
||||||
|
'/api/conversations/whatsapp/open',
|
||||||
|
payload,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WhatsAppGroupInfo {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
participants: Array<{
|
||||||
|
id: string
|
||||||
|
admin?: 'admin' | 'superadmin' | null
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWhatsAppGroup(id: string) {
|
||||||
|
return http.get<WhatsAppGroupInfo>(`/api/conversations/${id}/whatsapp/group`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runWhatsAppGroupAction(
|
||||||
|
id: string,
|
||||||
|
payload: {
|
||||||
|
action: 'add' | 'remove' | 'promote' | 'demote' | 'subject' | 'description' | 'invite-code' | 'revoke-invite'
|
||||||
|
participants?: string[]
|
||||||
|
subject?: string
|
||||||
|
description?: string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return http.post<unknown>(`/api/conversations/${id}/whatsapp/group/action`, payload)
|
||||||
|
}
|
||||||
|
|||||||
+157
-12
@@ -4,26 +4,26 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 220 14% 96%;
|
--background: 210 20% 98%;
|
||||||
--foreground: 215 25% 16%;
|
--foreground: 215 28% 13%;
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
--card-foreground: 215 25% 16%;
|
--card-foreground: 215 25% 16%;
|
||||||
--popover: 0 0% 100%;
|
--popover: 0 0% 100%;
|
||||||
--popover-foreground: 215 25% 16%;
|
--popover-foreground: 215 25% 16%;
|
||||||
--primary: 172 59% 30%;
|
--primary: 221 83% 53%;
|
||||||
--primary-foreground: 0 0% 100%;
|
--primary-foreground: 0 0% 100%;
|
||||||
--secondary: 210 16% 93%;
|
--secondary: 210 20% 96%;
|
||||||
--secondary-foreground: 215 20% 24%;
|
--secondary-foreground: 215 20% 24%;
|
||||||
--muted: 210 16% 93%;
|
--muted: 210 20% 96%;
|
||||||
--muted-foreground: 215 12% 46%;
|
--muted-foreground: 215 12% 43%;
|
||||||
--accent: 170 40% 93%;
|
--accent: 214 100% 97%;
|
||||||
--accent-foreground: 172 60% 22%;
|
--accent-foreground: 221 83% 40%;
|
||||||
--destructive: 0 70% 50%;
|
--destructive: 0 70% 50%;
|
||||||
--destructive-foreground: 0 0% 100%;
|
--destructive-foreground: 0 0% 100%;
|
||||||
--border: 214 15% 88%;
|
--border: 214 22% 91%;
|
||||||
--input: 214 15% 84%;
|
--input: 214 18% 86%;
|
||||||
--ring: 172 59% 34%;
|
--ring: 221 83% 53%;
|
||||||
--radius: 0.5rem;
|
--radius: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -40,6 +40,10 @@
|
|||||||
@apply bg-background text-foreground antialiased;
|
@apply bg-background text-foreground antialiased;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
|
letter-spacing: -0.006em;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 75% -10%, rgba(37, 99, 235, 0.05), transparent 26rem),
|
||||||
|
linear-gradient(180deg, #ffffff 0, hsl(var(--background)) 22rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
@@ -65,6 +69,104 @@
|
|||||||
.text-balance {
|
.text-balance {
|
||||||
text-wrap: 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 {
|
@keyframes message-in-left {
|
||||||
@@ -85,12 +187,55 @@
|
|||||||
to { opacity: 1; transform: translate3d(0, 0, 0) scale(1); }
|
to { opacity: 1; transform: translate3d(0, 0, 0) scale(1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes conversation-select {
|
||||||
|
0% {
|
||||||
|
transform: translate3d(0, 2px, 0) scale(.988);
|
||||||
|
box-shadow: 0 0 0 rgba(37, 99, 235, 0);
|
||||||
|
}
|
||||||
|
58% {
|
||||||
|
transform: translate3d(0, -1px, 0) scale(1.003);
|
||||||
|
box-shadow: 0 12px 28px -15px rgba(37, 99, 235, .48);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate3d(0, 0, 0) scale(1);
|
||||||
|
box-shadow: 0 9px 24px -15px rgba(37, 99, 235, .52), 0 0 0 1px rgba(96, 165, 250, .1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes conversation-sheen {
|
||||||
|
from { transform: translate3d(-170%, 0, 0) skewX(-18deg); opacity: 0; }
|
||||||
|
18% { opacity: .7; }
|
||||||
|
to { transform: translate3d(470%, 0, 0) skewX(-18deg); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-selected {
|
||||||
|
animation: conversation-select .42s cubic-bezier(.2, .82, .2, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation-selected::after {
|
||||||
|
content: '';
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
inset-block: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 24%;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .78), transparent);
|
||||||
|
animation: conversation-sheen .62s cubic-bezier(.2, .7, .2, 1) .04s both;
|
||||||
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.animate-message-in-left { animation: message-in-left .34s cubic-bezier(.2,.8,.2,1) both; }
|
.animate-message-in-left { animation: message-in-left .34s cubic-bezier(.2,.8,.2,1) both; }
|
||||||
.animate-message-in-right { animation: message-in-right .34s cubic-bezier(.2,.8,.2,1) both; }
|
.animate-message-in-right { animation: message-in-right .34s cubic-bezier(.2,.8,.2,1) both; }
|
||||||
.animate-incoming-notice { animation: incoming-notice .42s cubic-bezier(.2,.9,.2,1) both; }
|
.animate-incoming-notice { animation: incoming-notice .42s cubic-bezier(.2,.9,.2,1) both; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.conversation-selected,
|
||||||
|
.conversation-selected::after {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* RTL 微调 */
|
/* RTL 微调 */
|
||||||
[dir='rtl'] .rtl-flip {
|
[dir='rtl'] .rtl-flip {
|
||||||
transform: scaleX(-1);
|
transform: scaleX(-1);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface AiReplySuggestion {
|
|||||||
id: string
|
id: string
|
||||||
style: ReplyStyle
|
style: ReplyStyle
|
||||||
content: string
|
content: string
|
||||||
|
translationCn: string
|
||||||
targetLang: string
|
targetLang: string
|
||||||
reason: string
|
reason: string
|
||||||
knowledgeRefs: string[]
|
knowledgeRefs: string[]
|
||||||
@@ -40,6 +41,18 @@ export interface TranslateResult {
|
|||||||
translated: string
|
translated: string
|
||||||
detectedLang: string
|
detectedLang: string
|
||||||
terms: { source: string; target: 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'
|
export type AiTextAction = 'polish' | 'translate' | 'shorten' | 'expand' | 'tone'
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export interface Customer {
|
|||||||
telegramUserId?: string
|
telegramUserId?: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
telegramPhotoFileId?: number
|
telegramPhotoFileId?: number
|
||||||
|
telegramChatType?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomerFilters {
|
export interface CustomerFilters {
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export interface Message {
|
|||||||
type: MessageType
|
type: MessageType
|
||||||
originalText: string
|
originalText: string
|
||||||
translatedText?: string
|
translatedText?: string
|
||||||
|
/** 发出消息的中文客服备注,不会发送到外部渠道 */
|
||||||
|
translationCn?: string
|
||||||
translationStatus?: 'pending' | 'completed' | 'failed'
|
translationStatus?: 'pending' | 'completed' | 'failed'
|
||||||
translatedAt?: string
|
translatedAt?: string
|
||||||
originalLang: string
|
originalLang: string
|
||||||
@@ -39,10 +41,31 @@ export interface Message {
|
|||||||
productCard?: { name: string; sku: string; price: string }
|
productCard?: { name: string; sku: string; price: string }
|
||||||
replyToId?: string
|
replyToId?: string
|
||||||
replyPreview?: string
|
replyPreview?: string
|
||||||
|
editedAt?: string
|
||||||
|
isDeleted?: boolean
|
||||||
|
deletedAt?: string
|
||||||
|
reactions?: Array<{
|
||||||
|
actor: string
|
||||||
|
emoji: string
|
||||||
|
fromMe?: boolean
|
||||||
|
updatedAt?: string
|
||||||
|
}>
|
||||||
telegramFileId?: number
|
telegramFileId?: number
|
||||||
|
whatsappMessageId?: string
|
||||||
|
whatsappContentType?: string
|
||||||
attachmentMimeType?: string
|
attachmentMimeType?: string
|
||||||
attachmentSize?: number
|
attachmentSize?: number
|
||||||
stickerEmoji?: string
|
stickerEmoji?: string
|
||||||
|
emailUid?: number
|
||||||
|
emailMessageId?: string
|
||||||
|
emailSubject?: string
|
||||||
|
emailAttachments?: Array<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
mimeType: string
|
||||||
|
size: number
|
||||||
|
available: boolean
|
||||||
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Conversation {
|
export interface Conversation {
|
||||||
@@ -58,6 +81,21 @@ export interface Conversation {
|
|||||||
lastMessage?: Message
|
lastMessage?: Message
|
||||||
summary: string
|
summary: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
|
emailSubject?: string
|
||||||
|
channelId?: string
|
||||||
|
telegramChatId?: string
|
||||||
|
telegramChatType?: string
|
||||||
|
whatsappJid?: string
|
||||||
|
whatsappChatType?: string
|
||||||
|
isPinned?: boolean
|
||||||
|
archived?: boolean
|
||||||
|
mutedUntil?: string | null
|
||||||
|
markedUnread?: boolean
|
||||||
|
isBlocked?: boolean
|
||||||
|
leftAt?: string
|
||||||
|
presence?: 'available' | 'unavailable' | 'composing' | 'recording' | 'paused'
|
||||||
|
presenceUpdatedAt?: string
|
||||||
|
lastSeenAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ChannelStatus = 'connected' | 'disconnected' | 'error' | 'paused' | 'unconnected'
|
export type ChannelStatus = 'connected' | 'disconnected' | 'error' | 'paused' | 'unconnected'
|
||||||
@@ -72,7 +110,22 @@ export interface ChannelConnection {
|
|||||||
todayMessages: number
|
todayMessages: number
|
||||||
errorMessage?: string
|
errorMessage?: string
|
||||||
autoReply: boolean
|
autoReply: boolean
|
||||||
|
authState?: string
|
||||||
defaultOwnerId?: string
|
defaultOwnerId?: string
|
||||||
|
displayName?: string
|
||||||
|
imapHost?: string
|
||||||
|
imapPort?: number
|
||||||
|
imapSecure?: boolean
|
||||||
|
imapFolder?: string
|
||||||
|
smtpHost?: string
|
||||||
|
smtpPort?: number
|
||||||
|
smtpSecure?: boolean
|
||||||
|
syncDays?: number
|
||||||
|
lastVerifiedAt?: string
|
||||||
|
isDefault?: boolean
|
||||||
|
provider?: string
|
||||||
|
pairingCode?: string
|
||||||
|
qrDataUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChannelSyncLog {
|
export interface ChannelSyncLog {
|
||||||
@@ -87,6 +140,8 @@ export interface ChannelSyncLog {
|
|||||||
/** 发送消息请求 */
|
/** 发送消息请求 */
|
||||||
export interface SendMessagePayload {
|
export interface SendMessagePayload {
|
||||||
text: string
|
text: string
|
||||||
|
/** 已确认的中文参考译文,仅在内部消息记录中保存 */
|
||||||
|
translationCn?: string
|
||||||
replyToId?: string
|
replyToId?: string
|
||||||
replyPreview?: string
|
replyPreview?: string
|
||||||
type?: MessageType
|
type?: MessageType
|
||||||
|
|||||||
+1
-1
@@ -138,7 +138,7 @@ export interface SystemSettings {
|
|||||||
|
|
||||||
export interface AppNotification {
|
export interface AppNotification {
|
||||||
id: string
|
id: string
|
||||||
type: 'inquiry' | 'risk' | 'task' | 'channel' | 'system'
|
type: 'inquiry' | 'message' | 'risk' | 'task' | 'channel' | 'system'
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
|
|||||||
+10
-10
@@ -13,16 +13,16 @@ export default {
|
|||||||
primary: {
|
primary: {
|
||||||
DEFAULT: 'hsl(var(--primary))',
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
foreground: 'hsl(var(--primary-foreground))',
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
50: '#effaf7',
|
50: '#eff6ff',
|
||||||
100: '#d7f2ec',
|
100: '#dbeafe',
|
||||||
200: '#b2e4da',
|
200: '#bfdbfe',
|
||||||
300: '#83cfc2',
|
300: '#93c5fd',
|
||||||
400: '#52b3a5',
|
400: '#60a5fa',
|
||||||
500: '#32998c',
|
500: '#3b82f6',
|
||||||
600: '#247b71',
|
600: '#2563eb',
|
||||||
700: '#1f635c',
|
700: '#1d4ed8',
|
||||||
800: '#1c504b',
|
800: '#1e40af',
|
||||||
900: '#1b423f',
|
900: '#1e3a8a',
|
||||||
},
|
},
|
||||||
secondary: {
|
secondary: {
|
||||||
DEFAULT: 'hsl(var(--secondary))',
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ test.describe('真实收件箱 AI 交互', () => {
|
|||||||
await expect(customerBubble.first()).toHaveClass(/animate-message-in-left/)
|
await expect(customerBubble.first()).toHaveClass(/animate-message-in-left/)
|
||||||
const agentBubble = page.locator('[data-testid="message-bubble"][data-sender="agent"]')
|
const agentBubble = page.locator('[data-testid="message-bubble"][data-sender="agent"]')
|
||||||
await expect(agentBubble.first()).toHaveClass(/animate-message-in-right/)
|
await expect(agentBubble.first()).toHaveClass(/animate-message-in-right/)
|
||||||
|
const outgoingChineseNotes = page.getByTestId('outgoing-chinese-note')
|
||||||
|
await expect.poll(() => outgoingChineseNotes.count()).toBeGreaterThan(0)
|
||||||
|
await expect(outgoingChineseNotes.first()).toContainText('中文备注 · 仅内部可见')
|
||||||
|
await expect(outgoingChineseNotes.first()).toContainText(/[\u3400-\u9fff]/u)
|
||||||
await page.getByTitle('客户洞察').click()
|
await page.getByTitle('客户洞察').click()
|
||||||
await expect(page.getByText('成交概率', { exact: true })).toBeVisible()
|
await expect(page.getByText('成交概率', { exact: true })).toBeVisible()
|
||||||
await expect(page.getByText('分析可信度', { exact: true })).toBeVisible()
|
await expect(page.getByText('分析可信度', { exact: true })).toBeVisible()
|
||||||
@@ -49,6 +53,10 @@ test.describe('真实收件箱 AI 交互', () => {
|
|||||||
await page.getByRole('button', { name: '生成回复建议', exact: true }).click()
|
await page.getByRole('button', { name: '生成回复建议', exact: true }).click()
|
||||||
const adoptButtons = page.getByRole('button', { name: '采用', exact: true })
|
const adoptButtons = page.getByRole('button', { name: '采用', exact: true })
|
||||||
await expect(adoptButtons).toHaveCount(3, { timeout: 30_000 })
|
await expect(adoptButtons).toHaveCount(3, { timeout: 30_000 })
|
||||||
|
const translationPreviews = page.getByTestId('ai-reply-translation')
|
||||||
|
await expect(translationPreviews).toHaveCount(3)
|
||||||
|
await expect(translationPreviews.first()).toContainText(/[\u3400-\u9fff]/u)
|
||||||
|
await expect(translationPreviews.first()).toContainText('中文参考 · 不会发送')
|
||||||
await adoptButtons.first().click()
|
await adoptButtons.first().click()
|
||||||
|
|
||||||
const composer = page.getByPlaceholder('输入回复内容,Enter 发送,Shift+Enter 换行')
|
const composer = page.getByPlaceholder('输入回复内容,Enter 发送,Shift+Enter 换行')
|
||||||
|
|||||||
+218
-6
@@ -1,4 +1,4 @@
|
|||||||
import { expect, test, type Page } from '@playwright/test'
|
import { expect, test, type Page, type WebSocketRoute } from '@playwright/test'
|
||||||
|
|
||||||
const account = process.env.E2E_ACCOUNT || 'e2e-1785008967@local.test'
|
const account = process.env.E2E_ACCOUNT || 'e2e-1785008967@local.test'
|
||||||
const password = process.env.E2E_PASSWORD || 'Lingxi-E2E-2026!'
|
const password = process.env.E2E_PASSWORD || 'Lingxi-E2E-2026!'
|
||||||
@@ -11,6 +11,27 @@ async function login(page: Page, loginAccount = account, loginPassword = passwor
|
|||||||
await expect(page).toHaveURL(/\/dashboard$/)
|
await expect(page).toHaveURL(/\/dashboard$/)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('过期登录状态会回到登录页而不是显示网络错误', async ({ page }) => {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.localStorage.setItem('lx-auth', JSON.stringify({
|
||||||
|
state: {
|
||||||
|
token: 'eyJhbGciOiJIUzI1NiJ9.eyJleHAiOjF9.expired',
|
||||||
|
user: { id: 'expired-user', name: 'Expired User', role: 'admin' },
|
||||||
|
tenant: { id: 'expired-tenant', name: 'Expired Tenant', plan: 'pro' },
|
||||||
|
onboarded: true,
|
||||||
|
},
|
||||||
|
version: 0,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
await page.goto('/dashboard')
|
||||||
|
await expect(page).toHaveURL(/\/login(?:\?|$)/)
|
||||||
|
await expect(page.getByText('登录状态已过期,请重新登录')).toBeVisible()
|
||||||
|
await expect.poll(() => page.evaluate(() => {
|
||||||
|
const persisted = JSON.parse(window.localStorage.getItem('lx-auth') || '{}')
|
||||||
|
return persisted.state?.token ?? null
|
||||||
|
})).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
test('Telegram 手机号验证码和二步密码登录向导', async ({ page, request }, testInfo) => {
|
test('Telegram 手机号验证码和二步密码登录向导', async ({ page, request }, testInfo) => {
|
||||||
const suffix = `${Date.now()}-${testInfo.project.name}`
|
const suffix = `${Date.now()}-${testInfo.project.name}`
|
||||||
const loginAccount = `channel-e2e-${suffix}@local.test`
|
const loginAccount = `channel-e2e-${suffix}@local.test`
|
||||||
@@ -20,13 +41,79 @@ test('Telegram 手机号验证码和二步密码登录向导', async ({ page, re
|
|||||||
})
|
})
|
||||||
expect(response.ok()).toBeTruthy()
|
expect(response.ok()).toBeTruthy()
|
||||||
await login(page, loginAccount, loginPassword)
|
await login(page, loginAccount, loginPassword)
|
||||||
await page.goto('/channels')
|
await page.goto('/channels?setup=choose')
|
||||||
await page.getByRole('button', { name: '连接', exact: true }).click()
|
await expect(page.getByTestId('channel-onboarding')).toContainText('不强制绑定 Telegram')
|
||||||
|
await expect(page.getByTestId('choose-whatsapp')).toBeVisible()
|
||||||
|
await expect(page.getByTestId('choose-email')).toBeVisible()
|
||||||
|
await page.getByTestId('choose-telegram').click()
|
||||||
await expect(page.getByPlaceholder('+8613800138000')).toBeVisible()
|
await expect(page.getByPlaceholder('+8613800138000')).toBeVisible()
|
||||||
await expect(page.getByText('Telegram 验证码')).toHaveCount(0)
|
await expect(page.getByLabel('Telegram 验证码', { exact: true })).toHaveCount(0)
|
||||||
|
const sendTelegramCode = page.getByRole('button', { name: '发送 Telegram 验证码' })
|
||||||
|
await expect(sendTelegramCode).toBeVisible()
|
||||||
|
await sendTelegramCode.click()
|
||||||
|
await expect(page.getByText('请输入国际格式手机号,例如 +8613800138000')).toBeVisible()
|
||||||
|
await expect(page.getByPlaceholder('+8613800138000')).toBeVisible()
|
||||||
|
await expect(page.getByText('可以继续添加其他工作账号', { exact: false })).toBeVisible()
|
||||||
await page.screenshot({ path: testInfo.outputPath('telegram-login-wizard.png'), fullPage: true })
|
await page.screenshot({ path: testInfo.outputPath('telegram-login-wizard.png'), fullPage: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('WhatsApp 普通账号关联设备向导', async ({ page, request }, testInfo) => {
|
||||||
|
const suffix = `${Date.now()}-${testInfo.project.name}`
|
||||||
|
const loginAccount = `whatsapp-e2e-${suffix}@local.test`
|
||||||
|
const loginPassword = 'Lingxi-WhatsApp-2026!'
|
||||||
|
const response = await request.post('/api/auth/register', {
|
||||||
|
data: { name: 'WhatsApp E2E', account: loginAccount, password: loginPassword, company: 'WhatsApp E2E', industry: '跨境电商', teamSize: '1-10人' },
|
||||||
|
})
|
||||||
|
expect(response.ok()).toBeTruthy()
|
||||||
|
await login(page, loginAccount, loginPassword)
|
||||||
|
await page.goto('/channels?setup=choose')
|
||||||
|
await page.getByTestId('choose-whatsapp').click()
|
||||||
|
await expect(page.getByText('普通 WhatsApp 关联设备接入', { exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByText('并非 Meta 官方 Business Cloud API', { exact: false })).toBeVisible()
|
||||||
|
await expect(page.getByRole('button', { name: '生成 WhatsApp 配对码' })).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: '生成 WhatsApp 配对码' }).click()
|
||||||
|
await expect(page.getByText('请输入包含国家/地区代码的 WhatsApp 手机号')).toBeVisible()
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('whatsapp-link-device-wizard.png'), fullPage: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('企业邮箱 IMAP 和 SMTP 连接向导', async ({ page, request }, testInfo) => {
|
||||||
|
const suffix = `${Date.now()}-${testInfo.project.name}`
|
||||||
|
const loginAccount = `email-e2e-${suffix}@local.test`
|
||||||
|
const loginPassword = 'Lingxi-Email-2026!'
|
||||||
|
const response = await request.post('/api/auth/register', {
|
||||||
|
data: { name: 'Email E2E', account: loginAccount, password: loginPassword, company: 'Email E2E', industry: '跨境电商', teamSize: '1-10人' },
|
||||||
|
})
|
||||||
|
expect(response.ok()).toBeTruthy()
|
||||||
|
await login(page, loginAccount, loginPassword)
|
||||||
|
await page.goto('/channels')
|
||||||
|
const emailCard = page.getByTestId('channel-card-email')
|
||||||
|
await emailCard.getByRole('button', { name: '连接', exact: true }).click()
|
||||||
|
await expect(page.getByText('IMAP 收信 + SMTP 发信', { exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByPlaceholder('sales@company.com')).toBeVisible()
|
||||||
|
await expect(page.getByPlaceholder('邮箱密码或应用专用密码')).toHaveAttribute('type', 'password')
|
||||||
|
await expect(page.getByRole('button', { name: '验证并连接邮箱' })).toBeVisible()
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('email-connection-wizard.png'), fullPage: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('渠道真实消息统计与多 Telegram 账号入口', async ({ page }) => {
|
||||||
|
await login(page)
|
||||||
|
const responsePromise = page.waitForResponse((response) =>
|
||||||
|
response.url().endsWith('/api/channels') && response.request().method() === 'GET',
|
||||||
|
)
|
||||||
|
await page.goto('/channels')
|
||||||
|
const response = await responsePromise
|
||||||
|
expect(response.ok()).toBeTruthy()
|
||||||
|
const payload = await response.json()
|
||||||
|
for (const channel of payload.data) {
|
||||||
|
expect(Number.isInteger(channel.todayMessages)).toBeTruthy()
|
||||||
|
expect(channel.todayMessages).toBeGreaterThanOrEqual(0)
|
||||||
|
}
|
||||||
|
await page.getByRole('button', { name: 'Telegram', exact: true }).click()
|
||||||
|
await expect(page.getByText('连接 Telegram', { exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByPlaceholder('+8613800138000')).toBeVisible()
|
||||||
|
await expect(page.getByText('可以继续添加其他工作账号', { exact: false })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
test('登录与核心工作台视觉链路', async ({ page }, testInfo) => {
|
test('登录与核心工作台视觉链路', async ({ page }, testInfo) => {
|
||||||
await login(page)
|
await login(page)
|
||||||
await expect(page).toHaveURL(/\/dashboard$/)
|
await expect(page).toHaveURL(/\/dashboard$/)
|
||||||
@@ -37,11 +124,120 @@ test('登录与核心工作台视觉链路', async ({ page }, testInfo) => {
|
|||||||
await page.screenshot({ path: testInfo.outputPath('channels.png'), fullPage: true })
|
await page.screenshot({ path: testInfo.outputPath('channels.png'), fullPage: true })
|
||||||
|
|
||||||
await page.goto('/inbox')
|
await page.goto('/inbox')
|
||||||
await page.getByTestId('conversation-item').first().click()
|
await page.getByRole('button', { name: '添加联系人或群组' }).click()
|
||||||
|
await expect(page.getByRole('dialog')).toContainText('添加联系人或群组')
|
||||||
|
await expect(page.getByLabel('用户名、手机号或用户 ID')).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: '群组/频道', exact: true }).click()
|
||||||
|
await expect(page.getByLabel('群组/频道用户名或 Chat ID')).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: '邀请链接', exact: true }).click()
|
||||||
|
await expect(page.getByLabel('Telegram 邀请链接')).toBeVisible()
|
||||||
|
await page.getByRole('button', { name: '关闭', exact: true }).click()
|
||||||
|
const privateConversation = page.locator(
|
||||||
|
'[data-testid="conversation-item"][data-channel="telegram"][data-last-sender="agent"]:not([data-telegram-chat-type="channel"])',
|
||||||
|
).first()
|
||||||
|
await expect(privateConversation).toBeVisible()
|
||||||
|
await privateConversation.click()
|
||||||
|
await expect(page.getByTestId('message-content')).toHaveClass(/justify-end/)
|
||||||
|
const languageSwitcher = page.getByRole('radiogroup', { name: '消息显示语言' })
|
||||||
|
const originalMode = languageSwitcher.getByRole('radio', { name: '原文', exact: true })
|
||||||
|
const translatedMode = languageSwitcher.getByRole('radio', { name: '译文', exact: true })
|
||||||
|
await originalMode.click()
|
||||||
|
await expect(originalMode).toHaveAttribute('aria-checked', 'true')
|
||||||
|
await translatedMode.click()
|
||||||
|
await expect(translatedMode).toHaveAttribute('aria-checked', 'true')
|
||||||
|
await expect.poll(() => page.evaluate(() => localStorage.getItem('lingxi-message-view-mode'))).toBe('translated')
|
||||||
await expect(page.locator('input[type="file"]')).toHaveCount(4)
|
await expect(page.locator('input[type="file"]')).toHaveCount(4)
|
||||||
|
const outgoingSurface = page.locator('[data-testid="message-surface"][data-direction="outgoing"]')
|
||||||
|
await expect(outgoingSurface.first()).toHaveClass(/telegram-bubble-out/)
|
||||||
|
const incomingSurface = page.locator('[data-testid="message-surface"][data-direction="incoming"]')
|
||||||
|
if (await incomingSurface.count()) await expect(incomingSurface.first()).toHaveClass(/telegram-bubble-in/)
|
||||||
|
if (testInfo.project.name !== 'mobile-chromium') {
|
||||||
|
await expect(privateConversation).toHaveClass(/conversation-selected/)
|
||||||
|
await expect(page.getByTestId('conversation-list-panel')).toHaveClass(/border-slate-300/)
|
||||||
|
await expect(page.getByTestId('chat-panel')).toHaveClass(/bg-\[#eaf2f9\]/)
|
||||||
|
} else {
|
||||||
|
await expect(page.getByRole('button', { name: '返回会话列表' })).toBeVisible()
|
||||||
|
}
|
||||||
|
await expect(page.getByTestId('conversation-insight-bar').locator('img')).toHaveCount(0)
|
||||||
|
await expect(page.getByTestId('conversation-insight-bar')).toContainText('客户洞察')
|
||||||
|
await page.getByRole('button', { name: 'Telegram 会话操作' }).click()
|
||||||
|
await expect(page.getByText(/置顶会话|取消置顶/)).toBeVisible()
|
||||||
|
await expect(page.getByText(/归档会话|移出归档/)).toBeVisible()
|
||||||
|
await expect(page.getByText(/静音 8 小时|恢复通知/)).toBeVisible()
|
||||||
|
await expect(page.getByText('清空消息', { exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByText('删除会话', { exact: true })).toBeVisible()
|
||||||
|
await page.keyboard.press('Escape')
|
||||||
|
await page.getByRole('button', { name: '切换聊天背景' }).click()
|
||||||
|
await page.getByText('极光紫', { exact: true }).click()
|
||||||
|
await expect(page.getByTestId('message-list')).toHaveClass(/chat-wallpaper-aurora/)
|
||||||
|
await page.getByRole('button', { name: '切换聊天背景' }).click()
|
||||||
|
await page.getByText('日落橙', { exact: true }).click()
|
||||||
|
await expect(page.getByTestId('message-list')).toHaveClass(/chat-wallpaper-sunset/)
|
||||||
|
await page.getByRole('button', { name: '切换聊天背景' }).click()
|
||||||
|
await page.getByText('薄荷绿', { exact: true }).click()
|
||||||
|
await expect(page.getByTestId('message-list')).toHaveClass(/chat-wallpaper-mint/)
|
||||||
|
await expect(page.getByPlaceholder('输入回复内容,Enter 发送,Shift+Enter 换行')).toHaveClass(/min-h-\[86px\]/)
|
||||||
await page.screenshot({ path: testInfo.outputPath('inbox.png'), fullPage: true })
|
await page.screenshot({ path: testInfo.outputPath('inbox.png'), fullPage: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('mobile conversation swipe reveals Telegram actions', async ({ page }, testInfo) => {
|
||||||
|
test.skip(testInfo.project.name !== 'mobile-chromium')
|
||||||
|
await login(page)
|
||||||
|
await page.goto('/inbox')
|
||||||
|
const item = page.locator('[data-testid="conversation-item"][data-channel="telegram"]').first()
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.dispatchEvent('touchstart', { touches: [{ identifier: 1, clientX: 320, clientY: 120 }] })
|
||||||
|
await item.dispatchEvent('touchmove', { touches: [{ identifier: 1, clientX: 120, clientY: 120 }] })
|
||||||
|
await page.waitForTimeout(100)
|
||||||
|
await item.dispatchEvent('touchend', { changedTouches: [{ identifier: 1, clientX: 120, clientY: 120 }] })
|
||||||
|
const actions = item.locator('xpath=..').getByTestId('conversation-swipe-actions')
|
||||||
|
await expect(actions).toHaveClass(/opacity-100/)
|
||||||
|
await expect(actions.getByRole('button', { name: '删除' })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('conversation list reorders immediately from WebSocket events', async ({ page }) => {
|
||||||
|
const sockets: WebSocketRoute[] = []
|
||||||
|
await page.routeWebSocket(/\/api\/ws/, (socket) => {
|
||||||
|
sockets.push(socket)
|
||||||
|
socket.send(JSON.stringify({ type: 'connected', at: new Date().toISOString() }))
|
||||||
|
})
|
||||||
|
await login(page)
|
||||||
|
await page.goto('/inbox')
|
||||||
|
const items = page.getByTestId('conversation-item')
|
||||||
|
await expect(items.nth(1)).toBeVisible()
|
||||||
|
await expect.poll(() => sockets.length).toBeGreaterThanOrEqual(2)
|
||||||
|
|
||||||
|
const target = items.nth(1)
|
||||||
|
const targetId = await target.getAttribute('data-conversation-id')
|
||||||
|
expect(targetId).toBeTruthy()
|
||||||
|
const message = {
|
||||||
|
id: `realtime-${Date.now()}`,
|
||||||
|
conversationId: targetId,
|
||||||
|
sender: 'customer',
|
||||||
|
type: 'text',
|
||||||
|
originalText: 'Realtime list regression',
|
||||||
|
originalLang: 'en',
|
||||||
|
status: 'delivered',
|
||||||
|
createdAt: new Date(Date.now() + 60_000).toISOString(),
|
||||||
|
}
|
||||||
|
sockets.at(-1)!.send(JSON.stringify({
|
||||||
|
type: 'message.received',
|
||||||
|
conversationId: targetId,
|
||||||
|
message,
|
||||||
|
conversation: {
|
||||||
|
id: targetId,
|
||||||
|
unreadCount: 7,
|
||||||
|
status: 'pending',
|
||||||
|
lastMessage: message,
|
||||||
|
updatedAt: message.createdAt,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
await expect(items.first()).toHaveAttribute('data-conversation-id', targetId!)
|
||||||
|
await expect(items.first()).toContainText('Realtime list regression')
|
||||||
|
await expect(items.first()).toContainText('7')
|
||||||
|
})
|
||||||
|
|
||||||
test('知识文件真实上传解析', async ({ page }, testInfo) => {
|
test('知识文件真实上传解析', async ({ page }, testInfo) => {
|
||||||
await login(page)
|
await login(page)
|
||||||
await page.goto('/knowledge')
|
await page.goto('/knowledge')
|
||||||
@@ -56,6 +252,22 @@ test('知识文件真实上传解析', async ({ page }, testInfo) => {
|
|||||||
await page.screenshot({ path: testInfo.outputPath('knowledge-uploaded.png'), fullPage: true })
|
await page.screenshot({ path: testInfo.outputPath('knowledge-uploaded.png'), fullPage: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('客户画像雷达与采购情报可视化', async ({ page }, testInfo) => {
|
||||||
|
await login(page)
|
||||||
|
await page.goto('/customers')
|
||||||
|
const rows = page.getByTestId('customer-row')
|
||||||
|
await expect(rows.first()).toBeVisible()
|
||||||
|
await rows.first().getByRole('cell').nth(1).click()
|
||||||
|
await expect(page.getByText('客户价值雷达', { exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByText('客户洞察', { exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByText('采购信息', { exact: true })).toBeVisible()
|
||||||
|
await expect(page.getByText('标签画像', { exact: true })).toBeVisible()
|
||||||
|
const semanticTags = page.locator('[data-tag-category]')
|
||||||
|
if (await semanticTags.count()) await expect(semanticTags.first()).toBeVisible()
|
||||||
|
else await expect(page.getByText('暂无标签,收到更多客户消息后将自动补充。')).toBeVisible()
|
||||||
|
await page.screenshot({ path: testInfo.outputPath('customer-profile-radar.png'), fullPage: true })
|
||||||
|
})
|
||||||
|
|
||||||
test('团队邀请和支付页不显示虚假成功', async ({ page }, testInfo) => {
|
test('团队邀请和支付页不显示虚假成功', async ({ page }, testInfo) => {
|
||||||
await login(page)
|
await login(page)
|
||||||
await page.goto('/team')
|
await page.goto('/team')
|
||||||
@@ -63,6 +275,6 @@ test('团队邀请和支付页不显示虚假成功', async ({ page }, testInfo)
|
|||||||
await page.screenshot({ path: testInfo.outputPath('team.png'), fullPage: true })
|
await page.screenshot({ path: testInfo.outputPath('team.png'), fullPage: true })
|
||||||
|
|
||||||
await page.goto('/billing')
|
await page.goto('/billing')
|
||||||
await expect(page.getByText('Stripe Checkout', { exact: false })).toBeVisible()
|
await expect(page.getByRole('heading', { name: '套餐与账单' })).toBeVisible()
|
||||||
await page.screenshot({ path: testInfo.outputPath('billing.png'), fullPage: true })
|
await page.screenshot({ path: testInfo.outputPath('billing.png'), fullPage: true })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.env
|
||||||
|
data
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
FROM node:20-bookworm-slim
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends ca-certificates \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
RUN mkdir -p /data/auth /data/store \
|
||||||
|
&& chown -R node:node /data /app
|
||||||
|
|
||||||
|
USER node
|
||||||
|
EXPOSE 3200
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \
|
||||||
|
CMD node -e "fetch('http://127.0.0.1:3200/health/ready').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"
|
||||||
|
|
||||||
|
CMD ["node", "src/index.js"]
|
||||||
Generated
+2761
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "lingxi-whatsapp-gateway",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "灵犀跨境普通 WhatsApp 多账号网关",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js",
|
||||||
|
"check": "node --check src/index.js",
|
||||||
|
"test": "node --test test/api.test.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hapi/boom": "10.0.1",
|
||||||
|
"baileys": "7.0.0-rc13",
|
||||||
|
"express": "4.22.2",
|
||||||
|
"express-async-errors": "3.1.1",
|
||||||
|
"https-proxy-agent": "^7.0.6",
|
||||||
|
"multer": "2.2.0",
|
||||||
|
"pino": "9.6.0",
|
||||||
|
"qrcode": "1.5.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,119 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import fs from 'node:fs/promises'
|
||||||
|
import os from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
const token = 'gateway-test-token-12345678901234567890'
|
||||||
|
|
||||||
|
async function waitForReady(baseUrl, child) {
|
||||||
|
for (let attempt = 0; attempt < 80; attempt++) {
|
||||||
|
if (child.exitCode !== null) throw new Error(`网关提前退出,代码 ${child.exitCode}`)
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/health/ready`)
|
||||||
|
if (response.ok) return
|
||||||
|
} catch {
|
||||||
|
// 服务正在启动。
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
}
|
||||||
|
throw new Error('等待 WhatsApp 网关启动超时')
|
||||||
|
}
|
||||||
|
|
||||||
|
test('网关执行健康检查、令牌鉴权和账号 CRUD', async () => {
|
||||||
|
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'lingxi-wa-gateway-'))
|
||||||
|
const port = 32991
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`
|
||||||
|
const child = spawn(process.execPath, ['src/index.js'], {
|
||||||
|
cwd: path.resolve('.'),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PORT: String(port),
|
||||||
|
DATA_DIR: dataDir,
|
||||||
|
WHATSAPP_GATEWAY_TOKEN: token,
|
||||||
|
LOG_LEVEL: 'silent',
|
||||||
|
},
|
||||||
|
stdio: 'ignore',
|
||||||
|
})
|
||||||
|
try {
|
||||||
|
await waitForReady(baseUrl, child)
|
||||||
|
const unauthorized = await fetch(`${baseUrl}/accounts`)
|
||||||
|
assert.equal(unauthorized.status, 401)
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
authorization: `Bearer ${token}`,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
}
|
||||||
|
const created = await fetch(`${baseUrl}/accounts`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ phoneNumber: '15550008888', name: 'Gateway Test' }),
|
||||||
|
})
|
||||||
|
assert.equal(created.status, 201)
|
||||||
|
const account = (await created.json()).data
|
||||||
|
assert.equal(account.phoneNumber, '15550008888')
|
||||||
|
assert.equal(account.status, 'created')
|
||||||
|
|
||||||
|
const duplicate = await fetch(`${baseUrl}/accounts`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ phoneNumber: '15550008888' }),
|
||||||
|
})
|
||||||
|
assert.equal(duplicate.status, 409)
|
||||||
|
|
||||||
|
const fetched = await fetch(`${baseUrl}/accounts/${account.id}`, { headers })
|
||||||
|
assert.equal(fetched.status, 200)
|
||||||
|
|
||||||
|
const webhookSecret = 'webhook-test-secret-that-must-be-encrypted'
|
||||||
|
const webhook = await fetch(`${baseUrl}/accounts/${account.id}/webhook`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: 'http://127.0.0.1:39999/webhook',
|
||||||
|
secret: webhookSecret,
|
||||||
|
active: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
assert.equal(webhook.status, 200)
|
||||||
|
assert.equal((await webhook.json()).data.webhookSecretConfigured, true)
|
||||||
|
const persistedAccounts = await fs.readFile(path.join(dataDir, 'accounts.json'), 'utf8')
|
||||||
|
assert.equal(persistedAccounts.includes(webhookSecret), false)
|
||||||
|
|
||||||
|
const metrics = await fetch(`${baseUrl}/metrics`, { headers })
|
||||||
|
assert.equal(metrics.status, 200)
|
||||||
|
assert.equal((await metrics.json()).data.accounts.total, 1)
|
||||||
|
|
||||||
|
const invalidProxy = await fetch(`${baseUrl}/accounts/${account.id}/proxy`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ url: 'socks5://127.0.0.1:1080' }),
|
||||||
|
})
|
||||||
|
assert.equal(invalidProxy.status, 400)
|
||||||
|
|
||||||
|
const unsafeReauthorize = await fetch(`${baseUrl}/accounts/${account.id}/reauthorize`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ confirm: 'wrong-account-id' }),
|
||||||
|
})
|
||||||
|
assert.equal(unsafeReauthorize.status, 400)
|
||||||
|
|
||||||
|
const backup = await fetch(`${baseUrl}/accounts/${account.id}/backup`, { headers })
|
||||||
|
assert.equal(backup.status, 200)
|
||||||
|
const backupData = (await backup.json()).data
|
||||||
|
assert.equal(backupData.accountId, account.id)
|
||||||
|
assert.match(backupData.blob, /^v1\./)
|
||||||
|
|
||||||
|
const removed = await fetch(`${baseUrl}/accounts/${account.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
assert.equal(removed.status, 200)
|
||||||
|
const missing = await fetch(`${baseUrl}/accounts/${account.id}`, { headers })
|
||||||
|
assert.equal(missing.status, 404)
|
||||||
|
} finally {
|
||||||
|
child.kill('SIGTERM')
|
||||||
|
await new Promise((resolve) => child.once('exit', resolve))
|
||||||
|
await fs.rm(dataDir, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user