chore(history): import initial web product snapshot

This commit is contained in:
wmq
2026-07-25 00:30:00 +08:00
commit 6e87e6bf37
131 changed files with 15693 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# ===== 灵犀跨境 前端环境变量 =====
# 是否使用内置 Mock 服务:true = 本地模拟数据(默认);false = 请求真实后端
VITE_USE_MOCK=true
# 真实后端 API 基础地址(VITE_USE_MOCK=false 时生效)
# 注意:请通过服务端环境变量管理密钥,前端代码中禁止放置任何真实 API Key
VITE_API_BASE_URL=http://localhost:8080/api
+11
View File
@@ -0,0 +1,11 @@
node_modules
dist
dist-ssr
*.local
.env.local
.env.*.local
.DS_Store
.idea
.vscode/*
!.vscode/extensions.json
npm-debug.log*
+156
View File
@@ -0,0 +1,156 @@
# 灵犀跨境 · 跨境 AI 经营服务平台(前端)
> 让跨境沟通更简单 — 徐州欧汇科技有限公司
本仓库为「灵犀跨境」SaaS 平台的前端部分,面向中小外贸企业、跨境卖家和产业带企业,将 WhatsApp / Telegram / 邮件 / 独立站表单等渠道的客户咨询统一接入,通过 AI 翻译、意图识别、情感分析、风险预警与客户建档,将聊天记录沉淀为可追踪、可成交的客户资产。
## 技术栈
- **React 18** + **TypeScript 5**
- **Vite 5** — 开发与构建
- **Tailwind CSS 3.4** — 原子化样式
- **shadcn/ui 风格** — 手写轻量企业 UI 组件
- **React Router 6** — 路由
- **Zustand 4** — 全局状态(认证/UI/Toast/确认弹窗)
- **TanStack Query 5** — 数据请求与缓存
- **Recharts 2** — 图表
- **React Hook Form 7** + **Zod 3** — 表单与校验
- **Lucide React** — 图标
## 快速开始
```bash
# 1. 安装依赖
npm install
# 2. 启动开发服务器(默认 Mock 模式)
npm run dev
# 3. 浏览器打开 http://localhost:5173
```
登录页点击下方**演示角色一键登录**即可进入系统:
- 企业管理员 / 业务主管 / 外贸业务员 / 客服人员 / 只读成员
也可以使用验证码模式:任意手机号 / 邮箱 + 任意 6 位验证码即可登录。
## 构建生产包
```bash
npm run build # TypeScript 检查 + Vite 构建
npm run preview # 预览构建结果
```
## 环境变量与模式切换
项目通过 `.env` 文件控制数据来源:
```env
# 使用内置 Mock 服务(默认,无需后端)
VITE_USE_MOCK=true
# 真实后端 API 地址(VITE_USE_MOCK=false 时生效)
VITE_API_BASE_URL=http://localhost:8080/api
```
- **Mock 模式**(`VITE_USE_MOCK=true`):前端内置完整演示数据集(30 个客户、20 条会话、100+ 条多语种消息、20 个跟进任务、15 条风险预警、30 条企业知识、8 名团队成员、13 个渠道),所有 API 请求在前端通过 Mock 路由处理,无需任何后端服务。
- **真实模式**(`VITE_USE_MOCK=false`):所有 API 请求转发到 `VITE_API_BASE_URL`,自动携带 Bearer Token。
> ⚠️ **安全提示**:请勿在前端代码中存放任何真实 API 密钥。所有密钥应由服务端环境变量管理。
## 项目结构
```
src/
├── app/
│ ├── router/index.tsx # React Router 路由配置
│ ├── providers/index.tsx # QueryClient + i18n + Toast + Confirm
│ └── store/ # Zustand stores (auth / ui / toast / confirm)
├── components/
│ ├── layout/ # AppLayout / Sidebar / Topbar / GlobalSearch
│ ├── common/ # PageHeader / StatCard / ChannelIcon / CountryFlag / StatusBadges / PermissionGate / DemoNote
│ ├── ui/ # UI 基础组件(button / input / select / dialog / drawer / tabs / table / toast / pagination 等)
│ ├── inbox/ # 统一消息专属组件(ConversationList / MessageBubble / Composer / InsightPanel / AiReplies)
│ ├── customer/ # 客户表单弹窗(CustomerFormDialog)
│ ├── crm/ # 商机与任务表单(TaskFormDialog)
│ └── charts/ # 图表封装(TrendChart / DonutChart / FunnelChart / RankBars)
├── pages/
│ ├── auth/ # 登录 / 注册
│ ├── onboarding/ # 8 步企业初始化向导
│ ├── dashboard/ # 今日经营工作台
│ ├── inbox/ # 统一消息中心(三栏布局 + 移动适配)
│ ├── customers/ # 客户管理(表格/卡片/看板) + 客户详情
│ ├── opportunities/ # 商机看板 + 跟进任务 + 日历
│ ├── translate/ # AI 翻译中心
│ ├── interpret/ # 同声传译(模拟实时双向字幕)
│ ├── risks/ # 风险预警中心
│ ├── knowledge/ # 企业知识库 + 检索测试 + 使用统计
│ ├── analytics/ # 数据分析 + 老板看板
│ ├── channels/ # 渠道中心 + 连接向导
│ ├── team/ # 团队协作 + 角色权限矩阵
│ ├── billing/ # 套餐与账单
│ ├── settings/ # 系统设置(13 组)
│ └── help/ # 帮助与培训 + 交付进度
├── services/
│ ├── api/ # 领域 API 服务(auth / dashboard / channels / conversations / ai / customers / crm / knowledge / org / search)
│ └── mock/ # Mock 路由与处理器(按 HTTP 方法 + 路径匹配,支持 :param 参数)
├── mocks/
│ ├── db.ts # 内存数据库(增删改持久到刷新)
│ └── seed/ # 种子数据(customers / conversations / messages / tasks / opportunities / risks / knowledge / team / channels / analytics / misc)
├── types/ # TypeScript 类型定义
├── config/ # 全局配置(套餐价格 / 支持语言数 / 角色权限 / 导航 / 业务常量 / 环境变量)
├── i18n/ # 国际化(zh / en / ar + RTL)
├── hooks/ # 自定义 Hooks(usePermission / useDebounce / useMediaQuery)
├── utils/ # 工具函数(cn / format / misc)
└── styles/ # 全局样式与 CSS 变量
```
## 接入真实后端
项目中预留了完整的 API 接口层,对应后端路由如下:
| 领域 | 核心接口 |
|------|---------|
| 认证 | `POST /api/auth/login`, `POST /api/auth/register`, `GET /api/auth/me` |
| 工作台 | `GET /api/dashboard/overview` |
| 渠道 | `GET /api/channels`, `POST /api/channels/connect`, `POST /api/channels/test`, `PATCH /api/channels/:id`, `DELETE /api/channels/:id` |
| 会话 | `GET /api/conversations`, `GET /api/conversations/:id`, `GET /api/conversations/:id/messages`, `POST /api/conversations/:id/messages`, `PATCH /api/conversations/:id` |
| AI | `POST /api/ai/translate`, `POST /api/ai/analyze`, `POST /api/ai/replies`, `POST /api/ai/summarize`, `POST /api/ai/interpretation`, `POST /api/ai/text-action` |
| 客户 | `GET/POST /api/customers`, `GET/PATCH/DELETE /api/customers/:id`, `POST /api/customers/merge`, `POST /api/customers/import`, `GET /api/customers/export`, `GET /api/customers/duplicates` |
| 商机 | `GET/POST /api/opportunities`, `PATCH /api/opportunities/:id` |
| 任务 | `GET/POST /api/tasks`, `PATCH /api/tasks/:id` |
| 风险 | `GET /api/risks`, `PATCH /api/risks/:id` |
| 知识库 | `GET/POST /api/knowledge`, `PATCH/DELETE /api/knowledge/:id`, `POST /api/knowledge/upload`, `POST /api/knowledge/search-test`, `GET /api/knowledge/stats` |
| 分析 | `GET /api/analytics` |
| 团队 | `GET /api/team`, `POST /api/team/invite`, `PATCH/DELETE /api/team/:id` |
| 账单 | `GET /api/billing`, `POST /api/billing/upgrade` |
| 设置 | `GET/PATCH /api/settings` |
| 通知 | `GET /api/notifications`, `POST /api/notifications/read-all` |
| 搜索 | `GET /api/search?q=` |
后端可使用 Java / Node.js / Python 等任意技术栈实现。参考 Mock 服务中 `src/services/mock/handlers/` 目录下的处理器即可了解各接口的请求/响应 Shape。
## 套餐与价格配置
所有套餐价格集中维护在 `src/config/pricingPlans.ts` 中,包含四档套餐(轻助手版 / 标准工作台版 / 专业版 / 增长版/定制版),一处修改全局生效。
## 语言支持
平台 UI 支持中文 / English / العربية(RTL),RTL 布局通过 `document.documentElement.dir = 'rtl'` 原生实现。
翻译引擎支持语言数配置在 `src/config/languages.ts` 的 `supportedLanguageCount` 字段,当前默认值为 96。
## 演示数据说明
所有图表、经营指标与客户数据均为**模拟演示数据**(数据来源:`src/mocks/seed/` 目录),不代表真实企业经营状况。每个图表卡片均标注「演示数据」提示。
客户与消息覆盖以下真实语言场景:
- 🇵🇰 乌尔都语(巴基斯坦 · WhatsApp)
- 🇦🇪 / 🇸🇦 阿拉伯语(阿联酋 / 沙特 · 邮件 / Telegram)
- 🇧🇩 孟加拉语(孟加拉国 · WhatsApp)
- 🇪🇸 西班牙语(西班牙 · 独立站表单 / WhatsApp)
- 🇬🇧 / 🇺🇸 / 🇩🇪 英语(英国 / 美国 / 德国 · 邮件 / Messenger)
## License
本项目为徐州欧汇科技有限公司内部产品,未开源授权。仅供演示评估使用。
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="灵犀跨境 - 让跨境沟通更简单。面向中小外贸企业的跨境AI经营服务平台。" />
<title>灵犀跨境 · 跨境AI经营服务平台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3278
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "lingxi-crossborder-saas",
"private": true,
"version": "1.0.0",
"type": "module",
"description": "灵犀跨境 - 跨境AI经营服务平台前端(演示环境,Mock数据)",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hookform/resolvers": "^3.9.1",
"@tanstack/react-query": "^5.62.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.462.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hook-form": "^7.54.0",
"react-router-dom": "^6.28.0",
"recharts": "^2.15.0",
"tailwind-merge": "^2.5.5",
"zod": "^3.24.1",
"zustand": "^4.5.5"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@types/react": "^18.3.14",
"@types/react-dom": "^18.3.2",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.16",
"typescript": "^5.7.2",
"vite": "^5.4.11"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+10
View File
@@ -0,0 +1,10 @@
import { AppProviders } from '@/app/providers'
import { AppRouter } from '@/app/router'
export default function App() {
return (
<AppProviders>
<AppRouter />
</AppProviders>
)
}
+27
View File
@@ -0,0 +1,27 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { I18nProvider } from '@/i18n'
import { Toaster } from '@/components/ui/toaster'
import { Confirmer } from '@/components/ui/confirmer'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
staleTime: 30_000,
refetchOnWindowFocus: false,
},
},
})
export function AppProviders({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<I18nProvider>
{children}
<Toaster />
<Confirmer />
</I18nProvider>
</QueryClientProvider>
)
}
+80
View File
@@ -0,0 +1,80 @@
import { createBrowserRouter, Navigate, RouterProvider } from 'react-router-dom'
import { useAuthStore } from '@/app/store/auth'
import { AppLayout } from '@/components/layout/AppLayout'
import type { ReactNode } from 'react'
import { LoginPage } from '@/pages/auth/LoginPage'
import { RegisterPage } from '@/pages/auth/RegisterPage'
import { OnboardingPage } from '@/pages/onboarding/OnboardingPage'
import { DashboardPage } from '@/pages/dashboard/DashboardPage'
import { InboxPage } from '@/pages/inbox/InboxPage'
import { CustomersPage } from '@/pages/customers/CustomersPage'
import { CustomerDetailPage } from '@/pages/customers/CustomerDetailPage'
import { OpportunitiesPage } from '@/pages/opportunities/OpportunitiesPage'
import { TranslatePage } from '@/pages/translate/TranslatePage'
import { InterpretPage } from '@/pages/interpret/InterpretPage'
import { RisksPage } from '@/pages/risks/RisksPage'
import { KnowledgePage } from '@/pages/knowledge/KnowledgePage'
import { AnalyticsPage } from '@/pages/analytics/AnalyticsPage'
import { ChannelsPage } from '@/pages/channels/ChannelsPage'
import { TeamPage } from '@/pages/team/TeamPage'
import { BillingPage } from '@/pages/billing/BillingPage'
import { SettingsPage } from '@/pages/settings/SettingsPage'
import { HelpPage } from '@/pages/help/HelpPage'
import { NotFoundPage } from '@/pages/NotFoundPage'
function RequireAuth({ children }: { children: ReactNode }) {
const token = useAuthStore((s) => s.token)
if (!token) return <Navigate to="/login" replace />
return <>{children}</>
}
function GuestOnly({ children }: { children: ReactNode }) {
const token = useAuthStore((s) => s.token)
if (token) return <Navigate to="/dashboard" replace />
return <>{children}</>
}
const router = createBrowserRouter([
{ path: '/login', element: <GuestOnly><LoginPage /></GuestOnly> },
{ path: '/register', element: <GuestOnly><RegisterPage /></GuestOnly> },
{
path: '/onboarding',
element: (
<RequireAuth>
<OnboardingPage />
</RequireAuth>
),
},
{
path: '/',
element: (
<RequireAuth>
<AppLayout />
</RequireAuth>
),
children: [
{ index: true, element: <Navigate to="/dashboard" replace /> },
{ path: 'dashboard', element: <DashboardPage /> },
{ path: 'inbox', element: <InboxPage /> },
{ path: 'customers', element: <CustomersPage /> },
{ path: 'customers/:id', element: <CustomerDetailPage /> },
{ path: 'opportunities', element: <OpportunitiesPage /> },
{ path: 'translate', element: <TranslatePage /> },
{ path: 'interpret', element: <InterpretPage /> },
{ path: 'risks', element: <RisksPage /> },
{ path: 'knowledge', element: <KnowledgePage /> },
{ path: 'analytics', element: <AnalyticsPage /> },
{ path: 'channels', element: <ChannelsPage /> },
{ path: 'team', element: <TeamPage /> },
{ path: 'billing', element: <BillingPage /> },
{ path: 'settings', element: <SettingsPage /> },
{ path: 'help', element: <HelpPage /> },
{ path: '*', element: <NotFoundPage /> },
],
},
])
export function AppRouter() {
return <RouterProvider router={router} />
}
+43
View File
@@ -0,0 +1,43 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { AuthSession, Tenant, User } from '@/types'
import type { RoleKey } from '@/config/roles'
import { ROLE_META } from '@/config/roles'
import { setTokenGetter } from '@/services/api/client'
interface AuthState {
token: string | null
user: User | null
tenant: Tenant | null
onboarded: boolean
setSession: (session: AuthSession) => void
logout: () => void
/** 演示用:切换当前用户角色以预览不同权限 */
switchRole: (role: RoleKey) => void
setOnboarded: (v: boolean) => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
token: null,
user: null,
tenant: null,
onboarded: true,
setSession: (session) =>
set({ token: session.token, user: session.user, tenant: session.tenant, onboarded: session.onboarded }),
logout: () => set({ token: null, user: null, tenant: null }),
switchRole: (role) =>
set((s) => (s.user ? { user: { ...s.user, role, title: ROLE_META[role].label } } : s)),
setOnboarded: (v) => set({ onboarded: v }),
}),
{ name: 'lx-auth' },
),
)
// 让 API client 能读取 token(真实后端模式使用)
setTokenGetter(() => useAuthStore.getState().token)
export function useCurrentRole(): RoleKey {
return useAuthStore((s) => s.user?.role ?? 'viewer')
}
+27
View File
@@ -0,0 +1,27 @@
import { create } from 'zustand'
export interface ConfirmOptions {
title: string
description?: string
confirmText?: string
cancelText?: string
danger?: boolean
onConfirm: () => void | Promise<void>
}
interface ConfirmState {
current: ConfirmOptions | null
ask: (opts: ConfirmOptions) => void
close: () => void
}
export const useConfirmStore = create<ConfirmState>()((set) => ({
current: null,
ask: (opts) => set({ current: opts }),
close: () => set({ current: null }),
}))
/** 任意位置调用:confirmDialog({ title, danger, onConfirm }) */
export function confirmDialog(opts: ConfirmOptions): void {
useConfirmStore.getState().ask(opts)
}
+43
View File
@@ -0,0 +1,43 @@
import { create } from 'zustand'
import { uid } from '@/utils/misc'
export type ToastType = 'success' | 'error' | 'info' | 'warning'
export interface ToastItem {
id: string
type: ToastType
title: string
description?: string
}
interface ToastState {
toasts: ToastItem[]
push: (toast: Omit<ToastItem, 'id'>) => void
dismiss: (id: string) => void
}
export const useToastStore = create<ToastState>()((set) => ({
toasts: [],
push: (t) => {
const id = uid('toast')
set((s) => ({ toasts: [...s.toasts.slice(-4), { ...t, id }] }))
setTimeout(() => {
set((s) => ({ toasts: s.toasts.filter((x) => x.id !== id) }))
}, 3600)
},
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((x) => x.id !== id) })),
}))
/** 任意位置调用:toast.success('已保存') */
export const toast = {
success: (title: string, description?: string) => useToastStore.getState().push({ type: 'success', title, description }),
error: (title: string, description?: string) => useToastStore.getState().push({ type: 'error', title, description }),
info: (title: string, description?: string) => useToastStore.getState().push({ type: 'info', title, description }),
warning: (title: string, description?: string) => useToastStore.getState().push({ type: 'warning', title, description }),
}
/** 从异常提取可读信息 */
export function errorMessage(e: unknown): string {
if (e instanceof Error) return e.message
return '操作失败,请稍后重试'
}
+15
View File
@@ -0,0 +1,15 @@
import { create } from 'zustand'
interface UiState {
sidebarCollapsed: boolean
mobileNavOpen: boolean
toggleSidebar: () => void
setMobileNavOpen: (open: boolean) => void
}
export const useUiStore = create<UiState>()((set) => ({
sidebarCollapsed: false,
mobileNavOpen: false,
toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
setMobileNavOpen: (open) => set({ mobileNavOpen: open }),
}))
+212
View File
@@ -0,0 +1,212 @@
import type { ReactNode } from 'react'
import {
Area,
AreaChart,
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import { Card, CardHeader } from '@/components/ui/card'
import { DemoNote } from '@/components/common/DemoNote'
import { cn } from '@/utils/cn'
export const CHART_COLORS = ['#247b71', '#32998c', '#52b3a5', '#83cfc2', '#f59e0b', '#3b82f6', '#8b5cf6', '#94a3b8']
export function ChartCard({
title,
description,
children,
height = 260,
action,
className,
}: {
title: ReactNode
description?: string
children: ReactNode
height?: number
action?: ReactNode
className?: string
}) {
return (
<Card className={className}>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold">{title}</h3>
{description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
</div>
{action ?? <DemoNote />}
</div>
</CardHeader>
<div className="p-3" style={{ height }}>
{children}
</div>
</Card>
)
}
const tooltipStyle = {
fontSize: 12,
borderRadius: 8,
border: '1px solid hsl(var(--border))',
background: 'hsl(var(--popover))',
}
/** 趋势面积图 */
export function TrendChart({
data,
series,
xKey = 'date',
}: {
data: Record<string, unknown>[]
series: { key: string; name: string; color?: string }[]
xKey?: string
}) {
return (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}>
<defs>
{series.map((s, i) => {
const color = s.color ?? CHART_COLORS[i]
return (
<linearGradient key={s.key} id={`g-${s.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={0.25} />
<stop offset="100%" stopColor={color} stopOpacity={0.02} />
</linearGradient>
)
})}
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis dataKey={xKey} tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }} tickLine={false} axisLine={false} />
<YAxis tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }} tickLine={false} axisLine={false} />
<Tooltip contentStyle={tooltipStyle} />
<Legend wrapperStyle={{ fontSize: 12 }} />
{series.map((s, i) => {
const color = s.color ?? CHART_COLORS[i]
return (
<Area
key={s.key}
type="monotone"
dataKey={s.key}
name={s.name}
stroke={color}
strokeWidth={2}
fill={`url(#g-${s.key})`}
/>
)
})}
</AreaChart>
</ResponsiveContainer>
)
}
/** 环形占比图 */
export function DonutChart({ data, nameKey = 'name', valueKey = 'value' }: { data: Record<string, unknown>[]; nameKey?: string; valueKey?: string }) {
return (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={data} dataKey={valueKey} nameKey={nameKey} innerRadius="55%" outerRadius="80%" paddingAngle={2}>
{data.map((_, i) => (
<Cell key={i} fill={CHART_COLORS[i % CHART_COLORS.length]} />
))}
</Pie>
<Tooltip contentStyle={tooltipStyle} />
<Legend layout="vertical" align="right" verticalAlign="middle" wrapperStyle={{ fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
)
}
/** 柱状图 */
export function SimpleBarChart({
data,
xKey,
barKey,
name,
color = CHART_COLORS[0],
}: {
data: Record<string, unknown>[]
xKey: string
barKey: string
name?: string
color?: string
}) {
return (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 8, right: 8, left: -18, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis dataKey={xKey} tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }} tickLine={false} axisLine={false} />
<YAxis tick={{ fontSize: 11, fill: 'hsl(var(--muted-foreground))' }} tickLine={false} axisLine={false} />
<Tooltip contentStyle={tooltipStyle} />
<Bar dataKey={barKey} name={name ?? barKey} fill={color} radius={[4, 4, 0, 0]} maxBarSize={28} />
</BarChart>
</ResponsiveContainer>
)
}
/** 转化漏斗(自定义实现,样式可控) */
export function FunnelChart({ data }: { data: { label: string; count: number }[] }) {
const max = Math.max(...data.map((d) => d.count), 1)
return (
<div className="flex h-full flex-col justify-center gap-2 px-2">
{data.map((d, i) => {
const pct = Math.max(18, (d.count / max) * 100)
const conv = i > 0 ? Math.round((d.count / data[i - 1].count) * 100) : 100
return (
<div key={d.label} className="flex items-center gap-2">
<span className="w-16 shrink-0 text-end text-xs text-muted-foreground">{d.label}</span>
<div className="flex-1">
<div
className="flex h-7 items-center rounded-md px-2 text-xs font-medium text-white transition-all"
style={{ width: `${pct}%`, background: CHART_COLORS[i % CHART_COLORS.length], opacity: 1 - i * 0.1 }}
>
{d.count}
</div>
</div>
<span className="w-12 shrink-0 text-xs text-muted-foreground">{i > 0 ? `${conv}%` : ''}</span>
</div>
)
})}
</div>
)
}
/** 横向排行条(自定义实现) */
export function RankBars({
data,
unit = '',
max,
}: {
data: { name: string; value: number; sub?: string }[]
unit?: string
max?: number
}) {
const m = max ?? Math.max(...data.map((d) => d.value), 1)
return (
<div className="flex h-full flex-col justify-center gap-2.5 px-2">
{data.map((d, i) => (
<div key={d.name} className="flex items-center gap-2 text-xs">
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded text-[11px] font-medium', i < 3 ? 'bg-primary-50 text-primary-700' : 'bg-muted text-muted-foreground')}>
{i + 1}
</span>
<span className="w-14 shrink-0 truncate font-medium">{d.name}</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary-400" style={{ width: `${Math.max(4, (d.value / m) * 100)}%` }} />
</div>
<span className="w-16 shrink-0 text-end text-muted-foreground">
{d.value}
{unit}
</span>
</div>
))}
</div>
)
}
+60
View File
@@ -0,0 +1,60 @@
import {
MessageCircle,
Send,
Facebook,
Mail,
FormInput,
Globe,
MousePointerClick,
Share2,
QrCode,
Braces,
Webhook,
type LucideIcon,
} from 'lucide-react'
import type { ChannelType } from '@/types/messaging'
import { CHANNEL_META } from '@/config/constants'
import { cn } from '@/utils/cn'
const CHANNEL_ICONS: Record<ChannelType, LucideIcon> = {
whatsapp: MessageCircle,
telegram: Send,
messenger: Facebook,
email: Mail,
gmail: Mail,
outlook: Mail,
webform: FormInput,
website: Globe,
landing: MousePointerClick,
social: Share2,
exhibition: QrCode,
api: Braces,
webhook: Webhook,
}
export function ChannelIcon({
channel,
size = 'md',
withBg = true,
className,
}: {
channel: ChannelType
size?: 'xs' | 'sm' | 'md' | 'lg'
withBg?: boolean
className?: string
}) {
const Icon = CHANNEL_ICONS[channel] ?? MessageCircle
const meta = CHANNEL_META[channel]
const boxSize = { xs: 'h-5 w-5', sm: 'h-6 w-6', md: 'h-8 w-8', lg: 'h-10 w-10' }[size]
const iconSize = { xs: 'h-3 w-3', sm: 'h-3.5 w-3.5', md: 'h-4 w-4', lg: 'h-5 w-5' }[size]
if (!withBg) return <Icon className={cn(iconSize, meta.color, className)} />
return (
<span className={cn('inline-flex items-center justify-center rounded-md', boxSize, meta.bg, className)}>
<Icon className={cn(iconSize, meta.color)} />
</span>
)
}
export function channelName(channel: ChannelType): string {
return CHANNEL_META[channel]?.name ?? channel
}
+22
View File
@@ -0,0 +1,22 @@
import { countryMeta } from '@/config/constants'
import { cn } from '@/utils/cn'
export function CountryFlag({
code,
withName = true,
className,
nameClassName,
}: {
code: string
withName?: boolean
className?: string
nameClassName?: string
}) {
const meta = countryMeta(code)
return (
<span className={cn('inline-flex items-center gap-1 whitespace-nowrap', className)} title={meta.en}>
<span className="text-base leading-none">{meta.flag}</span>
{withName && <span className={cn('text-sm', nameClassName)}>{meta.zh}</span>}
</span>
)
}
+18
View File
@@ -0,0 +1,18 @@
import { Info } from 'lucide-react'
import { cn } from '@/utils/cn'
/** “演示数据”标注 */
export function DemoNote({ className, text = '演示数据' }: { className?: string; text?: string }) {
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded bg-amber-50 px-1.5 py-0.5 text-[11px] text-amber-600',
className,
)}
title="当前环境使用模拟数据演示,接入真实后端后显示真实经营数据"
>
<Info className="h-3 w-3" />
{text}
</span>
)
}
+29
View File
@@ -0,0 +1,29 @@
import type { ReactNode } from 'react'
import { cn } from '@/utils/cn'
export function PageHeader({
title,
description,
actions,
className,
extra,
}: {
title: ReactNode
description?: ReactNode
actions?: ReactNode
extra?: ReactNode
className?: string
}) {
return (
<div className={cn('mb-4', className)}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h1 className="text-lg font-semibold text-foreground">{title}</h1>
{description && <p className="mt-0.5 text-sm text-muted-foreground">{description}</p>}
</div>
{actions && <div className="flex flex-wrap items-center gap-2">{actions}</div>}
</div>
{extra && <div className="mt-3">{extra}</div>}
</div>
)
}
+18
View File
@@ -0,0 +1,18 @@
import type { ReactNode } from 'react'
import { usePermission } from '@/hooks/usePermission'
import type { Permission } from '@/config/roles'
/** 按权限渲染:无权限时不渲染(或渲染 fallback) */
export function PermissionGate({
permission,
children,
fallback = null,
}: {
permission: Permission | Permission[]
children: ReactNode
fallback?: ReactNode
}) {
const { can, canAny } = usePermission()
const allowed = Array.isArray(permission) ? canAny(...permission) : can(permission)
return allowed ? <>{children}</> : <>{fallback}</>
}
+58
View File
@@ -0,0 +1,58 @@
import type { LucideIcon } from 'lucide-react'
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
import { Card } from '@/components/ui/card'
import { cn } from '@/utils/cn'
export function StatCard({
title,
value,
delta,
icon: Icon,
suffix,
invertDelta,
onClick,
}: {
title: string
value: string | number
delta?: number
icon: LucideIcon
suffix?: string
/** 数值下降为好事(如响应时长) */
invertDelta?: boolean
onClick?: () => void
}) {
const showDelta = delta !== undefined
const positive = invertDelta ? (delta ?? 0) < 0 : (delta ?? 0) > 0
const neutral = (delta ?? 0) === 0
const DeltaIcon = neutral ? Minus : (delta ?? 0) > 0 ? TrendingUp : TrendingDown
return (
<Card
className={cn('p-4', onClick && 'cursor-pointer transition-shadow hover:shadow-md')}
onClick={onClick}
>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{title}</span>
<span className="flex h-7 w-7 items-center justify-center rounded-md bg-primary-50">
<Icon className="h-4 w-4 text-primary-600" />
</span>
</div>
<div className="mt-2 flex items-baseline gap-1">
<span className="text-2xl font-semibold tracking-tight text-foreground">{value}</span>
{suffix && <span className="text-xs text-muted-foreground">{suffix}</span>}
</div>
{showDelta && (
<div
className={cn(
'mt-1.5 flex items-center gap-1 text-xs',
neutral ? 'text-muted-foreground' : positive ? 'text-emerald-600' : 'text-red-600',
)}
>
<DeltaIcon className="h-3.5 w-3.5" />
<span>
{Math.abs((delta ?? 0) * 100).toFixed(1)}% 较上期
</span>
</div>
)}
</Card>
)
}
+70
View File
@@ -0,0 +1,70 @@
import { cn } from '@/utils/cn'
import { Badge } from '@/components/ui/badge'
import {
customerStageMeta,
intentLevelOf,
intentLevelMeta,
sentimentMeta,
riskLevelMeta,
opportunityStageMeta,
taskPriorityMeta,
} from '@/config/constants'
import type { CustomerStage, OpportunityStage, TaskPriority } from '@/types/crm'
import type { Sentiment } from '@/types/common'
/** 意向分数(0—100 彩色徽标 + 可选进度条) */
export function IntentScoreBadge({ score, withBar = false, className }: { score: number; withBar?: boolean; className?: string }) {
const level = intentLevelOf(score)
const meta = intentLevelMeta(level)
const barColor =
score >= 80 ? 'bg-emerald-500' : score >= 60 ? 'bg-teal-500' : score >= 40 ? 'bg-amber-500' : 'bg-gray-400'
return (
<span className={cn('inline-flex items-center gap-1.5', className)} title={`意向等级:${meta.label}`}>
<span className={cn('inline-flex h-6 min-w-9 items-center justify-center rounded-md px-1 text-xs font-semibold', meta.color)}>
{score}
</span>
{withBar && (
<span className="h-1.5 w-12 overflow-hidden rounded-full bg-muted">
<span className={cn('block h-full rounded-full', barColor)} style={{ width: `${score}%` }} />
</span>
)}
</span>
)
}
export function CustomerStageBadge({ stage }: { stage: CustomerStage }) {
const meta = customerStageMeta(stage)
return <Badge className={meta.color}>{meta.label}</Badge>
}
export function OpportunityStageBadge({ stage }: { stage: OpportunityStage }) {
const meta = opportunityStageMeta(stage)
return <Badge className={meta.color}>{meta.label}</Badge>
}
export function SentimentBadge({ sentiment, emojiOnly = false }: { sentiment: Sentiment; emojiOnly?: boolean }) {
const meta = sentimentMeta(sentiment)
if (emojiOnly) return <span title={meta.label}>{meta.emoji}</span>
return (
<span className={cn('inline-flex items-center gap-1 text-xs', meta.color)} title="情绪状态">
<span>{meta.emoji}</span>
<span>{meta.label}</span>
</span>
)
}
export function RiskLevelBadge({ level }: { level: string }) {
if (level === 'none') return <span className="text-xs text-muted-foreground">—</span>
const meta = riskLevelMeta(level)
return (
<Badge className={meta.color}>
<span className={cn('mr-1 inline-block h-1.5 w-1.5 rounded-full', meta.dot)} />
{meta.label}风险
</Badge>
)
}
export function TaskPriorityBadge({ priority }: { priority: TaskPriority }) {
const meta = taskPriorityMeta(priority)
return <Badge className={meta.color}>{meta.label}优先级</Badge>
}
+137
View File
@@ -0,0 +1,137 @@
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Dialog } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input, Textarea, Field } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { createTask } from '@/services/api/crm'
import { getCustomers } from '@/services/api/customers'
import { getTeam } from '@/services/api/org'
import { TASK_PRIORITIES, TASK_TYPES } from '@/config/constants'
import { toast, errorMessage } from '@/app/store/toast'
const schema = z.object({
title: z.string().min(2, '请输入任务标题(至少2个字)'),
type: z.string().min(1, '请选择任务类型'),
priority: z.string().min(1),
customerId: z.string().min(1, '请选择关联客户'),
ownerId: z.string().min(1, '请选择负责人'),
dueAt: z.string().min(1, '请选择完成时间'),
note: z.string().optional(),
})
type FormValues = z.infer<typeof schema>
function defaultDue(): string {
const d = new Date(Date.now() + 86400000)
d.setMinutes(0, 0, 0)
// datetime-local 格式
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
}
export function TaskFormDialog({
open,
onOpenChange,
presetCustomerId,
presetTitle,
}: {
open: boolean
onOpenChange: (open: boolean) => void
presetCustomerId?: string
presetTitle?: string
}) {
const queryClient = useQueryClient()
const { data: customersData } = useQuery({ queryKey: ['customers', 'all'], queryFn: () => getCustomers(), staleTime: 60_000 })
const { data: team } = useQuery({ queryKey: ['team'], queryFn: getTeam, staleTime: 300_000 })
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { title: '', type: 'revisit', priority: 'medium', customerId: '', ownerId: '', dueAt: defaultDue(), note: '' },
})
useEffect(() => {
if (open) {
reset({
title: presetTitle ?? '',
type: 'revisit',
priority: 'medium',
customerId: presetCustomerId ?? '',
ownerId: '',
dueAt: defaultDue(),
note: '',
})
}
}, [open, presetCustomerId, presetTitle, reset])
const mutation = useMutation({
mutationFn: (values: FormValues) =>
createTask({ ...values, type: values.type as any, priority: values.priority as any, dueAt: new Date(values.dueAt).toISOString() }),
onSuccess: () => {
toast.success('跟进任务已创建')
queryClient.invalidateQueries({ queryKey: ['tasks'] })
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
onOpenChange(false)
},
onError: (e) => toast.error(errorMessage(e)),
})
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title="新建跟进任务"
width="max-w-xl"
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button loading={mutation.isPending} onClick={handleSubmit((v) => mutation.mutate(v))}>创建任务</Button>
</>
}
>
<form className="space-y-4" onSubmit={handleSubmit((v) => mutation.mutate(v))}>
<Field label="任务标题" required error={errors.title?.message}>
<Input placeholder="如:发送阶梯报价单" {...register('title')} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="任务类型" required error={errors.type?.message}>
<Select options={TASK_TYPES.map((t) => ({ value: t.value, label: t.label }))} {...register('type')} />
</Field>
<Field label="优先级" required>
<Select options={TASK_PRIORITIES.map((p) => ({ value: p.value, label: p.label }))} {...register('priority')} />
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field label="关联客户" required error={errors.customerId?.message}>
<Select
placeholder="请选择客户"
options={(customersData?.list ?? []).map((c) => ({ value: c.id, label: c.name }))}
{...register('customerId')}
/>
</Field>
<Field label="负责人" required error={errors.ownerId?.message}>
<Select
placeholder="请选择成员"
options={(team ?? []).map((m) => ({ value: m.id, label: m.name }))}
{...register('ownerId')}
/>
</Field>
</div>
<Field label="完成时间" required error={errors.dueAt?.message}>
<Input type="datetime-local" {...register('dueAt')} />
</Field>
<Field label="备注">
<Textarea rows={3} placeholder="补充说明、背景信息等" {...register('note')} />
</Field>
</form>
</Dialog>
)
}
@@ -0,0 +1,183 @@
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Dialog } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input, Field } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { createCustomer, updateCustomer } from '@/services/api/customers'
import { getTeam } from '@/services/api/org'
import { CHANNEL_META, COUNTRIES, CUSTOMER_TAGS, PRODUCTS } from '@/config/constants'
import type { ChannelType } from '@/types/messaging'
import { toast, errorMessage } from '@/app/store/toast'
import { cn } from '@/utils/cn'
import type { Customer } from '@/types'
const schema = z.object({
name: z.string().min(1, '请输入客户姓名'),
company: z.string().optional(),
country: z.string().min(1, '请选择国家/地区'),
email: z.string().email('邮箱格式不正确').or(z.literal('')),
phone: z.string().optional(),
channel: z.string().min(1, '请选择来源渠道'),
ownerId: z.string().optional(),
})
type FormValues = z.infer<typeof schema>
const CHANNEL_OPTIONS = (Object.keys(CHANNEL_META) as ChannelType[]).map((c) => ({
value: c,
label: CHANNEL_META[c].name,
}))
export function CustomerFormDialog({
open,
onOpenChange,
customer,
}: {
open: boolean
onOpenChange: (open: boolean) => void
/** 传入则为编辑模式 */
customer?: Customer | null
}) {
const queryClient = useQueryClient()
const isEdit = !!customer
const { data: team } = useQuery({ queryKey: ['team'], queryFn: getTeam, staleTime: 300_000 })
const [products, setProducts] = useState<string[]>([])
const [tags, setTags] = useState<string[]>([])
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: '', company: '', country: '', email: '', phone: '', channel: '', ownerId: '' },
})
useEffect(() => {
if (open) {
reset({
name: customer?.name ?? '',
company: customer?.company ?? '',
country: customer?.country ?? '',
email: customer?.email ?? '',
phone: customer?.phone ?? '',
channel: customer?.channel ?? '',
ownerId: customer?.ownerId ?? '',
})
setProducts(customer?.products ?? [])
setTags(customer?.tags ?? [])
}
}, [open, customer, reset])
const mutation = useMutation({
mutationFn: (values: FormValues) => {
const payload = { ...values, channel: values.channel as any, ownerId: values.ownerId || null, products, tags }
return isEdit ? updateCustomer(customer!.id, payload) : createCustomer(payload as any)
},
onSuccess: () => {
toast.success(isEdit ? '客户信息已更新' : '客户创建成功')
queryClient.invalidateQueries({ queryKey: ['customers'] })
onOpenChange(false)
},
onError: (e) => toast.error(errorMessage(e)),
})
const toggle = (list: string[], v: string, set: (l: string[]) => void) =>
set(list.includes(v) ? list.filter((x) => x !== v) : [...list, v])
return (
<Dialog
open={open}
onOpenChange={onOpenChange}
title={isEdit ? '编辑客户' : '新建客户'}
description={isEdit ? `更新 ${customer?.name} 的资料` : '手动录入客户,系统将通过后续会话自动完善画像'}
width="max-w-2xl"
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button loading={mutation.isPending} onClick={handleSubmit((v) => mutation.mutate(v))}>
{isEdit ? '保存修改' : '创建客户'}
</Button>
</>
}
>
<form className="grid grid-cols-1 gap-4 md:grid-cols-2" onSubmit={handleSubmit((v) => mutation.mutate(v))}>
<Field label="客户姓名" required error={errors.name?.message}>
<Input placeholder="如 Ahmed Raza" {...register('name')} />
</Field>
<Field label="公司名称" error={errors.company?.message}>
<Input placeholder="如 Raza Trading Co." {...register('company')} />
</Field>
<Field label="国家/地区" required error={errors.country?.message}>
<Select
placeholder="请选择"
options={COUNTRIES.map((c) => ({ value: c.code, label: `${c.flag} ${c.zh}` }))}
{...register('country')}
/>
</Field>
<Field label="来源渠道" required error={errors.channel?.message}>
<Select placeholder="请选择" options={CHANNEL_OPTIONS} {...register('channel')} />
</Field>
<Field label="邮箱" error={errors.email?.message}>
<Input placeholder="name@example.com" {...register('email')} />
</Field>
<Field label="电话 / WhatsApp" error={errors.phone?.message}>
<Input placeholder="+92 300 0000000" {...register('phone')} />
</Field>
<Field label="负责人" className="md:col-span-2">
<Select
placeholder="暂不分配"
options={(team ?? []).map((m) => ({ value: m.id, label: `${m.name}(${m.department})` }))}
{...register('ownerId')}
/>
</Field>
<Field label="感兴趣产品" className="md:col-span-2">
<ChipPicker options={PRODUCTS} selected={products} onToggle={(v) => toggle(products, v, setProducts)} />
</Field>
<Field label="客户标签" className="md:col-span-2">
<ChipPicker options={CUSTOMER_TAGS} selected={tags} onToggle={(v) => toggle(tags, v, setTags)} />
</Field>
</form>
</Dialog>
)
}
export function ChipPicker({
options,
selected,
onToggle,
}: {
options: string[]
selected: string[]
onToggle: (v: string) => void
}) {
return (
<div className="flex flex-wrap gap-1.5">
{options.map((opt) => {
const active = selected.includes(opt)
return (
<button
key={opt}
type="button"
onClick={() => onToggle(opt)}
className={cn(
'rounded-md border px-2 py-1 text-xs transition-colors',
active
? 'border-primary bg-primary-50 text-primary-700'
: 'border-border bg-card text-muted-foreground hover:border-primary-300',
)}
>
{opt}
</button>
)
})}
</div>
)
}
+147
View File
@@ -0,0 +1,147 @@
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { Sparkles, Copy, Edit, Check, Lightbulb, Globe, AlertTriangle, BookOpen } from 'lucide-react'
import { generateReplies } from '@/services/api/ai'
import type { AiReplySuggestion } from '@/types/ai'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Spinner } from '@/components/ui/feedback'
import { toast, errorMessage } from '@/app/store/toast'
import { languageName } from '@/config/languages'
const STYLE_LABELS = {
stable: { title: '稳健回复', color: 'bg-blue-100 text-blue-700', icon: '🛡️' },
active: { title: '积极促单回复', color: 'bg-emerald-100 text-emerald-700', icon: '🔥' },
brief: { title: '简洁快速回复', color: 'bg-gray-100 text-gray-600', icon: '⚡' },
}
export function AiReplies({
conversationId,
onAdopt,
onEdit,
}: {
conversationId: string
onAdopt: (content: string) => void
onEdit: (content: string) => void
}) {
const [items, setItems] = useState<AiReplySuggestion[] | null>(null)
const mutation = useMutation({
mutationFn: () => generateReplies(conversationId),
onSuccess: (data) => {
setItems(Array.isArray(data) ? data : [])
toast.success('AI 回复建议已生成', '根据客户画像、知识库和历史成功回复综合生成')
},
onError: (e) => toast.error(errorMessage(e)),
})
return (
<div className="border-t border-border p-4">
<div className="mb-3 flex items-center justify-between">
<h4 className="flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
<Sparkles className="h-3.5 w-3.5 text-primary" />
AI 智能回复
</h4>
<Button
variant="ghost"
size="sm"
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
>
{mutation.isPending ? (
<>
<Spinner className="h-3 w-3" />
正在生成…
</>
) : (
'生成回复建议'
)}
</Button>
</div>
{mutation.isPending && (
<div className="space-y-3">
{[0, 1, 2].map((i) => (
<div key={i} className="animate-pulse rounded-lg border border-border bg-card p-3">
<div className="mb-2 h-2.5 w-20 rounded bg-muted" />
<div className="space-y-1.5">
<div className="h-2 w-full rounded bg-muted" />
<div className="h-2 w-3/4 rounded bg-muted" />
</div>
</div>
))}
</div>
)}
{items && items.length > 0 && (
<div className="space-y-3">
{items.map((s) => {
const style = STYLE_LABELS[s.style]
return (
<div key={s.id} className="rounded-lg border border-border bg-card p-3">
<div className="mb-2 flex items-center gap-2">
<span>{style.icon}</span>
<Badge className={style.color}>{style.title}</Badge>
<span className="text-[11px] text-muted-foreground">
{languageName(s.targetLang)}
</span>
</div>
<p className="text-sm leading-relaxed whitespace-pre-wrap">{s.content}</p>
<div className="mt-2 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
{s.knowledgeRefs.slice(0, 2).map((ref) => (
<span key={ref} className="flex items-center gap-1 rounded bg-muted px-1.5 py-0.5">
<BookOpen className="h-3 w-3" />
{ref}
</span>
))}
</div>
<div className="mt-2 space-y-1 text-xs">
<div className="flex items-start gap-1">
<Lightbulb className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
<span className="text-muted-foreground">{s.reason}</span>
</div>
<div className="flex items-start gap-1">
<Globe className="mt-0.5 h-3 w-3 shrink-0 text-blue-500" />
<span className="text-muted-foreground">{s.expectedEffect}</span>
</div>
{s.cultureTip && (
<div className="flex items-start gap-1">
<AlertTriangle className="mt-0.5 h-3 w-3 shrink-0 text-amber-500" />
<span className="text-amber-700">{s.cultureTip}</span>
</div>
)}
</div>
<div className="mt-2 flex gap-1.5">
<Button size="sm" variant="default" onClick={() => onAdopt(s.content)}>
<Check className="h-3.5 w-3.5" />
采用
</Button>
<Button size="sm" variant="outline" onClick={() => onEdit(s.content)}>
<Edit className="h-3.5 w-3.5" />
编辑后采用
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => { navigator.clipboard.writeText(s.content); toast.success('已复制') }}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)
})}
</div>
)}
{!items && !mutation.isPending && (
<p className="py-3 text-center text-xs text-muted-foreground">
点击「生成回复建议」让 AI 根据客户上下文、知识库与历史成功回复为您生成回复
</p>
)}
</div>
)
}
+201
View File
@@ -0,0 +1,201 @@
import { useRef, useState } from 'react'
import {
Send,
Paperclip,
Image,
Mic,
Sparkles,
Wand2,
FileText,
Shrink,
Expand,
ChevronDown,
} from 'lucide-react'
import { Segmented } from '@/components/ui/tabs'
import { Button } from '@/components/ui/button'
import { Dropdown } from '@/components/ui/dropdown'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import { toast } from '@/app/store/toast'
import { cn } from '@/utils/cn'
const QUICK_PHRASES = [
{ label: '报价催促', text: 'Friendly reminder: the quotation is valid until this Friday. Please confirm at your earliest convenience.' },
{ label: '物流通知', text: 'Your order has been shipped via DHL. The tracking number will be shared within 24 hours.' },
{ label: '售后安抚', text: 'We sincerely apologize for the inconvenience. We will resolve this for you within 24 hours.' },
{ label: '样品跟进', text: 'The samples were dispatched today by express. They usually arrive within 3-5 working days.' },
{ label: '开场问候', text: 'Hello, thank you for reaching out! How may I assist you today?' },
{ label: '比价回应', text: 'We understand price matters. Here is our tiered pricing and the quality differentiators.' },
]
const TONES = [
{ value: 'professional', label: '专业正式' },
{ value: 'friendly', label: '友好自然' },
{ value: 'concise', label: '简洁直接' },
{ value: 'negotiation', label: '商务谈判' },
{ value: 'comfort', label: '售后安抚' },
{ value: 'urge', label: '催单跟进' },
]
export function Composer({
onSend,
disabled,
}: {
onSend: (text: string, translatedText?: string) => Promise<void>
disabled?: boolean
}) {
const [text, setText] = useState('')
const [sending, setSending] = useState(false)
const [mode, setMode] = useState<'text' | 'preview'>('text')
const [previewText, setPreviewText] = useState('')
const [confirmBeforeSend, setConfirmBeforeSend] = useState(true)
const [aiLoading, setAiLoading] = useState(false)
const textarea = useRef<HTMLTextAreaElement>(null)
const send = async () => {
const body = confirmBeforeSend ? previewText || text : text
if (!body.trim()) return
setSending(true)
try {
await onSend(body, previewText || undefined)
setText('')
setPreviewText('')
} finally {
setSending(false)
}
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
if (!sending) send()
}
}
const insert = (txt: string) => {
setText((prev) => (prev ? `${prev}\n${txt}` : txt))
textarea.current?.focus()
}
const aiAction = async (action: string, tone?: string) => {
if (!text.trim()) return
setAiLoading(true)
try {
const { textAction } = await import('@/services/api/ai')
const res = await textAction({ text, action: action as any, tone, targetLang: 'en' })
setText(res.result)
toast.success('AI 处理完成')
} catch {
toast.error('AI 处理失败')
} finally {
setAiLoading(false)
}
}
const toolbar = (
<div className="flex flex-wrap items-center gap-0.5 border-b border-border px-2 py-1.5">
{/* 话术 */}
<Dropdown
trigger={
<button className="flex items-center gap-0.5 rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground">
<FileText className="h-3.5 w-3.5" />
常用话术
<ChevronDown className="h-3 w-3" />
</button>
}
items={QUICK_PHRASES.map((p) => ({ key: p.label, label: p.label, onSelect: () => insert(p.text) }))}
/>
<span className="mx-1 h-4 w-px bg-border" />
<button
className="rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={() => toast.info('文件上传功能已就绪(演示)')}
title="上传文件"
type="button"
>
<Paperclip className="inline h-3.5 w-3.5" /> 文件
</button>
<button
className="rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={() => toast.info('图片上传功能已就绪(演示)')}
title="上传图片"
type="button"
>
<Image className="inline h-3.5 w-3.5" /> 图片
</button>
<button
className="rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted hover:text-foreground"
onClick={() => toast.info('语音录制功能已就绪(演示)')}
title="语音输入"
type="button"
>
<Mic className="inline h-3.5 w-3.5" /> 语音
</button>
<span className="mx-1 h-4 w-px bg-border" />
{/* AI 工具 */}
{[
{ label: 'AI润色', action: 'polish', icon: Wand2 },
{ label: 'AI翻译', action: 'translate', icon: Sparkles },
{ label: '缩写', action: 'shorten' as const, icon: Shrink },
{ label: '扩写', action: 'expand' as const, icon: Expand },
].map((t) => (
<button
key={t.action}
type="button"
disabled={aiLoading || !text.trim()}
onClick={() => aiAction(t.action)}
className="rounded px-1.5 py-1 text-xs text-teal-600 hover:bg-teal-50 disabled:opacity-50"
>
<t.icon className="inline h-3.5 w-3.5" /> {t.label}
</button>
))}
<span className="mx-1 h-4 w-px bg-border" />
<Dropdown
trigger={
<button className="flex items-center gap-0.5 rounded px-1.5 py-1 text-xs text-muted-foreground hover:bg-muted">
修改语气 <ChevronDown className="h-3 w-3" />
</button>
}
items={TONES.map((t) => ({ key: t.value, label: t.label, onSelect: () => aiAction('tone', t.value) }))}
/>
</div>
)
return (
<div className="border-t border-border bg-card">
{toolbar}
<div className="p-3">
<textarea
ref={textarea}
value={text}
onChange={(e) => {
setText(e.target.value)
setPreviewText(e.target.value)
}}
onKeyDown={handleKeyDown}
placeholder="输入回复内容,Enter 发送,Shift+Enter 换行"
rows={3}
className="w-full resize-none rounded-md border border-input bg-background px-3 py-2 text-sm placeholder:text-muted-foreground outline-none focus:border-primary focus:ring-1 focus:ring-ring scrollbar-thin"
disabled={sending || disabled}
/>
<div className="mt-2 flex items-center justify-between">
<div className="flex items-center gap-3">
<label className="flex cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<Switch checked={confirmBeforeSend} onCheckedChange={setConfirmBeforeSend} />
发送前预览
</label>
{aiLoading && (
<Badge variant="primary" className="animate-pulse">
正在调用 AI…
</Badge>
)}
</div>
<Button onClick={send} size="sm" disabled={!text.trim() || sending || disabled} loading={sending}>
<Send className="h-4 w-4" />
发 送 (↵)
</Button>
</div>
</div>
</div>
)
}
+149
View File
@@ -0,0 +1,149 @@
import { Search, ShieldAlert } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { getTeam } from '@/services/api/org'
import type { ConversationListItem } from '@/services/api/conversations'
import { Avatar } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { ChannelIcon } from '@/components/common/ChannelIcon'
import { CountryFlag } from '@/components/common/CountryFlag'
import { SentimentBadge } from '@/components/common/StatusBadges'
import { Skeleton } from '@/components/ui/feedback'
import { formatRelative } from '@/utils/format'
import { intentLevelMeta, sentimentMeta } from '@/config/constants'
import { cn } from '@/utils/cn'
import { useI18n } from '@/i18n'
const FILTERS = [
{ value: 'all', label: '全部' },
{ value: 'unread', label: '未读' },
{ value: 'pending', label: '待回复' },
{ value: 'high-intent', label: '高意向' },
{ value: 'risk', label: '有风险' },
{ value: 'unassigned', label: '无负责人' },
{ value: 'mine', label: '我的客户' },
]
export function ConversationList({
items,
loading,
activeId,
onSelect,
filter,
onFilterChange,
keyword,
onKeywordChange,
}: {
items: ConversationListItem[]
loading: boolean
activeId: string | null
onSelect: (id: string) => void
filter: string
onFilterChange: (f: string) => void
keyword: string
onKeywordChange: (k: string) => void
}) {
const { t } = useI18n()
const { data: team } = useQuery({ queryKey: ['team'], queryFn: getTeam, staleTime: 300_000 })
const memberName = (id: string | null) => (id ? (team?.find((m) => m.id === id)?.name ?? '—') : null)
return (
<div className="flex h-full flex-col">
{/* 搜索 */}
<div className="border-b border-border p-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground rtl:left-auto rtl:right-2.5" />
<input
value={keyword}
onChange={(e) => onKeywordChange(e.target.value)}
placeholder="搜索客户/国家/产品/消息内容"
className="h-9 w-full rounded-md border border-input bg-muted/40 pl-8 pr-2 text-sm placeholder:text-muted-foreground focus:border-primary focus:bg-card focus:outline-none rtl:pl-2 rtl:pr-8"
/>
</div>
{/* 筛选 */}
<div className="mt-2.5 flex flex-wrap gap-1">
{FILTERS.map((f) => (
<button
key={f.value}
onClick={() => onFilterChange(f.value)}
className={cn(
'rounded-full px-2.5 py-1 text-xs transition-colors',
filter === f.value
? 'bg-primary font-medium text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-border',
)}
>
{f.label}
</button>
))}
</div>
</div>
{/* 列表 */}
<div className="flex-1 overflow-y-auto scrollbar-thin">
{loading &&
Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="flex gap-3 border-b border-border px-3 py-3">
<Skeleton className="h-10 w-10 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3.5 w-2/3" />
<Skeleton className="h-3 w-full" />
</div>
</div>
))}
{!loading && items.length === 0 && (
<p className="py-16 text-center text-sm text-muted-foreground">没有符合条件的会话</p>
)}
{!loading &&
items.map((item) => {
const c = item.customer
const active = item.id === activeId
const intent = intentLevelMeta(item.intentLevel)
return (
<button
key={item.id}
onClick={() => onSelect(item.id)}
className={cn(
'flex w-full gap-2.5 border-b border-border px-3 py-3 text-start transition-colors',
active ? 'bg-primary-50/70' : 'hover:bg-muted/50',
)}
>
<div className="relative shrink-0">
<Avatar name={c.name} size="md" />
<span className="absolute -bottom-1 -right-1 rounded-full border border-card bg-card">
<ChannelIcon channel={item.channel} size="xs" />
</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">{c.name}</span>
<CountryFlag code={c.country} withName={false} />
{item.hasRisk && <ShieldAlert className="h-3.5 w-3.5 shrink-0 text-red-500" />}
<span className="ms-auto shrink-0 text-[11px] text-muted-foreground">
{formatRelative(item.updatedAt)}
</span>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{item.lastMessage?.sender === 'agent' ? '我:' : ''}
{item.lastMessage?.translatedText ?? item.lastMessage?.originalText ?? item.summary}
</p>
<div className="mt-1.5 flex items-center gap-1.5">
<Badge className={cn('text-[10px] px-1 py-0', intent.color)}>{intent.label}</Badge>
<SentimentBadge sentiment={item.sentiment} emojiOnly />
{item.assigneeId ? (
<span className="text-[10px] text-muted-foreground">负责人:{memberName(item.assigneeId)}</span>
) : (
<span className="text-[10px] text-amber-600">未分配</span>
)}
{item.unreadCount > 0 && (
<span className="ms-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-medium text-white">
{item.unreadCount}
</span>
)}
</div>
</div>
</button>
)
})}
</div>
</div>
)
}
+212
View File
@@ -0,0 +1,212 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import {
Building2,
Clock,
ExternalLink,
Mail,
MapPin,
Phone,
Tags,
Target,
Zap,
AlertTriangle,
} from 'lucide-react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { updateCustomer } from '@/services/api/customers'
import { analyzeConversation } from '@/services/api/ai'
import type { Customer, Conversation } from '@/types'
import { Avatar } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Select } from '@/components/ui/select'
import { Card, CardHeader } from '@/components/ui/card'
import { Switch } from '@/components/ui/switch'
import { Spinner } from '@/components/ui/feedback'
import { ChannelIcon } from '@/components/common/ChannelIcon'
import { CountryFlag } from '@/components/common/CountryFlag'
import { IntentScoreBadge, CustomerStageBadge, SentimentBadge, RiskLevelBadge } from '@/components/common/StatusBadges'
import { CUSTOMER_STAGES, NEXT_ACTIONS, countryMeta, INTENT_LEVELS } from '@/config/constants'
import { toast, errorMessage } from '@/app/store/toast'
import { cn } from '@/utils/cn'
export function InsightPanel({
customer,
conversation,
}: {
customer: Customer
conversation: Conversation
}) {
const queryClient = useQueryClient()
const [analyzing, setAnalyzing] = useState(false)
const [analysis, setAnalysis] = useState(customer.aiProfile.summary)
const c = countryMeta(customer.country)
const localTime = new Date(
new Date().getTime() + new Date().getTimezoneOffset() * 60000 + c.tzOffset * 3600000,
).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
const updateMut = useMutation({
mutationFn: (data: Partial<Customer>) => updateCustomer(customer.id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customers'] })
queryClient.invalidateQueries({ queryKey: ['conversations'] })
toast.success('客户信息已更新')
},
onError: (e) => toast.error(errorMessage(e)),
})
const handleAnalyze = async () => {
setAnalyzing(true)
try {
const result = await analyzeConversation(conversation.id)
setAnalysis(result.summary)
toast.success('AI 分析完成', `意向评分 ${result.intentScore} · ${INTENT_LEVELS.find(l => l.value === result.intentLevel)?.label}`)
} catch {
toast.error('分析失败,请稍后重试')
} finally { setAnalyzing(false) }
}
return (
<div className="flex h-full flex-col overflow-y-auto scrollbar-thin">
{/* 客户基本信息 */}
<div className="border-b border-border p-4">
<div className="mb-3 flex items-center gap-3">
<Avatar name={customer.name} size="lg" />
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{customer.name}</p>
{customer.company && (
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<Building2 className="h-3 w-3" />
{customer.company}
</p>
)}
</div>
</div>
<div className="space-y-1.5 text-sm">
<div className="flex items-center gap-2">
<MapPin className="h-3.5 w-3.5 text-muted-foreground" />
<CountryFlag code={customer.country} />
</div>
<div className="flex items-center gap-2">
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">当地时间:{localTime}</span>
</div>
{customer.email && (
<div className="flex items-center gap-2">
<Mail className="h-3.5 w-3.5 text-muted-foreground" />
<span className="truncate text-xs">{customer.email}</span>
</div>
)}
{customer.phone && (
<div className="flex items-center gap-2">
<Phone className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs">{customer.phone}</span>
</div>
)}
<div className="flex items-center gap-2">
<ChannelIcon channel={customer.channel} size="xs" withBg={false} />
<span className="text-xs text-muted-foreground">
{customer.channel} · {customer.sourcePath ?? '—'}
</span>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
{customer.products?.map((p) => (
<Badge key={p} variant="primary">{p}</Badge>
))}
{customer.tags?.map((t) => (
<Badge key={t} variant="outline">{t}</Badge>
))}
</div>
<div className="mt-3 grid grid-cols-2 gap-y-1.5 text-xs">
<div className="flex items-center gap-1">
<Target className="h-3 w-3 text-muted-foreground" />
<span>意向评分</span>
</div>
<IntentScoreBadge score={customer.intentScore} withBar />
<div className="flex items-center gap-1">
<Zap className="h-3 w-3 text-muted-foreground" />
<span>成交阶段</span>
</div>
<Select
value={customer.stage}
onChange={(e) => updateMut.mutate({ stage: e.target.value as any })}
options={CUSTOMER_STAGES.map((s) => ({ value: s.value, label: s.label }))}
className="h-7 text-xs"
/>
<div className="flex items-center gap-1">😊 情绪</div>
<SentimentBadge sentiment={customer.sentiment} />
<div className="flex items-center gap-1">
<AlertTriangle className="h-3 w-3 text-muted-foreground" />
<span>风险</span>
</div>
<RiskLevelBadge level={customer.riskLevel} />
</div>
</div>
{/* AI 分析 */}
<div className="border-b border-border p-4">
<div className="flex items-center justify-between">
<h4 className="text-xs font-semibold text-muted-foreground">AI 分析摘要</h4>
<Button variant="ghost" size="sm" onClick={handleAnalyze} disabled={analyzing}>
{analyzing ? <Spinner className="h-3 w-3" /> : '重新分析'}
</Button>
</div>
{analyzing ? (
<div className="mt-3 flex items-center justify-center py-6">
<Spinner />
<span className="ml-2 text-sm text-muted-foreground">正在分析会话上下文…</span>
</div>
) : (
<p className="mt-2 text-xs leading-relaxed text-muted-foreground">{analysis}</p>
)}
{/* AI 提取关键信息 */}
{customer.aiProfile.inquiryQuantity && (
<div className="mt-3 space-y-1 border-t border-border pt-3">
{Object.entries({
询价数量: customer.aiProfile.inquiryQuantity,
目标价格: customer.aiProfile.targetPrice,
MOQ: customer.aiProfile.moqRequirement,
样品: customer.aiProfile.sampleNeed,
定制: customer.aiProfile.customRequirement,
交付: customer.aiProfile.deliveryTime,
物流: customer.aiProfile.logistics,
付款: customer.aiProfile.payment,
顾虑: customer.aiProfile.concerns?.join('、'),
}).filter(([, v]) => v).map(([k, v]) => (
<div key={k} className="flex gap-2 text-xs">
<span className="shrink-0 text-muted-foreground">{k}</span>
<span className="text-foreground">{v as string}</span>
</div>
))}
</div>
)}
</div>
{/* 推荐动作 */}
<div className="p-4">
<h4 className="text-xs font-semibold text-muted-foreground">AI 推荐下一步</h4>
<div className="mt-2 flex flex-wrap gap-1.5">
{customer.aiProfile.nextActions.map((a) => (
<button
key={a}
onClick={() => toast.info(`建议操作:${a}`)}
className="rounded-full border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs text-primary-700 hover:bg-primary-100"
>
{a}
</button>
))}
</div>
<Link
to={`/customers/${customer.id}`}
className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
查看完整客户档案 <ExternalLink className="h-3 w-3" />
</Link>
</div>
</div>
)
}
+166
View File
@@ -0,0 +1,166 @@
import { useState } from 'react'
import {
Check,
CheckCheck,
Clock,
AlertCircle,
Copy,
Reply,
Forward,
StickyNote,
Star,
ListTodo,
BookOpen,
MoreHorizontal,
} from 'lucide-react'
import type { Message } from '@/types'
import { cn } from '@/utils/cn'
import { toast } from '@/app/store/toast'
import { formatTime } from '@/utils/format'
import { languageName } from '@/config/languages'
import { Dropdown } from '@/components/ui/dropdown'
import { Badge } from '@/components/ui/badge'
export type ViewMode = 'original' | 'translated' | 'bilingual'
export function MessageBubble({
msg,
viewMode,
onQuote,
onMarkImportant,
onConvertToTask,
onAddToKnowledge,
onAddNote,
}: {
msg: Message
viewMode: ViewMode
onQuote: () => void
onMarkImportant: () => void
onConvertToTask: () => void
onAddToKnowledge: () => void
onAddNote: () => void
}) {
const [showActions, setShowActions] = useState(false)
const isMe = msg.sender === 'agent'
const isSystem = msg.sender === 'system'
const hasTranslation = msg.translatedText && msg.translatedText !== msg.originalText
if (isSystem) {
return (
<div className="flex justify-center py-2">
<span className="rounded-full bg-amber-50 px-3 py-1 text-xs text-amber-700">{msg.originalText}</span>
</div>
)
}
const renderContent = () => {
if (viewMode === 'bilingual' && hasTranslation) {
return (
<div className="space-y-2">
<div>
<span className="text-[10px] text-muted-foreground">{languageName(msg.originalLang)}</span>
<p className="mt-0.5 whitespace-pre-wrap break-words text-sm">{msg.originalText}</p>
</div>
<div className="border-t border-border/40 pt-2">
<span className="text-[10px] text-muted-foreground">中文释义</span>
<p className="mt-0.5 whitespace-pre-wrap break-words text-sm text-muted-foreground">{msg.translatedText}</p>
</div>
</div>
)
}
if (viewMode === 'translated' && msg.translatedText) {
return (
<div>
<span className="text-[10px] text-muted-foreground">{languageName(msg.originalLang)} → 中文</span>
<p className="mt-0.5 whitespace-pre-wrap break-words text-sm">{msg.translatedText}</p>
</div>
)
}
return (
<div>
<span className="text-[10px] text-muted-foreground">{languageName(msg.originalLang)}</span>
<p className="mt-0.5 whitespace-pre-wrap break-words text-sm">{msg.originalText}</p>
</div>
)
}
const statusIcon = () => {
switch (msg.status) {
case 'sending':
return <Clock className="h-3 w-3 text-muted-foreground animate-pulse" />
case 'sent':
return <Check className="h-3 w-3 text-muted-foreground" />
case 'delivered':
return <CheckCheck className="h-3 w-3 text-muted-foreground" />
case 'read':
return <CheckCheck className="h-3 w-3 text-primary" />
case 'failed':
return <AlertCircle className="h-3 w-3 text-red-500" />
default:
return null
}
}
const moreActions = [
{ key: 'copy', label: '复制', icon: Copy, onSelect: () => { navigator.clipboard.writeText(msg.originalText); toast.success('已复制') } },
{ key: 'reply', label: '引用回复', icon: Reply, onSelect: onQuote },
{ key: 'forward', label: '转发', icon: Forward, onSelect: () => toast.info('转发功能:选择目标会话(演示)') },
{ key: 'note', label: '添加内部备注', icon: StickyNote, onSelect: onAddNote },
{ key: 'important', label: msg.isImportant ? '取消重要' : '标记重要', icon: Star, onSelect: onMarkImportant },
{ key: 'task', label: '转为待办', icon: ListTodo, onSelect: onConvertToTask },
{ key: 'knowledge', label: '添加到知识库', icon: BookOpen, onSelect: onAddToKnowledge },
]
return (
<div
className={cn('group flex gap-2 px-4 py-2.5', isMe ? 'justify-end' : 'justify-start')}
onMouseEnter={() => setShowActions(true)}
onMouseLeave={() => setShowActions(false)}
>
<div className={cn('max-w-[70%] min-w-0', isMe ? 'order-2' : 'order-1')}>
{/* 消息气泡 */}
<div
className={cn(
'relative rounded-lg px-3 py-2.5',
isMe ? 'bg-primary-50' : 'bg-card border border-border',
msg.isImportant && 'ring-1 ring-amber-400',
)}
>
{renderContent()}
{msg.attachmentName && msg.type === 'image' && (
<div className="mt-2 flex h-24 items-center justify-center rounded bg-muted text-xs text-muted-foreground">
📷 {msg.attachmentName}
</div>
)}
{msg.attachmentName && msg.type === 'file' && (
<div className="mt-1.5 rounded bg-muted px-2 py-1 text-xs text-muted-foreground">
📎 {msg.attachmentName}
</div>
)}
</div>
{/* 底栏:时间 + 状态 */}
<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>
{isMe && statusIcon()}
{msg.isImportant && <Star className="h-2.5 w-2.5 fill-amber-400 text-amber-400" />}
{msg.internalNote && <StickyNote className="h-2.5 w-2.5 text-blue-500" />}
</div>
</div>
{/* 悬停操作按钮 */}
<div className={cn('flex items-center', isMe ? 'order-1' : 'order-2')}>
{showActions && (
<Dropdown
trigger={
<button className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-muted">
<MoreHorizontal className="h-4 w-4 text-muted-foreground" />
</button>
}
items={moreActions}
/>
)}
</div>
</div>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { Outlet } from 'react-router-dom'
import { Sidebar } from './Sidebar'
import { Topbar } from './Topbar'
import { Drawer } from '@/components/ui/drawer'
import { useUiStore } from '@/app/store/ui'
export function AppLayout() {
const { mobileNavOpen, setMobileNavOpen } = useUiStore()
return (
<div className="flex h-screen overflow-hidden bg-background">
{/* 桌面端固定侧边栏 */}
<div className="hidden shrink-0 lg:block">
<Sidebar />
</div>
{/* 移动端抽屉导航 */}
<Drawer open={mobileNavOpen} onOpenChange={setMobileNavOpen} side="left" width="w-64" title="灵犀跨境">
<div className="h-full">
<Sidebar />
</div>
</Drawer>
<div className="flex min-w-0 flex-1 flex-col">
<Topbar />
<main className="flex-1 overflow-y-auto scrollbar-thin" id="main-scroll">
<Outlet />
</main>
</div>
</div>
)
}
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Search, User, MessagesSquare, BookOpen, ListTodo } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { globalSearch } from '@/services/api/search'
import { useDebounce } from '@/hooks/useDebounce'
import { useI18n } from '@/i18n'
import { cn } from '@/utils/cn'
/** 顶栏全局搜索(⌘/Ctrl + K 唤起) */
export function GlobalSearch() {
const { t } = useI18n()
const navigate = useNavigate()
const [open, setOpen] = useState(false)
const [q, setQ] = useState('')
const debounced = useDebounce(q, 250)
const ref = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const { data } = useQuery({
queryKey: ['global-search', debounced],
queryFn: () => globalSearch(debounced),
enabled: debounced.trim().length > 0,
})
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
inputRef.current?.focus()
setOpen(true)
}
if (e.key === 'Escape') setOpen(false)
}
const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('keydown', onKey)
document.addEventListener('mousedown', onClick)
return () => {
document.removeEventListener('keydown', onKey)
document.removeEventListener('mousedown', onClick)
}
}, [])
const go = (path: string) => {
setOpen(false)
setQ('')
navigate(path)
}
const hasResult =
data && (data.customers.length + data.conversations.length + data.knowledge.length + data.tasks.length > 0)
const groups = [
{ key: 'customers', label: '客户', icon: User, items: data?.customers ?? [], to: (id: string) => `/customers/${id}` },
{ key: 'conversations', label: '会话', icon: MessagesSquare, items: data?.conversations ?? [], to: (id: string) => `/inbox?c=${id}` },
{ key: 'knowledge', label: '知识库', icon: BookOpen, items: data?.knowledge ?? [], to: () => `/knowledge` },
{ key: 'tasks', label: '任务', icon: ListTodo, items: data?.tasks ?? [], to: () => `/opportunities?tab=tasks` },
]
return (
<div ref={ref} className="relative w-full max-w-md">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground rtl:left-auto rtl:right-3" />
<input
ref={inputRef}
value={q}
onChange={(e) => {
setQ(e.target.value)
setOpen(true)
}}
onFocus={() => setOpen(true)}
placeholder={t('topbar.searchPlaceholder')}
className="h-9 w-full rounded-md border border-input bg-muted/50 pl-9 pr-12 text-sm placeholder:text-muted-foreground focus:border-primary focus:bg-card focus:outline-none focus:ring-2 focus-visible:ring-ring rtl:pl-12 rtl:pr-9"
/>
<kbd className="pointer-events-none absolute right-2.5 top-1/2 hidden -translate-y-1/2 rounded border border-border bg-card px-1.5 text-[10px] text-muted-foreground md:block rtl:right-auto rtl:left-2.5">
⌘K
</kbd>
{open && q.trim() && (
<div className="absolute z-50 mt-1.5 max-h-[420px] w-full overflow-y-auto rounded-lg border border-border bg-popover shadow-lg scrollbar-thin">
{!hasResult && (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
{debounced ? '未找到相关结果' : '输入关键词开始搜索'}
</p>
)}
{groups.map(
(g) =>
g.items.length > 0 && (
<div key={g.key} className="py-1.5">
<p className="flex items-center gap-1.5 px-3 py-1 text-[11px] font-medium text-muted-foreground">
<g.icon className="h-3.5 w-3.5" />
{g.label}
</p>
{g.items.map((item) => (
<button
key={item.id}
onClick={() => go(g.to(item.id))}
className={cn('flex w-full flex-col px-4 py-1.5 text-start hover:bg-muted')}
>
<span className="truncate text-sm font-medium">{item.title}</span>
<span className="truncate text-xs text-muted-foreground">{item.sub}</span>
</button>
))}
</div>
),
)}
</div>
)}
</div>
)
}
+121
View File
@@ -0,0 +1,121 @@
import { NavLink, useNavigate } from 'react-router-dom'
import { PanelLeftClose, PanelLeftOpen, Sparkles } from 'lucide-react'
import { NAV_ITEMS } from '@/config/navigation'
import { useUiStore } from '@/app/store/ui'
import { useAuthStore } from '@/app/store/auth'
import { usePermission } from '@/hooks/usePermission'
import { useI18n } from '@/i18n'
import { cn } from '@/utils/cn'
import { getPlan } from '@/config/pricingPlans'
import { Progress } from '@/components/ui/feedback'
import { Avatar } from '@/components/ui/avatar'
import { useQuery } from '@tanstack/react-query'
import { getBilling } from '@/services/api/org'
export function Sidebar() {
const { sidebarCollapsed, toggleSidebar, setMobileNavOpen } = useUiStore()
const { t } = useI18n()
const { can } = usePermission()
const tenant = useAuthStore((s) => s.tenant)
const user = useAuthStore((s) => s.user)
const navigate = useNavigate()
const { data: billing } = useQuery({ queryKey: ['billing'], queryFn: getBilling, staleTime: 300_000 })
const plan = getPlan(billing?.info.planKey ?? tenant?.plan ?? 'pro')
const msgUsage = billing?.info.usage.messages
const usagePct = msgUsage ? Math.round((msgUsage.used / msgUsage.total) * 100) : 0
const items = NAV_ITEMS.filter((item) => !item.permission || can(item.permission))
return (
<aside
className={cn(
'flex h-full flex-col border-e border-border bg-card transition-[width] duration-200',
sidebarCollapsed ? 'w-16' : 'w-60',
)}
>
{/* Logo */}
<div
className="flex h-14 cursor-pointer items-center gap-2.5 border-b border-border px-4"
onClick={() => navigate('/dashboard')}
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Sparkles className="h-4.5 w-4.5 h-[18px] w-[18px]" />
</span>
{!sidebarCollapsed && (
<div className="min-w-0">
<p className="truncate text-sm font-semibold leading-tight">{t('app.name')}</p>
<p className="truncate text-[11px] text-muted-foreground leading-tight">{t('app.slogan')}</p>
</div>
)}
</div>
{/* 导航 */}
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 py-3 scrollbar-thin">
{items.map((item) => (
<NavLink
key={item.key}
to={item.path}
onClick={() => setMobileNavOpen(false)}
title={sidebarCollapsed ? t(item.labelKey as never) : undefined}
className={({ isActive }) =>
cn(
'flex items-center gap-2.5 rounded-md px-2.5 py-2 text-sm transition-colors',
isActive
? 'bg-primary-50 font-medium text-primary-700'
: 'text-secondary-foreground hover:bg-muted',
sidebarCollapsed && 'justify-center px-0',
)
}
>
<item.icon className="h-[18px] w-[18px] shrink-0" />
{!sidebarCollapsed && <span className="truncate">{t(item.labelKey as never)}</span>}
</NavLink>
))}
</nav>
{/* 底部:套餐与企业信息 */}
{!sidebarCollapsed && (
<div className="border-t border-border p-3">
<div className="rounded-md bg-muted/60 p-3">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">{t('sidebar.currentPlan')}</span>
<span className="text-xs font-medium text-primary-700">{plan.name}</span>
</div>
<div className="mt-2">
<div className="mb-1 flex justify-between text-[11px] text-muted-foreground">
<span>{t('sidebar.usage')}(消息)</span>
<span>{usagePct}%</span>
</div>
<Progress value={usagePct} />
</div>
<button
onClick={() => navigate('/billing')}
className="mt-2 text-xs font-medium text-primary hover:underline"
>
升级套餐 →
</button>
</div>
<div className="mt-3 flex items-center gap-2 px-1">
<Avatar name={user?.name ?? '?'} size="sm" online />
<div className="min-w-0 flex-1">
<p className="truncate text-xs font-medium">{tenant?.name ?? '—'}</p>
<p className="truncate text-[11px] text-muted-foreground">
{user?.name} · {user?.title}
</p>
</div>
</div>
</div>
)}
{/* 收起按钮 */}
<button
onClick={toggleSidebar}
className="hidden h-10 items-center justify-center gap-2 border-t border-border text-xs text-muted-foreground hover:bg-muted hover:text-foreground lg:flex"
>
{sidebarCollapsed ? <PanelLeftOpen className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
{!sidebarCollapsed && <span>{t('sidebar.collapse')}</span>}
</button>
</aside>
)
}
+207
View File
@@ -0,0 +1,207 @@
import { useNavigate } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
Bell,
Building2,
CheckCheck,
ChevronDown,
CircleHelp,
Globe,
LogOut,
Menu,
UserRound,
UsersRound,
} from 'lucide-react'
import { getNotifications, readAllNotifications } from '@/services/api/org'
import { useAuthStore } from '@/app/store/auth'
import { useUiStore } from '@/app/store/ui'
import { useI18n } from '@/i18n'
import { UI_LOCALES, type UiLocale } from '@/config/languages'
import { ROLE_META, type RoleKey } from '@/config/roles'
import { toast } from '@/app/store/toast'
import { confirmDialog } from '@/app/store/confirm'
import { Avatar } from '@/components/ui/avatar'
import { Badge } from '@/components/ui/badge'
import { Dropdown } from '@/components/ui/dropdown'
import { GlobalSearch } from './GlobalSearch'
import { formatRelative } from '@/utils/format'
import { cn } from '@/utils/cn'
const NOTIF_TYPE_COLOR: Record<string, string> = {
inquiry: 'bg-emerald-100 text-emerald-700',
risk: 'bg-red-100 text-red-700',
task: 'bg-amber-100 text-amber-700',
channel: 'bg-blue-100 text-blue-700',
system: 'bg-gray-100 text-gray-600',
}
export function Topbar() {
const { t, locale, setLocale } = useI18n()
const navigate = useNavigate()
const queryClient = useQueryClient()
const { user, tenant, switchRole, logout } = useAuthStore()
const setMobileNavOpen = useUiStore((s) => s.setMobileNavOpen)
const { data: notifications } = useQuery({
queryKey: ['notifications'],
queryFn: getNotifications,
refetchInterval: 60_000,
})
const unread = notifications?.filter((n) => !n.read).length ?? 0
const handleLogout = () => {
confirmDialog({
title: '退出登录',
description: '确定要退出当前账号吗?',
onConfirm: () => {
logout()
navigate('/login')
},
})
}
return (
<header className="flex h-14 shrink-0 items-center gap-3 border-b border-border bg-card px-3 md:px-4">
{/* 移动端菜单 */}
<button
className="rounded-md p-2 text-muted-foreground hover:bg-muted lg:hidden"
onClick={() => setMobileNavOpen(true)}
aria-label="打开导航"
>
<Menu className="h-5 w-5" />
</button>
{/* 企业切换 */}
<Dropdown
trigger={
<button className="hidden items-center gap-1.5 rounded-md px-2 py-1.5 text-sm font-medium hover:bg-muted md:flex">
<Building2 className="h-4 w-4 text-muted-foreground" />
<span className="max-w-[180px] truncate">{tenant?.name ?? '—'}</span>
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
</button>
}
items={[
{ key: 'current', label: `${tenant?.name ?? ''}(当前企业)`, icon: Building2, onSelect: () => toast.info('当前演示环境仅一个企业') },
'separator',
{ key: 'switch', label: '创建/加入新企业', icon: UsersRound, onSelect: () => navigate('/register') },
]}
/>
{/* 全局搜索 */}
<div className="flex flex-1 justify-center px-1">
<GlobalSearch />
</div>
{/* 语言切换 */}
<Dropdown
trigger={
<button className="flex items-center gap-1 rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground" title={t('lang.name')}>
<Globe className="h-[18px] w-[18px]" />
</button>
}
items={UI_LOCALES.map((l) => ({
key: l.code,
label: (
<span className="flex items-center gap-2">
{l.nativeName}
{locale === l.code && <span className="text-primary">✓</span>}
{l.rtl && <Badge variant="outline">RTL</Badge>}
</span>
),
onSelect: () => {
setLocale(l.code as UiLocale)
toast.success(l.rtl ? '已切换为 RTL 布局(演示)' : `界面语言已切换:${l.nativeName}`)
},
}))}
/>
{/* 帮助 */}
<button
className="rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground"
title={t('topbar.help')}
onClick={() => navigate('/help')}
>
<CircleHelp className="h-[18px] w-[18px]" />
</button>
{/* 通知 */}
<Dropdown
trigger={
<button className="relative rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground" title={t('topbar.notifications')}>
<Bell className="h-[18px] w-[18px]" />
{unread > 0 && (
<span className="absolute right-1 top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium text-white">
{unread}
</span>
)}
</button>
}
className="[&>div]:w-80"
items={[
{
key: 'read-all',
label: (
<span className="flex items-center gap-2 text-primary">
<CheckCheck className="h-4 w-4" /> 全部标为已读
</span>
),
onSelect: () => {
readAllNotifications().then(() => queryClient.invalidateQueries({ queryKey: ['notifications'] }))
},
},
'separator',
...(notifications ?? []).slice(0, 6).map((n) => ({
key: n.id,
label: (
<span className="flex flex-col gap-0.5 py-0.5">
<span className="flex items-center gap-1.5">
<Badge className={NOTIF_TYPE_COLOR[n.type]}>{n.type === 'inquiry' ? '询盘' : n.type === 'risk' ? '风险' : n.type === 'task' ? '任务' : n.type === 'channel' ? '渠道' : '系统'}</Badge>
{!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>
<span className="text-sm font-medium leading-snug">{n.title}</span>
<span className="text-xs text-muted-foreground line-clamp-1">{n.description}</span>
</span>
),
onSelect: () => n.link && navigate(n.link),
})),
]}
/>
{/* 用户菜单 */}
<Dropdown
trigger={
<button className="flex items-center gap-2 rounded-md px-1.5 py-1 hover:bg-muted">
<Avatar name={user?.name ?? '?'} size="sm" online />
<span className="hidden text-start md:block">
<span className="block max-w-[90px] truncate text-sm font-medium leading-tight">{user?.name}</span>
<span className="block text-[11px] text-muted-foreground leading-tight">{ROLE_META[user?.role ?? 'viewer'].label}</span>
</span>
<ChevronDown className="hidden h-3.5 w-3.5 text-muted-foreground md:block" />
</button>
}
items={[
{ key: 'profile', label: t('topbar.profile'), icon: UserRound, onSelect: () => navigate('/settings') },
'separator',
...(Object.keys(ROLE_META) as RoleKey[]).map((r) => ({
key: `role-${r}`,
label: (
<span className={cn('flex items-center gap-2', user?.role === r && 'font-medium text-primary')}>
{ROLE_META[r].label}
{user?.role === r && '✓'}
</span>
),
icon: UsersRound,
onSelect: () => {
switchRole(r)
queryClient.clear()
toast.success(`已切换为「${ROLE_META[r].label}」(演示)`, '导航与按钮权限已同步更新')
},
})),
'separator',
{ key: 'logout', label: t('topbar.logout'), icon: LogOut, danger: true, onSelect: handleLogout },
]}
/>
</header>
)
}
+43
View File
@@ -0,0 +1,43 @@
import { cn } from '@/utils/cn'
import { colorFromString, initials } from '@/utils/misc'
export function Avatar({
name,
size = 'md',
className,
online,
}: {
name: string
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
className?: string
online?: boolean
}) {
const sizes = {
xs: 'h-6 w-6 text-[10px]',
sm: 'h-8 w-8 text-xs',
md: 'h-10 w-10 text-sm',
lg: 'h-12 w-12 text-base',
xl: 'h-16 w-16 text-lg',
}
return (
<span className={cn('relative inline-flex shrink-0', className)}>
<span
className={cn(
'flex items-center justify-center rounded-full font-medium',
sizes[size],
colorFromString(name || '?'),
)}
>
{initials(name)}
</span>
{online !== undefined && (
<span
className={cn(
'absolute bottom-0 right-0 h-2.5 w-2.5 rounded-full border-2 border-card',
online ? 'bg-emerald-500' : 'bg-gray-300',
)}
/>
)}
</span>
)
}
+35
View File
@@ -0,0 +1,35 @@
import type { HTMLAttributes, ReactNode } from 'react'
import { cn } from '@/utils/cn'
const variants: Record<string, string> = {
default: 'bg-secondary text-secondary-foreground',
primary: 'bg-primary-50 text-primary-700',
success: 'bg-emerald-100 text-emerald-700',
warning: 'bg-amber-100 text-amber-700',
danger: 'bg-red-100 text-red-700',
outline: 'border border-border text-muted-foreground',
blue: 'bg-blue-100 text-blue-700',
violet: 'bg-violet-100 text-violet-700',
}
export function Badge({
className,
variant = 'default',
dot,
children,
...props
}: HTMLAttributes<HTMLSpanElement> & { variant?: keyof typeof variants; dot?: boolean }) {
return (
<span
className={cn(
'inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-medium leading-4 whitespace-nowrap',
variants[variant],
className,
)}
{...props}
>
{dot && <span className="h-1.5 w-1.5 rounded-full bg-current" />}
{children as ReactNode}
</span>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { forwardRef, type ButtonHTMLAttributes } from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { Loader2 } from 'lucide-react'
import { cn } from '@/utils/cn'
const buttonVariants = cva(
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 select-none',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary-700 active:bg-primary-800',
secondary: 'bg-secondary text-secondary-foreground hover:bg-border',
outline: 'border border-input bg-card hover:bg-muted',
ghost: 'hover:bg-muted text-foreground',
destructive: 'bg-destructive text-destructive-foreground hover:bg-red-600',
dangerOutline: 'border border-red-200 bg-card text-red-600 hover:bg-red-50',
link: 'text-primary underline-offset-4 hover:underline h-auto px-0',
},
size: {
sm: 'h-8 px-3 text-xs',
default: 'h-9 px-4',
lg: 'h-10 px-5',
icon: 'h-9 w-9',
'icon-sm': 'h-7 w-7',
},
},
defaultVariants: { variant: 'default', size: 'default' },
},
)
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
loading?: boolean
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, loading, disabled, children, ...props }, ref) => (
<button
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
disabled={disabled || loading}
{...props}
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
{children}
</button>
),
)
Button.displayName = 'Button'
export { buttonVariants }
+39
View File
@@ -0,0 +1,39 @@
import type { HTMLAttributes, ReactNode } from 'react'
import { cn } from '@/utils/cn'
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('rounded-lg border border-border bg-card shadow-[0_1px_2px_rgba(16,24,40,0.04)]', className)}
{...props}
/>
)
}
export function CardHeader({
className,
title,
description,
action,
children,
...props
}: HTMLAttributes<HTMLDivElement> & { title?: ReactNode; description?: ReactNode; action?: ReactNode }) {
return (
<div className={cn('flex items-start justify-between gap-3 border-b border-border px-4 py-3', className)} {...props}>
<div className="min-w-0">
{title && <h3 className="text-sm font-semibold text-foreground">{title}</h3>}
{description && <p className="mt-0.5 text-xs text-muted-foreground">{description}</p>}
{children}
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
)
}
export function CardContent({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('p-4', className)} {...props} />
}
export function CardFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('border-t border-border px-4 py-3', className)} {...props} />
}
+40
View File
@@ -0,0 +1,40 @@
import { Check, Minus } from 'lucide-react'
import { cn } from '@/utils/cn'
export function Checkbox({
checked,
onCheckedChange,
disabled,
className,
indeterminate,
label,
}: {
checked: boolean
onCheckedChange?: (v: boolean) => void
disabled?: boolean
indeterminate?: boolean
className?: string
label?: string
}) {
return (
<label className={cn('inline-flex cursor-pointer items-center gap-2', disabled && 'cursor-not-allowed opacity-50', className)}>
<button
type="button"
role="checkbox"
aria-checked={indeterminate ? 'mixed' : checked}
disabled={disabled}
onClick={(e) => {
e.preventDefault()
onCheckedChange?.(!checked)
}}
className={cn(
'flex h-4 w-4 items-center justify-center rounded border transition-colors',
checked || indeterminate ? 'border-primary bg-primary text-white' : 'border-input bg-card hover:border-primary',
)}
>
{indeterminate ? <Minus className="h-3 w-3" /> : checked ? <Check className="h-3 w-3" /> : null}
</button>
{label && <span className="text-sm">{label}</span>}
</label>
)
}
+45
View File
@@ -0,0 +1,45 @@
import { useState } from 'react'
import { useConfirmStore } from '@/app/store/confirm'
import { Dialog } from './dialog'
import { Button } from './button'
/** 全局确认弹窗宿主(挂载于 Providers) */
export function Confirmer() {
const { current, close } = useConfirmStore()
const [busy, setBusy] = useState(false)
if (!current) return null
return (
<Dialog
open
onOpenChange={() => !busy && close()}
title={current.title}
width="max-w-sm"
footer={
<>
<Button variant="outline" onClick={close} disabled={busy}>
{current.cancelText ?? '取消'}
</Button>
<Button
variant={current.danger ? 'destructive' : 'default'}
loading={busy}
onClick={async () => {
try {
setBusy(true)
await current.onConfirm()
close()
} finally {
setBusy(false)
}
}}
>
{current.confirmText ?? '确认'}
</Button>
</>
}
>
{current.description && <p className="text-sm text-muted-foreground">{current.description}</p>}
</Dialog>
)
}
+78
View File
@@ -0,0 +1,78 @@
import { useEffect, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { cn } from '@/utils/cn'
export interface DialogProps {
open: boolean
onOpenChange: (open: boolean) => void
title?: ReactNode
description?: ReactNode
children: ReactNode
footer?: ReactNode
width?: string
/** 禁止点击遮罩关闭 */
persistent?: boolean
}
export function Dialog({
open,
onOpenChange,
title,
description,
children,
footer,
width = 'max-w-lg',
persistent,
}: DialogProps) {
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onOpenChange(false)
}
document.addEventListener('keydown', onKey)
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = ''
}
}, [open, onOpenChange])
if (!open) return null
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-black/40 animate-fade-in"
onClick={() => !persistent && onOpenChange(false)}
/>
<div
role="dialog"
aria-modal="true"
className={cn(
'relative flex max-h-[88vh] w-full flex-col rounded-lg bg-card shadow-xl animate-zoom-in',
width,
)}
>
{(title || description) && (
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
{title && <h2 className="text-base font-semibold">{title}</h2>}
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
</div>
<button
onClick={() => onOpenChange(false)}
className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="关闭"
>
<X className="h-4 w-4" />
</button>
</div>
)}
<div className="flex-1 overflow-y-auto px-5 py-4 scrollbar-thin">{children}</div>
{footer && <div className="flex items-center justify-end gap-2 border-t border-border px-5 py-3.5">{footer}</div>}
</div>
</div>,
document.body,
)
}
+62
View File
@@ -0,0 +1,62 @@
import { useEffect, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { cn } from '@/utils/cn'
export function Drawer({
open,
onOpenChange,
title,
children,
footer,
side = 'right',
width = 'w-[420px]',
}: {
open: boolean
onOpenChange: (open: boolean) => void
title?: ReactNode
children: ReactNode
footer?: ReactNode
side?: 'left' | 'right'
width?: string
}) {
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && onOpenChange(false)
document.addEventListener('keydown', onKey)
document.body.style.overflow = 'hidden'
return () => {
document.removeEventListener('keydown', onKey)
document.body.style.overflow = ''
}
}, [open, onOpenChange])
if (!open) return null
return createPortal(
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-black/40 animate-fade-in" onClick={() => onOpenChange(false)} />
<div
className={cn(
'absolute inset-y-0 flex max-w-full flex-col bg-card shadow-xl',
side === 'right' ? 'right-0 animate-slide-in-right' : 'left-0 animate-slide-in-left',
width,
)}
>
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">{title}</h2>
<button
onClick={() => onOpenChange(false)}
className="rounded-md p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="关闭"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto scrollbar-thin">{children}</div>
{footer && <div className="border-t border-border px-4 py-3">{footer}</div>}
</div>
</div>,
document.body,
)
}
+82
View File
@@ -0,0 +1,82 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import type { LucideIcon } from 'lucide-react'
import { cn } from '@/utils/cn'
export interface DropdownItem {
key: string
label: ReactNode
icon?: LucideIcon
danger?: boolean
disabled?: boolean
onSelect?: () => void
}
export function Dropdown({
trigger,
items,
align = 'end',
className,
}: {
trigger: ReactNode
items: (DropdownItem | 'separator')[]
align?: 'start' | 'end'
className?: string
}) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
const onKey = (e: KeyboardEvent) => e.key === 'Escape' && setOpen(false)
document.addEventListener('mousedown', onClick)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onClick)
document.removeEventListener('keydown', onKey)
}
}, [open])
return (
<div ref={ref} className={cn('relative inline-block', className)}>
<span onClick={() => setOpen((v) => !v)} className="inline-flex cursor-pointer">
{trigger}
</span>
{open && (
<div
className={cn(
'absolute z-40 mt-1 min-w-[160px] overflow-hidden rounded-md border border-border bg-popover py-1 shadow-lg animate-slide-in-top',
align === 'end' ? 'right-0 rtl:right-auto rtl:left-0' : 'left-0 rtl:left-auto rtl:right-0',
)}
>
{items.map((item, i) =>
item === 'separator' ? (
<div key={i} className="my-1 border-t border-border" />
) : (
<button
key={item.key}
disabled={item.disabled}
onClick={() => {
setOpen(false)
item.onSelect?.()
}}
className={cn(
'flex w-full items-center gap-2 px-3 py-1.5 text-sm transition-colors',
item.danger
? 'text-red-600 hover:bg-red-50'
: 'text-foreground hover:bg-muted',
item.disabled && 'cursor-not-allowed opacity-50',
)}
>
{item.icon && <item.icon className="h-4 w-4 shrink-0" />}
{item.label}
</button>
),
)}
</div>
)}
</div>
)
}
+95
View File
@@ -0,0 +1,95 @@
import type { ReactNode } from 'react'
import { Inbox, Loader2 } from 'lucide-react'
import { cn } from '@/utils/cn'
export function Spinner({ className, size = 'md' }: { className?: string; size?: 'sm' | 'md' | 'lg' }) {
const s = size === 'sm' ? 'h-4 w-4' : size === 'lg' ? 'h-8 w-8' : 'h-5 w-5'
return <Loader2 className={cn('animate-spin text-primary', s, className)} />
}
export function PageLoading({ text = '加载中…' }: { text?: string }) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-muted-foreground">
<Spinner size="lg" />
<p className="text-sm">{text}</p>
</div>
)
}
export function Skeleton({ className }: { className?: string }) {
return <div className={cn('animate-pulse rounded-md bg-muted', className)} />
}
export function EmptyState({
icon,
title,
description,
action,
className,
}: {
icon?: ReactNode
title: string
description?: string
action?: ReactNode
className?: string
}) {
return (
<div className={cn('flex flex-col items-center justify-center gap-2 py-12 text-center', className)}>
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
{icon ?? <Inbox className="h-6 w-6" />}
</div>
<p className="text-sm font-medium text-foreground">{title}</p>
{description && <p className="max-w-sm text-xs text-muted-foreground">{description}</p>}
{action && <div className="mt-2">{action}</div>}
</div>
)
}
export function ErrorState({
title = '加载失败',
description,
onRetry,
}: {
title?: string
description?: string
onRetry?: () => void
}) {
return (
<div className="flex flex-col items-center justify-center gap-2 py-12 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-red-50 text-red-500">
<span className="text-xl">!</span>
</div>
<p className="text-sm font-medium">{title}</p>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
{onRetry && (
<button onClick={onRetry} className="mt-2 text-sm font-medium text-primary hover:underline">
重试
</button>
)}
</div>
)
}
export function Progress({
value,
className,
barClassName,
}: {
value: number
className?: string
barClassName?: string
}) {
const v = Math.min(100, Math.max(0, value))
return (
<div className={cn('h-1.5 w-full overflow-hidden rounded-full bg-muted', className)}>
<div
className={cn('h-full rounded-full bg-primary transition-all', barClassName, v > 90 && 'bg-red-500', v > 75 && v <= 90 && 'bg-amber-500')}
style={{ width: `${v}%` }}
/>
</div>
)
}
export function Separator({ className, vertical }: { className?: string; vertical?: boolean }) {
return <div className={cn(vertical ? 'w-px self-stretch bg-border' : 'h-px w-full bg-border', className)} />
}
+65
View File
@@ -0,0 +1,65 @@
import { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes, type LabelHTMLAttributes, type ReactNode } from 'react'
import { cn } from '@/utils/cn'
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
({ className, ...props }, ref) => (
<input
ref={ref}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-card px-3 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-primary disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
),
)
Input.displayName = 'Input'
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement>>(
({ className, ...props }, ref) => (
<textarea
ref={ref}
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-card px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:border-primary disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
),
)
Textarea.displayName = 'Textarea'
export function Label({ className, ...props }: LabelHTMLAttributes<HTMLLabelElement>) {
return <label className={cn('text-sm font-medium text-foreground', className)} {...props} />
}
/** 表单字段容器:标签 + 控件 + 错误信息 */
export function Field({
label,
error,
required,
children,
className,
hint,
}: {
label?: ReactNode
error?: string
required?: boolean
hint?: string
children: ReactNode
className?: string
}) {
return (
<div className={cn('space-y-1.5', className)}>
{label && (
<Label>
{label}
{required && <span className="ml-0.5 text-red-500">*</span>}
</Label>
)}
{children}
{hint && !error && <p className="text-xs text-muted-foreground">{hint}</p>}
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
)
}
+44
View File
@@ -0,0 +1,44 @@
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { cn } from '@/utils/cn'
import { Button } from './button'
export function Pagination({
page,
total,
pageSize,
onChange,
className,
}: {
page: number
total: number
pageSize: number
onChange: (page: number) => void
className?: string
}) {
const pages = Math.max(1, Math.ceil(total / pageSize))
if (pages <= 1) return null
const nums: number[] = []
const start = Math.max(1, Math.min(page - 2, pages - 4))
for (let i = start; i <= Math.min(pages, start + 4); i++) nums.push(i)
return (
<div className={cn('flex items-center justify-between gap-3 px-1 py-2 text-sm text-muted-foreground', className)}>
<span>
共 {total} 条 · 第 {page}/{pages} 页
</span>
<div className="flex items-center gap-1">
<Button variant="outline" size="icon-sm" disabled={page <= 1} onClick={() => onChange(page - 1)}>
<ChevronLeft className="h-4 w-4 rtl-flip" />
</Button>
{nums.map((n) => (
<Button key={n} variant={n === page ? 'default' : 'outline'} size="icon-sm" onClick={() => onChange(n)}>
{n}
</Button>
))}
<Button variant="outline" size="icon-sm" disabled={page >= pages} onClick={() => onChange(page + 1)}>
<ChevronRight className="h-4 w-4 rtl-flip" />
</Button>
</div>
</div>
)
}
+35
View File
@@ -0,0 +1,35 @@
import { forwardRef, type SelectHTMLAttributes } from 'react'
import { ChevronDown } from 'lucide-react'
import { cn } from '@/utils/cn'
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
options?: { value: string; label: string }[]
placeholder?: string
}
/** 企业风格原生下拉(可控、可访问) */
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ className, options, placeholder, children, ...props }, ref) => (
<div className={cn('relative', className)}>
<select
ref={ref}
className={cn(
'h-9 w-full appearance-none rounded-md border border-input bg-card pl-3 pr-8 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50',
props.value === '' && 'text-muted-foreground',
)}
{...props}
>
{placeholder !== undefined && <option value="">{placeholder}</option>}
{options
? options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))
: children}
</select>
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground rtl:right-auto rtl:left-2.5" />
</div>
),
)
Select.displayName = 'Select'
+35
View File
@@ -0,0 +1,35 @@
import { cn } from '@/utils/cn'
export function Switch({
checked,
onCheckedChange,
disabled,
className,
}: {
checked: boolean
onCheckedChange?: (v: boolean) => void
disabled?: boolean
className?: string
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onCheckedChange?.(!checked)}
className={cn(
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
checked ? 'bg-primary' : 'bg-input',
className,
)}
>
<span
className={cn(
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
checked ? 'translate-x-[18px] rtl:-translate-x-[18px]' : 'translate-x-[2px] rtl:-translate-x-[2px]',
)}
/>
</button>
)
}
+35
View File
@@ -0,0 +1,35 @@
import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from 'react'
import { cn } from '@/utils/cn'
export function Table({ className, ...props }: HTMLAttributes<HTMLTableElement>) {
return (
<div className="w-full overflow-x-auto scrollbar-thin">
<table className={cn('w-full text-sm', className)} {...props} />
</div>
)
}
export function THead({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return <thead className={cn('border-b border-border bg-muted/50', className)} {...props} />
}
export function TBody({ className, ...props }: HTMLAttributes<HTMLTableSectionElement>) {
return <tbody className={cn('divide-y divide-border', className)} {...props} />
}
export function Tr({ className, ...props }: HTMLAttributes<HTMLTableRowElement>) {
return <tr className={cn('transition-colors hover:bg-muted/40', className)} {...props} />
}
export function Th({ className, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
return (
<th
className={cn('whitespace-nowrap px-3 py-2.5 text-start text-xs font-medium text-muted-foreground', className)}
{...props}
/>
)
}
export function Td({ className, colSpan, ...props }: TdHTMLAttributes<HTMLTableCellElement>) {
return <td className={cn('px-3 py-2.5 align-middle', className)} colSpan={colSpan} {...props} />
}
+84
View File
@@ -0,0 +1,84 @@
import type { ReactNode } from 'react'
import { cn } from '@/utils/cn'
export interface TabItem {
value: string
label: ReactNode
count?: number
}
export function Tabs({
value,
onChange,
items,
className,
}: {
value: string
onChange: (v: string) => void
items: TabItem[]
className?: string
}) {
return (
<div className={cn('flex items-center gap-1 border-b border-border overflow-x-auto scrollbar-thin', className)}>
{items.map((item) => {
const active = item.value === value
return (
<button
key={item.value}
onClick={() => onChange(item.value)}
className={cn(
'relative whitespace-nowrap px-3 py-2 text-sm transition-colors',
active ? 'font-medium text-primary' : 'text-muted-foreground hover:text-foreground',
)}
>
{item.label}
{item.count !== undefined && (
<span
className={cn(
'ml-1.5 rounded-full px-1.5 py-0.5 text-[11px] leading-none',
active ? 'bg-primary-50 text-primary-700' : 'bg-muted text-muted-foreground',
)}
>
{item.count}
</span>
)}
{active && <span className="absolute inset-x-2 -bottom-px h-0.5 rounded-full bg-primary" />}
</button>
)
})}
</div>
)
}
/** 分段选择器(原文/译文/双语) */
export function Segmented<T extends string>({
value,
onChange,
options,
size = 'default',
className,
}: {
value: T
onChange: (v: T) => void
options: { value: T; label: ReactNode }[]
size?: 'sm' | 'default'
className?: string
}) {
return (
<div className={cn('inline-flex items-center rounded-md bg-muted p-0.5', className)}>
{options.map((o) => (
<button
key={o.value}
onClick={() => onChange(o.value)}
className={cn(
'rounded px-2.5 text-xs font-medium transition-colors',
size === 'sm' ? 'h-6' : 'h-7',
o.value === value ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
)}
>
{o.label}
</button>
))}
</div>
)
}
+44
View File
@@ -0,0 +1,44 @@
import { CheckCircle2, AlertCircle, Info, AlertTriangle, X } from 'lucide-react'
import { useToastStore, type ToastType } from '@/app/store/toast'
import { cn } from '@/utils/cn'
const ICONS: Record<ToastType, typeof CheckCircle2> = {
success: CheckCircle2,
error: AlertCircle,
info: Info,
warning: AlertTriangle,
}
const COLORS: Record<ToastType, string> = {
success: 'text-emerald-600',
error: 'text-red-600',
info: 'text-blue-600',
warning: 'text-amber-600',
}
/** 全局 Toast 渲染器(挂载于 Providers) */
export function Toaster() {
const { toasts, dismiss } = useToastStore()
return (
<div className="pointer-events-none fixed right-4 top-4 z-[100] flex w-[340px] max-w-[calc(100vw-2rem)] flex-col gap-2 rtl:right-auto rtl:left-4">
{toasts.map((t) => {
const Icon = ICONS[t.type]
return (
<div
key={t.id}
className="pointer-events-auto flex items-start gap-2.5 rounded-lg border border-border bg-card px-3.5 py-3 shadow-lg animate-slide-in-top"
>
<Icon className={cn('mt-0.5 h-4.5 w-4.5 h-[18px] w-[18px] shrink-0', COLORS[t.type])} />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">{t.title}</p>
{t.description && <p className="mt-0.5 text-xs text-muted-foreground">{t.description}</p>}
</div>
<button onClick={() => dismiss(t.id)} className="rounded p-0.5 text-muted-foreground hover:text-foreground">
<X className="h-3.5 w-3.5" />
</button>
</div>
)
})}
</div>
)
}
+280
View File
@@ -0,0 +1,280 @@
import type { ChannelType } from '@/types/messaging'
import type { CustomerStage, OpportunityStage, RiskType, TaskPriority, TaskType } from '@/types/crm'
import type { KnowledgeCategory } from '@/types/knowledge'
import type { IntentLevel, Sentiment, ValueLevel } from '@/types/common'
/** 国家/地区 */
export interface CountryMeta {
code: string
zh: string
en: string
flag: string
tzOffset: number // 相对 UTC 小时
lang: string
}
export const COUNTRIES: CountryMeta[] = [
{ code: 'PK', zh: '巴基斯坦', en: 'Pakistan', flag: '🇵🇰', tzOffset: 5, lang: 'ur' },
{ code: 'BD', zh: '孟加拉国', en: 'Bangladesh', flag: '🇧🇩', tzOffset: 6, lang: 'bn' },
{ code: 'AE', zh: '阿联酋', en: 'UAE', flag: '🇦🇪', tzOffset: 4, lang: 'ar' },
{ code: 'SA', zh: '沙特阿拉伯', en: 'Saudi Arabia', flag: '🇸🇦', tzOffset: 3, lang: 'ar' },
{ code: 'US', zh: '美国', en: 'United States', flag: '🇺🇸', tzOffset: -5, lang: 'en' },
{ code: 'GB', zh: '英国', en: 'United Kingdom', flag: '🇬🇧', tzOffset: 0, lang: 'en' },
{ code: 'DE', zh: '德国', en: 'Germany', flag: '🇩🇪', tzOffset: 1, lang: 'de' },
{ code: 'ES', zh: '西班牙', en: 'Spain', flag: '🇪🇸', tzOffset: 1, lang: 'es' },
{ code: 'CN', zh: '中国', en: 'China', flag: '🇨🇳', tzOffset: 8, lang: 'zh' },
]
export function countryMeta(code: string): CountryMeta {
return COUNTRIES.find((c) => c.code === code) ?? { code, zh: code, en: code, flag: '🏳️', tzOffset: 0, lang: 'en' }
}
/** 行业 */
export const INDUSTRIES = ['服装纺织', '玻璃制品', '3C电子', '家居用品', '通用外贸']
/** 客户阶段 */
export const CUSTOMER_STAGES: { value: CustomerStage; label: string; color: string }[] = [
{ value: 'initial', label: '初次咨询', color: 'bg-sky-100 text-sky-700' },
{ value: 'comparing', label: '价格比较', color: 'bg-amber-100 text-amber-700' },
{ value: 'sample', label: '样品确认', color: 'bg-violet-100 text-violet-700' },
{ value: 'custom', label: '定制沟通', color: 'bg-cyan-100 text-cyan-700' },
{ value: 'closing', label: '成交推进', color: 'bg-emerald-100 text-emerald-700' },
{ value: 'fulfillment', label: '履约跟踪', color: 'bg-teal-100 text-teal-700' },
{ value: 'aftersales', label: '售后处理', color: 'bg-orange-100 text-orange-700' },
{ value: 'repurchase', label: '复购培育', color: 'bg-rose-100 text-rose-700' },
]
export function customerStageMeta(stage: CustomerStage) {
return CUSTOMER_STAGES.find((s) => s.value === stage) ?? CUSTOMER_STAGES[0]
}
/** 商机阶段 */
export const OPPORTUNITY_STAGES: { value: OpportunityStage; label: string; color: string }[] = [
{ value: 'new', label: '新询盘', color: 'bg-sky-100 text-sky-700' },
{ value: 'requirement', label: '需求确认', color: 'bg-blue-100 text-blue-700' },
{ value: 'quoting', label: '报价中', color: 'bg-amber-100 text-amber-700' },
{ value: 'sample', label: '样品沟通', color: 'bg-violet-100 text-violet-700' },
{ value: 'custom', label: '定制确认', color: 'bg-cyan-100 text-cyan-700' },
{ value: 'negotiation', label: '商务谈判', color: 'bg-orange-100 text-orange-700' },
{ value: 'pending-deal', label: '待成交', color: 'bg-emerald-100 text-emerald-700' },
{ value: 'won', label: '已成交', color: 'bg-green-100 text-green-700' },
{ value: 'fulfillment', label: '履约中', color: 'bg-teal-100 text-teal-700' },
{ value: 'aftersales', label: '售后中', color: 'bg-yellow-100 text-yellow-700' },
{ value: 'repurchase', label: '复购培育', color: 'bg-rose-100 text-rose-700' },
{ value: 'lost', label: '已流失', color: 'bg-gray-200 text-gray-500' },
]
export function opportunityStageMeta(stage: OpportunityStage) {
return OPPORTUNITY_STAGES.find((s) => s.value === stage) ?? OPPORTUNITY_STAGES[0]
}
/** 意向等级 */
export const INTENT_LEVELS: { value: IntentLevel; label: string; color: string; range: string }[] = [
{ value: 'high', label: '高价值', color: 'bg-emerald-100 text-emerald-700', range: '80—100' },
{ value: 'medium-high', label: '中高价值', color: 'bg-teal-100 text-teal-700', range: '60—79' },
{ value: 'medium', label: '中等价值', color: 'bg-amber-100 text-amber-700', range: '40—59' },
{ value: 'low', label: '低价值', color: 'bg-gray-100 text-gray-500', range: '0—39' },
]
export function intentLevelOf(score: number): IntentLevel {
if (score >= 80) return 'high'
if (score >= 60) return 'medium-high'
if (score >= 40) return 'medium'
return 'low'
}
export function intentLevelMeta(level: IntentLevel) {
return INTENT_LEVELS.find((l) => l.value === level) ?? INTENT_LEVELS[3]
}
export const VALUE_LEVELS: { value: ValueLevel; label: string; color: string }[] = [
{ value: 'A', label: 'A级(重点)', color: 'bg-emerald-100 text-emerald-700' },
{ value: 'B', label: 'B级(优质)', color: 'bg-teal-100 text-teal-700' },
{ value: 'C', label: 'C级(普通)', color: 'bg-amber-100 text-amber-700' },
{ value: 'D', label: 'D级(观察)', color: 'bg-gray-100 text-gray-500' },
]
/** 情绪状态 */
export const SENTIMENTS: { value: Sentiment; label: string; color: string; emoji: string }[] = [
{ value: 'positive', label: '积极', color: 'text-emerald-600', emoji: '😊' },
{ value: 'neutral-positive', label: '中性偏积极', color: 'text-teal-600', emoji: '🙂' },
{ value: 'neutral', label: '中性', color: 'text-gray-500', emoji: '😐' },
{ value: 'negative', label: '消极', color: 'text-amber-600', emoji: '😟' },
{ value: 'angry', label: '不满', color: 'text-red-600', emoji: '😠' },
]
export function sentimentMeta(s: Sentiment) {
return SENTIMENTS.find((x) => x.value === s) ?? SENTIMENTS[2]
}
/** 风险等级 */
export const RISK_LEVELS = [
{ value: 'low', label: '低', color: 'bg-gray-100 text-gray-600', dot: 'bg-gray-400' },
{ value: 'medium', label: '中', color: 'bg-amber-100 text-amber-700', dot: 'bg-amber-500' },
{ value: 'high', label: '高', color: 'bg-orange-100 text-orange-700', dot: 'bg-orange-500' },
{ value: 'urgent', label: '紧急', color: 'bg-red-100 text-red-700', dot: 'bg-red-500' },
] as const
export function riskLevelMeta(level: string) {
return RISK_LEVELS.find((l) => l.value === level) ?? RISK_LEVELS[0]
}
/** 风险类型 */
export const RISK_TYPES: { value: RiskType; label: string }[] = [
{ value: 'negative-sentiment', label: '客户负面情绪' },
{ value: 'dispute', label: '潜在交易纠纷' },
{ value: 'price-objection', label: '价格异议' },
{ value: 'delivery', label: '交期风险' },
{ value: 'logistics', label: '物流风险' },
{ value: 'complaint', label: '售后投诉' },
{ value: 'culture', label: '文化敏感表达' },
{ value: 'religion', label: '宗教敏感表达' },
{ value: 'follow-up-delay', label: '高价值客户久未跟进' },
{ value: 'churn', label: '客户可能流失' },
{ value: 'task-overdue', label: '跟进任务逾期' },
{ value: 'account-abnormal', label: '异常账号登录' },
]
export function riskTypeLabel(type: RiskType): string {
return RISK_TYPES.find((t) => t.value === type)?.label ?? type
}
/** 跟进任务类型 */
export const TASK_TYPES: { value: TaskType; label: string }[] = [
{ value: 'quote', label: '发送报价' },
{ value: 'material', label: '发送资料' },
{ value: 'sample', label: '样品确认' },
{ value: 'custom', label: '定制沟通' },
{ value: 'revisit', label: '回访客户' },
{ value: 'urge', label: '催单跟进' },
{ value: 'logistics', label: '物流跟进' },
{ value: 'aftersales', label: '售后处理' },
{ value: 'repurchase', label: '复购提醒' },
{ value: 'custom-task', label: '自定义任务' },
]
export function taskTypeLabel(type: TaskType): string {
return TASK_TYPES.find((t) => t.value === type)?.label ?? type
}
export const TASK_PRIORITIES: { value: TaskPriority; label: string; color: string }[] = [
{ value: 'high', label: '高', color: 'bg-red-100 text-red-700' },
{ value: 'medium', label: '中', color: 'bg-amber-100 text-amber-700' },
{ value: 'low', label: '低', color: 'bg-gray-100 text-gray-600' },
]
export function taskPriorityMeta(p: TaskPriority) {
return TASK_PRIORITIES.find((x) => x.value === p) ?? TASK_PRIORITIES[1]
}
/** 渠道元信息 */
export const CHANNEL_META: Record<ChannelType, { name: string; color: string; bg: string; desc: string }> = {
whatsapp: { name: 'WhatsApp', color: 'text-emerald-600', bg: 'bg-emerald-50', desc: '海外即时沟通主渠道' },
telegram: { name: 'Telegram', color: 'text-sky-600', bg: 'bg-sky-50', desc: '中东/中亚客户常用' },
messenger: { name: 'Messenger', color: 'text-blue-600', bg: 'bg-blue-50', desc: 'Facebook 私信咨询' },
email: { name: '企业邮箱', color: 'text-gray-600', bg: 'bg-gray-100', desc: '正式商务邮件往来' },
gmail: { name: 'Gmail', color: 'text-red-500', bg: 'bg-red-50', desc: 'Google 邮箱账号' },
outlook: { name: 'Outlook', color: 'text-blue-500', bg: 'bg-blue-50', desc: '微软邮箱账号' },
webform: { name: '独立站表单', color: 'text-violet-600', bg: 'bg-violet-50', desc: '独立站询盘表单' },
website: { name: '官网咨询按钮', color: 'text-teal-600', bg: 'bg-teal-50', desc: '官网在线客服入口' },
landing: { name: '广告落地页', color: 'text-amber-600', bg: 'bg-amber-50', desc: '广告投放线索页面' },
social: { name: '社交媒体主页', color: 'text-pink-600', bg: 'bg-pink-50', desc: '社媒主页私信与留言' },
exhibition: { name: '展会二维码', color: 'text-cyan-600', bg: 'bg-cyan-50', desc: '展会扫码留资' },
api: { name: 'API 接口', color: 'text-slate-600', bg: 'bg-slate-100', desc: '开放 API 对接自有系统' },
webhook: { name: 'Webhook', color: 'text-indigo-600', bg: 'bg-indigo-50', desc: '事件推送到自有服务' },
}
/** 回复语气 */
export const REPLY_TONES = [
{ value: 'professional', label: '专业正式' },
{ value: 'friendly', label: '友好自然' },
{ value: 'concise', label: '简洁直接' },
{ value: 'negotiation', label: '商务谈判' },
{ value: 'comfort', label: '售后安抚' },
{ value: 'urge', label: '催单跟进' },
]
/** 翻译场景 */
export const TRANSLATE_SCENARIOS = [
{ value: 'chat', label: '即时聊天' },
{ value: 'email', label: '商务邮件' },
{ value: 'product', label: '产品介绍' },
{ value: 'quote', label: '报价沟通' },
{ value: 'sample', label: '样品沟通' },
{ value: 'custom', label: '定制要求' },
{ value: 'logistics', label: '物流说明' },
{ value: 'contract', label: '合同沟通' },
{ value: 'aftersales', label: '售后处理' },
]
/** 知识库分类 */
export const KNOWLEDGE_CATEGORIES: { value: KnowledgeCategory; label: string }[] = [
{ value: 'product', label: '产品资料' },
{ value: 'pricing', label: '价格规则' },
{ value: 'moq', label: 'MOQ规则' },
{ value: 'customization', label: '定制说明' },
{ value: 'logistics', label: '物流政策' },
{ value: 'payment', label: '付款方式' },
{ value: 'aftersales', label: '售后政策' },
{ value: 'faq', label: 'FAQ' },
{ value: 'company', label: '企业介绍' },
{ value: 'certification', label: '产品认证' },
{ value: 'success-reply', label: '历史成功回复' },
{ value: 'terminology', label: '行业术语' },
{ value: 'culture', label: '文化注意事项' },
]
export function knowledgeCategoryLabel(c: KnowledgeCategory): string {
return KNOWLEDGE_CATEGORIES.find((x) => x.value === c)?.label ?? c
}
/** 客户常用标签 */
export const CUSTOMER_TAGS = [
'重点客户',
'大客户潜力',
'价格敏感',
'交期敏感',
'需要样品',
'定制需求',
'复购客户',
'展会客户',
'老客户',
'高风险',
'斋月注意',
'决策人直联',
]
/** AI 推荐动作 */
export const NEXT_ACTIONS = [
'发送报价',
'补充资料',
'发送认证文件',
'确认样品',
'询问采购时间',
'回访客户',
'提醒重点跟进',
'售后安抚',
'复购提醒',
]
/** 向导用常用语言清单(中文名) */
export const LANGUAGES_LIST = [
'中文', '英语', '阿拉伯语', '乌尔都语', '孟加拉语', '西班牙语', '葡萄牙语',
'俄语', '法语', '德语', '土耳其语', '越南语', '印尼语', '日语', '韩语',
]
/** 产品库(Mock 企业主营产品) */
export const PRODUCTS = [
'双层玻璃杯 350ml',
'高硼硅玻璃杯 500ml',
'定制Logo玻璃水杯',
'玻璃储物罐套装',
'男士纯棉T恤',
'女士针织开衫',
'运动速干POLO衫',
'TWS蓝牙耳机',
'65W氮化镓充电器',
'智能手表',
'北欧棉麻四件套',
'ins风陶瓷餐具套装',
]
+7
View File
@@ -0,0 +1,7 @@
/** 环境配置:Mock 与真实后端通过环境变量切换 */
export const env = {
/** true = 使用内置 Mock 服务;false = 请求真实后端 */
useMock: (import.meta.env.VITE_USE_MOCK ?? 'true') !== 'false',
/** 真实后端地址 */
apiBaseUrl: import.meta.env.VITE_API_BASE_URL ?? '/api',
}
+66
View File
@@ -0,0 +1,66 @@
/**
* 语言配置
* supportedLanguageCount 为平台支持的语言数量,此处统一配置,禁止在组件中写死。
* 界面文案优先使用「支持全球主流贸易国家和地区语言」。
*/
export const supportedLanguageCount = 96
export interface LanguageOption {
code: string
name: string
nativeName: string
rtl?: boolean
}
/** 界面支持的语言(UI 语言) */
export const UI_LOCALES = [
{ code: 'zh', name: '简体中文', nativeName: '简体中文', rtl: false },
{ code: 'en', name: 'English', nativeName: 'English', rtl: false },
{ code: 'ar', name: '阿拉伯语', nativeName: 'العربية', rtl: true },
] as const
export type UiLocale = (typeof UI_LOCALES)[number]['code']
/** 翻译/沟通常用语言 */
export const LANGUAGES: LanguageOption[] = [
{ code: 'auto', name: '自动识别', nativeName: 'Auto' },
{ code: 'zh', name: '中文', nativeName: '中文' },
{ code: 'en', name: '英语', nativeName: 'English' },
{ code: 'ar', name: '阿拉伯语', nativeName: 'العربية', rtl: true },
{ code: 'ur', name: '乌尔都语', nativeName: 'اردو', rtl: true },
{ code: 'bn', name: '孟加拉语', nativeName: 'বাংলা' },
{ code: 'es', name: '西班牙语', nativeName: 'Español' },
{ code: 'pt', name: '葡萄牙语', nativeName: 'Português' },
{ code: 'ru', name: '俄语', nativeName: 'Русский' },
{ code: 'fr', name: '法语', nativeName: 'Français' },
{ code: 'de', name: '德语', nativeName: 'Deutsch' },
{ code: 'it', name: '意大利语', nativeName: 'Italiano' },
{ code: 'tr', name: '土耳其语', nativeName: 'Türkçe' },
{ code: 'vi', name: '越南语', nativeName: 'Tiếng Việt' },
{ code: 'th', name: '泰语', nativeName: 'ไทย' },
{ code: 'id', name: '印尼语', nativeName: 'Bahasa Indonesia' },
{ code: 'ms', name: '马来语', nativeName: 'Bahasa Melayu' },
{ code: 'hi', name: '印地语', nativeName: 'हिन्दी' },
{ code: 'fa', name: '波斯语', nativeName: 'فارسی', rtl: true },
{ code: 'ja', name: '日语', nativeName: '日本語' },
{ code: 'ko', name: '韩语', nativeName: '한국어' },
{ code: 'pl', name: '波兰语', nativeName: 'Polski' },
{ code: 'nl', name: '荷兰语', nativeName: 'Nederlands' },
{ code: 'sv', name: '瑞典语', nativeName: 'Svenska' },
{ code: 'uk', name: '乌克兰语', nativeName: 'Українська' },
{ code: 'he', name: '希伯来语', nativeName: 'עברית', rtl: true },
{ code: 'sw', name: '斯瓦希里语', nativeName: 'Kiswahili' },
{ code: 'ro', name: '罗马尼亚语', nativeName: 'Română' },
{ code: 'cs', name: '捷克语', nativeName: 'Čeština' },
{ code: 'el', name: '希腊语', nativeName: 'Ελληνικά' },
]
export const RTL_LANG_CODES = LANGUAGES.filter((l) => l.rtl).map((l) => l.code)
export function isRtlLang(code: string): boolean {
return RTL_LANG_CODES.includes(code)
}
export function languageName(code: string): string {
return LANGUAGES.find((l) => l.code === code)?.name ?? code
}
+44
View File
@@ -0,0 +1,44 @@
import {
LayoutDashboard,
MessagesSquare,
Users,
KanbanSquare,
Languages,
AudioLines,
ShieldAlert,
BookOpen,
BarChart3,
Cable,
UsersRound,
CreditCard,
Settings,
GraduationCap,
type LucideIcon,
} from 'lucide-react'
import type { Permission } from './roles'
export interface NavItem {
key: string
path: string
/** i18n key */
labelKey: string
icon: LucideIcon
permission?: Permission
}
export const NAV_ITEMS: NavItem[] = [
{ key: 'dashboard', path: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard },
{ key: 'inbox', path: '/inbox', labelKey: 'nav.inbox', icon: MessagesSquare },
{ key: 'customers', path: '/customers', labelKey: 'nav.customers', icon: Users, permission: 'customer:view' },
{ key: 'opportunities', path: '/opportunities', labelKey: 'nav.opportunities', icon: KanbanSquare },
{ key: 'translate', path: '/translate', labelKey: 'nav.translate', icon: Languages },
{ key: 'interpret', path: '/interpret', labelKey: 'nav.interpret', icon: AudioLines },
{ key: 'risks', path: '/risks', labelKey: 'nav.risks', icon: ShieldAlert },
{ key: 'knowledge', path: '/knowledge', labelKey: 'nav.knowledge', icon: BookOpen },
{ key: 'analytics', path: '/analytics', labelKey: 'nav.analytics', icon: BarChart3, permission: 'analytics:view' },
{ key: 'channels', path: '/channels', labelKey: 'nav.channels', icon: Cable },
{ key: 'team', path: '/team', labelKey: 'nav.team', icon: UsersRound },
{ key: 'billing', path: '/billing', labelKey: 'nav.billing', icon: CreditCard },
{ key: 'settings', path: '/settings', labelKey: 'nav.settings', icon: Settings },
{ key: 'help', path: '/help', labelKey: 'nav.help', icon: GraduationCap },
]
+98
View File
@@ -0,0 +1,98 @@
/**
* 套餐与价格配置(唯一价格来源,修改此处即可全局生效)
* 注意:不同材料中的套餐价格存在差异,统一在此文件维护。
*/
export type PlanKey = 'lite' | 'standard' | 'pro' | 'growth'
export interface PlanLimits {
members: number // 成员席位
messagesPerMonth: number // 每月消息量
translationsPerMonth: number // 每月翻译量
knowledgeStorageMb: number // 知识库存储
}
export interface PricingPlan {
key: PlanKey
name: string
tagline: string
priceNote: string // 展示价格文案
monthlyPrice: number | null // 月付价(元),null 表示定制
yearlyPrice: number | null // 年付价(元)
target: string
highlighted?: boolean
features: string[]
limits: PlanLimits
}
export const PRICING_PLANS: PricingPlan[] = [
{
key: 'lite',
name: '轻助手版',
tagline: '个人卖家与小团队起步',
priceNote: '¥99—299/月',
monthlyPrice: 99,
yearlyPrice: 999,
target: '适合个人卖家和微型团队',
features: ['AI 翻译(基础额度)', '常用话术辅助', '一键复制辅助', '单渠道接入', '基础客户卡片'],
limits: { members: 2, messagesPerMonth: 2000, translationsPerMonth: 5000, knowledgeStorageMb: 200 },
},
{
key: 'standard',
name: '标准工作台版',
tagline: '3—5 人客服团队的标准配置',
priceNote: '¥19,800/年',
monthlyPrice: null,
yearlyPrice: 19800,
target: '适合 3—5 人客服团队',
features: [
'多渠道统一接入(WhatsApp/Telegram/邮件等)',
'消息实时翻译与双语对照',
'客户卡片与自动建档',
'跟进任务与提醒',
'团队协作与分配规则',
],
limits: { members: 5, messagesPerMonth: 20000, translationsPerMonth: 50000, knowledgeStorageMb: 1024 },
},
{
key: 'pro',
name: '专业版',
tagline: '5—20 人团队的 AI 经营中枢',
priceNote: '¥49,800/年',
monthlyPrice: null,
yearlyPrice: 49800,
target: '适合 5—20 人外贸团队',
highlighted: true,
features: [
'包含标准版全部能力',
'客户情感分析与意图预测',
'AI 智能回复建议(多策略)',
'风险预警与文化敏感检测',
'老板经营看板',
'同声传译',
],
limits: { members: 20, messagesPerMonth: 100000, translationsPerMonth: 200000, knowledgeStorageMb: 5120 },
},
{
key: 'growth',
name: '增长版 / 定制版',
tagline: '规模化团队的定制化方案',
priceNote: '¥98,000/年起',
monthlyPrice: null,
yearlyPrice: 98000,
target: '适合规模化企业与产业带客户',
features: [
'包含专业版全部能力',
'企业知识库与行业知识包',
'API / Webhook 系统对接',
'私有化部署选项',
'专属客户成功经理',
'定制开发与培训',
],
limits: { members: 100, messagesPerMonth: 1000000, translationsPerMonth: 1000000, knowledgeStorageMb: 51200 },
},
]
export function getPlan(key: string): PricingPlan {
return PRICING_PLANS.find((p) => p.key === key) ?? PRICING_PLANS[1]
}
+114
View File
@@ -0,0 +1,114 @@
/** 角色与权限配置(前端 Mock 权限控制) */
export type RoleKey = 'admin' | 'manager' | 'sales' | 'service' | 'viewer'
export type Permission =
| 'customer:view'
| 'customer:edit'
| 'customer:delete'
| 'customer:export'
| 'customer:assign'
| 'message:send'
| 'conversation:view-all'
| 'opportunity:manage'
| 'task:manage'
| 'risk:handle'
| 'analytics:view'
| 'channel:manage'
| 'knowledge:manage'
| 'team:manage'
| 'billing:manage'
| 'settings:manage'
export const ROLE_META: Record<RoleKey, { label: string; description: string; color: string }> = {
admin: { label: '企业管理员', description: '管理企业、成员、权限、渠道、套餐与系统设置', color: 'bg-violet-100 text-violet-700' },
manager: { label: '业务主管', description: '查看全部客户、团队数据、经营看板与客户分配', color: 'bg-blue-100 text-blue-700' },
sales: { label: '外贸业务员', description: '查看分配给自己的客户、消息与跟进任务', color: 'bg-teal-100 text-teal-700' },
service: { label: '客服人员', description: '处理跨渠道消息、翻译、AI回复与售后咨询', color: 'bg-amber-100 text-amber-700' },
viewer: { label: '只读成员', description: '仅可查看被授权的数据,不能编辑或发送消息', color: 'bg-gray-100 text-gray-600' },
}
const ALL: Permission[] = [
'customer:view',
'customer:edit',
'customer:delete',
'customer:export',
'customer:assign',
'message:send',
'conversation:view-all',
'opportunity:manage',
'task:manage',
'risk:handle',
'analytics:view',
'channel:manage',
'knowledge:manage',
'team:manage',
'billing:manage',
'settings:manage',
]
export const ROLE_PERMISSIONS: Record<RoleKey, Permission[]> = {
admin: ALL,
manager: [
'customer:view',
'customer:edit',
'customer:export',
'customer:assign',
'message:send',
'conversation:view-all',
'opportunity:manage',
'task:manage',
'risk:handle',
'analytics:view',
'channel:manage',
'knowledge:manage',
],
sales: ['customer:view', 'customer:edit', 'message:send', 'opportunity:manage', 'task:manage', 'risk:handle'],
service: ['customer:view', 'message:send', 'task:manage', 'risk:handle', 'knowledge:manage'],
viewer: ['customer:view', 'analytics:view'],
}
/** 权限点说明(用于权限矩阵展示) */
export const PERMISSION_GROUPS: { module: string; items: { key: Permission; label: string }[] }[] = [
{
module: '客户管理',
items: [
{ key: 'customer:view', label: '查看客户' },
{ key: 'customer:edit', label: '编辑客户' },
{ key: 'customer:delete', label: '删除客户' },
{ key: 'customer:export', label: '导出客户' },
{ key: 'customer:assign', label: '分配客户' },
],
},
{
module: '消息会话',
items: [
{ key: 'message:send', label: '发送消息' },
{ key: 'conversation:view-all', label: '查看全部会话' },
],
},
{
module: '商机与任务',
items: [
{ key: 'opportunity:manage', label: '管理商机' },
{ key: 'task:manage', label: '管理任务' },
],
},
{
module: '风险与数据',
items: [
{ key: 'risk:handle', label: '处理风险' },
{ key: 'analytics:view', label: '查看团队数据' },
],
},
{
module: '系统管理',
items: [
{ key: 'channel:manage', label: '管理渠道' },
{ key: 'knowledge:manage', label: '管理知识库' },
{ key: 'team:manage', label: '管理成员' },
{ key: 'billing:manage', label: '管理账单' },
{ key: 'settings:manage', label: '管理系统设置' },
],
},
]
+10
View File
@@ -0,0 +1,10 @@
import { useEffect, useState } from 'react'
export function useDebounce<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debounced
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect, useState } from 'react'
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(() =>
typeof window !== 'undefined' ? window.matchMedia(query).matches : false,
)
useEffect(() => {
const mql = window.matchMedia(query)
const onChange = (e: MediaQueryListEvent) => setMatches(e.matches)
mql.addEventListener('change', onChange)
setMatches(mql.matches)
return () => mql.removeEventListener('change', onChange)
}, [query])
return matches
}
/** 小于 lg(1024px) 视为移动端/平板 */
export function useIsMobile(): boolean {
return useMediaQuery('(max-width: 1023px)')
}
+12
View File
@@ -0,0 +1,12 @@
import { useAuthStore } from '@/app/store/auth'
import { ROLE_PERMISSIONS, type Permission, type RoleKey } from '@/config/roles'
export function usePermission() {
const role = useAuthStore((s) => s.user?.role ?? 'viewer') as RoleKey
const perms = ROLE_PERMISSIONS[role] ?? []
return {
role,
can: (p: Permission) => perms.includes(p),
canAny: (...ps: Permission[]) => ps.some((p) => perms.includes(p)),
}
}
+51
View File
@@ -0,0 +1,51 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from 'react'
import { UI_LOCALES, type UiLocale } from '@/config/languages'
import { zh, type LocaleDict } from './locales/zh'
import { en } from './locales/en'
import { ar } from './locales/ar'
const DICTS: Record<UiLocale, LocaleDict> = { zh, en, ar }
interface I18nContextValue {
locale: UiLocale
dir: 'ltr' | 'rtl'
setLocale: (l: UiLocale) => void
t: (key: keyof LocaleDict) => string
}
const I18nContext = createContext<I18nContextValue | null>(null)
const STORAGE_KEY = 'lx-locale'
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<UiLocale>(() => {
const saved = localStorage.getItem(STORAGE_KEY)
return (saved === 'en' || saved === 'ar' || saved === 'zh' ? saved : 'zh') as UiLocale
})
const dir: 'ltr' | 'rtl' = UI_LOCALES.find((l) => l.code === locale)?.rtl ? 'rtl' : 'ltr'
useEffect(() => {
document.documentElement.lang = locale === 'zh' ? 'zh-CN' : locale
document.documentElement.dir = dir
localStorage.setItem(STORAGE_KEY, locale)
}, [locale, dir])
const value = useMemo<I18nContextValue>(
() => ({
locale,
dir,
setLocale: setLocaleState,
t: (key) => DICTS[locale][key] ?? zh[key] ?? String(key),
}),
[locale, dir],
)
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>
}
export function useI18n(): I18nContextValue {
const ctx = useContext(I18nContext)
if (!ctx) throw new Error('useI18n must be used within I18nProvider')
return ctx
}
+80
View File
@@ -0,0 +1,80 @@
import type { LocaleDict } from './zh'
/** 阿拉伯语(RTL 布局演示) */
export const ar: LocaleDict = {
'app.name': 'لينغشي عبر الحدود',
'app.slogan': 'نجعل التواصل عبر الحدود أبسط',
'app.company': 'شركة شوزو أوهوي للتكنولوجيا',
'nav.dashboard': 'لوحة العمل',
'nav.inbox': 'الرسائل الموحدة',
'nav.customers': 'إدارة العملاء',
'nav.opportunities': 'الفرص والمتابعة',
'nav.translate': 'الترجمة الذكية',
'nav.interpret': 'الترجمة الفورية',
'nav.risks': 'تنبيهات المخاطر',
'nav.knowledge': 'قاعدة المعرفة',
'nav.analytics': 'تحليل البيانات',
'nav.channels': 'مركز القنوات',
'nav.team': 'فريق العمل',
'nav.billing': 'الباقات والفواتير',
'nav.settings': 'إعدادات النظام',
'nav.help': 'المساعدة والتدريب',
'common.search': 'بحث',
'common.save': 'حفظ',
'common.cancel': 'إلغاء',
'common.confirm': 'تأكيد',
'common.delete': 'حذف',
'common.edit': 'تعديل',
'common.create': 'جديد',
'common.close': 'إغلاق',
'common.loading': 'جارٍ التحميل…',
'common.all': 'الكل',
'common.back': 'رجوع',
'common.export': 'تصدير',
'common.import': 'استيراد',
'common.more': 'المزيد',
'common.submit': 'إرسال',
'common.reset': 'إعادة تعيين',
'common.optional': 'اختياري',
'common.required': 'مطلوب',
'common.demoData': 'بيانات تجريبية',
'topbar.searchPlaceholder': 'ابحث عن العملاء والمحادثات والمعرفة والمهام…',
'topbar.notifications': 'الإشعارات',
'topbar.help': 'المساعدة',
'topbar.switchRole': 'تبديل الدور التجريبي',
'topbar.logout': 'تسجيل الخروج',
'topbar.profile': 'الملف الشخصي',
'sidebar.currentPlan': 'الباقة الحالية',
'sidebar.usage': 'استخدام الباقة',
'sidebar.collapse': 'طي القائمة',
'sidebar.expand': 'توسيع القائمة',
'login.title': 'تسجيل الدخول إلى لينغشي',
'login.subtitle': 'منصة الأعمال الذكية عبر الحدود',
'login.phoneTab': 'رمز التحقق',
'login.passwordTab': 'كلمة المرور',
'login.accountPlaceholder': 'الهاتف أو البريد الإلكتروني',
'login.codePlaceholder': 'رمز مكون من 6 أرقام',
'login.passwordPlaceholder': 'كلمة المرور',
'login.getCode': 'إرسال الرمز',
'login.remember': 'تذكرني',
'login.forgot': 'نسيت كلمة المرور؟',
'login.submit': 'تسجيل الدخول',
'login.register': 'تسجيل شركة جديدة',
'login.quick': 'دخول تجريبي بنقرة واحدة',
'login.agreementPrefix': 'بتسجيل الدخول فأنت توافق على',
'login.privacy': 'سياسة الخصوصية',
'login.terms': 'شروط الخدمة',
'login.and': 'و',
'dashboard.goodMorning': 'صباح الخير',
'dashboard.goodAfternoon': 'مساء الخير',
'dashboard.goodEvening': 'مساء الخير',
'dashboard.updatedAt': 'آخر تحديث',
'lang.name': 'اللغة',
}
+79
View File
@@ -0,0 +1,79 @@
import type { LocaleDict } from './zh'
export const en: LocaleDict = {
'app.name': 'Lingxi Cross-border',
'app.slogan': 'Make cross-border communication simple',
'app.company': 'Xuzhou Ouhui Technology Co., Ltd.',
'nav.dashboard': 'Workspace',
'nav.inbox': 'Unified Inbox',
'nav.customers': 'Customers',
'nav.opportunities': 'Deals & Follow-ups',
'nav.translate': 'AI Translation',
'nav.interpret': 'Interpretation',
'nav.risks': 'Risk Alerts',
'nav.knowledge': 'Knowledge Base',
'nav.analytics': 'Analytics',
'nav.channels': 'Channels',
'nav.team': 'Team',
'nav.billing': 'Plans & Billing',
'nav.settings': 'Settings',
'nav.help': 'Help & Training',
'common.search': 'Search',
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.confirm': 'Confirm',
'common.delete': 'Delete',
'common.edit': 'Edit',
'common.create': 'New',
'common.close': 'Close',
'common.loading': 'Loading…',
'common.all': 'All',
'common.back': 'Back',
'common.export': 'Export',
'common.import': 'Import',
'common.more': 'More',
'common.submit': 'Submit',
'common.reset': 'Reset',
'common.optional': 'Optional',
'common.required': 'Required',
'common.demoData': 'Demo data',
'topbar.searchPlaceholder': 'Search customers, chats, knowledge, tasks…',
'topbar.notifications': 'Notifications',
'topbar.help': 'Help',
'topbar.switchRole': 'Switch demo role',
'topbar.logout': 'Sign out',
'topbar.profile': 'Profile',
'sidebar.currentPlan': 'Current plan',
'sidebar.usage': 'Plan usage',
'sidebar.collapse': 'Collapse',
'sidebar.expand': 'Expand',
'login.title': 'Sign in to Lingxi',
'login.subtitle': 'Cross-border AI business platform',
'login.phoneTab': 'Code sign-in',
'login.passwordTab': 'Password',
'login.accountPlaceholder': 'Phone or email',
'login.codePlaceholder': '6-digit code',
'login.passwordPlaceholder': 'Password',
'login.getCode': 'Get code',
'login.remember': 'Remember me',
'login.forgot': 'Forgot password?',
'login.submit': 'Sign in',
'login.register': 'Register company',
'login.quick': 'One-click demo login',
'login.agreementPrefix': 'By signing in you agree to',
'login.privacy': 'Privacy Policy',
'login.terms': 'Terms of Service',
'login.and': 'and',
'dashboard.goodMorning': 'Good morning',
'dashboard.goodAfternoon': 'Good afternoon',
'dashboard.goodEvening': 'Good evening',
'dashboard.updatedAt': 'Updated at',
'lang.name': 'Language',
}
+78
View File
@@ -0,0 +1,78 @@
export const zh = {
'app.name': '灵犀跨境',
'app.slogan': '让跨境沟通更简单',
'app.company': '徐州欧汇科技有限公司',
'nav.dashboard': '工作台',
'nav.inbox': '统一消息',
'nav.customers': '客户管理',
'nav.opportunities': '商机与跟进',
'nav.translate': 'AI翻译',
'nav.interpret': '同声传译',
'nav.risks': '风险预警',
'nav.knowledge': '企业知识库',
'nav.analytics': '数据分析',
'nav.channels': '渠道中心',
'nav.team': '团队协作',
'nav.billing': '套餐与账单',
'nav.settings': '系统设置',
'nav.help': '帮助与培训',
'common.search': '搜索',
'common.save': '保存',
'common.cancel': '取消',
'common.confirm': '确认',
'common.delete': '删除',
'common.edit': '编辑',
'common.create': '新建',
'common.close': '关闭',
'common.loading': '加载中…',
'common.all': '全部',
'common.back': '返回',
'common.export': '导出',
'common.import': '导入',
'common.more': '更多',
'common.submit': '提交',
'common.reset': '重置',
'common.optional': '选填',
'common.required': '必填',
'common.demoData': '演示数据',
'topbar.searchPlaceholder': '搜索客户、会话、知识、任务…',
'topbar.notifications': '通知',
'topbar.help': '帮助',
'topbar.switchRole': '切换演示角色',
'topbar.logout': '退出登录',
'topbar.profile': '个人中心',
'sidebar.currentPlan': '当前套餐',
'sidebar.usage': '套餐使用量',
'sidebar.collapse': '收起导航',
'sidebar.expand': '展开导航',
'login.title': '登录灵犀跨境',
'login.subtitle': '跨境AI经营服务平台',
'login.phoneTab': '验证码登录',
'login.passwordTab': '密码登录',
'login.accountPlaceholder': '手机号或邮箱',
'login.codePlaceholder': '6位验证码',
'login.passwordPlaceholder': '密码',
'login.getCode': '获取验证码',
'login.remember': '记住登录状态',
'login.forgot': '忘记密码?',
'login.submit': '登 录',
'login.register': '注册新企业',
'login.quick': '演示角色一键登录',
'login.agreementPrefix': '登录即代表同意',
'login.privacy': '《隐私政策》',
'login.terms': '《服务协议》',
'login.and': '和',
'dashboard.goodMorning': '早上好',
'dashboard.goodAfternoon': '下午好',
'dashboard.goodEvening': '晚上好',
'dashboard.updatedAt': '数据更新于',
'lang.name': '语言',
}
export type LocaleDict = typeof zh
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import '@/styles/index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+108
View File
@@ -0,0 +1,108 @@
/**
* Mock 内存数据库
* 深克隆种子数据,会话期间的增删改都在这里生效(刷新页面后重置)。
*/
import type { Customer } from '@/types/crm'
import type { Conversation, Message, ChannelConnection, ChannelSyncLog } from '@/types/messaging'
import type { FollowTask, Opportunity, RiskAlert, ActivityLog, CustomerFile } from '@/types/crm'
import type { KnowledgeItem } from '@/types/knowledge'
import type { TeamMember, OperationLog, LoginRecord, ApiKeyItem, AppNotification, BillingInfo, BillingOrder, Invoice, SystemSettings } from '@/types/org'
import { customers } from './seed/customers'
import { conversations } from './seed/conversations'
import { messages } from './seed/messages'
import { tasks } from './seed/tasks'
import { opportunities } from './seed/opportunities'
import { risks } from './seed/risks'
import { knowledgeItems } from './seed/knowledge'
import { teamMembers } from './seed/team'
import { channels, channelSyncLogs } from './seed/channels'
import { notifications, activityLogs, customerFiles, apiKeys, billingInfo, billingOrders, invoices, defaultSettings, operationLogs, loginRecords } from './seed/misc'
export interface MockDb {
customers: Customer[]
conversations: Conversation[]
messages: Message[]
tasks: FollowTask[]
opportunities: Opportunity[]
risks: RiskAlert[]
knowledge: KnowledgeItem[]
team: TeamMember[]
channels: ChannelConnection[]
channelSyncLogs: ChannelSyncLog[]
notifications: AppNotification[]
activityLogs: ActivityLog[]
customerFiles: CustomerFile[]
apiKeys: ApiKeyItem[]
billing: BillingInfo
orders: BillingOrder[]
invoices: Invoice[]
settings: SystemSettings
operationLogs: OperationLog[]
loginRecords: LoginRecord[]
}
function clone<T>(v: T): T {
return JSON.parse(JSON.stringify(v)) as T
}
function buildDb(): MockDb {
const msgs = clone(messages)
const convs = clone(conversations)
// 计算每个会话的最后一条消息
for (const c of convs) {
const list = msgs
.filter((m) => m.conversationId === c.id)
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
c.lastMessage = list[list.length - 1]
}
return {
customers: clone(customers),
conversations: convs,
messages: msgs,
tasks: clone(tasks),
opportunities: clone(opportunities),
risks: clone(risks),
knowledge: clone(knowledgeItems),
team: clone(teamMembers),
channels: clone(channels),
channelSyncLogs: clone(channelSyncLogs),
notifications: clone(notifications),
activityLogs: clone(activityLogs),
customerFiles: clone(customerFiles),
apiKeys: clone(apiKeys),
billing: clone(billingInfo),
orders: clone(billingOrders),
invoices: clone(invoices),
settings: clone(defaultSettings),
operationLogs: clone(operationLogs),
loginRecords: clone(loginRecords),
}
}
export const db: MockDb = buildDb()
/** 便捷查询 */
export const dbHelpers = {
customer(id: string) {
return db.customers.find((c) => c.id === id)
},
member(id: string | null | undefined) {
return db.team.find((m) => m.id === id)
},
conversation(id: string) {
return db.conversations.find((c) => c.id === id)
},
messagesOf(conversationId: string) {
return db.messages
.filter((m) => m.conversationId === conversationId)
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
},
touchConversation(conversationId: string, msg: Message) {
const conv = db.conversations.find((c) => c.id === conversationId)
if (conv) {
conv.lastMessage = msg
conv.updatedAt = msg.createdAt
}
},
}
+208
View File
@@ -0,0 +1,208 @@
import type { AnalyticsData, DashboardOverview } from '@/types/analytics'
import { seededRandom } from '@/utils/misc'
import { lastNDays } from './helpers'
import { customers } from './customers'
import { tasks } from './tasks'
import { risks } from './risks'
const rand = seededRandom(20260722)
function series(n: number, base: number, spread: number): number[] {
let cur = base
return Array.from({ length: n }, () => {
cur = Math.max(0, cur + (rand() - 0.45) * spread)
return Math.round(cur)
})
}
const days30 = lastNDays(30)
const days14 = lastNDays(14)
const inquirySeries = series(30, 18, 10)
const customerSeries = inquirySeries.map((v) => Math.max(2, Math.round(v * (0.35 + rand() * 0.2))))
/** 工作台总览(演示数据) */
export const dashboardOverview: DashboardOverview = {
stats: {
newInquiriesToday: { value: 23, delta: 0.15 },
pendingReply: { value: 9, delta: -0.08 },
highIntentCustomers: { value: 7, delta: 0.17 },
todayFollowUps: { value: 12, delta: 0.0 },
openRisks: { value: 11, delta: 0.22 },
avgFirstResponseMin: { value: 14, delta: -0.18, unit: '分钟' },
monthConversionRate: { value: 18.6, delta: 0.023, isPercent: true },
aiAdoptionRate: { value: 72.4, delta: 0.051, isPercent: true },
},
todos: tasks.filter((t) => t.status !== 'done').slice(0, 7),
highValueCustomers: customers.filter((c) => c.intentScore >= 75).slice(0, 6),
channelOverview: [
{ channel: 'whatsapp', todayMessages: 46, pending: 4, unread: 5 },
{ channel: 'telegram', todayMessages: 12, pending: 0, unread: 0 },
{ channel: 'messenger', todayMessages: 8, pending: 1, unread: 1 },
{ channel: 'email', todayMessages: 21, pending: 3, unread: 3 },
{ channel: 'webform', todayMessages: 5, pending: 1, unread: 1 },
],
inquiryTrend: days14.map((date, i) => ({ date, inquiries: inquirySeries[i + 16], customers: customerSeries[i + 16] })),
sourceDistribution: [
{ name: 'WhatsApp', value: 38 },
{ name: '企业邮箱', value: 24 },
{ name: '独立站/落地页', value: 16 },
{ name: 'Telegram', value: 10 },
{ name: 'Messenger', value: 8 },
{ name: '展会/转介绍', value: 4 },
],
funnel: [
{ stage: 'initial', label: '初次咨询', count: 128 },
{ stage: 'comparing', label: '价格比较', count: 86 },
{ stage: 'sample', label: '样品确认', count: 47 },
{ stage: 'closing', label: '成交推进', count: 31 },
{ stage: 'won', label: '成交', count: 19 },
],
teamReplyRank: [
{ memberId: 'u-003', name: '李婷', avgResponseMin: 8, replies: 42 },
{ memberId: 'u-005', name: '周敏', avgResponseMin: 11, replies: 35 },
{ memberId: 'u-004', name: '赵磊', avgResponseMin: 14, replies: 31 },
{ memberId: 'u-008', name: '郑楠', avgResponseMin: 19, replies: 24 },
{ memberId: 'u-006', name: '孙倩', avgResponseMin: 26, replies: 12 },
],
recentRisks: risks.slice(0, 5),
updatedAt: new Date().toISOString(),
}
/** 数据分析中心(演示数据) */
export const analyticsData: AnalyticsData = {
kpis: {
newCustomers: { value: 186, delta: 0.12 },
validInquiries: { value: 342, delta: 0.09 },
highIntent: { value: 58, delta: 0.16 },
conversionRate: { value: 18.6, delta: 0.023, isPercent: true },
avgResponseMin: { value: 14, delta: -0.18, unit: '分钟' },
aiTranslateUsage: { value: 12840, delta: 0.31 },
aiAdoptionRate: { value: 72.4, delta: 0.051, isPercent: true },
taskCompletionRate: { value: 86.2, delta: 0.04, isPercent: true },
riskHandleRate: { value: 78.5, delta: 0.06, isPercent: true },
repurchaseRate: { value: 23.8, delta: 0.018, isPercent: true },
},
customerGrowth: days30.map((date, i) => ({ date, count: customerSeries[i] })),
channelInquiries: [
{ channel: 'whatsapp', name: 'WhatsApp', count: 128 },
{ channel: 'email', name: '企业邮箱', count: 82 },
{ channel: 'webform', name: '独立站表单', count: 45 },
{ channel: 'landing', name: '广告落地页', count: 38 },
{ channel: 'telegram', name: 'Telegram', count: 27 },
{ channel: 'messenger', name: 'Messenger', count: 22 },
],
countryDistribution: [
{ country: 'PK', name: '巴基斯坦', count: 52 },
{ country: 'BD', name: '孟加拉国', count: 44 },
{ country: 'AE', name: '阿联酋', count: 38 },
{ country: 'SA', name: '沙特阿拉伯', count: 31 },
{ country: 'US', name: '美国', count: 28 },
{ country: 'GB', name: '英国', count: 19 },
{ country: 'ES', name: '西班牙', count: 16 },
{ country: 'DE', name: '德国', count: 14 },
],
sourcePaths: [
{ path: 'WhatsApp 官网按钮', count: 68 },
{ path: 'Google 广告落地页', count: 52 },
{ path: 'Facebook 主页/广告', count: 41 },
{ path: '展会名片/二维码', count: 26 },
{ path: '客户转介绍', count: 18 },
{ path: 'LinkedIn 开发信', count: 12 },
],
funnel: [
{ stage: 'initial', label: '新询盘', count: 342 },
{ stage: 'requirement', label: '需求确认', count: 246 },
{ stage: 'quoting', label: '报价', count: 168 },
{ stage: 'sample', label: '样品/定制', count: 96 },
{ stage: 'negotiation', label: '谈判', count: 78 },
{ stage: 'won', label: '成交', count: 64 },
],
intentDistribution: [
{ range: '80—100 高价值', count: 58 },
{ range: '60—79 中高', count: 86 },
{ range: '40—59 中等', count: 102 },
{ range: '0—39 低', count: 96 },
],
sentimentTrend: days14.map((date) => ({
date,
positive: Math.round(8 + rand() * 8),
neutral: Math.round(10 + rand() * 8),
negative: Math.round(rand() * 4),
})),
riskTrend: days14.map((date) => ({ date, count: Math.round(2 + rand() * 6) })),
productRank: [
{ product: '定制Logo玻璃水杯', inquiries: 86 },
{ product: '男士纯棉T恤', inquiries: 64 },
{ product: 'TWS蓝牙耳机', inquiries: 52 },
{ product: '高硼硅玻璃杯 500ml', inquiries: 47 },
{ product: '女士针织开衫', inquiries: 38 },
{ product: '北欧棉麻四件套', inquiries: 26 },
{ product: '65W氮化镓充电器', inquiries: 24 },
{ product: 'ins风陶瓷餐具套装', inquiries: 19 },
],
teamReplyRank: [
{ name: '李婷', avgResponseMin: 8 },
{ name: '周敏', avgResponseMin: 11 },
{ name: '赵磊', avgResponseMin: 14 },
{ name: '郑楠', avgResponseMin: 19 },
{ name: '孙倩', avgResponseMin: 26 },
],
teamConversionRank: [
{ name: '李婷', rate: 26.5 },
{ name: '赵磊', rate: 22.8 },
{ name: '郑楠', rate: 17.2 },
{ name: '周敏', rate: 12.4 },
],
taskCompletion: [
{ name: '李婷', done: 38, total: 44 },
{ name: '赵磊', done: 31, total: 38 },
{ name: '周敏', done: 27, total: 30 },
{ name: '郑楠', done: 25, total: 34 },
{ name: '孙倩', done: 12, total: 15 },
],
churnReasons: [
{ reason: '价格未达成一致', count: 34 },
{ reason: '交期无法满足', count: 22 },
{ reason: '选择竞争对手', count: 18 },
{ reason: '长时间未跟进', count: 15 },
{ reason: '质量/售后问题', count: 9 },
{ reason: '客户项目取消', count: 7 },
],
industryDistribution: [
{ industry: '玻璃制品', count: 118 },
{ industry: '服装纺织', count: 96 },
{ industry: '3C电子', count: 82 },
{ industry: '家居用品', count: 46 },
],
bossBoard: {
urgentHighValue: [
{ id: 'c-001', name: 'Ahmed Raza', country: 'PK', reason: '明天确认订单,今日需发阶梯报价', intentScore: 86 },
{ id: 'c-012', name: 'Sarah Miller', country: 'US', reason: '季度框架单待签合同', intentScore: 88 },
{ id: 'c-028', name: 'Saud Al-Harbi', country: 'SA', reason: '朝觐季大单交付节点逼近', intentScore: 85 },
{ id: 'c-019', name: 'Tom Baker', country: 'US', reason: '紧急售后纠纷,最后通牒', intentScore: 38 },
],
overdueMembers: [
{ id: 'u-004', name: '赵磊', overdueCount: 2 },
{ id: 'u-003', name: '李婷', overdueCount: 1 },
{ id: 'u-008', name: '郑楠', overdueCount: 1 },
],
bestChannel: { channel: 'whatsapp', name: 'WhatsApp', conversion: 24.8 },
fastestCountries: [
{ country: 'PK', name: '巴基斯坦', growth: 0.42 },
{ country: 'SA', name: '沙特阿拉伯', growth: 0.35 },
{ country: 'BD', name: '孟加拉国', growth: 0.28 },
{ country: 'AE', name: '阿联酋', growth: 0.19 },
],
topProducts: [
{ product: '定制Logo玻璃水杯', inquiries: 86 },
{ product: '男士纯棉T恤', inquiries: 64 },
{ product: 'TWS蓝牙耳机', inquiries: 52 },
],
churningHighIntent: [
{ id: 'c-021', name: 'Hassan Ali', country: 'PK', daysSilent: 2, intentScore: 66 },
{ id: 'c-007', name: 'Emma Wilson', country: 'GB', daysSilent: 1, intentScore: 58 },
{ id: 'c-025', name: 'Imran Sheikh', country: 'PK', daysSilent: 1, intentScore: 79 },
],
},
}
+30
View File
@@ -0,0 +1,30 @@
import type { ChannelConnection, ChannelSyncLog } from '@/types/messaging'
import { daysAgo, hoursAgo, minutesAgo } from './helpers'
/** 13 个渠道连接状态(7个已连接/1异常/1暂停/4未连接) */
export const channels: ChannelConnection[] = [
{ id: 'ch-01', type: 'whatsapp', name: 'WhatsApp Business', status: 'connected', account: '+86 138****6621(徐州欧汇)', lastSyncAt: minutesAgo(2), todayMessages: 46, autoReply: true, defaultOwnerId: 'u-003' },
{ id: 'ch-02', type: 'telegram', name: 'Telegram', status: 'connected', account: '@OuhuiTrading', lastSyncAt: minutesAgo(5), todayMessages: 12, autoReply: true, defaultOwnerId: 'u-004' },
{ id: 'ch-03', type: 'messenger', name: 'Facebook Messenger', status: 'connected', account: 'Ouhui Tech 主页', lastSyncAt: minutesAgo(9), todayMessages: 8, autoReply: false, defaultOwnerId: 'u-008' },
{ id: 'ch-04', type: 'email', name: '企业邮箱', status: 'connected', account: 'sales@ouhui.tech', lastSyncAt: minutesAgo(3), todayMessages: 21, autoReply: true, defaultOwnerId: 'u-003' },
{ id: 'ch-05', type: 'gmail', name: 'Gmail', status: 'connected', account: 'ouhui.export@gmail.com', lastSyncAt: minutesAgo(14), todayMessages: 6, autoReply: false, defaultOwnerId: 'u-005' },
{ id: 'ch-06', type: 'webform', name: '独立站表单', status: 'connected', account: 'www.ouhui-glass.com', lastSyncAt: minutesAgo(31), todayMessages: 5, autoReply: true, defaultOwnerId: 'u-003' },
{ id: 'ch-07', type: 'landing', name: '广告落地页', status: 'connected', account: 'Google Ads / Facebook Ads', lastSyncAt: hoursAgo(1), todayMessages: 9, autoReply: true, defaultOwnerId: 'u-004' },
{ id: 'ch-08', type: 'outlook', name: 'Outlook', status: 'error', account: 'ouhui@outlook.com', lastSyncAt: hoursAgo(26), todayMessages: 0, errorMessage: '授权令牌已过期,请重新授权', autoReply: false, defaultOwnerId: 'u-005' },
{ id: 'ch-09', type: 'website', name: '官网咨询按钮', status: 'paused', account: 'www.ouhui-glass.com', lastSyncAt: daysAgo(2, 10), todayMessages: 0, autoReply: false, defaultOwnerId: 'u-005' },
{ id: 'ch-10', type: 'social', name: '社交媒体主页', status: 'unconnected', todayMessages: 0, autoReply: false },
{ id: 'ch-11', type: 'exhibition', name: '展会二维码', status: 'unconnected', todayMessages: 0, autoReply: false },
{ id: 'ch-12', type: 'api', name: 'API 接口', status: 'unconnected', todayMessages: 0, autoReply: false },
{ id: 'ch-13', type: 'webhook', name: 'Webhook', status: 'unconnected', todayMessages: 0, autoReply: false },
]
export const channelSyncLogs: ChannelSyncLog[] = [
{ id: 'sl-01', channelId: 'ch-01', action: '消息同步', detail: '同步 12 条新消息,3 个新会话建档', status: 'success', createdAt: minutesAgo(2) },
{ id: 'sl-02', channelId: 'ch-04', action: '邮件拉取', detail: '拉取 7 封新邮件,1 封识别为询盘', status: 'success', createdAt: minutesAgo(3) },
{ id: 'sl-03', channelId: 'ch-02', action: '消息同步', detail: '同步 2 条新消息', status: 'success', createdAt: minutesAgo(5) },
{ id: 'sl-04', channelId: 'ch-03', action: '主页私信同步', detail: '同步 1 条私信', status: 'success', createdAt: minutesAgo(9) },
{ id: 'sl-05', channelId: 'ch-08', action: '令牌刷新', detail: 'OAuth 令牌刷新失败:refresh_token expired', status: 'failed', createdAt: hoursAgo(26) },
{ id: 'sl-06', channelId: 'ch-01', action: '自动回复', detail: '非工作时间自动回复触发 3 次', status: 'success', createdAt: hoursAgo(9) },
{ id: 'sl-07', channelId: 'ch-06', action: '表单接收', detail: '新表单提交:Carmen López(西班牙)', status: 'success', createdAt: hoursAgo(9) },
{ id: 'sl-08', channelId: 'ch-07', action: '线索同步', detail: 'Google Ads 落地页新线索 2 条', status: 'success', createdAt: hoursAgo(1) },
]
+26
View File
@@ -0,0 +1,26 @@
import type { Conversation } from '@/types/messaging'
import { minutesAgo, hoursAgo, daysAgo } from './helpers'
/** 20 条会话(lastMessage 由 db 根据消息自动计算) */
export const conversations: Conversation[] = [
{ id: 'cv-001', customerId: 'c-001', channel: 'whatsapp', unreadCount: 2, status: 'pending', assigneeId: 'u-003', intentLevel: 'high', sentiment: 'neutral-positive', hasRisk: false, summary: '500个定制玻璃杯询价,关注价格/Logo/交期', updatedAt: minutesAgo(42) },
{ id: 'cv-002', customerId: 'c-002', channel: 'email', unreadCount: 1, status: 'pending', assigneeId: 'u-003', intentLevel: 'medium-high', sentiment: 'positive', hasRisk: false, summary: '酒店集团2000个高硼硅杯,等待耐温测试报告', updatedAt: hoursAgo(3) },
{ id: 'cv-003', customerId: 'c-003', channel: 'whatsapp', unreadCount: 1, status: 'pending', assigneeId: 'u-004', intentLevel: 'high', sentiment: 'positive', hasRisk: false, summary: '5000件T恤价格锁定$2.20,待付款条款', updatedAt: hoursAgo(1) },
{ id: 'cv-004', customerId: 'c-004', channel: 'email', unreadCount: 1, status: 'pending', assigneeId: 'u-005', intentLevel: 'medium', sentiment: 'negative', hasRisk: true, summary: '样品两次延误,客户威胁更换供应商', updatedAt: hoursAgo(5) },
{ id: 'cv-005', customerId: 'c-005', channel: 'webform', unreadCount: 1, status: 'pending', assigneeId: 'u-003', intentLevel: 'medium-high', sentiment: 'neutral', hasRisk: false, summary: '西班牙家居店首询:床品+餐具目录与MOQ', updatedAt: hoursAgo(7) },
{ id: 'cv-006', customerId: 'c-006', channel: 'telegram', unreadCount: 0, status: 'replied', assigneeId: 'u-004', intentLevel: 'medium-high', sentiment: 'neutral-positive', hasRisk: false, summary: '1000个阿文书法Logo礼品杯,斋月前交付', updatedAt: hoursAgo(9) },
{ id: 'cv-007', customerId: 'c-007', channel: 'messenger', unreadCount: 1, status: 'pending', assigneeId: 'u-008', intentLevel: 'medium', sentiment: 'neutral', hasRisk: false, summary: '800件开衫比价中,对方报价£3.95', updatedAt: hoursAgo(12) },
{ id: 'cv-008', customerId: 'c-008', channel: 'email', unreadCount: 0, status: 'replied', assigneeId: 'u-004', intentLevel: 'medium-high', sentiment: 'neutral', hasRisk: false, summary: '德国客户3000个GaN充电器,样品已寄', updatedAt: hoursAgo(26) },
{ id: 'cv-009', customerId: 'c-009', channel: 'whatsapp', unreadCount: 0, status: 'replied', assigneeId: 'u-003', intentLevel: 'medium', sentiment: 'neutral', hasRisk: false, summary: '1000件T恤询克重与颜色,已发色卡', updatedAt: hoursAgo(30) },
{ id: 'cv-010', customerId: 'c-010', channel: 'whatsapp', unreadCount: 1, status: 'pending', assigneeId: 'u-005', intentLevel: 'medium-high', sentiment: 'positive', hasRisk: false, summary: '300套餐具,关心电商运输防碎包装', updatedAt: hoursAgo(8) },
{ id: 'cv-011', customerId: 'c-011', channel: 'email', unreadCount: 0, status: 'replied', assigneeId: 'u-008', intentLevel: 'medium', sentiment: 'neutral', hasRisk: false, summary: '150套储物罐报价,询问巴塞罗那运输', updatedAt: daysAgo(1, 16) },
{ id: 'cv-012', customerId: 'c-012', channel: 'landing', unreadCount: 1, status: 'pending', assigneeId: 'u-004', intentLevel: 'high', sentiment: 'positive', hasRisk: false, summary: '2000台TWS耳机季度框架,问蓝牙参数', updatedAt: minutesAgo(95) },
{ id: 'cv-013', customerId: 'c-013', channel: 'messenger', unreadCount: 0, status: 'replied', assigneeId: 'u-008', intentLevel: 'medium-high', sentiment: 'neutral-positive', hasRisk: false, summary: 'POLO衫样品满意,待2000件最终报价', updatedAt: daysAgo(1, 11) },
{ id: 'cv-014', customerId: 'c-014', channel: 'email', unreadCount: 1, status: 'pending', assigneeId: null, intentLevel: 'medium', sentiment: 'neutral', hasRisk: false, summary: '储物罐批发询价,未分配负责人', updatedAt: daysAgo(2, 14) },
{ id: 'cv-015', customerId: 'c-015', channel: 'email', unreadCount: 0, status: 'replied', assigneeId: 'u-003', intentLevel: 'medium-high', sentiment: 'neutral-positive', hasRisk: false, summary: '600套床品£12/套达成,询问缩水率', updatedAt: daysAgo(1, 9) },
{ id: 'cv-016', customerId: 'c-016', channel: 'webform', unreadCount: 0, status: 'replied', assigneeId: 'u-004', intentLevel: 'medium-high', sentiment: 'neutral', hasRisk: false, summary: '充电器+手表各1000,关注质保与DDP', updatedAt: daysAgo(1, 18) },
{ id: 'cv-017', customerId: 'c-017', channel: 'whatsapp', unreadCount: 0, status: 'replied', assigneeId: 'u-008', intentLevel: 'high', sentiment: 'positive', hasRisk: false, summary: '1500只手表阿语适配,已报$11.2', updatedAt: hoursAgo(2) },
{ id: 'cv-018', customerId: 'c-018', channel: 'telegram', unreadCount: 0, status: 'replied', assigneeId: 'u-005', intentLevel: 'medium', sentiment: 'neutral', hasRisk: false, summary: '与土耳其供应商比价,突出差异化', updatedAt: daysAgo(2, 10) },
{ id: 'cv-019', customerId: 'c-019', channel: 'whatsapp', unreadCount: 1, status: 'pending', assigneeId: 'u-005', intentLevel: 'low', sentiment: 'angry', hasRisk: true, summary: '到货3%破损,客户强烈不满,最后通牒', updatedAt: minutesAgo(20) },
{ id: 'cv-020', customerId: 'c-020', channel: 'whatsapp', unreadCount: 0, status: 'replied', assigneeId: 'u-003', intentLevel: 'high', sentiment: 'positive', hasRisk: false, summary: '老客户秋冬复购1200件开衫', updatedAt: hoursAgo(4) },
]
+443
View File
@@ -0,0 +1,443 @@
import type { Customer } from '@/types/crm'
import { daysAgo, daysFromNow, hoursAgo, minutesAgo } from './helpers'
/** 30 个演示客户(覆盖 8 国、4 大行业、多渠道来源) */
export const customers: Customer[] = [
{
id: 'c-001', name: 'Ahmed Raza', company: 'Raza Trading Co.', country: 'PK', city: 'Lahore',
email: 'ahmed.raza@razatrading.pk', phone: '+92 300 1234567', language: 'ur',
channel: 'whatsapp', sourcePath: 'WhatsApp 直聊(官网按钮带入)',
products: ['定制Logo玻璃水杯', '双层玻璃杯 350ml'],
tags: ['重点客户', '交期敏感', '定制需求'], intentScore: 86, valueLevel: 'A',
stage: 'comparing', sentiment: 'neutral-positive', riskLevel: 'medium',
ownerId: 'u-003', lastContactAt: minutesAgo(42), nextFollowUpAt: daysFromNow(1),
createdAt: daysAgo(4), isKey: true,
aiProfile: {
inquiryQuantity: '500 个(意向扩展至 2000)', targetPrice: '$1.6—1.8/个',
moqRequirement: '500 起订', sampleNeed: '需要 2 个带Logo样品',
customRequirement: '双色 Logo 印刷,350ml 双层杯',
deliveryTime: '期望 10 天内(门店开业)', logistics: '卡拉奇港海运或空运',
payment: '倾向 30% 定金 + 见提单副本付款', aftersales: '破损补发政策',
concerns: ['价格', 'Logo定制效果', '交付时间'],
summary: '巴基斯坦拉合尔贸易商,为门店开业采购 500 个定制玻璃杯,对交期敏感,同时在比较 2—3 家供应商,价格接受区间明确。',
nextActions: ['发送阶梯报价和定制案例', '确认加急生产可行性', '安排样品寄送'],
},
},
{
id: 'c-002', name: 'Khalid Al Mansouri', company: 'Gulf Hospitality Group', country: 'AE', city: 'Dubai',
email: 'k.mansouri@gulfhospitality.ae', phone: '+971 50 234 5678', language: 'ar',
channel: 'email', sourcePath: '企业邮箱(展会名片录入)',
products: ['高硼硅玻璃杯 500ml'], tags: ['大客户潜力', '展会客户'],
intentScore: 78, valueLevel: 'B', stage: 'sample', sentiment: 'positive', riskLevel: 'low',
ownerId: 'u-003', lastContactAt: hoursAgo(3), nextFollowUpAt: daysFromNow(2),
createdAt: daysAgo(12), isKey: false,
aiProfile: {
inquiryQuantity: '2000 个(酒店集团)', targetPrice: '$2.2/个以内',
sampleNeed: '已寄样,等待反馈', deliveryTime: '45 天内分批交付',
payment: '信用证 L/C 或 T/T', concerns: ['杯体耐温差性能', '批量一致性'],
summary: '迪拜酒店集团采购负责人,为其连锁酒店采购玻璃杯,样品确认阶段,决策链较长但预算充足。',
nextActions: ['跟进样品反馈', '准备耐温差测试报告'],
},
},
{
id: 'c-003', name: 'Rahim Uddin', company: 'Dhaka Apparel Sourcing', country: 'BD', city: 'Dhaka',
email: 'rahim@dhakaapparel.com', phone: '+880 1712 345678', language: 'bn',
channel: 'whatsapp', sourcePath: 'WhatsApp(Facebook 广告落地页)',
products: ['男士纯棉T恤'], tags: ['价格敏感', '大客户潜力'],
intentScore: 82, valueLevel: 'A', stage: 'closing', sentiment: 'positive', riskLevel: 'none',
ownerId: 'u-004', lastContactAt: hoursAgo(1), nextFollowUpAt: daysFromNow(1),
createdAt: daysAgo(9), isKey: true,
aiProfile: {
inquiryQuantity: '5000 件', targetPrice: '$2.1/件', moqRequirement: '3000 起订可接受',
deliveryTime: '30 天', payment: 'T/T 30% 定金', concerns: ['克重与缩水率', '交期'],
summary: '孟加拉服装采购商,5000 件纯棉T恤进入议价尾声,价格接近成交区间,需尽快锁定。',
nextActions: ['发送最终报价', '确认付款方式'],
},
},
{
id: 'c-004', name: 'Mike Johnson', company: 'Pacific Tech Imports', country: 'US', city: 'Los Angeles',
email: 'mike.j@pacifictech.io', phone: '+1 310 555 0142', language: 'en',
channel: 'email', sourcePath: '企业邮箱(官网询盘表单)',
products: ['TWS蓝牙耳机'], tags: ['高风险'],
intentScore: 45, valueLevel: 'C', stage: 'aftersales', sentiment: 'negative', riskLevel: 'high',
ownerId: 'u-005', lastContactAt: hoursAgo(5), nextFollowUpAt: hoursAgo(-4) as unknown as string,
createdAt: daysAgo(35), isKey: false,
aiProfile: {
aftersales: '样品物流延误 6 天,客户不满', concerns: ['物流时效', '沟通响应'],
summary: '美国3C进口商,样品包裹延误引发投诉,情绪消极,需优先安抚并给出补偿方案,存在流失风险。',
nextActions: ['售后安抚', '提供加急补寄方案'],
},
},
{
id: 'c-005', name: 'Carmen López', company: 'Hogar & Estilo S.L.', country: 'ES', city: 'Madrid',
email: 'carmen@hogaryestilo.es', phone: '+34 612 345 678', language: 'es',
channel: 'webform', sourcePath: '独立站表单(Google 搜索广告)',
products: ['北欧棉麻四件套', 'ins风陶瓷餐具套装'], tags: [],
intentScore: 64, valueLevel: 'B', stage: 'initial', sentiment: 'neutral', riskLevel: 'none',
ownerId: 'u-003', lastContactAt: hoursAgo(7), nextFollowUpAt: daysFromNow(2),
createdAt: daysAgo(1), isKey: false,
aiProfile: {
inquiryQuantity: '待定(首批试单)', concerns: ['起订量', '色牢度'],
summary: '西班牙家居零售商,首次询盘,关注棉麻四件套与餐具,需要目录与报价单。',
nextActions: ['发送产品目录', '补充资料'],
},
},
{
id: 'c-006', name: 'Fahad Al-Otaibi', company: 'Riyadh Gifts Trading', country: 'SA', city: 'Riyadh',
email: 'fahad@riyadhgifts.sa', phone: '+966 55 678 9012', language: 'ar',
channel: 'telegram', sourcePath: 'Telegram 频道私信',
products: ['定制Logo玻璃水杯'], tags: ['定制需求', '斋月注意'],
intentScore: 71, valueLevel: 'B', stage: 'custom', sentiment: 'neutral-positive', riskLevel: 'none',
ownerId: 'u-004', lastContactAt: hoursAgo(9), nextFollowUpAt: daysFromNow(3),
createdAt: daysAgo(6), isKey: false,
aiProfile: {
inquiryQuantity: '1000 个(企业礼品)', customRequirement: '阿拉伯文书法Logo + 礼盒',
deliveryTime: '斋月前交付', concerns: ['阿文排版效果', '礼盒材质'],
summary: '沙特礼品贸易商,定制企业礼品玻璃杯,涉及阿拉伯文排版与宗教文化细节,需文化敏感校对。',
nextActions: ['发送阿文排版案例', '确认礼盒打样'],
},
},
{
id: 'c-007', name: 'Emma Wilson', company: 'London Threads Ltd', country: 'GB', city: 'London',
email: 'emma@londonthreads.co.uk', phone: '+44 20 7946 0812', language: 'en',
channel: 'messenger', sourcePath: 'Facebook 主页私信',
products: ['女士针织开衫'], tags: ['价格敏感'],
intentScore: 58, valueLevel: 'C', stage: 'comparing', sentiment: 'neutral', riskLevel: 'none',
ownerId: 'u-008', lastContactAt: hoursAgo(12), nextFollowUpAt: daysFromNow(4),
createdAt: daysAgo(5), isKey: false,
aiProfile: {
inquiryQuantity: '800 件', targetPrice: '£4.5/件以内', concerns: ['比价中', '色号准确性'],
summary: '英国女装批发商,多渠道比价中,对价格敏感,需提供差异化卖点与阶梯价。',
nextActions: ['发送阶梯报价', '强调质检流程'],
},
},
{
id: 'c-008', name: 'Jonas Weber', company: 'RheinTech GmbH', country: 'DE', city: 'Düsseldorf',
email: 'j.weber@rheintech.de', phone: '+49 211 555 0143', language: 'de',
channel: 'email', sourcePath: '企业邮箱(LinkedIn 开发信回复)',
products: ['65W氮化镓充电器'], tags: ['需要样品'],
intentScore: 74, valueLevel: 'B', stage: 'sample', sentiment: 'neutral', riskLevel: 'low',
ownerId: 'u-004', lastContactAt: hoursAgo(26), nextFollowUpAt: daysFromNow(1),
createdAt: daysAgo(14), isKey: false,
aiProfile: {
inquiryQuantity: '3000 个', customRequirement: '欧规插脚 + CE/RoHS 认证',
concerns: ['认证文件齐全性', '温升测试数据'],
summary: '德国3C配件商,注重合规认证与技术参数,要求提供 CE/RoHS 证书和测试报告,沟通风格严谨。',
nextActions: ['发送认证文件', '补测温升报告'],
},
},
{
id: 'c-009', name: 'Fatima Bibi', company: 'Karachi Textiles', country: 'PK', city: 'Karachi',
email: 'fatima@karachitextiles.pk', phone: '+92 321 7654321', language: 'ur',
channel: 'whatsapp', sourcePath: 'WhatsApp(客户转介绍)',
products: ['男士纯棉T恤'], tags: [],
intentScore: 49, valueLevel: 'C', stage: 'initial', sentiment: 'neutral', riskLevel: 'none',
ownerId: 'u-003', lastContactAt: hoursAgo(30), nextFollowUpAt: daysFromNow(5),
createdAt: daysAgo(2), isKey: false,
aiProfile: {
inquiryQuantity: '约 1000 件', concerns: ['面料克重选择'],
summary: '巴基斯坦纺织贸易公司初次接触,需求尚在了解阶段,需引导明确规格。',
nextActions: ['询问采购时间', '发送面料色卡'],
},
},
{
id: 'c-010', name: 'Layla Hassan', company: 'Dubai Home Decor LLC', country: 'AE', city: 'Dubai',
email: 'layla@dubaihomedecor.ae', phone: '+971 52 345 6789', language: 'ar',
channel: 'whatsapp', sourcePath: 'WhatsApp(Instagram 主页)',
products: ['ins风陶瓷餐具套装'], tags: [],
intentScore: 67, valueLevel: 'B', stage: 'comparing', sentiment: 'positive', riskLevel: 'none',
ownerId: 'u-005', lastContactAt: hoursAgo(8), nextFollowUpAt: daysFromNow(2),
createdAt: daysAgo(7), isKey: false,
aiProfile: {
inquiryQuantity: '300 套', concerns: ['包装设计', '易碎品运输'],
summary: '阿联酋家居电商,社媒获客,关注餐具套装颜值与运输破损率,沟通氛围积极。',
nextActions: ['发送包装方案', '说明破损赔付政策'],
},
},
{
id: 'c-011', name: 'Carlos Ruiz', company: 'Restaurante Sol', country: 'ES', city: 'Barcelona',
email: 'carlos@restaurantesol.es', phone: '+34 933 456 789', language: 'es',
channel: 'email', sourcePath: '企业邮箱(官网底部表单)',
products: ['玻璃储物罐套装'], tags: [],
intentScore: 52, valueLevel: 'C', stage: 'initial', sentiment: 'neutral', riskLevel: 'none',
ownerId: 'u-008', lastContactAt: daysAgo(1, 16), nextFollowUpAt: daysFromNow(6),
createdAt: daysAgo(3), isKey: false,
aiProfile: {
inquiryQuantity: '150 套', concerns: ['密封性'],
summary: '西班牙餐厅经营者,小批量采购储物罐,决策快但金额有限。',
nextActions: ['发送报价', '介绍混批政策'],
},
},
{
id: 'c-012', name: 'Sarah Miller', company: 'BrightWave Electronics', country: 'US', city: 'Austin',
email: 'sarah.m@brightwave.com', phone: '+1 512 555 0177', language: 'en',
channel: 'landing', sourcePath: '广告落地页(Google Ads - earbuds)',
products: ['TWS蓝牙耳机'], tags: ['重点客户', '决策人直联'],
intentScore: 88, valueLevel: 'A', stage: 'closing', sentiment: 'positive', riskLevel: 'none',
ownerId: 'u-004', lastContactAt: minutesAgo(95), nextFollowUpAt: daysFromNow(1),
createdAt: daysAgo(11), isKey: true,
aiProfile: {
inquiryQuantity: '2000 台(季度框架 8000)', targetPrice: '$8.5/台',
customRequirement: '丝印Logo + 定制包装', payment: 'T/T,接受阿里信保',
concerns: ['蓝牙版本与延迟', '售后换新政策'],
summary: '美国消费电子品牌采购经理,决策人直联,季度框架订单谈判中,成交概率高,需本周内推进合同。',
nextActions: ['发送合同草案', '确认首单交付计划'],
},
},
{
id: 'c-013', name: 'Tanvir Hasan', company: 'Chittagong Garments', country: 'BD', city: 'Chittagong',
email: 'tanvir@ctggarments.bd', phone: '+880 1812 987654', language: 'bn',
channel: 'messenger', sourcePath: 'Facebook 主页私信',
products: ['运动速干POLO衫'], tags: ['需要样品'],
intentScore: 61, valueLevel: 'B', stage: 'sample', sentiment: 'neutral-positive', riskLevel: 'none',
ownerId: 'u-008', lastContactAt: daysAgo(1, 11), nextFollowUpAt: daysFromNow(3),
createdAt: daysAgo(8), isKey: false,
aiProfile: {
inquiryQuantity: '2000 件', sampleNeed: 'M/L 码各 2 件', concerns: ['速干面料检测报告'],
summary: '孟加拉吉大港服装商,POLO衫样品阶段,关注面料功能性检测。',
nextActions: ['跟进样品签收', '发送检测报告'],
},
},
{
id: 'c-014', name: 'Omar Farooq', company: 'Lahore Homeware', country: 'PK', city: 'Lahore',
email: 'omar@lahorehomeware.pk', phone: '+92 333 4567890', language: 'ur',
channel: 'email', sourcePath: '企业邮箱(阿里国际站名片)',
products: ['玻璃储物罐套装'], tags: [],
intentScore: 43, valueLevel: 'C', stage: 'initial', sentiment: 'neutral', riskLevel: 'none',
ownerId: null, lastContactAt: daysAgo(2, 14), nextFollowUpAt: null,
createdAt: daysAgo(2), isKey: false,
aiProfile: {
inquiryQuantity: '待确认', concerns: ['批发价格体系'],
summary: '巴基斯坦家居批发商来信询价,尚未分配负责人,需尽快响应避免线索流失。',
nextActions: ['分配负责人', '发送报价'],
},
},
{
id: 'c-015', name: 'James Smith', company: 'Britannia Living', country: 'GB', city: 'Manchester',
email: 'james@britannialiving.co.uk', phone: '+44 161 496 0223', language: 'en',
channel: 'email', sourcePath: '企业邮箱(展会名片录入)',
products: ['北欧棉麻四件套'], tags: ['展会客户'],
intentScore: 76, valueLevel: 'B', stage: 'closing', sentiment: 'neutral-positive', riskLevel: 'low',
ownerId: 'u-003', lastContactAt: daysAgo(1, 9), nextFollowUpAt: daysFromNow(2),
createdAt: daysAgo(21), isKey: false,
aiProfile: {
inquiryQuantity: '600 套', targetPrice: '£12/套', payment: 'T/T 或 PayPal 小额',
concerns: ['洗后缩水率', '包装箱规'],
summary: '英国家居连锁买手,展会接触后持续跟进,条款接近一致,处于成交推进期。',
nextActions: ['确认最终条款', '准备形式发票'],
},
},
{
id: 'c-016', name: 'Anna Schmidt', company: 'München Elektro', country: 'DE', city: 'Munich',
email: 'anna.schmidt@melektro.de', phone: '+49 89 555 0188', language: 'de',
channel: 'webform', sourcePath: '独立站表单(自然搜索)',
products: ['65W氮化镓充电器', '智能手表'], tags: [],
intentScore: 69, valueLevel: 'B', stage: 'initial', sentiment: 'neutral', riskLevel: 'none',
ownerId: 'u-004', lastContactAt: daysAgo(1, 18), nextFollowUpAt: daysFromNow(2),
createdAt: daysAgo(2), isKey: false,
aiProfile: {
inquiryQuantity: '各 1000', concerns: ['质保周期', '欧盟关税与VAT'],
summary: '德国电子零售商,询盘充电器与手表,问题专业具体,重视质保与税务合规。',
nextActions: ['发送产品规格书', '说明DDP条款'],
},
},
{
id: 'c-017', name: 'Abdullah Al-Rashid', company: 'Jeddah Gadgets', country: 'SA', city: 'Jeddah',
email: 'abdullah@jeddahgadgets.sa', phone: '+966 56 789 0123', language: 'ar',
channel: 'whatsapp', sourcePath: 'WhatsApp(TikTok 广告)',
products: ['智能手表'], tags: ['大客户潜力'],
intentScore: 81, valueLevel: 'A', stage: 'closing', sentiment: 'positive', riskLevel: 'none',
ownerId: 'u-008', lastContactAt: hoursAgo(2), nextFollowUpAt: daysFromNow(1),
createdAt: daysAgo(10), isKey: true,
aiProfile: {
inquiryQuantity: '1500 只', targetPrice: '$11/只', customRequirement: '阿语表盘 + 开机画面',
concerns: ['阿语系统适配', '本地售后'],
summary: '沙特3C经销商,智能手表定制需求明确(阿语适配),意向强烈,临近下单。',
nextActions: ['确认阿语固件方案', '发送最终报价'],
},
},
{
id: 'c-018', name: 'Lucía Fernández', company: 'Casa Mediterránea', country: 'ES', city: 'Valencia',
email: 'lucia@casamediterranea.es', phone: '+34 654 321 098', language: 'es',
channel: 'telegram', sourcePath: 'Telegram 群组推荐',
products: ['ins风陶瓷餐具套装'], tags: ['价格敏感'],
intentScore: 55, valueLevel: 'C', stage: 'comparing', sentiment: 'neutral', riskLevel: 'none',
ownerId: 'u-005', lastContactAt: daysAgo(2, 10), nextFollowUpAt: daysFromNow(5),
createdAt: daysAgo(6), isKey: false,
aiProfile: {
inquiryQuantity: '200 套', targetPrice: '€6/套以内', concerns: ['与土耳其供应商比价'],
summary: '西班牙家居网店主,比价中,对价格敏感,可突出设计与破损包赔差异化。',
nextActions: ['发送差异化卖点', '阶梯报价'],
},
},
{
id: 'c-019', name: 'Tom Baker', company: 'Midwest Retail Group', country: 'US', city: 'Chicago',
email: 'tom.baker@midwestretail.com', phone: '+1 773 555 0119', language: 'en',
channel: 'whatsapp', sourcePath: 'WhatsApp(老客户导入)',
products: ['双层玻璃杯 350ml'], tags: ['高风险', '重点客户'],
intentScore: 38, valueLevel: 'B', stage: 'aftersales', sentiment: 'angry', riskLevel: 'urgent',
ownerId: 'u-005', lastContactAt: minutesAgo(20), nextFollowUpAt: hoursAgo(-2) as unknown as string,
createdAt: daysAgo(90), isKey: true,
aiProfile: {
aftersales: '到货 3% 破损,要求补发与赔偿', concerns: ['破损率', '赔付进度', '下次合作信任'],
summary: '美国零售集团老客户,上批货物破损引发强烈不满,情绪激烈,存在纠纷升级与流失风险,需管理层介入。',
nextActions: ['售后安抚', '管理层回电', '给出补发+赔付方案'],
},
},
{
id: 'c-020', name: 'Nusrat Jahan', company: 'Sylhet Fashion House', country: 'BD', city: 'Sylhet',
email: 'nusrat@sylhetfashion.bd', phone: '+880 1912 111222', language: 'bn',
channel: 'whatsapp', sourcePath: 'WhatsApp(历史成交客户)',
products: ['女士针织开衫'], tags: ['复购客户', '老客户'],
intentScore: 84, valueLevel: 'A', stage: 'repurchase', sentiment: 'positive', riskLevel: 'none',
ownerId: 'u-003', lastContactAt: hoursAgo(4), nextFollowUpAt: daysFromNow(2),
createdAt: daysAgo(150), isKey: true,
aiProfile: {
inquiryQuantity: '复购 1200 件(秋冬款)', concerns: ['新色卡', '交期赶在销售季前'],
summary: '孟加拉女装老客户,秋冬复购洽谈中,历史合作顺畅,关注新色卡与上市时间。',
nextActions: ['发送秋冬色卡', '确认复购排产'],
},
},
{
id: 'c-021', name: 'Hassan Ali', company: 'Islamabad Traders', country: 'PK', city: 'Islamabad',
email: 'hassan@islamabadtraders.pk', phone: '+92 345 6789012', language: 'ur',
channel: 'telegram', sourcePath: 'Telegram 频道私信',
products: ['高硼硅玻璃杯 500ml'], tags: [],
intentScore: 66, valueLevel: 'B', stage: 'comparing', sentiment: 'neutral', riskLevel: 'none',
ownerId: 'u-004', lastContactAt: daysAgo(1, 13), nextFollowUpAt: daysFromNow(3),
createdAt: daysAgo(4), isKey: false,
aiProfile: {
inquiryQuantity: '800 个', concerns: ['与本地供应商价差'],
summary: '巴基斯坦贸易商,询价比价阶段,需突出耐温差品质与出口包装。',
nextActions: ['发送测试视频', '阶梯报价'],
},
},
{
id: 'c-022', name: 'Mariam Al-Zaabi', company: 'Abu Dhabi Lifestyle', country: 'AE', city: 'Abu Dhabi',
email: 'mariam@adlifestyle.ae', phone: '+971 54 987 6543', language: 'ar',
channel: 'webform', sourcePath: '独立站表单(Instagram 广告)',
products: ['北欧棉麻四件套'], tags: [],
intentScore: 47, valueLevel: 'C', stage: 'initial', sentiment: 'neutral', riskLevel: 'none',
ownerId: 'u-008', lastContactAt: daysAgo(2, 17), nextFollowUpAt: daysFromNow(6),
createdAt: daysAgo(3), isKey: false,
aiProfile: {
inquiryQuantity: '100 套试单', concerns: ['面料成分'],
summary: '阿联酋家居电商新客,小额试单意向,需培育信任。',
nextActions: ['发送材质说明', '介绍试单政策'],
},
},
{
id: 'c-023', name: 'Peter Jones', company: 'Manchester Supplies', country: 'GB', city: 'Manchester',
email: 'peter@mansupplies.co.uk', phone: '+44 161 555 0134', language: 'en',
channel: 'whatsapp', sourcePath: 'WhatsApp(官网按钮)',
products: ['运动速干POLO衫'], tags: [],
intentScore: 59, valueLevel: 'C', stage: 'comparing', sentiment: 'neutral', riskLevel: 'none',
ownerId: 'u-003', lastContactAt: daysAgo(1, 15), nextFollowUpAt: daysFromNow(4),
createdAt: daysAgo(5), isKey: false,
aiProfile: {
inquiryQuantity: '1500 件', concerns: ['印字工艺牢度'],
summary: '英国团购服饰供应商,询POLO衫印字款,比价阶段。',
nextActions: ['发送印字工艺案例'],
},
},
{
id: 'c-024', name: 'Laura García', company: 'Valencia Home', country: 'ES', city: 'Valencia',
email: 'laura@valenciahome.es', phone: '+34 611 222 333', language: 'es',
channel: 'whatsapp', sourcePath: 'WhatsApp(Google 我的商家)',
products: ['玻璃储物罐套装'], tags: ['需要样品'],
intentScore: 72, valueLevel: 'B', stage: 'sample', sentiment: 'positive', riskLevel: 'none',
ownerId: 'u-005', lastContactAt: hoursAgo(6), nextFollowUpAt: daysFromNow(2),
createdAt: daysAgo(9), isKey: false,
aiProfile: {
inquiryQuantity: '400 套', sampleNeed: '1 套完整样品', concerns: ['硅胶圈密封性'],
summary: '西班牙家居连锁采购,样品已签收待测试反馈,进展良好。',
nextActions: ['回访样品测试结果'],
},
},
{
id: 'c-025', name: 'Imran Sheikh', company: 'Multan Glass House', country: 'PK', city: 'Multan',
email: 'imran@multanglass.pk', phone: '+92 301 7778889', language: 'ur',
channel: 'landing', sourcePath: '广告落地页(Facebook - custom glass)',
products: ['定制Logo玻璃水杯'], tags: ['定制需求'],
intentScore: 79, valueLevel: 'B', stage: 'custom', sentiment: 'neutral-positive', riskLevel: 'medium',
ownerId: 'u-004', lastContactAt: hoursAgo(10), nextFollowUpAt: daysFromNow(1),
createdAt: daysAgo(7), isKey: false,
aiProfile: {
inquiryQuantity: '600 个', customRequirement: '3色丝印 + 独立彩盒',
deliveryTime: '20 天内', concerns: ['打样费用', '颜色还原度'],
summary: '巴基斯坦玻璃制品经销商,定制需求明确,催问打样进度,对交期有硬要求,存在轻微流失风险。',
nextActions: ['确认打样排期', '发送色卡对照'],
},
},
{
id: 'c-026', name: 'Markus Braun', company: 'Berlin Smart Living', country: 'DE', city: 'Berlin',
email: 'markus@berlinsmartliving.de', phone: '+49 30 555 0145', language: 'de',
channel: 'telegram', sourcePath: 'Telegram(展会群)',
products: ['智能手表'], tags: ['展会客户'],
intentScore: 63, valueLevel: 'B', stage: 'initial', sentiment: 'neutral', riskLevel: 'none',
ownerId: 'u-008', lastContactAt: daysAgo(3, 10), nextFollowUpAt: daysFromNow(5),
createdAt: daysAgo(3), isKey: false,
aiProfile: {
inquiryQuantity: '500 只', concerns: ['GDPR 数据合规'],
summary: '德国智能家居渠道商,展会群接触,关注数据合规与隐私条款。',
nextActions: ['发送合规声明'],
},
},
{
id: 'c-027', name: 'Grace Thompson', company: 'Atlantic Kitchenware', country: 'US', city: 'New York',
email: 'grace@atlantickitchen.com', phone: '+1 212 555 0161', language: 'en',
channel: 'email', sourcePath: '企业邮箱(老客户推荐)',
products: ['高硼硅玻璃杯 500ml'], tags: [],
intentScore: 68, valueLevel: 'B', stage: 'closing', sentiment: 'neutral-positive', riskLevel: 'none',
ownerId: 'u-003', lastContactAt: daysAgo(1, 8), nextFollowUpAt: daysFromNow(3),
createdAt: daysAgo(16), isKey: false,
aiProfile: {
inquiryQuantity: '1200 个', payment: 'T/T', concerns: ['FDA 食品级检测'],
summary: '美国厨具进口商,推荐客户,条款谈判中,需提供 FDA 检测报告。',
nextActions: ['发送FDA报告', '确认订单细节'],
},
},
{
id: 'c-028', name: 'Saud Al-Harbi', company: 'Mecca Trading', country: 'SA', city: 'Jeddah',
email: 'saud@meccatrading.sa', phone: '+966 53 111 2233', language: 'ar',
channel: 'email', sourcePath: '企业邮箱(官网询盘表单)',
products: ['定制Logo玻璃水杯'], tags: ['重点客户', '斋月注意'],
intentScore: 85, valueLevel: 'A', stage: 'closing', sentiment: 'positive', riskLevel: 'low',
ownerId: 'u-004', lastContactAt: minutesAgo(150), nextFollowUpAt: daysFromNow(1),
createdAt: daysAgo(13), isKey: true,
aiProfile: {
inquiryQuantity: '3000 个(朝觐礼品季)', customRequirement: '礼盒 + 阿文祝福语烫金',
deliveryTime: '季节硬性节点', concerns: ['宗教用语准确性', '节点前交付'],
summary: '沙特大型贸易商,朝觐礼品季大单,金额高、节点硬,文案涉及宗教用语需严格校对,重点推进。',
nextActions: ['宗教文案校对确认', '锁定排产与船期'],
},
},
{
id: 'c-029', name: 'Farhana Akter', company: 'Barisal Textiles', country: 'BD', city: 'Barisal',
email: 'farhana@barisaltextiles.bd', phone: '+880 1712 555666', language: 'bn',
channel: 'email', sourcePath: '企业邮箱(B2B平台名片)',
products: ['男士纯棉T恤'], tags: [],
intentScore: 41, valueLevel: 'D', stage: 'initial', sentiment: 'neutral', riskLevel: 'none',
ownerId: null, lastContactAt: daysAgo(3, 14), nextFollowUpAt: null,
createdAt: daysAgo(3), isKey: false,
aiProfile: {
inquiryQuantity: '未明确', concerns: ['最低起订量'],
summary: '孟加拉小型纺织商,询盘信息简略,未分配负责人,需低成本触达筛选。',
nextActions: ['分配负责人', '发送公司简介'],
},
},
{
id: 'c-030', name: 'Omar bin Zayed', company: 'Sharjah Souq Trading', country: 'AE', city: 'Sharjah',
email: 'omar@sharjahsouq.ae', phone: '+971 56 444 5566', language: 'ar',
channel: 'messenger', sourcePath: 'Facebook 主页私信',
products: ['ins风陶瓷餐具套装'], tags: [],
intentScore: 56, valueLevel: 'C', stage: 'comparing', sentiment: 'neutral', riskLevel: 'none',
ownerId: 'u-005', lastContactAt: daysAgo(1, 19), nextFollowUpAt: daysFromNow(4),
createdAt: daysAgo(5), isKey: false,
aiProfile: {
inquiryQuantity: '250 套', concerns: ['批发阶梯价'],
summary: '阿联酋沙迦集市贸易商,习惯当面议价,线上比价阶段。',
nextActions: ['发送阶梯报价'],
},
},
]
+45
View File
@@ -0,0 +1,45 @@
/** Mock 时间辅助:所有演示时间相对当前时刻生成,保证演示时数据“新鲜” */
export function minutesAgo(n: number): string {
return new Date(Date.now() - n * 60000).toISOString()
}
export function hoursAgo(n: number): string {
return new Date(Date.now() - n * 3600000).toISOString()
}
export function daysAgo(n: number, hour = 10): string {
const d = new Date()
d.setDate(d.getDate() - n)
d.setHours(hour, Math.floor(Math.random() * 50) + 5, 0, 0)
return d.toISOString()
}
export function hoursFromNow(n: number): string {
return new Date(Date.now() + n * 3600000).toISOString()
}
export function daysFromNow(n: number, hour = 15): string {
const d = new Date()
d.setDate(d.getDate() + n)
d.setHours(hour, 0, 0, 0)
return d.toISOString()
}
/** 今天某个时间 */
export function todayAt(hour: number, minute = 0): string {
const d = new Date()
d.setHours(hour, minute, 0, 0)
return d.toISOString()
}
/** 近 N 天的日期标签数组(MM-dd) */
export function lastNDays(n: number): string[] {
const out: string[] = []
for (let i = n - 1; i >= 0; i--) {
const d = new Date()
d.setDate(d.getDate() - i)
out.push(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`)
}
return out
}
+45
View File
@@ -0,0 +1,45 @@
import type { IndustryPack, KnowledgeItem } from '@/types/knowledge'
import { daysAgo, hoursAgo } from './helpers'
/** 30 条企业知识 */
export const knowledgeItems: KnowledgeItem[] = [
{ id: 'k-001', title: '双层玻璃杯 350ml 产品规格书', category: 'product', content: '双层高硼硅玻璃杯,容量350ml,耐温-20°C—150°C,可定制1—3色Logo。杯身通透,双层隔热防烫。常规包装:白盒+气泡袋,可选彩盒。', tags: ['玻璃杯', '双层', '定制'], enabled: true, version: 3, updatedAt: daysAgo(10), updatedBy: '李婷', source: 'file', fileName: '双层玻璃杯350ml规格书.pdf', fileType: 'pdf', status: 'ready', citedCount: 128, languages: ['zh', 'en'], quality: 'good' },
{ id: 'k-002', title: '高硼硅玻璃杯 500ml 规格与检测', category: 'product', content: '高硼硅3.3玻璃,500ml,耐温差冲击≥150°C(50次循环测试报告编号 TR-BS2024-07)。通过FDA食品级检测。适用酒店、礼品渠道。', tags: ['高硼硅', 'FDA', '酒店'], enabled: true, version: 2, updatedAt: daysAgo(8), updatedBy: '李婷', source: 'file', fileName: '高硼硅500ml检测报告.pdf', fileType: 'pdf', status: 'ready', citedCount: 96, languages: ['zh', 'en', 'ar'], quality: 'good' },
{ id: 'k-003', title: '定制Logo玻璃水杯工艺说明', category: 'product', content: '丝印1—3色,支持潘通色号对色;另支持烤花、激光雕刻。打样周期3—5天,打样费$30(下单返还)。定制起订量500个。', tags: ['定制', '丝印', '打样'], enabled: true, version: 4, updatedAt: daysAgo(5), updatedBy: '赵磊', source: 'manual', status: 'ready', citedCount: 210, languages: ['zh', 'en', 'ur', 'ar'], quality: 'good' },
{ id: 'k-004', title: '玻璃储物罐套装(竹盖硅胶圈)', category: 'product', content: '三件套/五件套可选,竹木盖+食品级硅胶密封圈,密封防潮。容量200—1500ml。适合家居、餐饮渠道混批。', tags: ['储物罐', '套装'], enabled: true, version: 1, updatedAt: daysAgo(15), updatedBy: '周敏', source: 'file', fileName: '储物罐套装.xlsx', fileType: 'excel', status: 'ready', citedCount: 34, languages: ['zh', 'en', 'es'], quality: 'normal' },
{ id: 'k-005', title: '男士纯棉T恤(180gsm)产品资料', category: 'product', content: '180克精梳棉,预缩工艺缩水率≤3%,24色可选,S—XXL。支持定制领标、印花、刺绣。SGS检测报告齐全。', tags: ['T恤', '纯棉', '服装'], enabled: true, version: 2, updatedAt: daysAgo(6), updatedBy: '赵磊', source: 'file', fileName: 'T恤产品册.pdf', fileType: 'pdf', status: 'ready', citedCount: 88, languages: ['zh', 'en', 'bn'], quality: 'good' },
{ id: 'k-006', title: '女士针织开衫秋冬款资料', category: 'product', content: '12针针织,70%粘胶30%涤纶,手感柔软不易起球。24色,支持定制唛头。秋冬新色卡18色。', tags: ['开衫', '针织', '秋冬'], enabled: true, version: 1, updatedAt: daysAgo(4), updatedBy: '李婷', source: 'manual', status: 'ready', citedCount: 45, languages: ['zh', 'en', 'bn'], quality: 'normal' },
{ id: 'k-007', title: 'TWS蓝牙耳机规格(BT5.4)', category: 'product', content: '蓝牙5.4,游戏模式延迟45ms,ENC通话降噪,续航6+24小时。支持丝印Logo与定制包装。12个月免费换新。', tags: ['耳机', 'TWS', '3C'], enabled: true, version: 5, updatedAt: daysAgo(3), updatedBy: '赵磊', source: 'file', fileName: 'TWS耳机规格书.pdf', fileType: 'pdf', status: 'ready', citedCount: 156, languages: ['zh', 'en'], quality: 'good' },
{ id: 'k-008', title: '65W氮化镓充电器规格与认证', category: 'product', content: 'GaN 65W,双C口+单A口,满载外壳温升≤42°C。CE/RoHS/REACH齐全,TÜV检测,18个月质保。欧规/美规/英规插脚可选。', tags: ['充电器', 'GaN', '认证'], enabled: true, version: 3, updatedAt: daysAgo(7), updatedBy: '赵磊', source: 'file', fileName: 'GaN充电器认证包.zip', fileType: 'pdf', status: 'ready', citedCount: 112, languages: ['zh', 'en', 'de'], quality: 'good' },
{ id: 'k-009', title: '智能手表(阿语版)功能说明', category: 'product', content: '1.85寸屏,菜单与通知完整支持阿拉伯语,开机画面可定制Logo。心率/血氧/睡眠监测。GDPR数据合规声明可提供。', tags: ['智能手表', '阿语', '中东'], enabled: true, version: 2, updatedAt: daysAgo(9), updatedBy: '郑楠', source: 'manual', status: 'ready', citedCount: 67, languages: ['zh', 'en', 'ar'], quality: 'good' },
{ id: 'k-010', title: '玻璃杯阶梯报价规则(2026 Q3)', category: 'pricing', content: '双层350ml:500个$1.85|1000个$1.68|2000个$1.52(含单色Logo,双色+$0.08/色)。高硼硅500ml:1000个$2.10|2000个$1.95|5000个$1.78。加急费$80(10天)。', tags: ['报价', '阶梯价', '玻璃杯'], enabled: true, version: 6, updatedAt: daysAgo(2), updatedBy: '陈昊', source: 'manual', status: 'ready', citedCount: 320, languages: ['zh', 'en'], quality: 'good' },
{ id: 'k-011', title: '服装类报价规则', category: 'pricing', content: 'T恤180gsm:1000件$2.55|3000件$2.35|5000件$2.20。开衫:800件£4.20|1500件£4.05。定制领标500件起免费。', tags: ['报价', '服装'], enabled: true, version: 4, updatedAt: daysAgo(3), updatedBy: '陈昊', source: 'manual', status: 'ready', citedCount: 186, languages: ['zh', 'en'], quality: 'good' },
{ id: 'k-012', title: '3C产品报价规则', category: 'pricing', content: 'TWS耳机:2000台$8.90|季度框架8000台$8.30。GaN充电器:3000个$6.50|5000个$6.10。智能手表:1000只$11.8|1500只$11.2。', tags: ['报价', '3C'], enabled: true, version: 3, updatedAt: daysAgo(4), updatedBy: '陈昊', source: 'manual', status: 'ready', citedCount: 145, languages: ['zh', 'en'], quality: 'good' },
{ id: 'k-013', title: 'MOQ 起订量规则总表', category: 'moq', content: '玻璃杯定制500起、光杯100起;T恤500件起、开衫500件起;3C类1000台起;家居餐具100套起(首单试单可50套,价格上浮5%)。', tags: ['MOQ', '起订量'], enabled: true, version: 2, updatedAt: daysAgo(12), updatedBy: '陈昊', source: 'manual', status: 'ready', citedCount: 98, languages: ['zh', 'en'], quality: 'good' },
{ id: 'k-014', title: '定制流程与打样政策', category: 'customization', content: '流程:需求确认→设计稿→客户确认→打样(3—5天)→确认→大货。打样费$30—50下单返还。阿文等右向左文字需专业排版校对。', tags: ['定制', '打样', '流程'], enabled: true, version: 2, updatedAt: daysAgo(11), updatedBy: '李婷', source: 'manual', status: 'ready', citedCount: 76, languages: ['zh', 'en', 'ar'], quality: 'good' },
{ id: 'k-015', title: '物流政策:中东/南亚线路', category: 'logistics', content: '阿联酋/沙特:空运5—7天,海运18—25天(杰贝阿里/达曼港)。巴基斯坦:海运卡拉奇港22—28天,支持DDP拉合尔。孟加拉:海运吉大港18—22天。', tags: ['物流', '中东', '南亚'], enabled: true, version: 5, updatedAt: daysAgo(4), updatedBy: '周敏', source: 'manual', status: 'ready', citedCount: 203, languages: ['zh', 'en'], quality: 'good' },
{ id: 'k-016', title: '物流政策:欧美线路', category: 'logistics', content: '美国:海运美西18—22天,空运5—8天,DDP可选。欧洲:海运28—35天,铁路18—22天,DDP慕尼黑/瓦伦西亚可选。样品默认DHL/FedEx。', tags: ['物流', '欧美'], enabled: true, version: 4, updatedAt: daysAgo(6), updatedBy: '周敏', source: 'manual', status: 'ready', citedCount: 167, languages: ['zh', 'en'], quality: 'good' },
{ id: 'k-017', title: '易碎品包装与破损赔付政策', category: 'logistics', content: '玻璃/陶瓷类:气泡膜+珍珠棉独立包装+五层瓦楞外箱,破损率承诺<0.5%。破损免费补发或按货值120%赔付,需提供开箱照片。', tags: ['包装', '破损', '赔付'], enabled: true, version: 3, updatedAt: daysAgo(5), updatedBy: '周敏', source: 'manual', status: 'ready', citedCount: 142, languages: ['zh', 'en', 'es', 'ar'], quality: 'good' },
{ id: 'k-018', title: '付款方式与条款', category: 'payment', content: '常规:T/T 30%定金+70%见提单副本。支持:信用证L/C(金额>$10,000)、阿里信保、PayPal(小额样品单)。老客户可申请月结。', tags: ['付款', 'T/T', '信用证'], enabled: true, version: 2, updatedAt: daysAgo(20), updatedBy: '王琳', source: 'manual', status: 'ready', citedCount: 88, languages: ['zh', 'en'], quality: 'good' },
{ id: 'k-019', title: '售后政策总纲', category: 'aftersales', content: '质量问题:7天内免费退换;补发默认海运,可加急空运(责任在我方时免费)。3C产品:12—18个月质保,DOA 7天内换新。投诉24小时内响应。', tags: ['售后', '质保', '退换'], enabled: true, version: 3, updatedAt: daysAgo(14), updatedBy: '王琳', source: 'manual', status: 'ready', citedCount: 119, languages: ['zh', 'en'], quality: 'good' },
{ id: 'k-020', title: 'FAQ:玻璃杯定制常见问题', category: 'faq', content: 'Q: Logo会掉色吗?A: 高温丝印,洗碗机200次测试不掉色。Q: 能做双色吗?A: 支持1—3色,每加一色+$0.08。Q: 最小定制量?A: 500个。', tags: ['FAQ', '玻璃杯'], enabled: true, version: 2, updatedAt: daysAgo(9), updatedBy: '周敏', source: 'manual', status: 'ready', citedCount: 154, languages: ['zh', 'en', 'ur'], quality: 'good' },
{ id: 'k-021', title: 'FAQ:服装面料与尺码', category: 'faq', content: 'Q: 180gsm是什么概念?A: 中厚款,四季可穿不透。Q: 缩水率?A: 预缩工艺≤3%。Q: 欧码如何对应?A: 提供尺码对照表,支持定制。', tags: ['FAQ', '服装'], enabled: true, version: 1, updatedAt: daysAgo(16), updatedBy: '周敏', source: 'manual', status: 'ready', citedCount: 52, languages: ['zh', 'en', 'bn'], quality: 'normal' },
{ id: 'k-022', title: '企业介绍(标准版)', category: 'company', content: '徐州欧汇科技有限公司,专注玻璃制品、服装纺织与3C配件出口12年,服务全球2000+客户。自有质检团队,支持OEM/ODM,年出口额$8M。', tags: ['企业介绍'], enabled: true, version: 4, updatedAt: daysAgo(30), updatedBy: '王琳', source: 'manual', status: 'ready', citedCount: 74, languages: ['zh', 'en', 'ar', 'es'], quality: 'good' },
{ id: 'k-023', title: '产品认证清单与证书编号', category: 'certification', content: '玻璃杯:FDA(编号FDA-2024-8842)、LFGB。3C:CE/RoHS/REACH/TÜV。服装:OEKO-TEX Standard 100、SGS检测。证书原件扫描件可发客户。', tags: ['认证', 'FDA', 'CE'], enabled: true, version: 2, updatedAt: daysAgo(18), updatedBy: '赵磊', source: 'file', fileName: '认证清单.pdf', fileType: 'pdf', status: 'ready', citedCount: 93, languages: ['zh', 'en', 'de'], quality: 'good' },
{ id: 'k-024', title: '成功回复案例:交期敏感客户促单', category: 'success-reply', content: '场景:客户因开业节点要求10天交付。成功话术:承认节点重要性→给出加急方案(10天+$80)→附同类开业客户案例→限定优惠时限。转化率:该场景下68%。', tags: ['成功案例', '促单', '交期'], enabled: true, version: 1, updatedAt: daysAgo(13), updatedBy: '李婷', source: 'reply', status: 'ready', citedCount: 87, languages: ['zh', 'en'], quality: 'good' },
{ id: 'k-025', title: '成功回复案例:比价客户差异化', category: 'success-reply', content: '场景:客户持竞品低价报价。成功话术:不跟价→拆解成本差异(面料/工艺/质检)→提供增值选项→给客户保留面子的台阶。转化率54%。', tags: ['成功案例', '比价'], enabled: true, version: 1, updatedAt: daysAgo(15), updatedBy: '赵磊', source: 'reply', status: 'ready', citedCount: 79, languages: ['zh', 'en'], quality: 'good' },
{ id: 'k-026', title: '外贸术语库(中英对照)', category: 'terminology', content: 'MOQ=最低起订量;FOB=离岸价;CIF=到岸价;DDP=完税后交货;OEM=代工;ODM=原创设计制造;Lead Time=交期;PI=形式发票;B/L=提单;GSM=克重。', tags: ['术语', '中英'], enabled: true, version: 3, updatedAt: daysAgo(25), updatedBy: '周敏', source: 'manual', status: 'ready', citedCount: 234, languages: ['zh', 'en'], quality: 'good' },
{ id: 'k-027', title: '玻璃行业术语(多语种)', category: 'terminology', content: 'Borosilicate glass=高硼硅玻璃;Double-wall=双层;Thermal shock resistance=耐温差冲击;Silk screen printing=丝印;Tempered=钢化。含乌尔都语/阿拉伯语对应表达。', tags: ['术语', '玻璃'], enabled: true, version: 2, updatedAt: daysAgo(21), updatedBy: '李婷', source: 'manual', status: 'ready', citedCount: 61, languages: ['zh', 'en', 'ur', 'ar'], quality: 'normal' },
{ id: 'k-028', title: '中东客户文化注意事项', category: 'culture', content: '斋月期间白天避免提及饮食;祝福用语谨慎引用宗教经文;商务问候宜正式(如 السلام عليكم);周五为主麻日避免催单;避免酒类、猪肉相关元素;右手递物。', tags: ['文化', '中东', '斋月'], enabled: true, version: 2, updatedAt: daysAgo(28), updatedBy: '王琳', source: 'manual', status: 'ready', citedCount: 108, languages: ['zh', 'en', 'ar'], quality: 'good' },
{ id: 'k-029', title: '南亚客户沟通注意事项', category: 'culture', content: '巴基斯坦/孟加拉客户重视关系建立,开场寒暄很重要;议价是常态,预留谈判空间;乌尔都语/孟加拉语问候能显著提升好感;注意节假日(开斋节、宰牲节)。', tags: ['文化', '南亚'], enabled: true, version: 1, updatedAt: daysAgo(22), updatedBy: '李婷', source: 'manual', status: 'ready', citedCount: 66, languages: ['zh', 'en', 'ur', 'bn'], quality: 'normal' },
{ id: 'k-030', title: '欧美客户沟通注意事项', category: 'culture', content: '欧美客户偏好直接、数据化的沟通;德国客户重视认证与技术细节;美国客户决策快但重视履约记录;西班牙客户午后回复延迟属正常(午休文化)。', tags: ['文化', '欧美'], enabled: true, version: 1, updatedAt: hoursAgo(36), updatedBy: '陈昊', source: 'manual', status: 'parsing', citedCount: 12, languages: ['zh', 'en', 'de', 'es'], quality: 'low' },
]
/** 行业知识包 */
export const industryPacks: IndustryPack[] = [
{ key: 'glass', name: '玻璃杯行业包', description: '玻璃材质、工艺、检测、定制与包装知识 86 条', itemCount: 86, installed: true },
{ key: 'apparel', name: '服装行业包', description: '面料、克重、工艺、尺码与验货知识 124 条', itemCount: 124, installed: true },
{ key: '3c', name: '3C电子行业包', description: '参数、认证、质保与合规知识 98 条', itemCount: 98, installed: false },
{ key: 'home', name: '家居用品行业包', description: '材质、包装、物流与平台话术知识 72 条', itemCount: 72, installed: false },
{ key: 'general', name: '通用外贸知识包', description: '贸易术语、付款、物流、谈判通用知识 156 条', itemCount: 156, installed: true },
]
+176
View File
@@ -0,0 +1,176 @@
import type { Message, MessageSender } from '@/types/messaging'
import { minutesAgo, hoursAgo, daysAgo } from './helpers'
let seq = 0
function m(
conversationId: string,
sender: MessageSender,
originalText: string,
translatedText: string | undefined,
originalLang: string,
createdAt: string,
extra?: Partial<Message>,
): Message {
seq += 1
return {
id: `msg-${String(seq).padStart(4, '0')}`,
conversationId,
sender,
type: 'text',
originalText,
translatedText,
originalLang,
status: sender === 'agent' ? 'read' : 'delivered',
createdAt,
...extra,
}
}
/** 105 条多语种演示消息(乌尔都语/阿拉伯语/孟加拉语/西班牙语/英语) */
export const messages: Message[] = [
// ===== cv-001 巴基斯坦 Ahmed Raza:500个定制玻璃杯(核心演示场景) =====
m('cv-001', 'customer', 'السلام علیکم! مجھے اپنی دکان کے لیے 500 کسٹم گلاس کپ چاہئیں۔ کیا آپ لوگو پرنٹ کر سکتے ہیں؟', '你好!我需要为我的商店订购500个定制玻璃杯。你们可以印Logo吗?', 'ur', hoursAgo(4)),
m('cv-001', 'agent', 'Wa alaikum assalam! Yes, we support custom logo printing in 1-3 colors. Could you share your logo file and the cup size you prefer?', '你好!可以的,我们支持1—3色Logo定制印刷。方便发一下Logo文件和您想要的杯型尺寸吗?', 'en', hoursAgo(3.8)),
m('cv-001', 'customer', 'ہمارا لوگو دو رنگوں میں ہے۔ 350ml ڈبل وال کپ چاہیے۔ قیمت کیا ہوگی؟', '我们的Logo是双色的,需要350ml双层杯。价格是多少?', 'ur', hoursAgo(3.5)),
m('cv-001', 'agent', 'For 500pcs double-wall 350ml with 2-color logo: $1.85/pc including logo setup. Lead time 12-15 days. Shall I send the formal quotation?', '500个双层350ml杯、双色Logo:含制版费单价$1.85,交期12—15天。我给您发正式报价单吗?', 'en', hoursAgo(3.2)),
m('cv-001', 'customer', 'قیمت ٹھیک ہے، لیکن ڈیلیوری 10 دن میں ممکن ہے؟ ہماری دکان کی افتتاحی تقریب ہے۔', '价格可以,但10天内能交货吗?我们店要举办开业活动。', 'ur', hoursAgo(2.8), { isImportant: true }),
m('cv-001', 'agent', 'We can arrange expedited production for your opening event: 10 days with a $80 rush fee. I will also prepare a tiered quotation (500/1000/2000pcs) with past customization cases.', '我们可以为您的开业活动安排加急生产:10天交付,加急费$80。我同时准备阶梯报价(500/1000/2000个)和过往定制案例。', 'en', hoursAgo(2.5)),
m('cv-001', 'customer', 'Very good. Please also send some photos of previous logo work.', '很好。也请发一些之前的Logo定制案例照片。', 'en', hoursAgo(2.2)),
m('cv-001', 'agent', '[图片] 双色Logo定制案例-01.jpg', undefined, 'zh', hoursAgo(2), { type: 'image', attachmentName: '双色Logo定制案例-01.jpg' }),
m('cv-001', 'agent', '[图片] 双色Logo定制案例-02.jpg', undefined, 'zh', hoursAgo(2), { type: 'image', attachmentName: '双色Logo定制案例-02.jpg' }),
m('cv-001', 'customer', 'بہت خوب! میں مالک سے بات کر کے کل تک کنفرم کرتا ہوں۔', '很好!我跟老板商量后明天确认。', 'ur', minutesAgo(42)),
m('cv-001', 'system', 'AI 分析完成:意向评分 86(高价值)|阶段:价格比较|关注点:价格 / Logo定制 / 交付时间|建议:发送阶梯报价和定制案例', undefined, 'zh', minutesAgo(40)),
// ===== cv-002 阿联酋 Khalid:酒店集团采购 =====
m('cv-002', 'customer', 'السلام عليكم ورحمة الله. نحن مجموعة فنادق في دبي ونبحث عن مورد أكواب زجاجية بوروسيليكات 500 مل بكمية 2000 قطعة.', '愿平安归于你。我们是迪拜的酒店集团,寻找500ml高硼硅玻璃杯供应商,数量2000个。', 'ar', daysAgo(2, 11)),
m('cv-002', 'agent', 'Wa alaikum assalam. Thank you for your inquiry. Our borosilicate cups withstand -20°C to 150°C. I will send full specs and quotation today.', '您好,感谢您的询盘。我们的高硼硅杯可承受-20°C至150°C温差。今天给您发完整规格和报价。', 'en', daysAgo(2, 14)),
m('cv-002', 'customer', 'ممتاز. هل يمكن إرسال عينة إلى دبي؟ نحتاج تقييم الجودة أولاً.', '很好。能寄样品到迪拜吗?我们需要先评估质量。', 'ar', daysAgo(2, 16)),
m('cv-002', 'agent', 'Samples sent via DHL, tracking AE8842116. Usually 3-4 days to Dubai.', '样品已通过DHL寄出,单号AE8842116,一般3—4天到迪拜。', 'en', daysAgo(1, 10)),
m('cv-002', 'customer', 'تم استلام العينة. الجودة جيدة بشكل عام، لكن نريد اختبار تحمل الحرارة.', '样品已收到,整体质量不错,但我们想测试耐温性能。', 'ar', daysAgo(1, 15)),
m('cv-002', 'agent', 'I will share the thermal shock test report (-20°C to 150°C, 50 cycles) and can arrange a live video test if needed.', '我给您发耐温差冲击测试报告(-20°C至150°C,50次循环),需要的话也可以安排视频实测。', 'en', hoursAgo(4)),
m('cv-002', 'customer', 'جيد، في انتظار التقرير.', '好,等你的报告。', 'ar', hoursAgo(3)),
// ===== cv-003 孟加拉 Rahim:5000件T恤 =====
m('cv-003', 'customer', 'আসসালামু আলাইকুম। ৫০০০ পিস কটন টি-শার্টের দাম জানতে চাই। 180 GSM।', '你好,想咨询5000件纯棉T恤价格,180克重。', 'bn', hoursAgo(6)),
m('cv-003', 'agent', 'Wa alaikum assalam! $2.35/pc for 5000pcs, 180gsm combed cotton, S-XXL mixed sizes.', '你好!5000件、180克精梳棉、S—XXL混码,单价$2.35。', 'en', hoursAgo(5.6)),
m('cv-003', 'customer', 'দাম একটু বেশি। আমাদের টার্গেট $2.10।', '价格有点高,我们的目标是$2.10。', 'bn', hoursAgo(5)),
m('cv-003', 'agent', 'For $2.10 we can offer 170gsm, or keep 180gsm at $2.20 with free custom neck labels. Which works better for you?', '$2.10可以做170克;或者保持180克、$2.20并免费做定制领标。您看哪种更合适?', 'en', hoursAgo(4.5)),
m('cv-003', 'customer', '১৮০ GSM রাখুন। শ্রিংকেজ রেট কত?', '保持180克。缩水率是多少?', 'bn', hoursAgo(3.8)),
m('cv-003', 'agent', 'Pre-shrunk fabric, shrinkage ≤3%. SGS test report available.', '预缩工艺,缩水率≤3%,可提供SGS检测报告。', 'en', hoursAgo(2)),
m('cv-003', 'customer', 'ঠিক আছে, $2.20 মেনে নিচ্ছি। পেমেন্ট টার্মস পাঠান।', '好的,接受$2.20。请发付款条款。', 'bn', hoursAgo(1), { isImportant: true }),
// ===== cv-004 美国 Mike:样品延误投诉(风险演示) =====
m('cv-004', 'customer', 'Hi, following up on the TWS earbuds sample order #S-2205. It was supposed to arrive last Friday.', '你好,跟进一下TWS耳机样品订单#S-2205,原定上周五到货。', 'en', daysAgo(1, 9)),
m('cv-004', 'agent', 'Hi Mike, DHL shows a customs delay at LAX. Expected delivery Tuesday. Sorry for the wait.', 'Mike你好,DHL显示在洛杉矶清关延误,预计周二送达,非常抱歉。', 'en', daysAgo(1, 10)),
m('cv-004', 'customer', 'This is the second delay. We have a product launch review on Wednesday. This is unacceptable.', '这已经是第二次延误了。我们周三有新品发布评审,这无法接受。', 'en', daysAgo(1, 14), { isImportant: true }),
m('cv-004', 'agent', 'Totally understand. I have escalated with DHL and prepared a backup unit via FedEx Priority (arrives Tuesday morning) at our cost.', '完全理解。我已向DHL升级催办,同时自费用FedEx优先件补寄一台备用机(周二上午到)。', 'en', daysAgo(1, 15)),
m('cv-004', 'customer', 'Fine. But if it misses Wednesday, we will look at other suppliers.', '好吧。但如果赶不上周三,我们会考虑其他供应商。', 'en', hoursAgo(6), { isImportant: true }),
m('cv-004', 'system', 'AI 风险预警:客户情绪转为消极,提及“更换供应商”,流失风险高,建议2小时内升级处理。', undefined, 'zh', hoursAgo(5.9)),
m('cv-004', 'agent', 'Understood. You will have the FedEx tracking within 2 hours, and I will personally confirm the delivery on Tuesday.', '明白。2小时内给您FedEx单号,周二我会亲自确认签收。', 'en', hoursAgo(5)),
// ===== cv-005 西班牙 Carmen:独立站表单首询 =====
m('cv-005', 'customer', 'Hola, me interesan los juegos de cama de lino y la vajilla de cerámica. ¿Pueden enviar catálogo y precios?', '你好,我对棉麻床品套装和陶瓷餐具感兴趣,能发一下产品目录和价格吗?', 'es', hoursAgo(9)),
m('cv-005', 'agent', '¡Hola Carmen! Claro, adjunto el catálogo y la lista de precios mayoristas. MOQ 100 juegos, se pueden mezclar modelos.', 'Carmen你好!当然,附上产品目录和批发价目表。起订量100套,可混款。', 'es', hoursAgo(8.5)),
m('cv-005', 'customer', '¿El MOQ es negociable? Somos una tienda pequeña y queremos probar primero.', '起订量可以商量吗?我们是小店,想先试单。', 'es', hoursAgo(8)),
m('cv-005', 'agent', 'Para el primer pedido de prueba aceptamos 50 juegos con un ajuste de precio del 5%.', '首单试单我们接受50套,价格上浮5%。', 'es', hoursAgo(7.6)),
m('cv-005', 'customer', 'Perfecto. ¿Los colores resisten bien el lavado?', '完美。颜色耐洗吗?', 'es', hoursAgo(7.2)),
m('cv-005', 'agent', 'Sí, solidez del color grado 4-5. Te envío el informe de prueba junto con el catálogo.', '是的,色牢度4—5级。检测报告随目录一起发您。', 'es', hoursAgo(7)),
// ===== cv-006 沙特 Fahad:阿文书法礼品杯 =====
m('cv-006', 'customer', 'السلام عليكم، أريد 1000 كوب زجاجي كهدايا شركات، مع شعار بالخط العربي وعلبة هدايا.', '你好,我需要1000个玻璃杯作为企业礼品,配阿拉伯文书法Logo和礼盒。', 'ar', hoursAgo(14)),
m('cv-006', 'agent', 'وعليكم السلام. ندعم الطباعة بالخط العربي (نسخ/رقعة) وعلب الهدايا. سأرسل نماذج سابقة.', '您好。我们支持阿拉伯文书法印刷(誊抄体/波斯体)和礼盒包装,我发一些过往案例。', 'ar', hoursAgo(13)),
m('cv-006', 'customer', 'ممتاز. هل التسليم قبل رمضان ممكن؟', '很好。斋月前能交付吗?', 'ar', hoursAgo(12)),
m('cv-006', 'agent', 'نعم، الإنتاج 18 يوم + الشحن الجوي 5 أيام. ننصح بتأكيد التصميم هذا الأسبوع.', '可以,生产18天+空运5天。建议本周内确认设计稿。', 'ar', hoursAgo(11)),
m('cv-006', 'customer', 'سأرسل ملف الشعار اليوم. أرجو مراعاة الدقة في الخط العربي.', '我今天发Logo文件,请务必注意阿拉伯书法的准确性。', 'ar', hoursAgo(10), { isImportant: true }),
m('cv-006', 'agent', 'حتماً. لدينا مصمم عربي يراجع التصميم قبل الطباعة، وسنرسل لك المعاينة للاعتماد.', '一定。我们有阿拉伯设计师在印刷前校对设计稿,并发送预览给您确认。', 'ar', hoursAgo(9)),
// ===== cv-007 英国 Emma:开衫比价 =====
m('cv-007', 'customer', "Hi! Do you do women's knit cardigans? Looking for around 800 pcs.", '你好!你们做女士针织开衫吗?大约要800件。', 'en', hoursAgo(15)),
m('cv-007', 'agent', 'Hi Emma! Yes - 12gg knit, 70% viscose 30% polyester, 24 colors available. MOQ 500. Want the color card?', 'Emma你好!做的——12针针织,70%粘胶30%涤纶,24色可选,起订量500件。需要色卡吗?', 'en', hoursAgo(14)),
m('cv-007', 'customer', "Yes please. Also your best price for 800 - I'm comparing a few suppliers.", '需要。另外800件的最优价是多少——我在比较几家供应商。', 'en', hoursAgo(13.5)),
m('cv-007', 'agent', '£4.20/pc at 800pcs including custom labels, with a free pre-production sample.', '800件单价£4.20,含定制唛头,并提供免费产前样。', 'en', hoursAgo(13)),
m('cv-007', 'customer', 'Hmm, another factory quoted £3.95. Can you match?', '嗯,另一家工厂报£3.95,你们能匹配吗?', 'en', hoursAgo(12.5), { isImportant: true }),
m('cv-007', 'agent', '£3.95 is below our yarn cost. I can do £4.05 with upgraded shell buttons, or keep £4.20 with free hang tags and polybags.', '£3.95已低于我们的纱线成本。我可以做到£4.05并升级贝壳扣,或者保持£4.20免费送吊牌和包装袋。', 'en', hoursAgo(12)),
// ===== cv-008 德国 Jonas:GaN充电器认证 =====
m('cv-008', 'customer', 'Guten Tag. We need 3000 pcs 65W GaN chargers, EU plug. Please confirm CE and RoHS certificates.', '您好。我们需要3000个65W氮化镓充电器(欧规插脚),请确认CE和RoHS认证。', 'en', daysAgo(2, 9)),
m('cv-008', 'agent', 'Hello Mr. Weber. CE, RoHS and REACH certificates are all available, TÜV tested. Sending certificates and datasheet now.', 'Weber先生您好。CE、RoHS、REACH证书齐全,TÜV检测。现在给您发证书和规格书。', 'en', daysAgo(2, 10)),
m('cv-008', 'customer', 'Danke. What is the temperature rise at full load?', '谢谢。满载温升是多少?', 'en', daysAgo(1, 9)),
m('cv-008', 'agent', 'Max 42°C case temperature at 65W full load (25°C ambient). Test report attached.', '65W满载时外壳最高42°C(环境25°C),测试报告见附件。', 'en', daysAgo(1, 10)),
m('cv-008', 'customer', 'OK. Please send 2 samples to Düsseldorf for our lab test.', '好的,请寄2个样品到杜塞尔多夫给我们实验室测试。', 'en', daysAgo(1, 14)),
m('cv-008', 'agent', 'Samples shipped today, UPS tracking 1Z8842EU. Lab test video is also available.', '样品今天已寄出,UPS单号1Z8842EU,也可提供实验室测试视频。', 'en', hoursAgo(26)),
// ===== cv-009 巴基斯坦 Fatima:面料咨询 =====
m('cv-009', 'customer', 'السلام علیکم، مجھے گارمنٹس کا کاروبار ہے۔ 1000 ٹی شرٹس کی قیمت بتائیں۔', '你好,我做服装生意,请报1000件T恤的价格。', 'ur', daysAgo(1, 11)),
m('cv-009', 'agent', 'Wa alaikum assalam! For 1000pcs the price is $2.55/pc, 180gsm combed cotton.', '你好!1000件单价$2.55,180克精梳棉。', 'en', daysAgo(1, 12)),
m('cv-009', 'customer', 'کپڑا کون سا GSM ہے اور رنگ کتنے ہیں؟', '面料多少克重,有多少种颜色?', 'ur', daysAgo(1, 13)),
m('cv-009', 'agent', '180gsm combed cotton, 24 colors. Sending you the color card now.', '180克精梳棉,24种颜色。现在发色卡给您。', 'en', daysAgo(1, 13)),
m('cv-009', 'customer', 'شکریہ، میں دیکھ کر بتاؤں گی۔', '谢谢,我看完之后回复你。', 'ur', hoursAgo(30)),
// ===== cv-010 阿联酋 Layla:餐具防碎 =====
m('cv-010', 'customer', 'مرحبا! شفت طقم الأواني عندكم بالانستقرام. كم سعر 300 طقم؟', '你好!在Instagram上看到你们的餐具套装,300套多少钱?', 'ar', hoursAgo(11)),
m('cv-010', 'agent', 'أهلاً ليلى! 300 طقم بسعر 7.8$ للطقم، شامل تغليف الهدايا.', 'Layla你好!300套,每套$7.8,含礼品包装。', 'ar', hoursAgo(10)),
m('cv-010', 'customer', 'التغليف كويس؟ أبيع أونلاين ولازم يوصل بدون كسر.', '包装好吗?我做电商销售,必须保证到货不碎。', 'ar', hoursAgo(9.5), { isImportant: true }),
m('cv-010', 'agent', 'كل قطعة مغلفة بفقاعات وفوم، نسبة الكسر أقل من 0.5%، وأي كسر نعوضه مجاناً.', '每件都有气泡膜+珍珠棉独立包装,破损率低于0.5%,破损免费补发。', 'ar', hoursAgo(9)),
m('cv-010', 'customer', 'ممتاز! أرسلي صور التغليف لو سمحتي.', '太好了!请发一下包装照片。', 'ar', hoursAgo(8)),
// ===== cv-011 西班牙 Carlos =====
m('cv-011', 'customer', 'Buenas, ¿precio para 150 juegos de tarros de vidrio con tapa?', '你好,150套带盖玻璃储物罐什么价格?', 'es', daysAgo(2, 10)),
m('cv-011', 'agent', 'Hola Carlos, 150 juegos a €3.9/juego, tapa de bambú con junta de silicona.', 'Carlos你好,150套单价€3.9,竹盖配硅胶密封圈。', 'es', daysAgo(2, 11)),
m('cv-011', 'customer', '¿Hacen envíos a Barcelona? ¿Qué plazo?', '你们发到巴塞罗那吗?多久?', 'es', daysAgo(2, 15)),
m('cv-011', 'agent', 'Sí, DDP Barcelona en 18-22 días. También tenemos servicio exprés de 10 días.', '可以,DDP巴塞罗那18—22天,也有10天加急服务。', 'es', daysAgo(1, 16)),
// ===== cv-012 美国 Sarah:TWS耳机框架单 =====
m('cv-012', 'customer', 'Saw your Google ad. Need 2000 TWS earbuds with our logo, quarterly commitment 8000. Best price?', '看到你们的Google广告。需要2000台印Logo的TWS耳机,季度框架8000台。最优价?', 'en', hoursAgo(3)),
m('cv-012', 'agent', '$8.90/unit at 2000, or $8.30 with the 8000 quarterly commitment. Custom logo and packaging included.', '2000台单价$8.90;承诺季度8000台则$8.30。含Logo定制与包装。', 'en', hoursAgo(2.6)),
m('cv-012', 'customer', 'Bluetooth 5.4? What is the latency for gaming?', '是蓝牙5.4吗?游戏延迟多少?', 'en', hoursAgo(2)),
m('cv-012', 'agent', 'BT 5.4 with 45ms low-latency game mode. Sending the spec sheet and warranty policy (12 months, free replacement).', '蓝牙5.4,游戏模式延迟45ms。给您发规格书和保修政策(12个月免费换新)。', 'en', minutesAgo(95)),
// ===== cv-013 孟加拉 Tanvir:POLO样品反馈 =====
m('cv-013', 'customer', 'পোলো শার্টের স্যাম্পল পেয়েছি, কাপড় ভালো লেগেছে।', 'POLO衫样品收到了,面料感觉不错。', 'bn', daysAgo(2, 9)),
m('cv-013', 'agent', 'Thank you! I have sent the dry-fit test report, please check.', '谢谢!速干检测报告已发您,请查收。', 'en', daysAgo(2, 10)),
m('cv-013', 'customer', 'রিপোর্ট ঠিক আছে। ২০০০ পিসের ফাইনাল প্রাইস দিন।', '报告没问题。给2000件的最终价格。', 'bn', daysAgo(1, 10)),
m('cv-013', 'agent', '$3.10/pc for 2000pcs, with free hang tags and polybags.', '2000件单价$3.10,免费送吊牌和包装袋。', 'en', daysAgo(1, 11)),
// ===== cv-014 巴基斯坦 Omar(未分配) =====
m('cv-014', 'customer', 'Please quote glass storage jar sets for wholesale. We supply shops across Punjab.', '请报玻璃储物罐套装的批发价,我们给旁遮普省的商店供货。', 'en', daysAgo(3, 10)),
m('cv-014', 'customer', 'کیا آپ پاکستان بھیجتے ہیں؟', '你们发到巴基斯坦吗?', 'ur', daysAgo(3, 10)),
m('cv-014', 'system', 'AI 提示:该会话尚未分配负责人,已超过 24 小时未响应,建议立即分配。', undefined, 'zh', daysAgo(2, 14)),
// ===== cv-015 英国 James =====
m('cv-015', 'customer', 'Following up on the bedding sets. £12/set is our ceiling for 600 sets.', '跟进一下床品套装,600套我们的上限是£12/套。', 'en', daysAgo(2, 8)),
m('cv-015', 'agent', 'We can meet £12 for the 4-piece set in 200TC cotton-linen blend. PI is ready to send.', '200TC棉麻混纺四件套可以做到£12,形式发票已备好。', 'en', daysAgo(2, 9)),
m('cv-015', 'customer', 'What is the shrinkage after washing?', '水洗后的缩水率是多少?', 'en', daysAgo(1, 8)),
m('cv-015', 'agent', '≤3% with pre-shrunk, OEKO-TEX certified fabric. I will include the certificate with the PI.', '预缩面料缩水率≤3%,OEKO-TEX认证,证书随PI一起发您。', 'en', daysAgo(1, 9)),
// ===== cv-016 德国 Anna =====
m('cv-016', 'customer', 'Hallo, interested in 1000 GaN chargers and 1000 smart watches. What are the warranty terms?', '你好,对1000个GaN充电器和1000只智能手表感兴趣,质保条款是什么?', 'en', daysAgo(2, 9)),
m('cv-016', 'agent', '18-month warranty on chargers, 12 months on watches, DOA replacement within 7 days.', '充电器18个月质保,手表12个月,到货7天内故障免费换新。', 'en', daysAgo(2, 10)),
m('cv-016', 'customer', 'Do you handle EU VAT and customs? We need DDP Munich.', '你们处理欧盟VAT和清关吗?我们需要DDP慕尼黑。', 'en', daysAgo(1, 17)),
m('cv-016', 'agent', 'Yes, DDP Munich is available with all duties included. The detailed quote will reach you tomorrow.', '可以,DDP慕尼黑含全部税费,详细报价明天发您。', 'en', daysAgo(1, 18)),
// ===== cv-017 沙特 Abdullah =====
m('cv-017', 'customer', 'الساعة الذكية تدعم اللغة العربية كاملة؟ الواجهة والقوائم؟', '智能手表完整支持阿拉伯语吗?界面和菜单?', 'ar', hoursAgo(5)),
m('cv-017', 'agent', 'نعم، القوائم والإشعارات بالعربية بالكامل، ويمكن تخصيص شاشة التشغيل بشعاركم.', '是的,菜单和通知完整支持阿拉伯语,开机画面可定制您的Logo。', 'ar', hoursAgo(4)),
m('cv-017', 'customer', 'ممتاز. كم سعر 1500 قطعة؟', '很好。1500只多少钱?', 'ar', hoursAgo(3)),
m('cv-017', 'agent', '$11.2 للقطعة، مع شاشة تشغيل مخصصة وضمان محلي عبر شريكنا في جدة.', '每只$11.2,含定制开机画面,并通过吉达合作伙伴提供本地保修。', 'ar', hoursAgo(2)),
// ===== cv-018 西班牙 Lucía =====
m('cv-018', 'customer', '¿Precio de 200 juegos de vajilla? Tengo otra oferta de Turquía.', '200套餐具什么价格?我还有一个土耳其的报价。', 'es', daysAgo(3, 9)),
m('cv-018', 'agent', '200 juegos a €6.4/juego. Nuestro esmaltado es grado A y las roturas están cubiertas al 100%.', '200套单价€6.4。我们的釉面是A级,破损100%包赔。', 'es', daysAgo(3, 10)),
m('cv-018', 'customer', 'Turquía me da €6.0. ¿Qué me pueden ofrecer mejor?', '土耳其给我€6.0,你们能提供什么更好的条件?', 'es', daysAgo(2, 9)),
m('cv-018', 'agent', 'Diseños exclusivos sin molde compartido, reposición gratuita de roturas y envío DDP Valencia.', '独家设计不开公共模、破损免费补发、DDP瓦伦西亚送货。', 'es', daysAgo(2, 10)),
// ===== cv-019 美国 Tom:破损投诉(紧急风险) =====
m('cv-019', 'customer', 'The shipment arrived with 3% broken cups. 37 cartons damaged. This is the second time!', '到货有3%的杯子破损,37箱受损,这已经是第二次了!', 'en', hoursAgo(1.2), { isImportant: true }),
m('cv-019', 'agent', 'Tom, I am really sorry. We will replace all broken units for free and cover the return shipping.', 'Tom,非常抱歉。我们将免费补发所有破损件并承担退货运费。', 'en', hoursAgo(1)),
m('cv-019', 'customer', 'Replacement takes 3 weeks. My promotion starts in 10 days. What are you going to do about it?', '补发要3周,我的促销10天后开始。你们打算怎么解决?', 'en', minutesAgo(40), { isImportant: true }),
m('cv-019', 'agent', 'We will air-ship replacements at our cost (5 days) plus an 8% credit on this order. My manager will call you today.', '我们将自费空运补发(5天到),并给本单8%的补偿额度。我的主管今天会亲自致电您。', 'en', minutesAgo(30)),
m('cv-019', 'customer', "You'd better. This is your last chance.", '最好如此,这是你们最后的机会。', 'en', minutesAgo(20), { isImportant: true }),
// ===== cv-020 孟加拉 Nusrat:复购 =====
m('cv-020', 'customer', 'আমাদের শীতের জন্য কার্ডিগান রিঅর্ডার করতে চাই।', '我们想为冬季复购开衫。', 'bn', hoursAgo(5)),
m('cv-020', 'agent', 'Great! Sending the new winter color card - 18 new colors this season.', '太好了!给您发新的冬季色卡——本季新增18个颜色。', 'en', hoursAgo(4.6)),
m('cv-020', 'customer', '১২০০ পিস। আগের মতো কোয়ালিটি চাই। ডেলিভারি কবে?', '1200件,品质要和之前一样。什么时候交貨?', 'bn', hoursAgo(4.2)),
m('cv-020', 'agent', 'Same fabric and workmanship, delivery in 25 days - well before November.', '同样的面料和工艺,25天交付——远在11月之前。', 'en', hoursAgo(4)),
]
+109
View File
@@ -0,0 +1,109 @@
import type { ActivityLog, CustomerFile } from '@/types/crm'
import type { ApiKeyItem, AppNotification, BillingInfo, BillingOrder, Invoice, LoginRecord, OperationLog, SystemSettings } from '@/types/org'
import { daysAgo, hoursAgo, minutesAgo } from './helpers'
export const notifications: AppNotification[] = [
{ id: 'n-001', type: 'inquiry', title: '新询盘:Sarah Miller(美国)', description: '通过广告落地页咨询 2000 台 TWS 耳机,意向评分 88', createdAt: minutesAgo(90), read: false, link: '/inbox?c=cv-012' },
{ id: 'n-002', type: 'risk', title: '紧急风险:Tom Baker 售后纠纷', description: '客户情绪不满,发出最后通牒,请立即处理', createdAt: minutesAgo(18), read: false, link: '/risks' },
{ id: 'n-003', type: 'task', title: '任务即将到期:发送阶梯报价', description: 'Ahmed Raza 的报价任务今天 16:00 截止', createdAt: minutesAgo(45), read: false, link: '/opportunities' },
{ id: 'n-004', type: 'channel', title: 'Outlook 渠道授权失效', description: '授权令牌已过期,请重新授权以恢复同步', createdAt: hoursAgo(26), read: true, link: '/channels' },
{ id: 'n-005', type: 'inquiry', title: '新询盘:Carmen López(西班牙)', description: '独立站表单提交,咨询床品与餐具', createdAt: hoursAgo(7), read: true, link: '/inbox?c=cv-005' },
{ id: 'n-006', type: 'system', title: '系统公告:同声传译功能上线', description: '实时语音翻译已开放试用,欢迎体验', createdAt: daysAgo(2, 9), read: true, link: '/interpret' },
]
export const activityLogs: ActivityLog[] = [
{ id: 'al-001', customerId: 'c-001', actorName: '李婷', action: '发送消息', detail: 'WhatsApp 发送定制案例图片 2 张', createdAt: hoursAgo(2) },
{ id: 'al-002', customerId: 'c-001', actorName: 'AI 助手', action: '意向评分更新', detail: '76 → 86(明确数量与开业节点)', createdAt: minutesAgo(40) },
{ id: 'al-003', customerId: 'c-001', actorName: '李婷', action: '创建任务', detail: '发送阶梯报价(今天 16:00 截止)', createdAt: daysAgo(1, 18) },
{ id: 'al-004', customerId: 'c-001', actorName: '系统', action: '自动建档', detail: '来源:WhatsApp(官网按钮),匹配手机号建档', createdAt: daysAgo(4, 9) },
{ id: 'al-005', customerId: 'c-019', actorName: '周敏', action: '风险确认', detail: '确认售后投诉风险,创建补偿方案任务', createdAt: minutesAgo(30) },
{ id: 'al-006', customerId: 'c-019', actorName: '王琳', action: '添加备注', detail: '管理层今日 14:00 前致电客户', createdAt: minutesAgo(25) },
{ id: 'al-007', customerId: 'c-012', actorName: '赵磊', action: '修改阶段', detail: '商务谈判 → 待成交', createdAt: hoursAgo(3) },
{ id: 'al-008', customerId: 'c-003', actorName: '赵磊', action: '发送消息', detail: '报价 $2.20/pc 被接受', createdAt: hoursAgo(1) },
]
export const customerFiles: CustomerFile[] = [
{ id: 'f-001', customerId: 'c-001', name: '报价单-RAZA-500pcs.pdf', kind: 'quote', size: '128 KB', uploadedBy: '李婷', createdAt: daysAgo(1) },
{ id: 'f-002', customerId: 'c-001', name: '客户Logo源文件.ai', kind: 'image', size: '2.4 MB', uploadedBy: 'Ahmed Raza', createdAt: daysAgo(2) },
{ id: 'f-003', customerId: 'c-002', name: '耐温差测试报告-TR-BS2024-07.pdf', kind: 'doc', size: '860 KB', uploadedBy: '李婷', createdAt: daysAgo(1) },
{ id: 'f-004', customerId: 'c-019', name: '破损照片-37箱.zip', kind: 'image', size: '18 MB', uploadedBy: 'Tom Baker', createdAt: hoursAgo(1) },
{ id: 'f-005', customerId: 'c-012', name: '框架合同草案-v2.docx', kind: 'contract', size: '96 KB', uploadedBy: '赵磊', createdAt: hoursAgo(3) },
]
export const apiKeys: ApiKeyItem[] = [
{ id: 'ak-001', name: 'ERP 对接(测试)', keyPreview: 'lx_live_8f2k••••••••9d21', createdAt: daysAgo(45), lastUsedAt: hoursAgo(6), status: 'active' },
{ id: 'ak-002', name: '官网表单 Webhook', keyPreview: 'lx_live_3m9q••••••••77ab', createdAt: daysAgo(120), lastUsedAt: minutesAgo(31), status: 'active' },
]
export const billingInfo: BillingInfo = {
planKey: 'pro',
planName: '专业版',
expireAt: daysAgo(-217),
autoRenew: true,
usage: {
members: { used: 8, total: 20, unit: '席' },
messages: { used: 42680, total: 100000, unit: '条/月' },
translations: { used: 12840, total: 200000, unit: '次/月' },
knowledgeStorage: { used: 486, total: 5120, unit: 'MB' },
},
}
export const billingOrders: BillingOrder[] = [
{ id: 'bo-001', orderNo: 'LX20250715001', planName: '专业版(年付)', period: '2025-07-15 至 2026-07-14', amount: 49800, status: 'paid', createdAt: daysAgo(372) },
{ id: 'bo-002', orderNo: 'LX20240712003', planName: '标准工作台版(年付)', period: '2024-07-12 至 2025-07-11', amount: 19800, status: 'paid', createdAt: daysAgo(740) },
{ id: 'bo-003', orderNo: 'LX20260702008', planName: '行业知识包-玻璃杯(加购)', period: '一次性', amount: 1200, status: 'paid', createdAt: daysAgo(20) },
]
export const invoices: Invoice[] = [
{ id: 'inv-001', invoiceNo: 'INV-2025-0715', title: '徐州欧汇科技有限公司', amount: 49800, status: 'issued', createdAt: daysAgo(365) },
{ id: 'inv-002', invoiceNo: 'INV-2026-0702', title: '徐州欧汇科技有限公司', amount: 1200, status: 'issued', createdAt: daysAgo(18) },
]
export const defaultSettings: SystemSettings = {
company: {
name: '徐州欧汇科技有限公司',
industry: '玻璃制品',
size: '21—50人',
contact: '0516-8888-6621',
email: 'contact@ouhui.tech',
intro: '专注玻璃制品、服装纺织与3C配件出口12年,服务全球2000+客户,支持OEM/ODM。',
products: ['定制Logo玻璃水杯', '高硼硅玻璃杯 500ml', '男士纯棉T恤'],
regions: ['中东', '南亚', '北美', '欧洲'],
},
ai: {
replyLanguage: 'auto', replyTone: 'professional', autoGenerate: true,
requireConfirm: true, sentimentAnalysis: true, riskAlert: true,
cultureCheck: true, allowHistoryRef: true,
},
notification: {
newInquiry: true, highIntent: true, riskAlert: true, taskDue: true,
taskOverdue: true, channelOffline: true, planExpire: true, byEmail: true, bySms: false,
},
translate: {
industry: '玻璃制品', formality: 'professional', targetCountry: 'auto',
localization: true, keepBrand: true, keepModel: true, autoUnit: true,
cultureReminder: true, religionReminder: true,
},
language: 'zh', timezone: 'Asia/Shanghai', sessionRetentionMonths: 24,
brandColor: '#247b71',
onboarding: { step1: true, step2: true, step3: true, step4: true, step5: false, step6: true, step7: false, step8: false },
}
export const operationLogs: OperationLog[] = [
{ id: 'op-001', actorId: 'u-001', actorName: '王琳', action: '邀请成员', target: '郑楠', detail: '邀请加入,角色:外贸业务员', ip: '112.84.12.66', createdAt: daysAgo(120) },
{ id: 'op-002', actorId: 'u-002', actorName: '陈昊', action: '批量分配客户', target: '8 个客户', detail: '分配给 李婷(4)、郑楠(4)', ip: '112.84.12.71', createdAt: daysAgo(3, 10) },
{ id: 'op-003', actorId: 'u-003', actorName: '李婷', action: '导出客户', target: '客户列表', detail: '导出 30 条客户数据(CSV)', ip: '112.84.12.83', createdAt: daysAgo(2, 15) },
{ id: 'op-004', actorId: 'u-004', actorName: '赵磊', action: '修改商机阶段', target: 'Sarah Miller 框架单', detail: '商务谈判 → 待成交', ip: '112.84.12.90', createdAt: hoursAgo(3) },
{ id: 'op-005', actorId: 'u-005', actorName: '周敏', action: '处理风险', target: 'Tom Baker 投诉', detail: '确认风险并创建补偿任务', ip: '112.84.12.95', createdAt: minutesAgo(30) },
{ id: 'op-006', actorId: 'u-001', actorName: '王琳', action: '修改AI设置', target: '系统设置', detail: '开启“发送前必须人工确认”', ip: '112.84.12.66', createdAt: daysAgo(1, 11) },
{ id: 'op-007', actorId: 'u-008', actorName: '郑楠', action: '上传知识', target: '智能手表(阿语版)功能说明', detail: '新增知识条目', ip: '112.84.12.99', createdAt: daysAgo(9, 14) },
]
export const loginRecords: LoginRecord[] = [
{ id: 'lr-001', memberId: 'u-003', memberName: '李婷', device: 'Chrome / Windows 11', ip: '112.84.12.83', location: '江苏徐州', status: 'success', createdAt: minutesAgo(8) },
{ id: 'lr-002', memberId: 'u-004', memberName: '赵磊', device: 'Chrome / macOS', ip: '119.23.44.12', location: '境外(拉合尔)', status: 'abnormal', createdAt: hoursAgo(16) },
{ id: 'lr-003', memberId: 'u-008', memberName: '郑楠', device: 'App / iPhone 15', ip: '112.84.15.201', location: '江苏徐州', status: 'success', createdAt: minutesAgo(3) },
{ id: 'lr-004', memberId: 'u-005', memberName: '周敏', device: 'Edge / Windows 10', ip: '112.84.12.95', location: '江苏徐州', status: 'success', createdAt: minutesAgo(15) },
{ id: 'lr-005', memberId: 'u-007', memberName: '吴迪', device: 'Safari / macOS', ip: '112.84.18.40', location: '江苏徐州', status: 'failed', createdAt: daysAgo(1, 9) },
{ id: 'lr-006', memberId: 'u-001', memberName: '王琳', device: 'Chrome / Windows 11', ip: '112.84.12.66', location: '江苏徐州', status: 'success', createdAt: minutesAgo(12) },
]
+30
View File
@@ -0,0 +1,30 @@
import type { Opportunity } from '@/types/crm'
import { daysAgo, daysFromNow, hoursAgo } from './helpers'
/** 18 个商机(覆盖看板各阶段) */
export const opportunities: Opportunity[] = [
{ id: 'o-001', name: '500个定制Logo玻璃杯(开业单)', customerId: 'c-001', customerName: 'Ahmed Raza', country: 'PK', product: '定制Logo玻璃水杯', quantity: '500个', amount: 925, probability: 65, stage: 'quoting', ownerId: 'u-003', expectedDealAt: daysFromNow(4), lastSummary: '客户接受$1.85单价,卡交期10天', nextAction: '发送阶梯报价和定制案例', updatedAt: hoursAgo(2) },
{ id: 'o-002', name: '酒店集团2000个高硼硅杯', customerId: 'c-002', customerName: 'Khalid Al Mansouri', country: 'AE', product: '高硼硅玻璃杯 500ml', quantity: '2000个', amount: 4200, probability: 55, stage: 'sample', ownerId: 'u-003', expectedDealAt: daysFromNow(20), lastSummary: '样品已签收,等待耐温测试报告反馈', nextAction: '发送测试报告并回访', updatedAt: hoursAgo(3) },
{ id: 'o-003', name: '5000件纯棉T恤', customerId: 'c-003', customerName: 'Rahim Uddin', country: 'BD', product: '男士纯棉T恤', quantity: '5000件', amount: 11000, probability: 80, stage: 'negotiation', ownerId: 'u-004', expectedDealAt: daysFromNow(5), lastSummary: '$2.20/pc 价格锁定,待付款条款', nextAction: '发送PI确认付款', updatedAt: hoursAgo(1) },
{ id: 'o-004', name: 'TWS耳机首单(样品阶段)', customerId: 'c-004', customerName: 'Mike Johnson', country: 'US', product: 'TWS蓝牙耳机', quantity: '1000台', amount: 8900, probability: 25, stage: 'aftersales', ownerId: 'u-005', expectedDealAt: daysFromNow(30), lastSummary: '样品两次延误引发不满', nextAction: '加急补寄+安抚', updatedAt: hoursAgo(5) },
{ id: 'o-005', name: '床品+餐具首单试单', customerId: 'c-005', customerName: 'Carmen López', country: 'ES', product: '北欧棉麻四件套', quantity: '50套', amount: 780, probability: 40, stage: 'new', ownerId: 'u-003', expectedDealAt: daysFromNow(15), lastSummary: '首询,已发目录,谈MOQ', nextAction: '发送试单政策', updatedAt: hoursAgo(7) },
{ id: 'o-006', name: '1000个阿文书法礼品杯', customerId: 'c-006', customerName: 'Fahad Al-Otaibi', country: 'SA', product: '定制Logo玻璃水杯', quantity: '1000个', amount: 2100, probability: 60, stage: 'custom', ownerId: 'u-004', expectedDealAt: daysFromNow(12), lastSummary: '斋月前交付,等客户Logo文件', nextAction: '设计稿内审后发确认', updatedAt: hoursAgo(9) },
{ id: 'o-007', name: '800件针织开衫', customerId: 'c-007', customerName: 'Emma Wilson', country: 'GB', product: '女士针织开衫', quantity: '800件', amount: 4100, probability: 45, stage: 'quoting', ownerId: 'u-008', expectedDealAt: daysFromNow(18), lastSummary: '比价中,对手£3.95', nextAction: '差异化报价', updatedAt: hoursAgo(12) },
{ id: 'o-008', name: '3000个GaN充电器', customerId: 'c-008', customerName: 'Jonas Weber', country: 'DE', product: '65W氮化镓充电器', quantity: '3000个', amount: 19500, probability: 55, stage: 'sample', ownerId: 'u-004', expectedDealAt: daysFromNow(35), lastSummary: '样品寄出,等实验室测试', nextAction: '跟进测试结果', updatedAt: hoursAgo(26) },
{ id: 'o-009', name: '300套餐具电商订单', customerId: 'c-010', customerName: 'Layla Hassan', country: 'AE', product: 'ins风陶瓷餐具套装', quantity: '300套', amount: 2340, probability: 55, stage: 'quoting', ownerId: 'u-005', expectedDealAt: daysFromNow(8), lastSummary: '关注防碎包装,已承诺破损包赔', nextAction: '发包装照片', updatedAt: hoursAgo(8) },
{ id: 'o-010', name: '2000台TWS耳机季度框架', customerId: 'c-012', customerName: 'Sarah Miller', country: 'US', product: 'TWS蓝牙耳机', quantity: '2000台/季', amount: 17800, probability: 75, stage: 'pending-deal', ownerId: 'u-004', expectedDealAt: daysFromNow(7), lastSummary: '参数确认完毕,待合同', nextAction: '发送合同草案', updatedAt: minutesAgoLocal() },
{ id: 'o-011', name: '2000件速干POLO衫', customerId: 'c-013', customerName: 'Tanvir Hasan', country: 'BD', product: '运动速干POLO衫', quantity: '2000件', amount: 6200, probability: 65, stage: 'negotiation', ownerId: 'u-008', expectedDealAt: daysFromNow(10), lastSummary: '样品满意,最终报价已发', nextAction: '等客户确认', updatedAt: daysAgo(1, 11) },
{ id: 'o-012', name: '600套棉麻四件套', customerId: 'c-015', customerName: 'James Smith', country: 'GB', product: '北欧棉麻四件套', quantity: '600套', amount: 9200, probability: 70, stage: 'pending-deal', ownerId: 'u-003', expectedDealAt: daysFromNow(6), lastSummary: '£12/套达成一致,PI已备', nextAction: '回访PI确认', updatedAt: daysAgo(1, 9) },
{ id: 'o-013', name: '1500只智能手表(阿语定制)', customerId: 'c-017', customerName: 'Abdullah Al-Rashid', country: 'SA', product: '智能手表', quantity: '1500只', amount: 16800, probability: 70, stage: 'pending-deal', ownerId: 'u-008', expectedDealAt: daysFromNow(9), lastSummary: '阿语适配确认,报价$11.2', nextAction: '发送固件演示', updatedAt: hoursAgo(2) },
{ id: 'o-014', name: '1200件开衫秋冬复购', customerId: 'c-020', customerName: 'Nusrat Jahan', country: 'BD', product: '女士针织开衫', quantity: '1200件', amount: 5900, probability: 85, stage: 'won', ownerId: 'u-003', expectedDealAt: daysFromNow(2), lastSummary: '复购意向明确,色卡确认中', nextAction: '确认排产', updatedAt: hoursAgo(4) },
{ id: 'o-015', name: '3000个朝觐礼品杯', customerId: 'c-028', customerName: 'Saud Al-Harbi', country: 'SA', product: '定制Logo玻璃水杯', quantity: '3000个', amount: 6900, probability: 75, stage: 'negotiation', ownerId: 'u-004', expectedDealAt: daysFromNow(11), lastSummary: '烫金阿文祝福语,节点硬', nextAction: '宗教文案校对', updatedAt: minutesAgoLocal() },
{ id: 'o-016', name: '600个三色丝印定制杯', customerId: 'c-025', customerName: 'Imran Sheikh', country: 'PK', product: '定制Logo玻璃水杯', quantity: '600个', amount: 1350, probability: 60, stage: 'custom', ownerId: 'u-004', expectedDealAt: daysFromNow(14), lastSummary: '催打样排期', nextAction: '确认打样', updatedAt: hoursAgo(10) },
{ id: 'o-017', name: '1200个高硼硅杯(FDA)', customerId: 'c-027', customerName: 'Grace Thompson', country: 'US', product: '高硼硅玻璃杯 500ml', quantity: '1200个', amount: 2700, probability: 65, stage: 'negotiation', ownerId: 'u-003', expectedDealAt: daysFromNow(13), lastSummary: '待FDA检测报告', nextAction: '发送报告', updatedAt: daysAgo(1, 8) },
{ id: 'o-018', name: '500只智能手表', customerId: 'c-026', customerName: 'Markus Braun', country: 'DE', product: '智能手表', quantity: '500只', amount: 5600, probability: 35, stage: 'requirement', ownerId: 'u-008', expectedDealAt: daysFromNow(25), lastSummary: '关注GDPR合规', nextAction: '发送合规声明', updatedAt: daysAgo(3, 10) },
{ id: 'o-019', name: '800个高硼硅杯(比价中)', customerId: 'c-021', customerName: 'Hassan Ali', country: 'PK', product: '高硼硅玻璃杯 500ml', quantity: '800个', amount: 1680, probability: 40, stage: 'quoting', ownerId: 'u-004', expectedDealAt: daysFromNow(16), lastSummary: '与本地供应商比价', nextAction: '发测试视频', updatedAt: daysAgo(1, 13) },
{ id: 'o-020', name: '37箱破损补发与赔付', customerId: 'c-019', customerName: 'Tom Baker', country: 'US', product: '双层玻璃杯 350ml', quantity: '补发111个', amount: 0, probability: 50, stage: 'aftersales', ownerId: 'u-005', expectedDealAt: daysFromNow(5), lastSummary: '空运补发+8%补偿额度', nextAction: '管理层致电', updatedAt: minutesAgoLocal() },
]
function minutesAgoLocal(): string {
return new Date(Date.now() - 90 * 60000).toISOString()
}
+126
View File
@@ -0,0 +1,126 @@
import type { RiskAlert } from '@/types/crm'
import { daysAgo, hoursAgo, minutesAgo } from './helpers'
/** 15 条风险预警 */
export const risks: RiskAlert[] = [
{
id: 'r-001', type: 'negative-sentiment', level: 'urgent',
customerId: 'c-019', customerName: 'Tom Baker', conversationId: 'cv-019',
reason: '客户使用“最后一次机会”等激烈措辞,情绪识别为“不满”',
aiExplanation: '近5条消息中客户负面情绪强度持续上升,出现 ultimatum 类表达(last chance / second time),且涉及金额较大的历史订单。破损问题叠加促销节点,若48小时内无实质补偿方案,纠纷升级概率约78%。',
suggestion: '1. 管理层今日内亲自致电;2. 空运补发(5天)+8%订单补偿额度;3. 出具书面预防方案(包装升级+出货全检)。',
ownerId: 'u-005', createdAt: minutesAgo(18), status: 'confirmed',
},
{
id: 'r-002', type: 'churn', level: 'high',
customerId: 'c-004', customerName: 'Mike Johnson', conversationId: 'cv-004',
reason: '客户明确表示“若周三收不到样品将考虑其他供应商”',
aiExplanation: '样品两次延误后客户信任度显著下降,消息中出现明确竞品转向信号。该客户处于样品验证关键期,流失将直接影响约$8,900首单及后续框架合作。',
suggestion: '2小时内提供FedEx加急单号,周二主动确认签收;同步准备备选供应商对比优势话术。',
ownerId: 'u-005', createdAt: hoursAgo(5), status: 'open',
},
{
id: 'r-003', type: 'delivery', level: 'high',
customerId: 'c-028', customerName: 'Saud Al-Harbi', conversationId: undefined,
reason: '朝觐礼品季订单交付节点为硬性时间窗,当前排产余量不足',
aiExplanation: '该商机预计交付窗口前仅剩23天,而定制工艺(烫金+礼盒)标准周期为18天+物流5天,缓冲为0。任何环节延误都将导致整单错过销售季,客户流失与索赔风险高。',
suggestion: '立即锁定排产与船期;与客户确认设计稿最终版;预留空运备选方案。',
ownerId: 'u-004', createdAt: hoursAgo(8), status: 'open',
},
{
id: 'r-004', type: 'religion', level: 'medium',
customerId: 'c-006', customerName: 'Fahad Al-Otaibi', conversationId: 'cv-006',
reason: '定制内容涉及阿拉伯文书法与宗教场景用语,未经专业校对',
aiExplanation: '客户礼品杯用于斋月场景,阿文书法字形、祝福语用词存在宗教敏感性。机器翻译或自行排版可能出现断字、连写错误或用词不当,在中东市场属于高风险失误。',
suggestion: '设计稿必须经阿拉伯语设计师双人校对;祝福语使用通用商务表达,避免直接引用宗教经文。',
ownerId: 'u-004', createdAt: hoursAgo(10), status: 'open',
},
{
id: 'r-005', type: 'price-objection', level: 'medium',
customerId: 'c-007', customerName: 'Emma Wilson', conversationId: 'cv-007',
reason: '客户明确告知竞争对手报价低6%,要求价格匹配',
aiExplanation: '客户处于比价阶段且主动披露对手报价(£3.95 vs £4.20),说明已进入决策后期。若无法在价值维度差异化,降价竞争概率大,预计毛利压缩4—6%。',
suggestion: '不直接降价,改用增值方案(升级辅料/免费吊牌/产前样)回应;同步发送质检流程资料建立信任。',
ownerId: 'u-008', createdAt: hoursAgo(12), status: 'open',
},
{
id: 'r-006', type: 'follow-up-delay', level: 'high',
customerId: 'c-014', customerName: 'Omar Farooq', conversationId: 'cv-014',
reason: '高意向新询盘超过24小时未分配负责人、未响应',
aiExplanation: '该客户主动二次留言(英语+乌尔都语),采购意向明确(批发储物罐)。数据显示:询盘1小时内响应的转化率是24小时后响应的7倍。当前线索处于流失边缘。',
suggestion: '立即分配负责人并发送首响话术;纳入“无负责人会话”自动分配规则。',
ownerId: null, createdAt: daysAgo(1, 14), status: 'open',
},
{
id: 'r-007', type: 'task-overdue', level: 'medium',
customerId: 'c-002', customerName: 'Khalid Al Mansouri', conversationId: 'cv-002',
reason: '“跟进样品反馈”任务已逾期20小时',
aiExplanation: '酒店集团决策链长,样品反馈窗口期是关键跟进点。逾期跟进会让客户感知专业度下降,该商机金额$4,200且为集团客户,建议优先处理。',
suggestion: '今日内发送耐温测试报告并预约视频实测;更新任务完成时间。',
ownerId: 'u-003', createdAt: hoursAgo(20), status: 'open',
},
{
id: 'r-008', type: 'task-overdue', level: 'high',
customerId: 'c-025', customerName: 'Imran Sheikh', conversationId: undefined,
reason: '客户三次催问打样排期,相关任务已逾期3小时',
aiExplanation: '客户在定制确认阶段反复催问,消息间隔缩短(24h→8h→2h),是不耐烦的典型信号。定制类客户一旦转向竞品,召回成本极高。',
suggestion: '2小时内回复明确打样排期(具体到日期);如产线紧张,提供加急打样选项。',
ownerId: 'u-004', createdAt: hoursAgo(3), status: 'open',
},
{
id: 'r-009', type: 'complaint', level: 'high',
customerId: 'c-019', customerName: 'Tom Baker', conversationId: 'cv-019',
reason: '到货破损率3%(37箱),为同一客户第二次投诉',
aiExplanation: '重复性质量问题投诉表明包装或物流环节存在系统性缺陷。该客户年采购额约$24,000,二次投诉后续约概率降至31%。',
suggestion: '启动质量复盘(包装跌落测试);本单补偿外,提供下单专属质检报告。',
ownerId: 'u-005', createdAt: minutesAgo(35), status: 'confirmed',
},
{
id: 'r-010', type: 'dispute', level: 'medium',
customerId: 'c-019', customerName: 'Tom Baker', conversationId: 'cv-019',
reason: '赔付金额与补发时效存在分歧,可能升级为平台纠纷',
aiExplanation: '客户要求10天内到货以配合促销,常规补发需3周。若协商失败客户可能通过信用卡拒付或平台仲裁,产生额外损失与账号风险。',
suggestion: '书面确认空运时效与补偿额度,留存沟通记录;必要时提供部分退款选项。',
ownerId: 'u-005', createdAt: minutesAgo(30), status: 'open',
},
{
id: 'r-011', type: 'culture', level: 'low',
customerId: 'c-010', customerName: 'Layla Hassan', conversationId: 'cv-010',
reason: '待发送的营销素材中包含酒类场景摆拍图(餐具套装场景图#7)',
aiExplanation: '阿联酋客户,伊斯兰文化背景下酒类场景图片可能引起不适。素材库检测到待发图片包含高脚杯盛酒场景。',
suggestion: '替换为家庭聚餐/咖啡场景图;中东客户素材默认过滤酒类与猪肉元素。',
ownerId: 'u-005', createdAt: hoursAgo(7), status: 'open',
},
{
id: 'r-012', type: 'follow-up-delay', level: 'medium',
customerId: 'c-021', customerName: 'Hassan Ali', conversationId: undefined,
reason: '比价阶段客户48小时未回复,未触发挽回跟进',
aiExplanation: '客户处于价格比较阶段,沉默48小时后成交概率每日衰减约12%。',
suggestion: '发送差异化价值内容(耐温差测试视频)+ 限时阶梯价,激活对话。',
ownerId: 'u-004', createdAt: daysAgo(1, 10), status: 'open',
},
{
id: 'r-013', type: 'account-abnormal', level: 'medium',
customerId: 'c-001', customerName: '系统安全', conversationId: undefined,
reason: '成员账号 zhaolei@ouhui.tech 在新设备登录(IP属地异常)',
aiExplanation: '该账号常用登录地为徐州,本次登录IP属地为境外(拉合尔),且为非工作时间。可能是本人出差,也可能是账号泄露。',
suggestion: '已强制二次验证通过。建议确认是否为本人操作;如非本人立即重置密码并审查操作日志。',
ownerId: 'u-001', createdAt: hoursAgo(16), status: 'confirmed',
},
{
id: 'r-014', type: 'logistics', level: 'medium',
customerId: 'c-011', customerName: 'Carlos Ruiz', conversationId: 'cv-011',
reason: '巴塞罗那航线舱位紧张,DDP时效可能延误3—5天',
aiExplanation: '物流监控显示近期华东—巴塞罗那航线爆仓率上升,承诺的18—22天时效存在滑期风险。客户为新客首单,首次交付体验直接影响复购。',
suggestion: '提前与客户沟通时效缓冲;报价时同时提供10天加急方案作备份。',
ownerId: 'u-008', createdAt: daysAgo(1, 16), status: 'open',
},
{
id: 'r-015', type: 'churn', level: 'low',
customerId: 'c-018', customerName: 'Lucía Fernández', conversationId: 'cv-018',
reason: '比价客户72小时未回复,且竞品价格更低',
aiExplanation: '客户明确持有土耳其供应商更低报价(€6.0 vs €6.4),沉默期延长。但客户体量小(200套),投入产出比一般,建议低成本自动化培育。',
suggestion: '发送一次差异化卖点总结后纳入7天自动培育队列;不投入过多人工。',
ownerId: 'u-005', createdAt: daysAgo(2, 11), status: 'ignored',
},
]
+26
View File
@@ -0,0 +1,26 @@
import type { FollowTask } from '@/types/crm'
import { daysAgo, daysFromNow, hoursAgo, hoursFromNow, todayAt } from './helpers'
/** 20 个跟进任务 */
export const tasks: FollowTask[] = [
{ id: 't-001', title: '发送阶梯报价(500/1000/2000)与定制案例', type: 'quote', priority: 'high', status: 'pending', customerId: 'c-001', customerName: 'Ahmed Raza', conversationId: 'cv-001', ownerId: 'u-003', dueAt: todayAt(16), note: '客户明天确认,今天务必发出', relatedSummary: '500个定制玻璃杯,开业节点10天', createdAt: daysAgo(1) },
{ id: 't-002', title: '发送耐温差冲击测试报告', type: 'material', priority: 'high', status: 'pending', customerId: 'c-002', customerName: 'Khalid Al Mansouri', conversationId: 'cv-002', ownerId: 'u-003', dueAt: todayAt(18), note: '报告编号 TR-BS2024-07', createdAt: daysAgo(1) },
{ id: 't-003', title: '确认 T 恤付款条款并发送 PI', type: 'quote', priority: 'high', status: 'pending', customerId: 'c-003', customerName: 'Rahim Uddin', conversationId: 'cv-003', ownerId: 'u-004', dueAt: todayAt(17), relatedSummary: '$2.20/pc 已接受,5000件', createdAt: daysAgo(1) },
{ id: 't-004', title: 'FedEx 补寄单号同步给 Mike', type: 'aftersales', priority: 'high', status: 'pending', customerId: 'c-004', customerName: 'Mike Johnson', conversationId: 'cv-004', ownerId: 'u-005', dueAt: hoursFromNow(2), note: '客户威胁换供应商,2小时内必须响应', createdAt: hoursAgo(5) },
{ id: 't-005', title: '发送产品目录+检测报告(西班牙语)', type: 'material', priority: 'medium', status: 'pending', customerId: 'c-005', customerName: 'Carmen López', conversationId: 'cv-005', ownerId: 'u-003', dueAt: daysFromNow(1, 11), createdAt: hoursAgo(7) },
{ id: 't-006', title: '阿文书法Logo设计稿内审', type: 'custom', priority: 'medium', status: 'pending', customerId: 'c-006', customerName: 'Fahad Al-Otaibi', conversationId: 'cv-006', ownerId: 'u-004', dueAt: daysFromNow(2, 10), note: '需阿拉伯设计师校对后再发客户', createdAt: daysAgo(1) },
{ id: 't-007', title: '回复 Emma 差异化报价方案', type: 'quote', priority: 'medium', status: 'pending', customerId: 'c-007', customerName: 'Emma Wilson', conversationId: 'cv-007', ownerId: 'u-008', dueAt: daysFromNow(1, 15), createdAt: hoursAgo(12) },
{ id: 't-008', title: '跟进 Jonas 样品签收与实验室排期', type: 'sample', priority: 'medium', status: 'pending', customerId: 'c-008', customerName: 'Jonas Weber', conversationId: 'cv-008', ownerId: 'u-004', dueAt: daysFromNow(3, 10), createdAt: daysAgo(1) },
{ id: 't-009', title: '发送秋冬色卡给 Nusrat', type: 'material', priority: 'high', status: 'pending', customerId: 'c-020', customerName: 'Nusrat Jahan', conversationId: 'cv-020', ownerId: 'u-003', dueAt: todayAt(15), relatedSummary: '复购1200件开衫', createdAt: hoursAgo(4) },
{ id: 't-010', title: '管理层致电 Tom 并确认空运补发', type: 'aftersales', priority: 'high', status: 'pending', customerId: 'c-019', customerName: 'Tom Baker', conversationId: 'cv-019', ownerId: 'u-005', dueAt: hoursFromNow(1), note: '紧急:客户最后通牒', createdAt: hoursAgo(1) },
{ id: 't-011', title: 'Sarah 合同草案与首单排产计划', type: 'quote', priority: 'high', status: 'pending', customerId: 'c-012', customerName: 'Sarah Miller', conversationId: 'cv-012', ownerId: 'u-004', dueAt: daysFromNow(1, 10), createdAt: hoursAgo(3) },
{ id: 't-012', title: '回访 Laura 样品密封性测试结果', type: 'sample', priority: 'medium', status: 'pending', customerId: 'c-024', customerName: 'Laura García', ownerId: 'u-005', dueAt: daysFromNow(2, 12), createdAt: daysAgo(1) },
{ id: 't-013', title: '宗教文案校对(Saud 朝觐礼品单)', type: 'custom', priority: 'high', status: 'pending', customerId: 'c-028', customerName: 'Saud Al-Harbi', ownerId: 'u-004', dueAt: daysFromNow(1, 9), note: '涉及宗教祝福语,必须双人校对', createdAt: daysAgo(1) },
{ id: 't-014', title: '发送 FDA 检测报告给 Grace', type: 'material', priority: 'medium', status: 'pending', customerId: 'c-027', customerName: 'Grace Thompson', ownerId: 'u-003', dueAt: daysFromNow(2, 14), createdAt: daysAgo(1) },
{ id: 't-015', title: ' Abdullah 阿语固件演示视频', type: 'material', priority: 'medium', status: 'pending', customerId: 'c-017', customerName: 'Abdullah Al-Rashid', conversationId: 'cv-017', ownerId: 'u-008', dueAt: daysFromNow(1, 16), createdAt: hoursAgo(2) },
{ id: 't-016', title: '回访 James:PI 是否收到并确认', type: 'revisit', priority: 'low', status: 'pending', customerId: 'c-015', customerName: 'James Smith', ownerId: 'u-003', dueAt: daysFromNow(2, 16), createdAt: daysAgo(1) },
{ id: 't-017', title: 'Imran 打样排期确认与色卡对照', type: 'custom', priority: 'high', status: 'pending', customerId: 'c-025', customerName: 'Imran Sheikh', ownerId: 'u-004', dueAt: hoursAgo(3), note: '已逾期风险:客户催打样', createdAt: daysAgo(2) },
{ id: 't-018', title: '跟进 Khalid 样品反馈(已逾期)', type: 'revisit', priority: 'medium', status: 'pending', customerId: 'c-002', customerName: 'Khalid Al Mansouri', ownerId: 'u-003', dueAt: hoursAgo(20), createdAt: daysAgo(3) },
{ id: 't-019', title: '季度复购提醒:Atlantic Kitchenware', type: 'repurchase', priority: 'low', status: 'done', customerId: 'c-027', customerName: 'Grace Thompson', ownerId: 'u-003', dueAt: daysAgo(2), createdAt: daysAgo(5), completedAt: daysAgo(2) },
{ id: 't-020', title: '发送物流单号(Carlos 储物罐报价单)', type: 'logistics', priority: 'low', status: 'done', customerId: 'c-011', customerName: 'Carlos Ruiz', ownerId: 'u-008', dueAt: daysAgo(1, 12), createdAt: daysAgo(3), completedAt: daysAgo(1, 12) },
]
+46
View File
@@ -0,0 +1,46 @@
import type { TeamMember } from '@/types/org'
import { daysAgo, hoursAgo, minutesAgo } from './helpers'
/** 8 名团队成员 */
export const teamMembers: TeamMember[] = [
{
id: 'u-001', name: '王琳', email: 'wanglin@ouhui.tech', phone: '138****6621',
department: '总经办', role: 'admin', online: true, customerCount: 0, todoCount: 3,
lastLoginAt: minutesAgo(12), status: 'active', joinedAt: daysAgo(420),
},
{
id: 'u-002', name: '陈昊', email: 'chenhao@ouhui.tech', phone: '139****8810',
department: '外贸一部', role: 'manager', online: true, customerCount: 0, todoCount: 6,
lastLoginAt: minutesAgo(35), status: 'active', joinedAt: daysAgo(380),
},
{
id: 'u-003', name: '李婷', email: 'liting@ouhui.tech', phone: '137****5523',
department: '外贸一部', role: 'sales', online: true, customerCount: 12, todoCount: 7,
lastLoginAt: minutesAgo(8), status: 'active', joinedAt: daysAgo(350),
},
{
id: 'u-004', name: '赵磊', email: 'zhaolei@ouhui.tech', phone: '135****9012',
department: '外贸二部', role: 'sales', online: true, customerCount: 10, todoCount: 5,
lastLoginAt: minutesAgo(22), status: 'active', joinedAt: daysAgo(320),
},
{
id: 'u-005', name: '周敏', email: 'zhoumin@ouhui.tech', phone: '136****3345',
department: '客服部', role: 'service', online: true, customerCount: 6, todoCount: 4,
lastLoginAt: minutesAgo(15), status: 'active', joinedAt: daysAgo(300),
},
{
id: 'u-006', name: '孙倩', email: 'sunqian@ouhui.tech', phone: '158****7789',
department: '客服部', role: 'service', online: false, customerCount: 2, todoCount: 2,
lastLoginAt: hoursAgo(18), status: 'active', joinedAt: daysAgo(260),
},
{
id: 'u-007', name: '吴迪', email: 'wudi@ouhui.tech', phone: '150****2234',
department: '财务部', role: 'viewer', online: false, customerCount: 0, todoCount: 0,
lastLoginAt: daysAgo(1, 9), status: 'active', joinedAt: daysAgo(200),
},
{
id: 'u-008', name: '郑楠', email: 'zhengnan@ouhui.tech', phone: '186****4456',
department: '外贸二部', role: 'sales', online: true, customerCount: 8, todoCount: 9,
lastLoginAt: minutesAgo(3), status: 'active', joinedAt: daysAgo(120),
},
]
+18
View File
@@ -0,0 +1,18 @@
import { Link } from 'react-router-dom'
import { FileX, Home } from 'lucide-react'
import { Button } from '@/components/ui/button'
export function NotFoundPage() {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 py-20">
<FileX className="h-12 w-12 text-muted-foreground" />
<h1 className="text-lg font-semibold">页面未找到</h1>
<p className="text-sm text-muted-foreground">您访问的页面不存在或已被移除。</p>
<Link to="/dashboard">
<Button variant="outline">
<Home className="h-4 w-4" /> 返回工作台
</Button>
</Link>
</div>
)
}
+71
View File
@@ -0,0 +1,71 @@
import { useQuery } from '@tanstack/react-query'
import { PageLoading, ErrorState } from '@/components/ui/feedback'
import { PageHeader } from '@/components/common/PageHeader'
import { Badge } from '@/components/ui/badge'
import { Card, CardHeader } from '@/components/ui/card'
import { StatCard } from '@/components/common/StatCard'
import { DemoNote } from '@/components/common/DemoNote'
import { CountryFlag } from '@/components/common/CountryFlag'
import { ChartCard, TrendChart, DonutChart, FunnelChart, RankBars, SimpleBarChart } from '@/components/charts'
import { getAnalytics } from '@/services/api/org'
import { formatNumber } from '@/utils/format'
import { Activity, BadgePercent, TrendingUp, Users, Zap, Timer, AlertTriangle, Target } from 'lucide-react'
export function AnalyticsPage() {
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['analytics'],
queryFn: () => getAnalytics(),
})
if (isLoading) return <PageLoading />
if (isError || !data) return <ErrorState onRetry={() => refetch()} />
const k = data.kpis
return (
<div className="p-4 md:p-6">
<PageHeader
title="数据分析中心"
description="企业经营数据全景 · 当前展示演示数据"
actions={<DemoNote />}
extra={
<div className="flex flex-wrap items-center gap-2">
{['today', '7d', '30d', 'quarter'].map((p) => (
<button key={p} className="rounded-full bg-muted px-3 py-1 text-xs text-muted-foreground hover:bg-border">{p === '30d' ? '近30天' : p === '7d' ? '近7天' : p === 'today' ? '今日' : '本季度'}</button>
))}
</div>
}
/>
<div className="grid grid-cols-2 gap-3 md:grid-cols-5 mb-4">
<StatCard title="新增客户" value={k.newCustomers.value} delta={k.newCustomers.delta} icon={Users} />
<StatCard title="有效询盘" value={k.validInquiries.value} delta={k.validInquiries.delta} icon={Activity} />
<StatCard title="高意向客户" value={k.highIntent.value} delta={k.highIntent.delta} icon={Target} />
<StatCard title="转化率" value={`${k.conversionRate.value}%`} delta={k.conversionRate.delta} icon={BadgePercent} />
<StatCard title="AI采纳率" value={`${k.aiAdoptionRate.value}%`} delta={k.aiAdoptionRate.delta} icon={Zap} />
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<ChartCard title="客户增长趋势" height={260}><TrendChart data={data.customerGrowth} series={[{ key: 'count', name: '新增客户', color: '#247b71' }]} /></ChartCard>
<ChartCard title="各渠道询盘数量" height={260}><SimpleBarChart data={data.channelInquiries} xKey="name" barKey="count" name="询盘数" /></ChartCard>
<ChartCard title="客户国家/地区分布" height={260}><DonutChart data={data.countryDistribution} /></ChartCard>
<ChartCard title="客户阶段转化漏斗" height={260}><FunnelChart data={data.funnel} /></ChartCard>
<ChartCard title="意向分数分布" height={260}><DonutChart data={data.intentDistribution} nameKey="range" /></ChartCard>
<ChartCard title="产品关注度排行" height={260}><RankBars data={data.productRank.map((p) => ({ name: p.product, value: p.inquiries }))} /></ChartCard>
<ChartCard title="团队回复效率排行" height={240}><RankBars data={data.teamReplyRank.map((r) => ({ name: r.name, value: r.avgResponseMin }))} unit="分钟" max={30} /></ChartCard>
<ChartCard title="团队客户转化排行" height={240}><RankBars data={data.teamConversionRank.map((r) => ({ name: r.name, value: r.rate }))} unit="%" /></ChartCard>
</div>
{/* 老板看板 */}
<h2 className="mt-6 mb-3 text-lg font-semibold">老板看板 <DemoNote className="align-middle" /></h2>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<Card><CardHeader title="🔥 今日需处理的高价值客户" /><div className="divide-y divide-border">{data.bossBoard.urgentHighValue.map((c) => (<div key={c.id} className="flex items-center justify-between px-4 py-2.5 text-sm"><span>{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="text-xs text-muted-foreground">{c.reason.slice(0, 20)}…</span></div>))}</div></Card>
<Card><CardHeader title="⚠️ 存在逾期跟进的业务员" /><div className="divide-y divide-border">{data.bossBoard.overdueMembers.map((m) => (<div key={m.id} className="flex items-center justify-between px-4 py-2.5 text-sm"><span>{m.name}</span><Badge variant="danger">{m.overdueCount} 项逾期</Badge></div>))}</div></Card>
<Card><CardHeader title="📈 客户增长最快的国家" /><div className="divide-y divide-border">{data.bossBoard.fastestCountries.map((c) => (<div key={c.country} className="flex items-center justify-between px-4 py-2.5 text-sm"><CountryFlag code={c.country} /><Badge variant="success">+{(c.growth * 100).toFixed(0)}%</Badge></div>))}</div></Card>
<Card><CardHeader title="🏅 最优渠道" /><div className="p-4 text-center"><p className="text-2xl font-semibold text-primary-700">{data.bossBoard.bestChannel.name}</p><p className="text-sm text-muted-foreground">转化率 {data.bossBoard.bestChannel.conversion}%</p></div></Card>
<Card><CardHeader title="📊 最热门产品" /><div className="space-y-2 p-4">{data.bossBoard.topProducts.map((p) => (<div key={p.product} className="flex items-center justify-between text-sm"><span>{p.product}</span><span className="text-muted-foreground">{p.inquiries} 次询盘</span></div>))}</div></Card>
<Card><CardHeader title="🚨 正在流失的高意向客户" /><div className="divide-y divide-border">{data.bossBoard.churningHighIntent.map((c) => (<div key={c.id} className="px-4 py-2.5 text-sm"><span>{c.name} <CountryFlag code={c.country} withName={false} /></span><span className="text-xs text-muted-foreground ml-2">{c.daysSilent}天未联系 · 意向{c.intentScore}分</span></div>))}</div></Card>
</div>
</div>
)
}
+264
View File
@@ -0,0 +1,264 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { Sparkles, MessageCircle, Languages, ShieldCheck, UsersRound } from 'lucide-react'
import { login } from '@/services/api/auth'
import { useAuthStore } from '@/app/store/auth'
import { useI18n } from '@/i18n'
import { toast, errorMessage } from '@/app/store/toast'
import { ROLE_META, type RoleKey } from '@/config/roles'
import { Button } from '@/components/ui/button'
import { Input, Field } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { Tabs } from '@/components/ui/tabs'
import { Dialog } from '@/components/ui/dialog'
import { UI_LOCALES, type UiLocale } from '@/config/languages'
import { Dropdown } from '@/components/ui/dropdown'
import { Globe } from 'lucide-react'
const schema = z.object({
account: z.string().min(4, '请输入正确的手机号或邮箱'),
password: z.string().optional(),
code: z.string().optional(),
})
type FormValues = z.infer<typeof schema>
export function LoginPage() {
const { t, locale, setLocale } = useI18n()
const navigate = useNavigate()
const setSession = useAuthStore((s) => s.setSession)
const [mode, setMode] = useState<'code' | 'password'>('code')
const [remember, setRemember] = useState(true)
const [agreed, setAgreed] = useState(true)
const [loading, setLoading] = useState(false)
const [codeSent, setCodeSent] = useState(0)
const [agreementOpen, setAgreementOpen] = useState<'privacy' | 'terms' | null>(null)
const [forgotOpen, setForgotOpen] = useState(false)
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({ resolver: zodResolver(schema), defaultValues: { account: '', password: '', code: '' } })
const doLogin = async (values: FormValues, role?: RoleKey) => {
if (!agreed) {
toast.warning('请先勾选并同意《隐私政策》和《服务协议》')
return
}
if (mode === 'password' && !values.password) {
toast.warning('请输入密码')
return
}
if (mode === 'code' && (values.code ?? '').length !== 6) {
toast.warning('请输入6位验证码(演示环境任意6位数字即可)')
return
}
try {
setLoading(true)
const session = await login({ ...values, mode, remember, role })
setSession(session)
toast.success(`欢迎回来,${session.user.name}`)
navigate('/dashboard')
} catch (e) {
toast.error(errorMessage(e))
} finally {
setLoading(false)
}
}
const quickLogin = (role: RoleKey) => {
doLogin({ account: 'demo@ouhui.tech', code: '888888', password: 'demo123456' }, role)
}
const sendCode = () => {
setCodeSent(60)
const timer = setInterval(() => {
setCodeSent((s) => {
if (s <= 1) clearInterval(timer)
return s - 1
})
}, 1000)
toast.success('验证码已发送(演示:任意6位数字均可登录)')
}
return (
<div className="flex min-h-screen bg-background">
{/* 品牌区 */}
<div className="hidden w-[46%] flex-col justify-between bg-primary-900 p-10 text-primary-50 lg:flex">
<div className="flex items-center gap-2.5">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-white/10">
<Sparkles className="h-5 w-5" />
</span>
<div>
<p className="text-base font-semibold">{t('app.name')}</p>
<p className="text-xs text-primary-200">{t('app.slogan')}</p>
</div>
</div>
<div className="space-y-8">
<h1 className="text-2xl font-semibold leading-snug">
把分散在全球渠道的客户对话,
<br />
沉淀为可成交的企业资产。
</h1>
<div className="space-y-4">
{[
{ icon: MessageCircle, text: 'WhatsApp / Telegram / 邮件 / 独立站,统一收件箱' },
{ icon: Languages, text: 'AI 翻译与行业术语,跨语种沟通专业自然' },
{ icon: ShieldCheck, text: '情感分析、风险预警、文化敏感检测' },
{ icon: UsersRound, text: '客户自动建档、意向评分与团队协同跟进' },
].map((f) => (
<div key={f.text} className="flex items-center gap-3 text-sm text-primary-100">
<span className="flex h-8 w-8 items-center justify-center rounded-md bg-white/10">
<f.icon className="h-4 w-4" />
</span>
{f.text}
</div>
))}
</div>
</div>
<p className="text-xs text-primary-300">© 2026 {t('app.company')} · 演示环境(Mock 数据)</p>
</div>
{/* 表单区 */}
<div className="flex flex-1 items-center justify-center p-6">
<div className="w-full max-w-[400px]">
<div className="mb-8 flex items-center justify-between">
<div className="flex items-center gap-2 lg:hidden">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-white">
<Sparkles className="h-4 w-4" />
</span>
<span className="font-semibold">{t('app.name')}</span>
</div>
<Dropdown
trigger={
<button className="flex items-center gap-1 rounded-md px-2 py-1 text-sm text-muted-foreground hover:bg-muted">
<Globe className="h-4 w-4" />
{UI_LOCALES.find((l) => l.code === locale)?.nativeName}
</button>
}
items={UI_LOCALES.map((l) => ({
key: l.code,
label: l.nativeName,
onSelect: () => setLocale(l.code as UiLocale),
}))}
/>
</div>
<h2 className="text-xl font-semibold">{t('login.title')}</h2>
<p className="mt-1 text-sm text-muted-foreground">{t('login.subtitle')}</p>
<div className="mt-6">
<Tabs
value={mode}
onChange={(v) => setMode(v as 'code' | 'password')}
items={[
{ value: 'code', label: t('login.phoneTab') },
{ value: 'password', label: t('login.passwordTab') },
]}
/>
</div>
<form className="mt-5 space-y-4" onSubmit={handleSubmit((v) => doLogin(v))}>
<Field error={errors.account?.message}>
<Input placeholder={t('login.accountPlaceholder')} autoComplete="username" {...register('account')} />
</Field>
{mode === 'code' ? (
<div className="flex gap-2">
<Input placeholder={t('login.codePlaceholder')} maxLength={6} {...register('code')} />
<Button type="button" variant="outline" className="shrink-0" disabled={codeSent > 0} onClick={sendCode}>
{codeSent > 0 ? `${codeSent}s` : t('login.getCode')}
</Button>
</div>
) : (
<Field error={errors.password?.message}>
<Input type="password" placeholder={t('login.passwordPlaceholder')} autoComplete="current-password" {...register('password')} />
</Field>
)}
<div className="flex items-center justify-between text-sm">
<Checkbox label={t('login.remember')} checked={remember} onCheckedChange={setRemember} />
<button type="button" className="text-primary hover:underline" onClick={() => setForgotOpen(true)}>
{t('login.forgot')}
</button>
</div>
<Button type="submit" className="w-full" size="lg" loading={loading}>
{t('login.submit')}
</Button>
<div className="text-center text-sm text-muted-foreground">
还没有账号?{' '}
<Link to="/register" className="font-medium text-primary hover:underline">
{t('login.register')}
</Link>
</div>
</form>
{/* 演示角色一键登录 */}
<div className="mt-8 rounded-lg border border-dashed border-border bg-muted/40 p-4">
<p className="mb-3 text-center text-xs text-muted-foreground">{t('login.quick')}</p>
<div className="grid grid-cols-3 gap-2">
{(Object.keys(ROLE_META) as RoleKey[]).map((r) => (
<Button key={r} variant="outline" size="sm" loading={loading} onClick={() => quickLogin(r)}>
{ROLE_META[r].label}
</Button>
))}
</div>
</div>
<p className="mt-6 text-center text-xs text-muted-foreground">
<Checkbox
checked={agreed}
onCheckedChange={setAgreed}
className="me-1 align-middle"
/>
{t('login.agreementPrefix')}
<button type="button" className="text-primary hover:underline" onClick={() => setAgreementOpen('privacy')}>
{t('login.privacy')}
</button>
{t('login.and')}
<button type="button" className="text-primary hover:underline" onClick={() => setAgreementOpen('terms')}>
{t('login.terms')}
</button>
</p>
</div>
</div>
{/* 协议弹窗 */}
<Dialog open={!!agreementOpen} onOpenChange={() => setAgreementOpen(null)} title={agreementOpen === 'privacy' ? '隐私政策' : '服务协议'}>
<div className="space-y-3 text-sm leading-relaxed text-muted-foreground">
<p>本页面为产品演示环境,以下协议为示意文本。</p>
<p>1. 我们仅收集为您提供跨境沟通与客户管理服务所必需的信息,包括账号信息、企业信息与您主动录入的客户数据。</p>
<p>2. 您的会话数据采用全链路加密存储,未经您授权,我们不会向任何第三方披露。</p>
<p>3. 您可以随时导出或删除您的数据;企业注销后,相关数据将在 30 天内完成清理。</p>
<p>4. 使用 AI 功能时,相关内容仅用于为您生成翻译与回复建议,不会用于其他用途。</p>
</div>
</Dialog>
{/* 忘记密码 */}
<Dialog
open={forgotOpen}
onOpenChange={setForgotOpen}
title="重置密码"
footer={
<Button
onClick={() => {
setForgotOpen(false)
toast.success('重置链接已发送(演示)', '请在邮箱或短信中查收重置指引')
}}
>
发送重置链接
</Button>
}
>
<Field label="手机号或邮箱" hint="我们将向该账号发送重置密码的链接">
<Input placeholder="请输入注册时使用的手机号或邮箱" />
</Field>
</Dialog>
</div>
)
}
+119
View File
@@ -0,0 +1,119 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { zodResolver } from '@hookform/resolvers/zod'
import { Sparkles } from 'lucide-react'
import { register as registerApi } from '@/services/api/auth'
import { useAuthStore } from '@/app/store/auth'
import { toast, errorMessage } from '@/app/store/toast'
import { Button } from '@/components/ui/button'
import { Input, Field } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { INDUSTRIES } from '@/config/constants'
const schema = z.object({
name: z.string().min(2, '请输入您的姓名'),
account: z.string().min(5, '请输入手机号或邮箱'),
password: z.string().min(6, '密码至少6位').max(32, '密码最长32位'),
company: z.string().min(2, '请输入企业名称'),
industry: z.string().min(1, '请选择所属行业'),
teamSize: z.string().min(1, '请选择团队规模'),
})
type FormValues = z.infer<typeof schema>
const TEAM_SIZES = ['仅我一人', '2—5人', '6—20人', '21—50人', '50人以上']
export function RegisterPage() {
const navigate = useNavigate()
const setSession = useAuthStore((s) => s.setSession)
const [agreed, setAgreed] = useState(false)
const [loading, setLoading] = useState(false)
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { name: '', account: '', password: '', company: '', industry: '', teamSize: '' },
})
const onSubmit = async (values: FormValues) => {
if (!agreed) {
toast.warning('请先同意《隐私政策》和《服务协议》')
return
}
try {
setLoading(true)
const session = await registerApi(values)
setSession(session)
toast.success('企业创建成功', '接下来请完成企业初始化配置')
navigate('/onboarding')
} catch (e) {
toast.error(errorMessage(e))
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-background p-6">
<div className="w-full max-w-[440px]">
<div className="mb-8 flex items-center gap-2.5">
<span className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary text-white">
<Sparkles className="h-5 w-5" />
</span>
<div>
<p className="text-base font-semibold">灵犀跨境</p>
<p className="text-xs text-muted-foreground">注册并创建您的企业</p>
</div>
</div>
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
<Field label="姓名" required error={errors.name?.message}>
<Input placeholder="您的姓名" {...register('name')} />
</Field>
<Field label="手机号或邮箱" required error={errors.account?.message}>
<Input placeholder="用于登录与接收通知" {...register('account')} />
</Field>
<Field label="设置密码" required error={errors.password?.message} hint="6—32位,建议包含字母与数字">
<Input type="password" placeholder="请设置登录密码" {...register('password')} />
</Field>
<Field label="企业名称" required error={errors.company?.message}>
<Input placeholder="如:徐州欧汇科技有限公司" {...register('company')} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="所属行业" required error={errors.industry?.message}>
<Select placeholder="请选择" options={INDUSTRIES.map((i) => ({ value: i, label: i }))} {...register('industry')} />
</Field>
<Field label="团队规模" required error={errors.teamSize?.message}>
<Select placeholder="请选择" options={TEAM_SIZES.map((s) => ({ value: s, label: s }))} {...register('teamSize')} />
</Field>
</div>
<div className="pt-1">
<Checkbox
checked={agreed}
onCheckedChange={setAgreed}
label="我已阅读并同意《隐私政策》和《服务协议》"
/>
</div>
<Button type="submit" className="w-full" size="lg" loading={loading}>
创建企业并开始使用
</Button>
<p className="text-center text-sm text-muted-foreground">
已有账号?{' '}
<Link to="/login" className="font-medium text-primary hover:underline">
直接登录
</Link>
</p>
</form>
</div>
</div>
)
}
+125
View File
@@ -0,0 +1,125 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { CreditCard, Check, Download, ExternalLink } from 'lucide-react'
import { toast, errorMessage } from '@/app/store/toast'
import { PageHeader } from '@/components/common/PageHeader'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardHeader } from '@/components/ui/card'
import { Progress } from '@/components/ui/feedback'
import { Dialog } from '@/components/ui/dialog'
import { PermissionGate } from '@/components/common/PermissionGate'
import { getBilling, upgradePlan } from '@/services/api/org'
import { PRICING_PLANS, type PlanKey } from '@/config/pricingPlans'
import { formatMoney, formatDateTime } from '@/utils/format'
import { cn } from '@/utils/cn'
import { DemoNote } from '@/components/common/DemoNote'
export function BillingPage() {
const queryClient = useQueryClient()
const { data } = useQuery({ queryKey: ['billing'], queryFn: getBilling })
const [upgradeTo, setUpgradeTo] = useState<PlanKey | null>(null)
const upgradeMut = useMutation({
mutationFn: (key: PlanKey) => upgradePlan(key),
onSuccess: () => { toast.success('套餐升级成功', '新套餐权益已生效'); queryClient.invalidateQueries({ queryKey: ['billing'] }); setUpgradeTo(null) },
onError: (e) => toast.error(errorMessage(e)),
})
const info = data?.info
const orders = data?.orders ?? []
const invoices = data?.invoices ?? []
return (
<div className="p-4 md:p-6">
<PageHeader title="套餐与账单" description="管理订阅与查看使用量" actions={<DemoNote text="价格来自演示配置" />} />
{info && (
<Card className="mb-4 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2"><span className="text-lg font-semibold">{info.planName}</span><Badge variant="success">当前套餐</Badge></div>
<p className="mt-1 text-sm text-muted-foreground">到期:{info.expireAt ? new Date(info.expireAt).toLocaleDateString('zh-CN') : '—'} · {info.autoRenew ? '自动续费' : '手动续费'}</p>
</div>
<PermissionGate permission="billing:manage">
<Button onClick={() => setUpgradeTo(getNextPlan(info.planKey))}>升级套餐</Button>
</PermissionGate>
</div>
<div className="mt-4 grid grid-cols-2 gap-4 md:grid-cols-4">
{Object.entries(info.usage).map(([key, u]) => (
<div key={key}>
<div className="mb-1 flex items-center justify-between text-xs text-muted-foreground">
<span>{key === 'members' ? '成员席位' : key === 'messages' ? '消息量' : key === 'translations' ? '翻译量' : '知识库'}</span>
<span>{u.used}/{u.total === 0 ? '无限制' : formatNumberLimit(u.total)}</span>
</div>
<Progress value={u.total === 0 ? 50 : (u.used / u.total) * 100} />
</div>
))}
</div>
</Card>
)}
{/* 套餐对比 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-4 mb-6">
{PRICING_PLANS.map((plan) => (
<Card key={plan.key} className={cn('p-4', plan.highlighted && 'ring-2 ring-primary')}>
{plan.highlighted && <Badge className="mb-2 bg-primary text-white">推荐</Badge>}
<h3 className="text-base font-semibold">{plan.name}</h3>
<p className="mt-0.5 text-xs text-muted-foreground">{plan.tagline}</p>
<p className="mt-2 text-xl font-bold text-primary-700">{plan.priceNote}</p>
<p className="text-xs text-muted-foreground">{plan.target}</p>
<ul className="mt-3 space-y-1">
{plan.features.map((f) => (
<li key={f} className="flex items-center gap-1.5 text-xs text-muted-foreground"><Check className="h-3 w-3 text-emerald-600 shrink-0" />{f}</li>
))}
</ul>
<Button size="sm" className="w-full mt-3" variant={plan.key === info?.planKey ? 'outline' : 'default'} disabled={plan.key === info?.planKey} onClick={() => setUpgradeTo(plan.key)}>
{plan.key === info?.planKey ? '当前套餐' : plan.key === 'growth' ? '联系销售' : '升级'}
</Button>
</Card>
))}
</div>
{/* 订单与发票 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<CardHeader title="历史订单" />
<div className="divide-y divide-border">
{orders.map((o) => (
<div key={o.id} className="flex items-center justify-between px-4 py-2.5 text-sm"><span>{o.planName}<br /><span className="text-xs text-muted-foreground">{o.orderNo} · {formatDateTime(o.createdAt)}</span></span><span className="font-medium">{formatMoney(o.amount)}</span></div>
))}
</div>
</Card>
<Card>
<CardHeader title="电子发票" />
<div className="divide-y divide-border">
{invoices.map((inv) => (
<div key={inv.id} className="flex items-center justify-between px-4 py-2.5 text-sm"><span>{inv.title}<br /><span className="text-xs text-muted-foreground">{inv.invoiceNo}</span></span><Button variant="ghost" size="sm"><Download className="h-4 w-4" />下载</Button></div>
))}
</div>
</Card>
</div>
<Dialog open={!!upgradeTo} onOpenChange={() => setUpgradeTo(null)} title="确认升级套餐" footer={
<>
<Button variant="outline" onClick={() => setUpgradeTo(null)}>取消</Button>
<Button loading={upgradeMut.isPending} onClick={() => upgradeTo && upgradeMut.mutate(upgradeTo)}>确认升级</Button>
</>
}>
{upgradeTo && (
<p className="text-sm text-muted-foreground">即将升级至 {PRICING_PLANS.find(p => p.key === upgradeTo)?.name}({PRICING_PLANS.find(p => p.key === upgradeTo)?.priceNote}),确认后将立即生效。</p>
)}
</Dialog>
</div>
)
}
function formatNumberLimit(n: number) {
return n >= 1000000 ? `${(n / 10000).toFixed(0)}万` : n.toLocaleString('zh-CN')
}
function getNextPlan(current: string): PlanKey {
const keys: PlanKey[] = ['lite', 'standard', 'pro', 'growth']
const idx = keys.indexOf(current as PlanKey)
return keys[Math.min(idx + 1, keys.length - 1)]
}
+134
View File
@@ -0,0 +1,134 @@
import { useState, useEffect } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Cable, Plus, Pause, Play, RefreshCw, Trash2, History, Settings } from 'lucide-react'
import { toast, errorMessage } from '@/app/store/toast'
import { confirmDialog } from '@/app/store/confirm'
import { PageHeader } from '@/components/common/PageHeader'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card } from '@/components/ui/card'
import { Dialog } from '@/components/ui/dialog'
import { Drawer } from '@/components/ui/drawer'
import { Select } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Input, Field } from '@/components/ui/input'
import { PermissionGate } from '@/components/common/PermissionGate'
import { ChannelIcon } from '@/components/common/ChannelIcon'
import { getChannels, connectChannel, testChannel, updateChannel, deleteChannel, getChannelLogs } from '@/services/api/channels'
import { CHANNEL_META } from '@/config/constants'
import { formatRelative } from '@/utils/format'
import { cn } from '@/utils/cn'
import type { ChannelType, ChannelConnection, ChannelSyncLog } from '@/types'
const STATUS_MAP: Record<string, { label: string; color: string }> = {
connected: { label: '已连接', color: 'bg-emerald-100 text-emerald-700' },
disconnected: { label: '已断开', color: 'bg-gray-100 text-gray-500' },
error: { label: '异常', color: 'bg-red-100 text-red-700' },
paused: { label: '已暂停', color: 'bg-amber-100 text-amber-700' },
unconnected: { label: '未连接', color: 'bg-gray-100 text-gray-400' },
}
export function ChannelsPage() {
const queryClient = useQueryClient()
const { data: channels } = useQuery({ queryKey: ['channels'], queryFn: getChannels })
const [wizard, setWizard] = useState<{ open: boolean; type: ChannelType | null; step: number }>({ open: false, type: null, step: 0 })
const [logDrawer, setLogDrawer] = useState<string | null>(null)
const [settingsDrawer, setSettingsDrawer] = useState<ChannelConnection | null>(null)
const [logItems, setLogItems] = useState<ChannelSyncLog[]>([])
const loadLogs = async (chId: string) => {
const logs = await getChannelLogs(chId)
setLogItems(logs)
setLogDrawer(chId)
}
const delMut = useMutation({
mutationFn: deleteChannel,
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['channels'] }); toast.success('已断开连接') },
onError: (e) => toast.error(errorMessage(e)),
})
const list = channels ?? []
return (
<div className="p-4 md:p-6">
<PageHeader title="渠道中心" description={`${list.filter((c) => c.status === 'connected').length} 个渠道已连接 · 共 ${list.length} 个可连接渠道`} />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{list.map((ch) => {
const meta = CHANNEL_META[ch.type]
const st = STATUS_MAP[ch.status]
return (
<Card key={ch.id} className="p-4">
<div className="flex items-start justify-between">
<ChannelIcon channel={ch.type} size="lg" withBg />
<Badge className={st.color}>{st.label}</Badge>
</div>
<p className="mt-3 text-sm font-medium">{meta.name}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{meta.desc}</p>
{ch.account && <p className="mt-1 text-xs text-muted-foreground">账号:{ch.account}</p>}
{ch.todayMessages > 0 && <p className="mt-1 text-xs">今日消息:{ch.todayMessages} 条</p>}
{ch.lastSyncAt && <p className="mt-0.5 text-xs text-muted-foreground">最近同步:{formatRelative(ch.lastSyncAt)}</p>}
{ch.errorMessage && <p className="mt-1 text-xs text-red-600">{ch.errorMessage}</p>}
<div className="mt-3 flex flex-wrap gap-1">
{ch.status === 'connected' ? (
<>
<Button size="sm" variant="outline" onClick={() => updateChannel(ch.id, { status: 'paused' }).then(() => queryClient.invalidateQueries({ queryKey: ['channels'] }))}><Pause className="h-3 w-3" />暂停</Button>
<Button size="sm" variant="outline" onClick={() => delMut.mutate(ch.id)}><Trash2 className="h-3 w-3" /></Button>
</>
) : ch.status === 'paused' ? (
<Button size="sm" variant="outline" onClick={() => updateChannel(ch.id, { status: 'connected' }).then(() => queryClient.invalidateQueries({ queryKey: ['channels'] }))}><Play className="h-3 w-3" />恢复</Button>
) : 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="default" onClick={() => setWizard({ open: true, type: ch.type, step: 1 })}><Cable 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>
</Card>
)
})}
</div>
{/* 连接向导 */}
<Dialog
open={wizard.open}
onOpenChange={(v) => setWizard({ ...wizard, open: v })}
title={`连接 ${wizard.type ? CHANNEL_META[wizard.type].name : ''}`}
footer={
<Button
onClick={async () => {
const t = wizard.type!
await connectChannel({ type: t, account: 'demo-account@mock', autoReply: true })
toast.success(`${CHANNEL_META[t].name} 连接成功(演示)`)
setWizard({ open: false, type: null, step: 0 })
queryClient.invalidateQueries({ queryKey: ['channels'] })
}}
>完成连接</Button>
}
>
<div className="space-y-3">
<div className="flex items-center gap-2">
<Badge variant="primary">步骤 {wizard.step + 1}/7</Badge>
<span className="text-sm font-medium">模拟授权连接</span>
</div>
<p className="text-sm text-muted-foreground">演示环境连接向导:选择渠道 → 模拟授权 → 选择同步范围 → 设置默认负责人 → 设置自动回复 → 测试连接 → 完成。</p>
<p className="text-sm text-muted-foreground">点击「完成连接」即可一键模拟全部步骤。</p>
</div>
</Dialog>
{/* 同步日志 */}
<Drawer open={!!logDrawer} onOpenChange={() => setLogDrawer(null)} title="同步日志" width="w-80">
{logItems.length === 0 ? <p className="px-4 py-8 text-sm text-muted-foreground text-center">暂无日志记录</p> : logItems.map((l) => (
<div key={l.id} className="px-4 py-2 border-b border-border text-sm">
<Badge variant={l.status === 'success' ? 'success' : 'danger'} className="mr-2">{l.status === 'success' ? '成功' : '失败'}</Badge>
{l.detail}
<p className="text-xs text-muted-foreground mt-0.5">{formatRelative(l.createdAt)}</p>
</div>
))}
</Drawer>
</div>
)
}
+275
View File
@@ -0,0 +1,275 @@
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, Edit, Send, UserRoundPlus, Tag, GitMerge, Download, ExternalLink } from 'lucide-react'
import { getCustomer, updateCustomer } from '@/services/api/customers'
import { useI18n } from '@/i18n'
import { toast, errorMessage } from '@/app/store/toast'
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback'
import { PageHeader } from '@/components/common/PageHeader'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardHeader } from '@/components/ui/card'
import { Avatar } from '@/components/ui/avatar'
import { Tabs } from '@/components/ui/tabs'
import { Select } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Table, THead, TBody, Tr, Th, Td } from '@/components/ui/table'
import { Field } from '@/components/ui/input'
import { Dialog } from '@/components/ui/dialog'
import { PermissionGate } from '@/components/common/PermissionGate'
import { CountryFlag } from '@/components/common/CountryFlag'
import { ChannelIcon } from '@/components/common/ChannelIcon'
import { IntentScoreBadge, CustomerStageBadge, SentimentBadge, RiskLevelBadge } from '@/components/common/StatusBadges'
import { ChipPicker } from '@/components/customer/CustomerFormDialog'
import { CUSTOMER_STAGES, CUSTOMER_TAGS, NEXT_ACTIONS } from '@/config/constants'
import { formatRelative, formatDateTime } from '@/utils/format'
import { cn } from '@/utils/cn'
import type { Customer } from '@/types'
export function CustomerDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [tab, setTab] = useState('overview')
const [tagDialog, setTagDialog] = useState(false)
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['customer-detail', id],
queryFn: () => getCustomer(id!),
enabled: !!id,
})
const updateMut = async (data: Partial<Customer>) => {
try {
await updateCustomer(id!, data)
queryClient.invalidateQueries({ queryKey: ['customer-detail', id] })
toast.success('已更新')
} catch (e) { toast.error(errorMessage(e)) }
}
if (isLoading) return <PageLoading />
if (isError || !data) return <ErrorState onRetry={() => refetch()} />
const c = data.customer
const FILE_ICONS: Record<string, string> = { quote: '📄', contract: '📝', image: '🖼️', doc: '📁', other: '📎' }
return (
<div className="p-4 md:p-6">
<button onClick={() => navigate(-1)} className="mb-3 flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
<ArrowLeft className="h-4 w-4 rtl-flip" /> 返回
</button>
{/* 顶部客户画像卡片 */}
<Card className="p-4 md:p-5">
<div className="flex flex-wrap gap-4">
<Avatar name={c.name} size="xl" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-lg font-semibold">{c.name}</h1>
<CountryFlag code={c.country} />
<ChannelIcon channel={c.channel} size="xs" />
{c.isKey && <Badge variant="warning">⭐ 重点客户</Badge>}
</div>
<p className="mt-1 text-sm text-muted-foreground">{c.company || '未填写公司'} · {c.email} · {c.phone}</p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<IntentScoreBadge score={c.intentScore} withBar />
<CustomerStageBadge stage={c.stage} />
<SentimentBadge sentiment={c.sentiment} />
{c.riskLevel !== 'none' && <RiskLevelBadge level={c.riskLevel} />}
<span className="text-muted-foreground">负责人:{c.ownerId === 'u-003' ? '李婷' : c.ownerId === 'u-004' ? '赵磊' : c.ownerId === 'u-005' ? '周敏' : c.ownerId === 'u-008' ? '郑楠' : '未分配'}</span>
</div>
<div className="mt-2 flex flex-wrap gap-1">
{c.tags.map((t) => <Badge key={t} variant="outline">{t}</Badge>)}
</div>
</div>
<div className="flex flex-wrap items-start gap-2">
<Button size="sm" variant="outline" onClick={() => navigate('/inbox?c=' + data.conversations[0]?.id)}>
<Send className="h-4 w-4" />发送消息
</Button>
<Button size="sm" variant="outline" onClick={() => setTagDialog(true)}>
<Tag className="h-4 w-4" />标签
</Button>
<Select
value={c.stage}
className="h-8 w-32"
onChange={(e) => updateMut({ stage: e.target.value as any })}
options={CUSTOMER_STAGES.map((s) => ({ value: s.value, label: s.label }))}
/>
</div>
</div>
</Card>
{/* 标签页 */}
<Tabs
value={tab}
onChange={setTab}
className="mt-4"
items={[
{ value: 'overview', label: '客户概览' },
{ value: 'conversations', label: '全部会话', count: data.conversations.length },
{ value: 'tasks', label: '跟进记录', count: data.tasks.length },
{ value: 'opportunities', label: '商机记录', count: data.opportunities.length },
{ value: 'files', label: '文件资料', count: data.files.length },
{ value: 'risks', label: '风险记录', count: data.risks.length },
{ value: 'logs', label: '操作日志', count: data.logs.length },
]}
/>
<div className="mt-4">
{tab === 'overview' && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader title="自动建档流程" description="客户从渠道识别到完整建档案的自动化历程" />
<div className="space-y-3 px-4 pb-4">
{[
'✅ 识别来源渠道(WhatsApp)',
'✅ 记录来源参数(官网按钮带入)',
'✅ 匹配客户身份(手机号 + 姓名字段)',
'✅ 自动发现重复客户(未匹配到重复)',
'✅ 人工确认建档(管理员 / 业务主管确认)',
'✅ 完整客户档案已建立',
].map((step, i) => (
<div key={i} className="flex items-center gap-2 text-sm">
<span className={cn('flex h-5 w-5 items-center justify-center rounded-full text-xs', step.startsWith('✅') ? 'bg-emerald-100 text-emerald-700' : 'bg-muted text-muted-foreground')}>{i + 1}</span>
{step.replace('✅ ', '')}
</div>
))}
</div>
</Card>
<Card>
<CardHeader title="AI 客户画像摘要" />
<p className="px-4 pb-4 text-sm text-muted-foreground leading-relaxed">{c.aiProfile.summary}</p>
{c.aiProfile.nextActions.length > 0 && (
<div className="px-4 pb-4">
<h4 className="text-xs font-semibold mb-2">推荐动作</h4>
<div className="flex flex-wrap gap-1.5">
{c.aiProfile.nextActions.map((a) => (
<button key={a} onClick={() => toast.info(a)} className="rounded-full border border-primary-200 bg-primary-50 px-2 py-1 text-xs text-primary-700">{a}</button>
))}
</div>
</div>
)}
</Card>
{/* AI 提取信息 */}
<Card className="lg:col-span-2">
<CardHeader title="AI 自动提取信息" />
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 px-4 pb-4 text-sm">
{Object.entries({
询价数量: c.aiProfile.inquiryQuantity,
目标价格: c.aiProfile.targetPrice,
MOQ需求: c.aiProfile.moqRequirement,
样品需求: c.aiProfile.sampleNeed,
定制要求: c.aiProfile.customRequirement,
交付时间: c.aiProfile.deliveryTime,
物流方式: c.aiProfile.logistics,
付款方式: c.aiProfile.payment,
售后诉求: c.aiProfile.aftersales,
主要顾虑: c.aiProfile.concerns?.join('、'),
}).filter(([, v]) => v).map(([k, v]) => (
<div key={k} className="rounded-md bg-muted/60 p-2">
<span className="text-xs text-muted-foreground">{k}</span>
<p className="text-sm font-medium mt-0.5">{v as string}</p>
</div>
))}
</div>
</Card>
</div>
)}
{tab === 'conversations' && (
<Card>
<Table>
<THead><Tr><Th>会话</Th><Th>渠道</Th><Th>消息数</Th><Th>最近活动</Th><Th>状态</Th></Tr></THead>
<TBody>
{data.conversations.length === 0 && <Tr><Td colSpan={5}><EmptyState title="暂无会话记录" /></Td></Tr>}
{data.conversations.map((conv) => (
<Tr key={conv.id} className="cursor-pointer" onClick={() => navigate(`/inbox?c=${conv.id}`)}>
<Td><span className="text-sm font-medium">{conv.summary.slice(0, 40)}</span></Td>
<Td><ChannelIcon channel={conv.channel} size="xs" /></Td>
<Td>{conv.messageCount}</Td>
<Td><span className="text-xs text-muted-foreground">{formatRelative(conv.updatedAt)}</span></Td>
<Td><Badge variant={conv.status === 'pending' ? 'warning' : 'success'}>{conv.status === 'pending' ? '待回复' : '已回复'}</Badge></Td>
</Tr>
))}
</TBody>
</Table>
</Card>
)}
{tab === 'tasks' && (
<Card>
<Table>
<THead><Tr><Th>任务</Th><Th>类型</Th><Th>优先级</Th><Th>截止时间</Th><Th>状态</Th></Tr></THead>
<TBody>
{data.tasks.length === 0 && <Tr><Td colSpan={5}><EmptyState title="暂无跟进记录" /></Td></Tr>}
{data.tasks.map((t) => (
<Tr key={t.id}>
<Td><span className="text-sm">{t.title}</span></Td>
<Td><Badge variant="outline">{t.type}</Badge></Td>
<Td><Badge variant={t.priority === 'high' ? 'danger' : t.priority === 'medium' ? 'warning' : 'default'}>{t.priority}</Badge></Td>
<Td><span className="text-xs">{formatDateTime(t.dueAt)}</span></Td>
<Td><Badge variant={t.status === 'done' ? 'success' : 'warning'}>{t.status === 'done' ? '已完成' : '待处理'}</Badge></Td>
</Tr>
))}
</TBody>
</Table>
</Card>
)}
{tab === 'opportunities' && <p className="text-sm text-muted-foreground">{data.opportunities.length} 个商机</p>}
{tab === 'files' && (
<Card>
<Table>
<THead><Tr><Th>文件</Th><Th>类型</Th><Th>大小</Th><Th>上传人</Th><Th>时间</Th></Tr></THead>
<TBody>
{data.files.map((f) => (
<Tr key={f.id}>
<Td>{FILE_ICONS[f.kind]} {f.name}</Td>
<Td>{f.kind}</Td>
<Td>{f.size}</Td>
<Td>{f.uploadedBy}</Td>
<Td><span className="text-xs">{formatDateTime(f.createdAt)}</span></Td>
</Tr>
))}
</TBody>
</Table>
</Card>
)}
{tab === 'risks' && (
<div className="space-y-2">
{data.risks.filter(r => r.status === 'open' || r.status === 'confirmed').map((r) => (
<Card key={r.id} className="p-3">
<div className="flex items-center gap-2"><Badge variant="danger">{r.level}</Badge><span className="text-sm font-medium">{r.reason}</span></div>
<p className="mt-1 text-xs text-muted-foreground">{r.aiExplanation.slice(0, 120)}</p>
</Card>
))}
</div>
)}
{tab === 'logs' && (
<Card>
<Table>
<THead><Tr><Th>时间</Th><Th>操作人</Th><Th>动作</Th><Th>详情</Th></Tr></THead>
<TBody>
{data.logs.map((log) => (
<Tr key={log.id}>
<Td><span className="text-xs">{formatDateTime(log.createdAt)}</span></Td>
<Td>{log.actorName}</Td>
<Td><Badge variant="outline">{log.action}</Badge></Td>
<Td><span className="text-xs">{log.detail}</span></Td>
</Tr>
))}
</TBody>
</Table>
</Card>
)}
</div>
<Dialog open={tagDialog} onOpenChange={setTagDialog} title="编辑客户标签">
<ChipPicker options={CUSTOMER_TAGS} selected={c.tags} onToggle={(tag) => updateMut({ tags: c.tags.includes(tag) ? c.tags.filter((t) => t !== tag) : [...c.tags, tag] })} />
<Button size="sm" className="mt-3" onClick={() => setTagDialog(false)}>完成</Button>
</Dialog>
</div>
)
}
+402
View File
@@ -0,0 +1,402 @@
import { useState, useMemo } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Download,
GitMerge,
LayoutGrid,
List,
MoreHorizontal,
Plus,
Search,
TableProperties,
Tag,
Trash2,
Upload,
UserRoundPlus,
} from 'lucide-react'
import { getCustomers, deleteCustomer, importCustomers, exportCustomers, getDuplicateCustomers, mergeCustomers } from '@/services/api/customers'
import { useI18n } from '@/i18n'
import { useAuthStore } from '@/app/store/auth'
import { usePermission } from '@/hooks/usePermission'
import { toast, errorMessage } from '@/app/store/toast'
import { confirmDialog } from '@/app/store/confirm'
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback'
import { PageHeader } from '@/components/common/PageHeader'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Badge } from '@/components/ui/badge'
import { Card } from '@/components/ui/card'
import { Avatar } from '@/components/ui/avatar'
import { Dropdown } from '@/components/ui/dropdown'
import { Table, THead, TBody, Tr, Th, Td } from '@/components/ui/table'
import { Dialog } from '@/components/ui/dialog'
import { Tabs } from '@/components/ui/tabs'
import { PermissionGate } from '@/components/common/PermissionGate'
import { CountryFlag } from '@/components/common/CountryFlag'
import { ChannelIcon } from '@/components/common/ChannelIcon'
import { IntentScoreBadge, CustomerStageBadge, SentimentBadge, RiskLevelBadge } from '@/components/common/StatusBadges'
import { CustomerFormDialog } from '@/components/customer/CustomerFormDialog'
import {
COUNTRIES,
CUSTOMER_STAGES,
CUSTOMER_TAGS,
CHANNEL_META,
INTENT_LEVELS,
} from '@/config/constants'
import { cn } from '@/utils/cn'
import type { Customer } from '@/types'
import type { ChannelType } from '@/types/messaging'
export function CustomersPage() {
const { t } = useI18n()
const navigate = useNavigate()
const queryClient = useQueryClient()
const { can } = usePermission()
const user = useAuthStore((s) => s.user)
const [searchParams, setSearchParams] = useSearchParams()
const [view, setView] = useState<'table' | 'card' | 'kanban'>('table')
const [keyword, setKeyword] = useState(searchParams.get('keyword') ?? '')
const [country, setCountry] = useState(searchParams.get('country') ?? '')
const [channel, setChannel] = useState(searchParams.get('channel') ?? '')
const [stage, setStage] = useState(searchParams.get('stage') ?? '')
const [intentLevel, setIntentLevel] = useState(searchParams.get('intentLevel') ?? '')
const [selected, setSelected] = useState<Set<string>>(new Set())
const [formDialog, setFormDialog] = useState(false)
const [editCustomer, setEditCustomer] = useState<Customer | null>(null)
const [importDialog, setImportDialog] = useState(false)
const [mergeDialog, setMergeDialog] = useState(false)
const [duplicateList, setDuplicateList] = useState<any[]>([])
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['customers', 'all'],
queryFn: () => getCustomers({ keyword, country, channel, stage, intentLevel }),
})
const updateMut = useMutation({
mutationFn: (p: { id: string; data: Partial<Customer> }) => import('@/services/api/customers').then((api) => api.updateCustomer(p.id, p.data)),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['customers'] })
toast.success('已更新')
},
onError: (e) => toast.error(errorMessage(e)),
})
const batchAssign = async () => {
const owner = prompt('请输入负责人姓名(演示环境请输入 李婷/赵磊/周敏/郑楠):')
if (!owner) return
const team = (await import('@/services/api/org').then((api) => api.getTeam())).find((m) => m.name === owner)
if (!team) return toast.error('未找到该成员')
for (const id of selected) {
try { await updateMut.mutateAsync({ id, data: { ownerId: team.id } }) } catch {}
}
setSelected(new Set())
toast.success(`已分配 ${selected.size} 位客户给 ${owner}`)
}
const batchTag = async () => {
const tag = prompt('请输入要添加的标签:')
if (!tag) return
for (const id of selected) {
const c = data?.list.find((x: Customer) => x.id === id)
if (c) {
try { await updateMut.mutateAsync({ id, data: { tags: [...c.tags, tag] } }) } catch {}
}
}
setSelected(new Set())
toast.success(`已为 ${selected.size} 位客户添加标签「${tag}」`)
}
const handleExport = async () => {
const res = await exportCustomers()
const { downloadText } = await import('@/utils/misc')
downloadText(`客户列表_${new Date().toISOString().slice(0, 10)}.csv`, res.csv, 'text/csv')
toast.success('导出成功')
}
const handleImport = async () => {
const rows = [
{ name: 'Demo Import 1', country: 'US', email: 'demo1@example.com' },
{ name: 'Demo Import 2', country: 'PK', email: 'demo2@example.com' },
]
const res = await importCustomers(rows)
toast.success(`导入完成:${res.imported} 条成功`)
setImportDialog(false)
queryClient.invalidateQueries({ queryKey: ['customers'] })
}
const handleCheckDuplicates = async () => {
const dup = await getDuplicateCustomers()
setDuplicateList(dup)
setMergeDialog(true)
}
const handleMerge = async (keepId: string, mergeId: string) => {
await mergeCustomers(keepId, mergeId)
toast.success('客户合并完成')
queryClient.invalidateQueries({ queryKey: ['customers'] })
setMergeDialog(false)
}
const handleDelete = (c: Customer) => {
confirmDialog({
title: `删除客户「${c.name}」?`,
description: '删除后相关会话和任务将被一同移除,此操作不可恢复。',
danger: true,
onConfirm: async () => {
await deleteCustomer(c.id)
toast.success('已删除')
queryClient.invalidateQueries({ queryKey: ['customers'] })
},
})
}
const list = data?.list ?? []
const total = data?.total ?? 0
if (isLoading) return <PageLoading />
if (isError) return <ErrorState onRetry={() => refetch()} />
return (
<div className="p-4 md:p-6">
<PageHeader
title="客户管理"
description={`共 ${total} 个客户 · 统一管理与跟进`}
actions={
<>
<PermissionGate permission="customer:export">
<Button variant="outline" size="sm" onClick={handleExport}>
<Download className="h-4 w-4" />
导出
</Button>
<Button variant="outline" size="sm" onClick={() => setImportDialog(true)}>
<Upload className="h-4 w-4" />
导入
</Button>
<Button variant="outline" size="sm" onClick={handleCheckDuplicates}>
<GitMerge className="h-4 w-4" />
去重
</Button>
</PermissionGate>
<PermissionGate permission="customer:edit">
<Button size="sm" onClick={() => { setEditCustomer(null); setFormDialog(true) }}>
<Plus className="h-4 w-4" />
新建客户
</Button>
</PermissionGate>
</>
}
extra={
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[200px] max-w-xs">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input
placeholder="搜索客户名称/公司/国家/产品"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
className="h-9 w-full rounded-md border border-input bg-card pl-8 pr-3 text-sm placeholder:text-muted-foreground focus:border-primary focus:outline-none"
/>
</div>
<Select placeholder="国家" value={country} onChange={(e) => setCountry(e.target.value)} options={COUNTRIES.map((c) => ({ value: c.code, label: `${c.flag} ${c.zh}` }))} className="w-32" />
<Select placeholder="渠道" value={channel} onChange={(e) => setChannel(e.target.value)} options={Object.entries(CHANNEL_META).map(([k, v]) => ({ value: k, label: v.name }))} className="w-28" />
<Select placeholder="阶段" value={stage} onChange={(e) => setStage(e.target.value)} options={CUSTOMER_STAGES.map((s) => ({ value: s.value, label: s.label }))} className="w-28" />
<Select placeholder="意向" value={intentLevel} onChange={(e) => setIntentLevel(e.target.value)} options={INTENT_LEVELS.map((l) => ({ value: l.value, label: l.label }))} className="w-28" />
<div className="flex items-center gap-1 rounded-md border border-border bg-card p-0.5 ml-auto">
{[
{ v: 'table' as const, icon: TableProperties },
{ v: 'card' as const, icon: LayoutGrid },
{ v: 'kanban' as const, icon: List },
].map((opt) => (
<button key={opt.v} onClick={() => setView(opt.v)} className={cn('rounded p-1.5', view === opt.v ? 'bg-muted text-foreground' : 'text-muted-foreground hover:text-foreground')}>
<opt.icon className="h-4 w-4" />
</button>
))}
</div>
</div>
}
/>
{/* 批量操作 */}
{selected.size > 0 && (
<div className="mb-3 flex items-center gap-2 rounded-lg bg-primary-50 px-3 py-2">
<span className="text-sm font-medium text-primary-700">已选 {selected.size} 位客户</span>
<div className="flex-1" />
<Button size="sm" variant="outline" onClick={batchAssign}><UserRoundPlus className="h-4 w-4" />批量分配</Button>
<Button size="sm" variant="outline" onClick={batchTag}><Tag className="h-4 w-4" />批量加标签</Button>
<Button size="sm" variant="ghost" onClick={() => setSelected(new Set())}>取消选择</Button>
</div>
)}
{/* 表格视图 */}
{view === 'table' && (
<div className="rounded-lg border border-border bg-card">
<Table>
<THead>
<Tr>
<Th className="w-10"><Checkbox checked={selected.size === list.length && list.length > 0} onCheckedChange={(v) => setSelected(v ? new Set(list.map((c: Customer) => c.id)) : new Set())} /></Th>
<Th>客户 · 公司</Th>
<Th>国家</Th>
<Th>联系人</Th>
<Th>来源</Th>
<Th>产品</Th>
<Th>意向</Th>
<Th>阶段</Th>
<Th>情绪</Th>
<Th>风险</Th>
<Th>负责人</Th>
<Th>最后联系</Th>
<Th className="w-12"></Th>
</Tr>
</THead>
<TBody>
{list.length === 0 && (
<Tr><Td colSpan={13}><EmptyState title="暂无匹配客户" description="尝试调整筛选条件" /></Td></Tr>
)}
{list.map((customer: Customer) => (
<Tr key={customer.id} className="cursor-pointer">
<Td onClick={(e) => e.stopPropagation()}><Checkbox checked={selected.has(customer.id)} onCheckedChange={(v) => setSelected((s) => { const ns = new Set(s); v ? ns.add(customer.id) : ns.delete(customer.id); return ns })} /></Td>
<Td onClick={() => navigate(`/customers/${customer.id}`)}>
<div className="flex items-center gap-2">
<Avatar name={customer.name} size="xs" />
<div>
<p className="text-sm font-medium hover:text-primary">{customer.name}</p>
{customer.company && <p className="text-xs text-muted-foreground">{customer.company}</p>}
</div>
{customer.isKey && <Badge variant="warning">⭐</Badge>}
</div>
</Td>
<Td onClick={() => navigate(`/customers/${customer.id}`)}><CountryFlag code={customer.country} /></Td>
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{customer.email || customer.phone || '—'}</span></Td>
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="flex items-center gap-1"><ChannelIcon channel={customer.channel} size="xs" withBg={false} />{customer.channel}</span></Td>
<Td onClick={() => navigate(`/customers/${customer.id}`)}><span className="text-xs">{customer.products[0] || '—'}</span></Td>
<Td><IntentScoreBadge score={customer.intentScore} /></Td>
<Td><CustomerStageBadge stage={customer.stage} /></Td>
<Td><SentimentBadge sentiment={customer.sentiment} emojiOnly /></Td>
<Td><RiskLevelBadge level={customer.riskLevel} /></Td>
<Td><span className="text-xs">{customer.ownerId === 'u-003' ? '李婷' : customer.ownerId === 'u-004' ? '赵磊' : customer.ownerId === 'u-005' ? '周敏' : customer.ownerId === 'u-008' ? '郑楠' : customer.ownerId ? '其他' : <span className="text-amber-600">未分配</span>}</span></Td>
<Td><span className="text-xs text-muted-foreground">{customer.lastContactAt ? new Date(customer.lastContactAt).toLocaleDateString('zh-CN') : '—'}</span></Td>
<Td>
<Dropdown
trigger={<button className="rounded p-1 hover:bg-muted"><MoreHorizontal className="h-4 w-4 text-muted-foreground" /></button>}
items={[
{ key: 'view', label: '查看详情', onSelect: () => navigate(`/customers/${customer.id}`) },
{ key: 'edit', label: '编辑', onSelect: () => { setEditCustomer(customer); setFormDialog(true) } },
{ key: 'delete', label: '删除', danger: true, onSelect: () => handleDelete(customer) },
]}
/>
</Td>
</Tr>
))}
</TBody>
</Table>
</div>
)}
{/* 卡片视图 */}
{view === 'card' && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{list.map((customer: Customer) => (
<Card key={customer.id} className="cursor-pointer p-4 hover:shadow-md transition-shadow" onClick={() => navigate(`/customers/${customer.id}`)}>
<div className="flex items-start justify-between">
<div className="flex items-center gap-2.5">
<Avatar name={customer.name} size="sm" />
<div className="min-w-0">
<p className="text-sm font-medium truncate">{customer.name}</p>
<p className="text-xs text-muted-foreground">{customer.company || '—'}</p>
</div>
</div>
<ChannelIcon channel={customer.channel} size="xs" />
</div>
<div className="mt-3 flex items-center gap-2">
<CountryFlag code={customer.country} />
<IntentScoreBadge score={customer.intentScore} />
</div>
<div className="mt-2 flex flex-wrap gap-1">
<CustomerStageBadge stage={customer.stage} />
<SentimentBadge sentiment={customer.sentiment} emojiOnly />
{customer.riskLevel !== 'none' && <RiskLevelBadge level={customer.riskLevel} />}
</div>
{customer.products.length > 0 && (
<p className="mt-2 text-xs text-muted-foreground truncate">{customer.products[0]}</p>
)}
</Card>
))}
</div>
)}
{/* 看板视图 */}
{view === 'kanban' && (
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-thin">
{CUSTOMER_STAGES.map((s) => {
const cols = list.filter((c: Customer) => c.stage === s.value)
return (
<div key={s.value} className="w-64 shrink-0">
<div className="mb-2 flex items-center justify-between rounded-md bg-muted px-3 py-2">
<span className="text-sm font-medium">{s.label}</span>
<Badge>{cols.length}</Badge>
</div>
<div className="space-y-2">
{cols.map((customer: Customer) => (
<Card
key={customer.id}
className="cursor-pointer p-3 hover:shadow-sm"
onClick={() => navigate(`/customers/${customer.id}`)}
>
<div className="flex items-center gap-2">
<Avatar name={customer.name} size="xs" />
<span className="text-sm font-medium truncate">{customer.name}</span>
</div>
<div className="mt-1.5 flex items-center gap-2">
<CountryFlag code={customer.country} withName={false} />
<IntentScoreBadge score={customer.intentScore} className="scale-75" />
</div>
</Card>
))}
</div>
</div>
)
})}
</div>
)}
<CustomerFormDialog open={formDialog} onOpenChange={setFormDialog} customer={editCustomer} />
{/* 去重合并弹窗 */}
<Dialog open={mergeDialog} onOpenChange={setMergeDialog} title="重复客户检测" width="max-w-2xl">
{duplicateList.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">未发现明显的重复客户,所有档案均为唯一。</p>
) : (
duplicateList.map((dup: any) => (
<div key={dup.id} className="mb-3 rounded-lg border border-border p-3">
<p className="text-xs text-muted-foreground mb-2">检测原因:{dup.reason}</p>
<div className="flex items-center gap-3">
<div className="flex-1 rounded bg-muted p-2 text-sm">{dup.customers[0]?.name} · {dup.customers[0]?.email}</div>
<GitMerge className="h-4 w-4 text-muted-foreground" />
<div className="flex-1 rounded bg-muted p-2 text-sm">{dup.customers[1]?.name} · {dup.customers[1]?.email}</div>
</div>
<Button size="sm" className="mt-2" onClick={() => handleMerge(dup.customers[0]?.id, dup.customers[1]?.id)}>
合并为客户「{dup.customers[0]?.name}」
</Button>
</div>
))
)}
</Dialog>
{/* 导入弹窗 */}
<Dialog
open={importDialog}
onOpenChange={setImportDialog}
title="批量导入客户"
footer={<Button onClick={handleImport}>模拟导入 2 条演示数据</Button>}
>
<p className="text-sm text-muted-foreground">
支持 Excel(.xlsx)或 CSV 文件上传,系统将自动识别列名(姓名/公司/国家/邮箱/电话)并去重建档。
</p>
</Dialog>
</div>
)
}
+306
View File
@@ -0,0 +1,306 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Activity,
AlertTriangle,
ArrowRight,
AudioLines,
BadgePercent,
Cable,
CalendarClock,
CheckCircle2,
Clock,
Languages,
MessageSquarePlus,
MessageSquareWarning,
Plus,
Send,
Target,
Timer,
TrendingUp,
UserPlus,
Users,
Zap,
BookOpen,
} from 'lucide-react'
import { getDashboardOverview } from '@/services/api/dashboard'
import { updateTask } from '@/services/api/crm'
import { useAuthStore } from '@/app/store/auth'
import { useI18n } from '@/i18n'
import { getPlan } from '@/config/pricingPlans'
import { countryMeta, taskTypeLabel, CHANNEL_META, riskLevelMeta } from '@/config/constants'
import { formatDateTime, formatRelative } from '@/utils/format'
import { toast, errorMessage } from '@/app/store/toast'
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback'
import { Card, CardHeader } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Avatar } from '@/components/ui/avatar'
import { Checkbox } from '@/components/ui/checkbox'
import { StatCard } from '@/components/common/StatCard'
import { DemoNote } from '@/components/common/DemoNote'
import { ChannelIcon } from '@/components/common/ChannelIcon'
import { CountryFlag } from '@/components/common/CountryFlag'
import { IntentScoreBadge, CustomerStageBadge } from '@/components/common/StatusBadges'
import { ChartCard, TrendChart, DonutChart, FunnelChart, RankBars } from '@/components/charts'
import { CustomerFormDialog } from '@/components/customer/CustomerFormDialog'
import { TaskFormDialog } from '@/components/crm/TaskFormDialog'
import { cn } from '@/utils/cn'
export function DashboardPage() {
const { t } = useI18n()
const navigate = useNavigate()
const queryClient = useQueryClient()
const user = useAuthStore((s) => s.user)
const tenant = useAuthStore((s) => s.tenant)
const [customerDialog, setCustomerDialog] = useState(false)
const [taskDialog, setTaskDialog] = useState(false)
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['dashboard', 'overview'],
queryFn: getDashboardOverview,
})
const completeTask = useMutation({
mutationFn: (id: string) => updateTask(id, { status: 'done' }),
onSuccess: () => {
toast.success('任务已完成')
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
queryClient.invalidateQueries({ queryKey: ['tasks'] })
},
onError: (e) => toast.error(errorMessage(e)),
})
if (isLoading) return <PageLoading text="正在加载今日经营数据…" />
if (isError || !data) return <ErrorState description="工作台数据加载失败,请检查网络后重试" onRetry={() => refetch()} />
const hour = new Date().getHours()
const greeting = hour < 12 ? t('dashboard.goodMorning') : hour < 18 ? t('dashboard.goodAfternoon') : t('dashboard.goodEvening')
const plan = getPlan(tenant?.plan ?? 'pro')
const dateStr = new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' })
const quickActions = [
{ label: '新建客户', icon: UserPlus, onClick: () => setCustomerDialog(true) },
{ label: '新建跟进', icon: CalendarClock, onClick: () => setTaskDialog(true) },
{ label: '上传知识', icon: BookOpen, onClick: () => navigate('/knowledge?action=upload') },
{ label: '连接渠道', icon: Cable, onClick: () => navigate('/channels') },
{ label: '开始翻译', icon: Languages, onClick: () => navigate('/translate') },
{ label: '发起同声传译', icon: AudioLines, onClick: () => navigate('/interpret') },
]
const s = data.stats
return (
<div className="p-4 md:p-6">
{/* 顶部欢迎区 */}
<div className="mb-5 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-xl font-semibold">
{greeting},{user?.name}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
{dateStr} · {tenant?.name} · {t('dashboard.updatedAt')} {formatDateTime(data.updatedAt).slice(11)}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="primary" className="h-7 px-2.5">{plan.name}</Badge>
<DemoNote />
<Button size="sm" onClick={() => navigate('/channels')}>
<Cable className="h-4 w-4" />
快速连接渠道
</Button>
</div>
</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="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatCard title="今日新增询盘" value={s.newInquiriesToday.value} delta={s.newInquiriesToday.delta} icon={MessageSquarePlus} onClick={() => navigate('/inbox')} />
<StatCard title="待回复消息" value={s.pendingReply.value} delta={s.pendingReply.delta} icon={MessageSquareWarning} onClick={() => navigate('/inbox?filter=pending')} />
<StatCard title="高意向客户" value={s.highIntentCustomers.value} delta={s.highIntentCustomers.delta} icon={Target} onClick={() => navigate('/customers?intentLevel=high')} />
<StatCard title="今日待跟进" value={s.todayFollowUps.value} delta={s.todayFollowUps.delta} icon={CalendarClock} onClick={() => navigate('/opportunities?tab=tasks')} />
<StatCard title="风险预警" value={s.openRisks.value} delta={s.openRisks.delta} icon={AlertTriangle} onClick={() => navigate('/risks')} />
<StatCard title="平均首次响应" value={s.avgFirstResponseMin.value} suffix="分钟" delta={s.avgFirstResponseMin.delta} icon={Timer} invertDelta />
<StatCard title="本月客户转化率" value={`${s.monthConversionRate.value}%`} delta={s.monthConversionRate.delta} icon={BadgePercent} />
<StatCard title="AI回复采纳率" value={`${s.aiAdoptionRate.value}%`} delta={s.aiAdoptionRate.delta} icon={Zap} />
</div>
{/* 中部三栏 */}
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-3">
{/* 今日待办 */}
<Card>
<CardHeader
title="今日待办"
description="按截止时间排序"
action={
<Button variant="ghost" size="sm" onClick={() => navigate('/opportunities?tab=tasks')}>
全部
<ArrowRight className="h-3.5 w-3.5 rtl-flip" />
</Button>
}
/>
<div className="max-h-[340px] overflow-y-auto p-2 scrollbar-thin">
{data.todos.length === 0 && <EmptyState title="今日待办已全部完成" description="可以新建跟进任务保持客户热度" />}
{data.todos.map((task) => {
const overdue = new Date(task.dueAt).getTime() < Date.now()
return (
<div key={task.id} className="flex items-start gap-2.5 rounded-md px-2 py-2.5 hover:bg-muted/60">
<Checkbox
checked={false}
onCheckedChange={() => completeTask.mutate(task.id)}
className="mt-0.5"
/>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug">{task.title}</p>
<p className="mt-0.5 flex flex-wrap items-center gap-1.5 text-xs text-muted-foreground">
<Badge variant="outline">{taskTypeLabel(task.type)}</Badge>
<span>{task.customerName}</span>
<span className={cn(overdue && 'font-medium text-red-600')}>
{overdue ? '已逾期 · ' : ''}
{formatRelative(task.dueAt)}截止
</span>
</p>
</div>
</div>
)
})}
</div>
</Card>
{/* 高价值客户 */}
<Card>
<CardHeader
title="高价值客户"
description="按意向评分排序,建议优先跟进"
action={
<Button variant="ghost" size="sm" onClick={() => navigate('/customers?intentLevel=high')}>
全部
<ArrowRight className="h-3.5 w-3.5 rtl-flip" />
</Button>
}
/>
<div className="max-h-[340px] divide-y divide-border overflow-y-auto scrollbar-thin">
{data.highValueCustomers.map((c) => (
<Link key={c.id} to={`/customers/${c.id}`} className="flex items-center gap-3 px-4 py-2.5 hover:bg-muted/50">
<Avatar name={c.name} size="sm" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{c.name}</span>
<CountryFlag code={c.country} withName={false} />
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{c.products[0] ?? '—'} · 建议:{c.aiProfile.nextActions[0] ?? '跟进'}
</p>
</div>
<div className="flex shrink-0 flex-col items-end gap-1">
<IntentScoreBadge score={c.intentScore} />
<CustomerStageBadge stage={c.stage} />
</div>
</Link>
))}
</div>
</Card>
{/* 实时消息概览 */}
<Card>
<CardHeader
title="实时消息概览"
description="各渠道今日消息量"
action={<DemoNote />}
/>
<div className="divide-y divide-border">
{data.channelOverview.map((ch) => (
<button
key={ch.channel}
onClick={() => navigate(`/inbox?channel=${ch.channel}`)}
className="flex w-full items-center gap-3 px-4 py-3 text-start hover:bg-muted/50"
>
<ChannelIcon channel={ch.channel} size="sm" />
<div className="flex-1">
<p className="text-sm font-medium">{CHANNEL_META[ch.channel].name}</p>
<p className="text-xs text-muted-foreground">今日 {ch.todayMessages} 条消息</p>
</div>
{ch.unread > 0 && <Badge variant="danger">{ch.unread} 未读</Badge>}
{ch.pending > 0 ? (
<Badge variant="warning">{ch.pending} 待回复</Badge>
) : (
<span className="text-xs text-muted-foreground">已清零</span>
)}
</button>
))}
</div>
<div className="border-t border-border p-3">
<Button variant="outline" className="w-full" onClick={() => navigate('/inbox')}>
<Send className="h-4 w-4" />
进入统一消息中心
</Button>
</div>
</Card>
</div>
{/* 图表区 */}
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-2">
<ChartCard title="询盘趋势(近14天)" height={240}>
<TrendChart data={data.inquiryTrend} series={[{ key: 'inquiries', name: '询盘数' }, { key: 'customers', name: '新增客户' }]} />
</ChartCard>
<ChartCard title="客户来源分布" height={240}>
<DonutChart data={data.sourceDistribution} />
</ChartCard>
<ChartCard title="客户阶段转化漏斗" height={240}>
<FunnelChart data={data.funnel} />
</ChartCard>
<ChartCard title="团队回复效率排行(平均响应时长)" height={240}>
<RankBars
data={data.teamReplyRank.map((r) => ({ name: r.name, value: r.avgResponseMin }))}
unit="分钟"
max={30}
/>
</ChartCard>
</div>
{/* 最近风险 */}
<Card className="mt-4">
<CardHeader
title="最近风险预警"
description="需要关注的风险信号"
action={
<Button variant="ghost" size="sm" onClick={() => navigate('/risks')}>
风险中心
<ArrowRight className="h-3.5 w-3.5 rtl-flip" />
</Button>
}
/>
<div className="divide-y divide-border">
{data.recentRisks.map((r) => {
const meta = riskLevelMeta(r.level)
return (
<button key={r.id} onClick={() => navigate('/risks')} className="flex w-full items-center gap-3 px-4 py-3 text-start hover:bg-muted/50">
<span className={cn('h-2 w-2 shrink-0 rounded-full', meta.dot)} />
<div className="min-w-0 flex-1">
<p className="truncate text-sm">{r.reason}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{r.customerName} · {formatRelative(r.createdAt)}
</p>
</div>
<Badge className={meta.color}>{meta.label}</Badge>
</button>
)
})}
</div>
</Card>
<CustomerFormDialog open={customerDialog} onOpenChange={setCustomerDialog} />
<TaskFormDialog open={taskDialog} onOpenChange={setTaskDialog} />
</div>
)
}
+92
View File
@@ -0,0 +1,92 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Check, ExternalLink, GraduationCap, HelpCircle, PlayCircle, Cable, BookOpen, FileUp, Headphones, Ticket, Calendar } from 'lucide-react'
import { PageHeader } from '@/components/common/PageHeader'
import { Button } from '@/components/ui/button'
import { Card, CardHeader } from '@/components/ui/card'
import { Dialog } from '@/components/ui/dialog'
import { Input, Field, Textarea } from '@/components/ui/input'
import { DemoNote } from '@/components/common/DemoNote'
import { toast } from '@/app/store/toast'
const DELIVERY_STEPS = [
{ key: '1', title: '账号开通', done: true },
{ key: '2', title: '渠道连接', done: true },
{ key: '3', title: '历史数据迁移', done: true },
{ key: '4', title: '功能配置', done: true },
{ key: '5', title: '员工培训', done: false },
{ key: '6', title: '正式上线', done: false },
{ key: '7', title: '运营支持', done: false },
{ key: '8', title: '使用反馈', done: false },
{ key: '9', title: '产品持续迭代', done: false },
]
const HELP_ITEMS = [
{ icon: GraduationCap, title: '新手指引', desc: '10分钟快速上手灵犀跨境' },
{ icon: PlayCircle, title: '视频教程', desc: '核心功能操作教学视频' },
{ icon: HelpCircle, title: '常见问题', desc: 'FAQ 与排查指引' },
{ icon: Cable, title: '渠道连接文档', desc: 'WhatsApp、邮件、表单等连接指南' },
{ icon: BookOpen, title: '知识库建设教程', desc: '如何构建高效的企业知识库' },
{ icon: FileUp, title: '客户导入教程', desc: '批量导入与去重操作指南' },
]
export function HelpPage() {
const navigate = useNavigate()
const [ticketOpen, setTicketOpen] = useState(false)
const doneCount = DELIVERY_STEPS.filter((s) => s.done).length
return (
<div className="p-4 md:p-6">
<PageHeader title="帮助与培训" description="从开通到深度运营,全程伴随" actions={<DemoNote />} />
{/* 交付进度 */}
<Card className="mb-4 p-4">
<h2 className="text-sm font-semibold mb-3">企业开通进度 <span className="text-xs text-muted-foreground font-normal">({doneCount}/{DELIVERY_STEPS.length} 步完成)</span></h2>
<div className="flex flex-wrap gap-3">
{DELIVERY_STEPS.map((s, i) => (
<div key={s.key} className="flex items-center gap-2 text-sm">
<span className={`flex h-6 w-6 items-center justify-center rounded-full text-xs ${s.done ? 'bg-emerald-100 text-emerald-700' : 'bg-muted text-muted-foreground'}`}>
{s.done ? <Check className="h-3.5 w-3.5" /> : i + 1}
</span>
<span className="text-muted-foreground">{s.title}</span>
{i < DELIVERY_STEPS.length - 1 && <span className="text-border">→</span>}
</div>
))}
</div>
</Card>
{/* 帮助卡片 */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 mb-6">
{HELP_ITEMS.map((item) => (
<Card key={item.title} className="cursor-pointer p-4 hover:shadow-md transition-shadow" onClick={() => toast.success(`打开:${item.title}(演示内容)`)}>
<item.icon className="h-8 w-8 text-primary-600 mb-2" />
<p className="text-sm font-medium">{item.title}</p>
<p className="text-xs text-muted-foreground mt-1">{item.desc}</p>
</Card>
))}
</div>
{/* 客服与工单 */}
<div className="flex flex-wrap gap-3">
<Button variant="outline" onClick={() => toast.info('在线客服:工作日 9:00—18:00(演示)')}>
<Headphones className="h-4 w-4" /> 联系客服
</Button>
<Button variant="outline" onClick={() => setTicketOpen(true)}>
<Ticket className="h-4 w-4" /> 提交工单
</Button>
<Button variant="outline" onClick={() => toast.info('预约培训已提交(演示)')}>
<Calendar className="h-4 w-4" /> 预约培训
</Button>
</div>
<Dialog open={ticketOpen} onOpenChange={setTicketOpen} title="提交工单" footer={
<Button onClick={() => { setTicketOpen(false); toast.success('工单已提交', '客服将在24小时内回复') }}>
提交
</Button>
}>
<Field label="工单主题" required><Input placeholder="请描述您的问题" /></Field>
<Field label="问题描述" className="mt-3"><Textarea rows={4} placeholder="详细描述您遇到的问题或需求" /></Field>
</Dialog>
</div>
)
}
+373
View File
@@ -0,0 +1,373 @@
import { useState, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ArrowLeft, UserRound, ChevronLeft } from 'lucide-react'
import {
getConversations,
getConversation,
getMessages,
sendMessage,
updateConversation,
} from '@/services/api/conversations'
import { updateCustomer } from '@/services/api/customers'
import { useAuthStore } from '@/app/store/auth'
import { useIsMobile } from '@/hooks/useMediaQuery'
import { toast, errorMessage } from '@/app/store/toast'
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback'
import { Button } from '@/components/ui/button'
import { Segmented, type TabItem } from '@/components/ui/tabs'
import { Avatar } from '@/components/ui/avatar'
import { ChannelIcon } from '@/components/common/ChannelIcon'
import { CountryFlag } from '@/components/common/CountryFlag'
import { IntentScoreBadge } from '@/components/common/StatusBadges'
import type { ViewMode } from '@/components/inbox/MessageBubble'
import type { Message } from '@/types'
import { ConversationList } from '@/components/inbox/ConversationList'
import { MessageBubble } from '@/components/inbox/MessageBubble'
import { Composer } from '@/components/inbox/Composer'
import { InsightPanel } from '@/components/inbox/InsightPanel'
import { AiReplies } from '@/components/inbox/AiReplies'
import { useDebounce } from '@/hooks/useDebounce'
export function InboxPage() {
const [searchParams, setSearchParams] = useSearchParams()
const convId = searchParams.get('c') || null
const filter = searchParams.get('filter') || 'all'
const channel = searchParams.get('channel') || ''
const queryClient = useQueryClient()
const user = useAuthStore((s) => s.user)
const isMobile = useIsMobile()
const [keyword, setKeyword] = useState('')
const debouncedKeyword = useDebounce(keyword, 300)
const [viewMode, setViewMode] = useState<ViewMode>('bilingual')
const [mobilePanel, setMobilePanel] = useState<'list' | 'chat' | 'insight'>('list')
const [showInsight, setShowInsight] = useState(false) // desktop right panel toggle
// 会话列表
const { data: convData, isLoading: convLoading } = useQuery({
queryKey: ['conversations', filter, channel, debouncedKeyword],
queryFn: () =>
getConversations({
filter,
channel,
keyword: debouncedKeyword,
userId: user?.id,
}),
})
// 当前会话详情
const { data: convDetail, isLoading: convDetailLoading } = useQuery({
queryKey: ['conversation', convId],
queryFn: () => getConversation(convId!),
enabled: !!convId,
})
// 消息列表
const { data: messages, isLoading: msgsLoading } = useQuery({
queryKey: ['messages', convId],
queryFn: () => getMessages(convId!),
enabled: !!convId,
refetchInterval: 30_000,
})
const setConv = useCallback(
(id: string) => {
setSearchParams((prev) => {
prev.set('c', id)
return prev
})
if (isMobile) setMobilePanel('chat')
},
[setSearchParams, isMobile],
)
const setFilter = useCallback(
(f: string) => {
setSearchParams((prev) => {
if (f === 'all') prev.delete('filter')
else prev.set('filter', f)
prev.delete('c')
return prev
})
},
[setSearchParams],
)
const sendMsg = useMutation({
mutationFn: async ({
text,
convId: id,
translatedText,
}: {
text: string
convId: string
translatedText?: string
}) => {
return sendMessage(id, { text, translatedText })
},
onSuccess: (msg) => {
queryClient.invalidateQueries({ queryKey: ['messages', convId] })
queryClient.invalidateQueries({ queryKey: ['conversations'] })
},
onError: (e) => toast.error(errorMessage(e)),
})
const handleSend = async (text: string, translatedText?: string) => {
if (!convId) return
await sendMsg.mutateAsync({ text, convId, translatedText })
}
const handleAction = {
quote: (msg: Message) => {
if (!convId) return
const t = `> ${msg.originalText.slice(0, 80)}…\n\n`
// 引用通过 Composer 预设文本实现
toast.info('引用已复制到输入区')
},
markImportant: (msg: Message) => {
queryClient.setQueryData(['messages', convId], (old: Message[] | undefined) =>
old?.map((m) => (m.id === msg.id ? { ...m, isImportant: !m.isImportant } : m)),
)
toast.success(msg.isImportant ? '已取消重要标记' : '已标记为重要')
},
addNote: (msg: Message) => {
const note = prompt('为这条消息添加内部备注:', msg.internalNote ?? '')
if (note === null) return
queryClient.setQueryData(['messages', convId], (old: Message[] | undefined) =>
old?.map((m) => (m.id === msg.id ? { ...m, internalNote: note } : m)),
)
toast.success('备注已保存')
},
task: () => toast.info('待办创建入口(可通过右上角快捷操作 "新建跟进" 创建)'),
knowledge: (msg: Message) => {
import('@/services/api/knowledge')
.then(({ createKnowledge }) =>
createKnowledge({
title: `会话消息提取:${msg.originalText.slice(0, 30)}`,
content: msg.originalText,
source: 'reply',
}),
)
.then(() => toast.success('已添加到知识库'))
.catch((e) => toast.error(errorMessage(e)))
},
}
const handleAdopt = (content: string) => {
toast.success('回复已采用,请在输入区编辑后发送')
}
const handleEdit = (content: string) => {
toast.info('回复已加载到输入区,请按需编辑', content.slice(0, 50))
}
const VIEW_LABELS: TabItem[] = [
{ value: 'original', label: '原文' },
{ value: 'translated', label: '译文' },
{ value: 'bilingual', label: '双语' },
]
const listItems = convData?.list ?? []
// 移动端:当前面板
if (isMobile) {
return (
<div className="flex h-[calc(100vh-56px)] flex-col">
{mobilePanel === 'list' && (
<ConversationList
items={listItems}
loading={convLoading}
activeId={convId}
onSelect={setConv}
filter={filter}
onFilterChange={setFilter}
keyword={keyword}
onKeywordChange={setKeyword}
/>
)}
{mobilePanel === 'chat' && (
<div className="flex flex-1 flex-col">
<ChatHeader
convId={convId}
detail={convDetail}
loading={convDetailLoading}
viewMode={viewMode}
onViewModeChange={setViewMode}
onBack={() => setMobilePanel('list')}
onInsight={() => setMobilePanel('insight')}
showInsightBtn
/>
<div className="flex-1 overflow-y-auto scrollbar-thin">
{msgsLoading ? <PageLoading /> : <>
{(messages ?? []).map((msg) => (
<MessageBubble
key={msg.id}
msg={msg}
viewMode={viewMode}
onQuote={() => handleAction.quote(msg)}
onMarkImportant={() => handleAction.markImportant(msg)}
onConvertToTask={() => handleAction.task()}
onAddToKnowledge={() => handleAction.knowledge(msg)}
onAddNote={() => handleAction.addNote(msg)}
/>
))}
</>}
</div>
<Composer onSend={(t) => handleSend(t)} disabled={!convId} />
</div>
)}
{mobilePanel === 'insight' && convDetail && (
<div className="flex flex-1 flex-col">
<div className="flex items-center gap-2 border-b border-border px-3 py-2.5">
<Button variant="ghost" size="icon-sm" onClick={() => setMobilePanel('chat')}>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="text-sm font-medium">客户洞察</span>
</div>
<InsightPanel customer={convDetail.customer} conversation={convDetail.conversation} />
</div>
)}
</div>
)
}
// 桌面端三栏布局
return (
<div className="flex h-[calc(100vh-56px)] overflow-hidden">
{/* 左栏:会话列表 360px */}
<div className="w-80 shrink-0 border-e border-border lg:w-[360px]">
<ConversationList
items={listItems}
loading={convLoading}
activeId={convId}
onSelect={setConv}
filter={filter}
onFilterChange={setFilter}
keyword={keyword}
onKeywordChange={setKeyword}
/>
</div>
{/* 中栏:聊天区 flex-1 */}
<div className={`flex min-w-0 flex-1 flex-col ${showInsight ? '' : 'border-e-0'}`}>
{convId ? (
<>
<ChatHeader
convId={convId}
detail={convDetail}
loading={convDetailLoading}
viewMode={viewMode}
onViewModeChange={setViewMode}
onInsight={() => setShowInsight((v) => !v)}
showInsightBtn
/>
<div className="flex-1 overflow-y-auto scrollbar-thin">
{msgsLoading ? (
<PageLoading />
) : (messages?.length ?? 0) === 0 ? (
<EmptyState title="暂无消息" description="选择左侧会话开始沟通" />
) : (
(messages ?? []).map((msg) => (
<MessageBubble
key={msg.id}
msg={msg}
viewMode={viewMode}
onQuote={() => handleAction.quote(msg)}
onMarkImportant={() => handleAction.markImportant(msg)}
onConvertToTask={() => handleAction.task()}
onAddToKnowledge={() => handleAction.knowledge(msg)}
onAddNote={() => handleAction.addNote(msg)}
/>
))
)}
</div>
<AiReplies
conversationId={convId}
onAdopt={handleAdopt}
onEdit={handleEdit}
/>
<Composer onSend={handleSend} disabled={!convId} />
</>
) : (
<EmptyState
title="选择会话开始沟通"
description="左侧选择一条会话,查看消息记录、AI 分析与客户洞察"
className="flex-1"
/>
)}
</div>
{/* 右栏:客户洞察 340px */}
{showInsight && convDetail && (
<div className="w-[340px] shrink-0 border-s border-border">
<InsightPanel customer={convDetail.customer} conversation={convDetail.conversation} />
</div>
)}
</div>
)
}
/** 聊天顶部栏 */
function ChatHeader({
convId,
detail,
loading,
viewMode,
onViewModeChange,
onBack,
onInsight,
showInsightBtn,
}: {
convId: string | null
detail?: { conversation: any; customer: any } | null
loading: boolean
viewMode: ViewMode
onViewModeChange: (v: ViewMode) => void
onBack?: () => void
onInsight?: () => void
showInsightBtn?: boolean
}) {
if (!convId) return null
const c = detail?.customer
const conv = detail?.conversation
return (
<div className="flex items-center gap-3 border-b border-border px-4 py-2.5">
{onBack && (
<Button variant="ghost" size="icon-sm" onClick={onBack}>
<ArrowLeft className="h-4 w-4" />
</Button>
)}
{loading ? (
<span className="text-sm text-muted-foreground">加载中…</span>
) : (
<>
<Avatar name={c?.name ?? '?'} size="sm" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{c?.name ?? '—'}</span>
{c && <CountryFlag code={c.country} withName={false} />}
{conv && <ChannelIcon channel={conv.channel} size="xs" withBg />}
{c && <IntentScoreBadge score={c.intentScore} className="scale-75" />}
</div>
</div>
<Segmented
value={viewMode}
onChange={onViewModeChange}
options={[
{ value: 'original' as ViewMode, label: '原文' },
{ value: 'translated' as ViewMode, label: '译文' },
{ value: 'bilingual' as ViewMode, label: '双语' },
]}
size="sm"
/>
{showInsightBtn && onInsight && (
<Button variant="ghost" size="icon-sm" onClick={onInsight} title="客户洞察">
<UserRound className="h-4 w-4" />
</Button>
)}
</>
)}
</div>
)
}
+152
View File
@@ -0,0 +1,152 @@
import { useState, useRef, useEffect } from 'react'
import { Mic, MicOff, Pause, Play, Square, PhoneCall, Download, ListTodo } from 'lucide-react'
import { useMutation } from '@tanstack/react-query'
import { PageHeader } from '@/components/common/PageHeader'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardHeader } from '@/components/ui/card'
import { Select } from '@/components/ui/select'
import { DemoNote } from '@/components/common/DemoNote'
import { nextInterpretLine } from '@/services/api/ai'
import { toast } from '@/app/store/toast'
import { LANGUAGES } from '@/config/languages'
import { cn } from '@/utils/cn'
import type { InterpretLine } from '@/types/ai'
const langOptions = LANGUAGES.slice(1).map(l => ({ value: l.code, label: `${l.nativeName} (${l.name})` }))
const STATES = {
idle: { label: '空闲', color: 'bg-gray-100 text-gray-600' },
connecting: { label: '正在连接', color: 'bg-blue-100 text-blue-600' },
listening: { label: '正在识别', color: 'bg-emerald-100 text-emerald-600' },
translating: { label: '正在翻译', color: 'bg-teal-100 text-teal-600' },
unstable: { label: '网络不稳定', color: 'bg-amber-100 text-amber-600' },
ended: { label: '会话已结束', color: 'bg-gray-200 text-gray-500' },
}
export function InterpretPage() {
const [myLang, setMyLang] = useState('zh')
const [peerLang, setPeerLang] = useState('ar')
const [state, setState] = useState<keyof typeof STATES>('idle')
const [lines, setLines] = useState<InterpretLine[]>([])
const [paused, setPaused] = useState(false)
const [vol, setVol] = useState(0)
const timerRef = useRef<ReturnType<typeof setInterval>>()
const lineIndex = useRef(0)
const simMut = useMutation({
mutationFn: () => nextInterpretLine(lineIndex.current),
onSuccess: (line) => {
lineIndex.current += 1
setLines((l) => [...l, { ...line, speaker: lineIndex.current % 2 === 0 ? 'me' : 'peer' }])
setState('translating')
setTimeout(() => { setState('listening'); setVol(Math.random()) }, 800)
},
})
useEffect(() => {
if (state === 'listening' && !paused) {
timerRef.current = setInterval(() => {
simMut.mutate()
}, 4000)
}
return () => clearInterval(timerRef.current)
}, [state, paused])
const start = () => {
setState('connecting')
setTimeout(() => { setState('listening'); setVol(0.6) }, 1200)
toast.success('同声传译会话已创建', `${LANGUAGES.find(l => l.code === myLang)?.name} ↔ ${LANGUAGES.find(l => l.code === peerLang)?.name}`)
}
const stop = () => {
setState('ended')
clearInterval(timerRef.current)
toast.info('会话已结束,可导出记录')
}
const statusInfo = STATES[state]
return (
<div className="p-4 md:p-6">
<PageHeader title="同声传译" description="实时语音翻译与双语字幕 · 支持模拟演示" actions={<DemoNote />} />
<div className="mx-auto max-w-2xl">
{/* 设置区 */}
<Card className="mb-4 p-4">
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm">我的语言</span>
<Select options={langOptions} value={myLang} onChange={(e) => setMyLang(e.target.value)} className="w-32" />
<span className="text-sm text-muted-foreground">↔</span>
<span className="text-sm">对方语言</span>
<Select options={langOptions} value={peerLang} onChange={(e) => setPeerLang(e.target.value)} className="w-32" />
<div className="flex-1" />
{state === 'idle' && <Button onClick={start}><PhoneCall className="h-4 w-4" />开始会话</Button>}
{state === 'ended' && <Button onClick={() => { setLines([]); setState('idle'); lineIndex.current = 0 }}>新建会话</Button>}
</div>
</Card>
{/* 状态区 */}
<Card className="mb-4 p-6 text-center">
<Badge className={cn('text-sm px-4 py-1.5', statusInfo.color)}>{statusInfo.label}</Badge>
<div className="mt-4 flex items-center justify-center gap-8">
<div className="text-center">
<div className={cn('flex h-12 w-12 items-center justify-center rounded-full mx-auto', state === 'listening' ? 'bg-emerald-100 text-emerald-600' : 'bg-muted text-muted-foreground')}>
<Mic className="h-6 w-6" />
</div>
<p className="mt-1 text-xs text-muted-foreground">{LANGUAGES.find(l => l.code === myLang)?.name}</p>
{state === 'listening' && vol > 0 && (
<div className="mt-1 h-1.5 w-16 mx-auto overflow-hidden rounded-full bg-muted">
<div className="h-full bg-emerald-500 transition-all" style={{ width: `${vol * 100}%` }} />
</div>
)}
</div>
<div className="flex gap-2">
{state !== 'idle' && state !== 'ended' && (
<>
<Button variant={paused ? 'default' : 'outline'} size="icon" onClick={() => { setPaused(!paused); toast.info(paused ? '已继续' : '已暂停') }} title="暂停/继续">
{paused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
</Button>
<Button variant="destructive" size="icon" onClick={stop} title="结束会话"><Square className="h-4 w-4" /></Button>
</>
)}
</div>
<div className="text-center">
<div className={cn('flex h-12 w-12 items-center justify-center rounded-full mx-auto', state === 'listening' ? 'bg-teal-100 text-teal-600' : 'bg-muted text-muted-foreground')}>
<Mic className="h-6 w-6" />
</div>
<p className="mt-1 text-xs text-muted-foreground">{LANGUAGES.find(l => l.code === peerLang)?.name}</p>
</div>
</div>
</Card>
{/* 字幕区 */}
<Card>
<CardHeader
title="双语字幕记录"
action={
<div className="flex gap-1">
<Button variant="ghost" size="sm" disabled={lines.length === 0} onClick={() => { const t = lines.map(l => `[${l.speaker}] ${l.original}\n → ${l.translated}`).join('\n\n'); const b = new Blob([t]); const a = document.createElement('a'); a.href = URL.createObjectURL(b); a.download = '同声传译记录.txt'; a.click(); toast.success('已导出') }}>
<Download className="h-4 w-4" /> 导出
</Button>
</div>
}
/>
<div className="max-h-80 overflow-y-auto scrollbar-thin">
{lines.length === 0 && <p className="py-10 text-center text-sm text-muted-foreground">字幕将在此实时显示</p>}
{lines.map((line) => (
<div key={line.id} className={cn('p-3 border-b border-border', line.speaker === 'me' ? 'bg-primary-50/30' : 'bg-card')}>
<div className="flex items-center gap-2 mb-1">
<Badge variant="outline">{line.speaker === 'me' ? '我' : '对方'}</Badge>
<span className="text-[11px] text-muted-foreground">{line.at ? new Date(line.at).toLocaleTimeString('zh-CN') : ''}</span>
</div>
<p className="text-sm">{line.original}</p>
<p className="mt-1 text-sm text-muted-foreground">→ {line.translated}</p>
</div>
))}
</div>
</Card>
</div>
</div>
)
}
+199
View File
@@ -0,0 +1,199 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Plus, Search, Upload, BookOpen, BarChart3, Zap, FileText, Download, RefreshCw } from 'lucide-react'
import { toast, errorMessage } from '@/app/store/toast'
import { confirmDialog } from '@/app/store/confirm'
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback'
import { PageHeader } from '@/components/common/PageHeader'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardHeader } from '@/components/ui/card'
import { Switch } from '@/components/ui/switch'
import { Input, Textarea, Field } from '@/components/ui/input'
import { Tabs } from '@/components/ui/tabs'
import { Dialog } from '@/components/ui/dialog'
import { DemoNote } from '@/components/common/DemoNote'
import {
getKnowledge, createKnowledge, updateKnowledge, deleteKnowledge,
uploadKnowledgeFiles, knowledgeSearchTest, getKnowledgeStats,
} from '@/services/api/knowledge'
import { KNOWLEDGE_CATEGORIES } from '@/config/constants'
import { formatRelative } from '@/utils/format'
import { cn } from '@/utils/cn'
import type { KnowledgeItem } from '@/types'
export function KnowledgePage() {
const queryClient = useQueryClient()
const [tab, setTab] = useState('list')
const [category, setCategory] = useState('')
const [keyword, setKeyword] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [editItem, setEditItem] = useState<KnowledgeItem | null>(null)
const [searchQ, setSearchQ] = useState('')
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['knowledge', category, keyword],
queryFn: () => getKnowledge({ category, keyword }),
})
const { data: stats } = useQuery({ queryKey: ['knowledge-stats'], queryFn: getKnowledgeStats, enabled: tab === 'stats' })
const [searchResult, setSearchResult] = useState<any>(null)
const [searchLoading, setSearchLoading] = useState(false)
const [uploadFiles, setUploadFiles] = useState<{ name: string; type: string }[]>([])
const [uploadDialog, setUploadDialog] = useState(false)
const createMut = useMutation({
mutationFn: (p: any) => createKnowledge(p),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['knowledge'] }); toast.success('知识已创建'); setCreateOpen(false) },
onError: (e) => toast.error(errorMessage(e)),
})
const toggleMut = useMutation({
mutationFn: ({ id, enabled }: any) => updateKnowledge(id, { enabled }),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['knowledge'] }) },
})
const deleteMut = useMutation({
mutationFn: (id: string) => deleteKnowledge(id),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['knowledge'] }); toast.success('已删除') },
})
const list = data?.list ?? []
if (isLoading) return <PageLoading />
if (isError) return <ErrorState onRetry={() => refetch()} />
return (
<div className="p-4 md:p-6">
<PageHeader
title="企业知识库"
description={`${list.length} 条知识 · AI 回复与翻译的精准来源`}
actions={
<>
<Button size="sm" variant="outline" onClick={() => setUploadDialog(true)}><Upload className="h-4 w-4" />上传文件</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}><Plus className="h-4 w-4" />新建知识</Button>
</>
}
/>
<Tabs value={tab} onChange={setTab} className="mb-4" items={[
{ value: 'list', label: '知识列表' },
{ value: 'search', label: '检索测试' },
{ value: 'stats', label: '使用统计' },
]} />
{tab === 'list' && (
<div className="flex gap-4">
<div className="w-48 shrink-0 space-y-1 hidden md:block">
<button onClick={() => setCategory('')} className={cn('block w-full text-start rounded px-2 py-1.5 text-sm', !category ? 'bg-primary-50 font-medium text-primary-700' : 'text-muted-foreground hover:text-foreground')}>全部分类</button>
{KNOWLEDGE_CATEGORIES.map((c) => (
<button key={c.value} onClick={() => setCategory(c.value)} className={cn('block w-full text-start rounded px-2 py-1.5 text-sm', category === c.value ? 'bg-primary-50 font-medium text-primary-700' : 'text-muted-foreground hover:text-foreground')}>{c.label}</button>
))}
</div>
<div className="flex-1 min-w-0">
<div className="mb-3 flex items-center gap-2">
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<input placeholder="搜索知识标题/内容/标签" value={keyword} onChange={(e) => setKeyword(e.target.value)} className="h-9 w-full rounded-md border border-input bg-card pl-8 pr-3 text-sm focus:border-primary focus:outline-none" />
</div>
</div>
<div className="space-y-2">
{list.map((item) => (
<Card key={item.id} className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{item.title}</span>
<Badge variant="outline">{KNOWLEDGE_CATEGORIES.find(c => c.value === item.category)?.label}</Badge>
<span className="text-xs text-muted-foreground">v{item.version}</span>
</div>
<p className="mt-1 text-xs text-muted-foreground line-clamp-2">{item.content}</p>
<div className="mt-2 flex items-center gap-2 text-xs text-muted-foreground">
<span>引用 {item.citedCount} 次</span>
<span>·</span>
<span>{formatRelative(item.updatedAt)}</span>
<span>·</span>
<span>{item.status === 'parsing' ? '解析中…' : item.status === 'failed' ? '解析失败' : ''}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Switch checked={item.enabled} onCheckedChange={(v) => toggleMut.mutate({ id: item.id, enabled: v })} />
<Button variant="ghost" size="icon-sm" onClick={() => confirmDialog({ title: '删除知识', description: `确认删除「${item.title}」?`, danger: true, onConfirm: () => deleteMut.mutate(item.id) })}><span className="font-bold text-red-500">×</span></Button>
</div>
</div>
</Card>
))}
</div>
</div>
</div>
)}
{tab === 'search' && (
<Card className="p-4">
<div className="flex gap-2">
<Input placeholder="输入跨境问题,测试知识库检索与 AI 答案生成" value={searchQ} onChange={(e) => setSearchQ(e.target.value)} className="flex-1" />
<Button loading={searchLoading} onClick={async () => {
if (!searchQ.trim()) return
setSearchLoading(true)
try {
const res = await knowledgeSearchTest(searchQ)
setSearchResult(res)
} catch (e) { toast.error(errorMessage(e)) } finally { setSearchLoading(false) }
}}><Search className="h-4 w-4" />检索</Button>
</div>
{searchResult && (
<div className="mt-4 space-y-3">
<Card className="p-3 bg-primary-50/30">
<p className="text-xs font-semibold text-muted-foreground mb-1">AI 答案</p>
<p className="text-sm">{searchResult.answer}</p>
</Card>
<p className="text-xs font-semibold text-muted-foreground">命中知识 ({searchResult.hits.length})</p>
{searchResult.hits.map((h: any) => (
<div key={h.item.id} className="rounded-md border border-border p-3">
<div className="flex items-center justify-between"><span className="text-sm font-medium">{h.item.title}</span><Badge variant="primary">{(h.score * 100).toFixed(0)}%</Badge></div>
<p className="mt-1 text-xs text-muted-foreground">{h.snippet}</p>
</div>
))}
</div>
)}
</Card>
)}
{tab === 'stats' && stats && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card className="p-4">
<h3 className="text-sm font-semibold mb-3">知识引用概况</h3>
<p className="text-2xl font-semibold">{stats.totalCitations} <span className="text-sm text-muted-foreground">次总引用</span></p>
<p className="text-sm text-muted-foreground mt-1">AI回复引用率:{(stats.aiReplyCitationRate * 100).toFixed(1)}%</p>
</Card>
<Card className="p-4">
<h3 className="text-sm font-semibold mb-3">最常引用</h3>
{stats.topUsed.map((k) => (
<div key={k.id} className="flex items-center justify-between text-sm py-1"><span>{k.title}</span><span className="text-muted-foreground">{k.citedCount}次</span></div>
))}
</Card>
</div>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen} title="新建知识条目" footer={
<Button onClick={() => {
const title = (document.querySelector('#k-title') as HTMLInputElement)?.value
const content = (document.querySelector('#k-content') as HTMLTextAreaElement)?.value
if (!title) { toast.warning('请输入标题'); return }
createMut.mutate({ title, content, category: category || 'faq' })
}}>创建</Button>
}>
<Field label="标题" required><Input id="k-title" placeholder="知识条目标题" /></Field>
<Field label="内容" className="mt-3"><Textarea id="k-content" rows={5} placeholder="知识内容…" /></Field>
</Dialog>
<Dialog open={uploadDialog} onOpenChange={setUploadDialog} title="上传文件" footer={
<Button onClick={() => {
uploadKnowledgeFiles([{ name: '产品手册-2026Q3.pdf', type: 'pdf' }, { name: '报价政策.docx', type: 'docx' }])
.then(() => { toast.success('文件已开始解析,约3—5秒完成'); queryClient.invalidateQueries({ queryKey: ['knowledge'] }); setUploadDialog(false) })
}}>上传</Button>
}>
<p className="text-sm text-muted-foreground">支持 Word/PDF/TXT/Excel/图片,AI 将自动解析为可引用的知识条目。</p>
</Dialog>
</div>
)
}
+278
View File
@@ -0,0 +1,278 @@
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Check, ChevronLeft, ChevronRight, Sparkles, Upload } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input, Field, Textarea } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Progress } from '@/components/ui/feedback'
import { ChannelIcon } from '@/components/common/ChannelIcon'
import { CHANNEL_META, COUNTRIES, INDUSTRIES, LANGUAGES_LIST } from '@/config/constants'
import { useAuthStore } from '@/app/store/auth'
import { toast } from '@/app/store/toast'
import { cn } from '@/utils/cn'
import type { ChannelType } from '@/types'
const STEPS = [
{ key: 'step1', title: '企业资料', desc: '完善企业基本信息' },
{ key: 'step2', title: '主营行业', desc: '选择您的主营行业' },
{ key: 'step3', title: '目标市场', desc: '主要出口国家和地区' },
{ key: 'step4', title: '沟通语言', desc: '常用沟通语言' },
{ key: 'step5', title: '邀请成员', desc: '组建您的团队' },
{ key: 'step6', title: '连接渠道', desc: '接入客户沟通渠道' },
{ key: 'step7', title: '导入客户', desc: '导入历史客户数据' },
{ key: 'step8', title: '知识库', desc: '上传企业知识资料' },
]
export function OnboardingPage() {
const navigate = useNavigate()
const { tenant, setOnboarded } = useAuthStore()
const [step, setStep] = useState(0)
const [done, setDone] = useState<Set<number>>(new Set())
const [company, setCompany] = useState({ name: tenant?.name ?? '', industry: '', size: '', contact: '', intro: '' })
const [industries, setIndustries] = useState<string[]>([])
const [countries, setCountries] = useState<string[]>([])
const [langs, setLangs] = useState<string[]>(['zh', 'en'])
const [emails, setEmails] = useState('')
const [connected, setConnected] = useState<ChannelType[]>([])
const progress = useMemo(() => Math.round(((done.size + (step > done.size ? step - done.size : 0)) / STEPS.length) * 100), [done, step])
const markDone = () => setDone((s) => new Set(s).add(step))
const next = () => {
markDone()
if (step < STEPS.length - 1) setStep(step + 1)
else finish()
}
const skip = () => {
if (step < STEPS.length - 1) setStep(step + 1)
else finish()
}
const finish = () => {
setOnboarded(true)
toast.success('企业初始化完成', '欢迎使用灵犀跨境,祝您开单顺利')
navigate('/dashboard')
}
const toggleIn = (list: string[], v: string, set: (l: string[]) => void) =>
set(list.includes(v) ? list.filter((x) => x !== v) : [...list, v])
const channelTypes: ChannelType[] = ['whatsapp', 'telegram', 'messenger', 'email', 'webform', 'landing']
return (
<div className="flex min-h-screen bg-background">
{/* 左侧步骤条 */}
<aside className="hidden w-72 shrink-0 flex-col border-e border-border bg-card p-6 md:flex">
<div className="mb-6 flex items-center gap-2">
<span className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-white">
<Sparkles className="h-4 w-4" />
</span>
<span className="font-semibold">企业初始化</span>
</div>
<div className="space-y-1">
{STEPS.map((s, i) => {
const isDone = done.has(i)
const isCurrent = i === step
return (
<button
key={s.key}
onClick={() => setStep(i)}
className={cn(
'flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-start transition-colors',
isCurrent ? 'bg-primary-50' : 'hover:bg-muted',
)}
>
<span
className={cn(
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-medium',
isDone ? 'bg-primary text-white' : isCurrent ? 'bg-primary-100 text-primary-700' : 'bg-muted text-muted-foreground',
)}
>
{isDone ? <Check className="h-3.5 w-3.5" /> : i + 1}
</span>
<span>
<span className={cn('block text-sm', isCurrent ? 'font-medium text-primary-700' : 'text-foreground')}>{s.title}</span>
<span className="block text-xs text-muted-foreground">{s.desc}</span>
</span>
</button>
)
})}
</div>
<div className="mt-auto pt-6">
<div className="mb-1.5 flex justify-between text-xs text-muted-foreground">
<span>完成进度</span>
<span>{progress}%</span>
</div>
<Progress value={progress} />
</div>
</aside>
{/* 右侧内容 */}
<main className="flex flex-1 flex-col p-6 md:p-10">
<div className="mx-auto w-full max-w-2xl flex-1">
<div className="mb-2 flex items-center gap-2 text-sm text-muted-foreground md:hidden">
<span>
第 {step + 1}/{STEPS.length} 步
</span>
<Progress value={progress} className="flex-1" />
</div>
<h1 className="text-xl font-semibold">{STEPS[step].title}</h1>
<p className="mt-1 text-sm text-muted-foreground">{STEPS[step].desc},也可以暂时跳过稍后在设置中完成。</p>
<div className="mt-6 rounded-lg border border-border bg-card p-6">
{step === 0 && (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Field label="企业名称" required>
<Input value={company.name} onChange={(e) => setCompany({ ...company, name: e.target.value })} />
</Field>
<Field label="所属行业">
<Select
placeholder="请选择"
options={INDUSTRIES.map((i) => ({ value: i, label: i }))}
value={company.industry}
onChange={(e) => setCompany({ ...company, industry: e.target.value })}
/>
</Field>
<Field label="企业规模">
<Select
placeholder="请选择"
options={['1—5人', '6—20人', '21—50人', '50人以上'].map((s) => ({ value: s, label: s }))}
value={company.size}
onChange={(e) => setCompany({ ...company, size: e.target.value })}
/>
</Field>
<Field label="联系方式">
<Input placeholder="电话或邮箱" value={company.contact} onChange={(e) => setCompany({ ...company, contact: e.target.value })} />
</Field>
<Field label="企业简介" className="md:col-span-2">
<Textarea rows={3} placeholder="一句话介绍您的主营业务" value={company.intro} onChange={(e) => setCompany({ ...company, intro: e.target.value })} />
</Field>
</div>
)}
{step === 1 && (
<ChipGrid options={INDUSTRIES} selected={industries} onToggle={(v) => toggleIn(industries, v, setIndustries)} />
)}
{step === 2 && (
<ChipGrid
options={COUNTRIES.filter((c) => c.code !== 'CN').map((c) => `${c.flag} ${c.zh}`)}
selected={countries}
onToggle={(v) => toggleIn(countries, v, setCountries)}
/>
)}
{step === 3 && (
<ChipGrid options={LANGUAGES_LIST} selected={langs} onToggle={(v) => toggleIn(langs, v, setLangs)} />
)}
{step === 4 && (
<div className="space-y-4">
<Field label="成员邮箱" hint="每行一个邮箱,邀请链接将发送到对方邮箱">
<Textarea rows={5} placeholder={'zhangsan@company.com\nlisi@company.com'} value={emails} onChange={(e) => setEmails(e.target.value)} />
</Field>
<Button
variant="outline"
onClick={() => {
const count = emails.split('\n').filter((e) => e.includes('@')).length
if (count === 0) return toast.warning('请先输入至少一个有效邮箱')
toast.success(`已向 ${count} 位成员发送邀请(演示)`)
}}
>
发送邀请
</Button>
</div>
)}
{step === 5 && (
<div className="grid grid-cols-2 gap-3 md:grid-cols-3">
{channelTypes.map((type) => {
const isConn = connected.includes(type)
return (
<button
key={type}
onClick={() => {
if (isConn) return
setConnected([...connected, type])
toast.success(`${CHANNEL_META[type].name} 连接成功(演示)`)
}}
className={cn(
'flex flex-col items-center gap-2 rounded-lg border p-4 text-center transition-colors',
isConn ? 'border-primary bg-primary-50' : 'border-border hover:border-primary-300',
)}
>
<ChannelIcon channel={type} size="lg" />
<span className="text-sm font-medium">{CHANNEL_META[type].name}</span>
<span className={cn('text-xs', isConn ? 'text-primary-700' : 'text-muted-foreground')}>
{isConn ? '已连接' : '点击模拟连接'}
</span>
</button>
)
})}
</div>
)}
{(step === 6 || step === 7) && (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<span className="flex h-14 w-14 items-center justify-center rounded-full bg-primary-50 text-primary-600">
<Upload className="h-6 w-6" />
</span>
<p className="text-sm font-medium">{step === 6 ? '导入历史客户' : '上传知识库资料'}</p>
<p className="max-w-sm text-xs text-muted-foreground">
{step === 6
? '支持 Excel / CSV 格式的客户名单,系统将自动识别字段并去重建档。'
: '支持 Word / PDF / TXT / Excel / 图片,AI 将自动解析为可引用的知识条目。'}
</p>
<Button
variant="outline"
onClick={() => toast.success('文件解析完成(演示)', '相关数据已加入您的企业空间')}
>
<Upload className="h-4 w-4" />
选择文件
</Button>
</div>
)}
</div>
<div className="mt-6 flex items-center justify-between">
<Button variant="ghost" disabled={step === 0} onClick={() => setStep(step - 1)}>
<ChevronLeft className="h-4 w-4 rtl-flip" />
上一步
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={skip}>
暂时跳过
</Button>
<Button onClick={next}>
{step === STEPS.length - 1 ? '完成并进入工作台' : '下一步'}
<ChevronRight className="h-4 w-4 rtl-flip" />
</Button>
</div>
</div>
</div>
</main>
</div>
)
}
function ChipGrid({ options, selected, onToggle }: { options: string[]; selected: string[]; onToggle: (v: string) => void }) {
return (
<div className="flex flex-wrap gap-2">
{options.map((opt) => {
const active = selected.includes(opt)
return (
<button
key={opt}
type="button"
onClick={() => onToggle(opt)}
className={cn(
'rounded-md border px-3 py-1.5 text-sm transition-colors',
active ? 'border-primary bg-primary-50 font-medium text-primary-700' : 'border-border bg-card text-muted-foreground hover:border-primary-300',
)}
>
{opt}
</button>
)
})}
</div>
)
}
@@ -0,0 +1,206 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useAuthStore } from '@/app/store/auth'
import { toast, errorMessage } from '@/app/store/toast'
import { confirmDialog } from '@/app/store/confirm'
import { PageLoading, ErrorState, EmptyState } from '@/components/ui/feedback'
import { PageHeader } from '@/components/common/PageHeader'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card } from '@/components/ui/card'
import { Avatar } from '@/components/ui/avatar'
import { Checkbox } from '@/components/ui/checkbox'
import { Tabs } from '@/components/ui/tabs'
import { Table, THead, TBody, Tr, Th, Td } from '@/components/ui/table'
import { DemoNote } from '@/components/common/DemoNote'
import { CountryFlag } from '@/components/common/CountryFlag'
import { IntentScoreBadge, OpportunityStageBadge, TaskPriorityBadge } from '@/components/common/StatusBadges'
import { TaskFormDialog } from '@/components/crm/TaskFormDialog'
import { getOpportunities, updateOpportunity, getTasks, updateTask } from '@/services/api/crm'
import { OPPORTUNITY_STAGES, taskTypeLabel, TASK_TYPES, TASK_PRIORITIES } from '@/config/constants'
import { formatDateTime, formatMoney } from '@/utils/format'
import { cn } from '@/utils/cn'
import type { FollowTask, Opportunity } from '@/types'
export function OpportunitiesPage() {
const queryClient = useQueryClient()
const user = useAuthStore((s) => s.user)
const [view, setView] = useState('kanban') // 'kanban' | 'tasks' | 'calendar'
const [taskFilter, setTaskFilter] = useState('all')
const [taskDialog, setTaskDialog] = useState(false)
const { data: oppData, isLoading: oppLoading } = useQuery({
queryKey: ['opportunities'],
queryFn: () => getOpportunities(),
})
const { data: taskData, isLoading: taskLoading } = useQuery({
queryKey: ['tasks', taskFilter],
queryFn: () => getTasks({ filter: taskFilter, ownerId: user?.id }),
})
const oppMut = useMutation({
mutationFn: ({ id, stage }: { id: string; stage: string }) => updateOpportunity(id, { stage: stage as any }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['opportunities'] })
toast.success('商机阶段已更新')
},
onError: (e) => toast.error(errorMessage(e)),
})
const taskMut = useMutation({
mutationFn: (p: { id: string; data: Partial<FollowTask> }) => updateTask(p.id, p.data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] })
toast.success('任务已更新')
},
onError: (e) => toast.error(errorMessage(e)),
})
const handleDragStart = (e: React.DragEvent, opp: Opportunity) => {
e.dataTransfer.setData('oppId', opp.id)
}
const handleDrop = (e: React.DragEvent, stage: string) => {
const id = e.dataTransfer.getData('oppId')
if (id) oppMut.mutate({ id, stage: stage as any })
}
const opps = oppData?.list ?? []
const tasks = taskData?.list ?? []
const groupedOpps = OPPORTUNITY_STAGES.map((s) => ({
...s,
items: opps.filter((o) => o.stage === s.value),
}))
// 简单月历
const today = new Date()
const monthDays = new Date(today.getFullYear(), today.getMonth() + 1, 0).getDate()
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1).getDay()
return (
<div className="p-4 md:p-6">
<PageHeader
title="商机与跟进"
description="管理商机流转与团队跟进任务"
actions={
<>
<Button size="sm" onClick={() => setTaskDialog(true)}>新建跟进</Button>
</>
}
/>
<Tabs
value={view}
onChange={setView}
className="mb-4"
items={[
{ value: 'kanban', label: '商机看板', count: opps.length },
{ value: 'tasks', label: '跟进任务', count: tasks.filter((t) => t.status !== 'done').length },
{ value: 'calendar', label: '日历视图' },
]}
/>
{view === 'kanban' && (
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-thin" style={{ minHeight: '60vh' }}>
{groupedOpps.map((col) => (
<div
key={col.value}
className="w-64 shrink-0 rounded-lg bg-muted/40"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => handleDrop(e, col.value)}
>
<div className="sticky top-0 z-10 flex items-center justify-between rounded-t-lg bg-muted px-3 py-2">
<span className="text-sm font-medium">{col.label}</span>
<Badge variant="primary">{col.items.length}</Badge>
</div>
<div className="space-y-2 p-2">
{col.items.map((opp) => (
<Card
key={opp.id}
draggable
onDragStart={(e) => handleDragStart(e, opp)}
className="cursor-grab p-3 active:cursor-grabbing hover:shadow-sm"
>
<p className="text-sm font-medium truncate">{opp.name}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{opp.customerName} <CountryFlag code={opp.country} withName={false} /></p>
<div className="mt-1.5 flex items-center justify-between">
<span className="text-xs text-foreground font-medium">{formatMoney(opp.amount)}</span>
<Badge variant="primary">{opp.probability}%</Badge>
</div>
<p className="mt-1 text-xs text-muted-foreground truncate">{opp.nextAction}</p>
</Card>
))}
{col.items.length === 0 && <p className="py-4 text-center text-xs text-muted-foreground">拖拽至此列</p>}
</div>
</div>
))}
</div>
)}
{view === 'tasks' && (
<div>
<div className="mb-3 flex flex-wrap gap-1">
{[
{ v: 'all', l: '全部' }, { v: 'mine', l: '我的待办' },
{ v: 'today', l: '今日' }, { v: 'overdue', l: '已逾期' },
{ v: 'done', l: '已完成' },
].map((f) => (
<button key={f.v} onClick={() => setTaskFilter(f.v)} className={cn('rounded-full px-2.5 py-1 text-xs transition-colors', taskFilter === f.v ? 'bg-primary font-medium text-white' : 'bg-muted text-muted-foreground hover:bg-border')}>{f.l}</button>
))}
</div>
<Card>
<Table>
<THead><Tr><Th className="w-10">状态</Th><Th>任务</Th><Th>类型</Th><Th>客户</Th><Th>优先级</Th><Th>截止时间</Th><Th>负责人</Th><Th className="w-20">操作</Th></Tr></THead>
<TBody>
{tasks.length === 0 && <Tr><Td colSpan={8}><EmptyState title="暂无跟进任务" /></Td></Tr>}
{tasks.map((t) => (
<Tr key={t.id}>
<Td><Checkbox checked={t.status === 'done'} onCheckedChange={() => taskMut.mutate({ id: t.id, data: { status: t.status === 'done' ? 'pending' : 'done' } })} /></Td>
<Td><span className={cn('text-sm', t.status === 'done' && 'line-through text-muted-foreground')}>{t.title}</span></Td>
<Td><Badge variant="outline">{taskTypeLabel(t.type)}</Badge></Td>
<Td><span className="text-xs">{t.customerName}</span></Td>
<Td><TaskPriorityBadge priority={t.priority} /></Td>
<Td><span className={cn('text-xs', t.status === 'overdue' && 'text-red-600 font-medium')}>{formatDateTime(t.dueAt)}</span></Td>
<Td><span className="text-xs">{t.ownerId === 'u-003' ? '李婷' : t.ownerId === 'u-004' ? '赵磊' : t.ownerId === 'u-005' ? '周敏' : t.ownerId === 'u-008' ? '郑楠' : '其他'}</span></Td>
<Td>
{t.status !== 'done' && (
<Button size="sm" variant="ghost" onClick={() => taskMut.mutate({ id: t.id, data: { status: 'done' } })}>完成</Button>
)}
</Td>
</Tr>
))}
</TBody>
</Table>
</Card>
</div>
)}
{view === 'calendar' && (
<Card className="p-4">
<div className="mb-3 text-center font-semibold">{today.getFullYear()} 年 {today.getMonth() + 1} 月</div>
<div className="grid grid-cols-7 gap-1">
{['日', '一', '二', '三', '四', '五', '六'].map((d) => <div key={d} className="py-1 text-center text-xs font-medium text-muted-foreground">{d}</div>)}
{Array.from({ length: firstDay }).map((_, i) => <div key={'e' + i} />)}
{Array.from({ length: monthDays }).map((_, i) => {
const d = i + 1
const key = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`
const dayTasks = tasks.filter((t) => t.dueAt?.startsWith?.(key))
const isToday = d === today.getDate() && today.getMonth() === new Date().getMonth()
return (
<div key={d} className={cn('min-h-14 rounded border border-border p-1.5 text-xs', isToday && 'bg-primary-50 border-primary-200')}>
<span className={cn(isToday && 'font-bold text-primary-700')}>{d}</span>
{dayTasks.slice(0, 2).map((dt) => (
<div key={dt.id} className="mt-0.5 truncate rounded bg-primary/10 px-1 text-[10px] text-primary-700 leading-5">{dt.title}</div>
))}
{dayTasks.length > 2 && <div className="text-[10px] text-muted-foreground">+{dayTasks.length - 2} 项</div>}
</div>
)
})}
</div>
</Card>
)}
<TaskFormDialog open={taskDialog} onOpenChange={setTaskDialog} />
</div>
)
}
+130
View File
@@ -0,0 +1,130 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { toast, errorMessage } from '@/app/store/toast'
import { PageLoading, ErrorState } from '@/components/ui/feedback'
import { PageHeader } from '@/components/common/PageHeader'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardHeader } from '@/components/ui/card'
import { StatCard } from '@/components/common/StatCard'
import { Select } from '@/components/ui/select'
import { Drawer } from '@/components/ui/drawer'
import { Textarea } from '@/components/ui/input'
import { PermissionGate } from '@/components/common/PermissionGate'
import { CountryFlag } from '@/components/common/CountryFlag'
import { getRisks, updateRisk } from '@/services/api/crm'
import { riskLevelMeta, riskTypeLabel, RISK_TYPES, RISK_LEVELS } from '@/config/constants'
import { formatRelative } from '@/utils/format'
import { cn } from '@/utils/cn'
import { AlertTriangle, Settings } from 'lucide-react'
export function RisksPage() {
const queryClient = useQueryClient()
const [level, setLevel] = useState('')
const [type, setType] = useState('')
const [status, setStatus] = useState('')
const [detailId, setDetailId] = useState<string | null>(null)
const { data, isLoading, isError, refetch } = useQuery({
queryKey: ['risks', level, type, status],
queryFn: () => getRisks({ level, type, status }),
})
const updateMut = useMutation({
mutationFn: ({ id, data }: { id: string; data: any }) => updateRisk(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['risks'] })
toast.success('风险状态已更新')
},
onError: (e) => toast.error(errorMessage(e)),
})
const list = data?.list ?? []
const openCount = list.filter((r) => r.status === 'open').length
const confirmedCount = list.filter((r) => r.status === 'confirmed').length
const urgentCount = list.filter((r) => r.level === 'urgent').length
const detail = list.find((r) => r.id === detailId)
if (isLoading) return <PageLoading />
if (isError) return <ErrorState onRetry={() => refetch()} />
return (
<div className="p-4 md:p-6">
<PageHeader
title="风险预警中心"
description="智能识别交易风险、客户流失信号与敏感表达,帮助团队主动应对"
actions={
<PermissionGate permission="risk:handle">
<Button size="sm" onClick={() => toast.info('风险处理操作已可用')}><Settings className="h-4 w-4" />规则设置</Button>
</PermissionGate>
}
/>
<div className="grid grid-cols-3 gap-3 mb-4">
<StatCard title="待处理" value={openCount} icon={AlertTriangle} onClick={() => setStatus('open')} />
<StatCard title="已确认" value={confirmedCount} icon={AlertTriangle} onClick={() => setStatus('confirmed')} />
<StatCard title="紧急" value={urgentCount} icon={AlertTriangle} onClick={() => setLevel('urgent')} />
</div>
<div className="mb-3 flex flex-wrap gap-2">
<Select placeholder="风险等级" value={level} onChange={(e) => setLevel(e.target.value)} options={RISK_LEVELS.map((l) => ({ value: l.value, label: l.label }))} className="w-28" />
<Select placeholder="风险类型" value={type} onChange={(e) => setType(e.target.value)} options={RISK_TYPES.map((t) => ({ value: t.value, label: t.label }))} className="w-36" />
<Select placeholder="处理状态" value={status} onChange={(e) => setStatus(e.target.value)} options={[{value:'open',label:'未处理'},{value:'confirmed',label:'已确认'},{value:'resolved',label:'已解决'},{value:'ignored',label:'已忽略'}]} className="w-28" />
</div>
<div className="space-y-2">
{list.length === 0 && <Card className="p-8 text-center text-sm text-muted-foreground">暂无匹配的风险预警</Card>}
{list.map((r) => {
const meta = riskLevelMeta(r.level)
return (
<Card key={r.id} className="cursor-pointer p-4 hover:bg-muted/40 transition-colors" onClick={() => setDetailId(r.id)}>
<div className="flex items-start justify-between gap-3">
<div className="flex items-start gap-2 min-w-0">
<span className={cn('mt-0.5 h-2 w-2 shrink-0 rounded-full', meta.dot)} />
<div>
<div className="flex flex-wrap items-center gap-2">
<Badge className={meta.color}>{meta.label}风险</Badge>
<Badge variant="outline">{riskTypeLabel(r.type)}</Badge>
<span className="text-sm font-medium truncate">{r.reason}</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">{r.customerName} · {formatRelative(r.createdAt)} · {r.status === 'open' ? '待处理' : r.status === 'confirmed' ? '已确认' : r.status === 'resolved' ? '已解决' : '已忽略'}</p>
</div>
</div>
<div className="flex gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
{r.status === 'open' && <Button size="sm" variant="outline" onClick={() => updateMut.mutate({ id: r.id, data: { status: 'confirmed' } })}>确认</Button>}
<Button size="sm" variant="ghost" onClick={() => updateMut.mutate({ id: r.id, data: { status: 'resolved' } })}>已处理</Button>
</div>
</div>
</Card>
)
})}
</div>
{/* 风险详情抽屉 */}
<Drawer open={!!detailId} onOpenChange={() => setDetailId(null)} title={detail?.reason ?? '风险详情'} width="w-[480px]">
{detail && (
<div className="space-y-4 p-4">
<div className="flex items-center gap-2">
<Badge variant="danger">{riskLevelMeta(detail.level).label}</Badge>
<Badge variant="outline">{riskTypeLabel(detail.type)}</Badge>
<span className="text-xs text-muted-foreground">{formatRelative(detail.createdAt)}</span>
</div>
<div>
<h4 className="text-sm font-semibold mb-1">AI 判断说明</h4>
<p className="text-sm text-muted-foreground leading-relaxed">{detail.aiExplanation}</p>
</div>
<div>
<h4 className="text-sm font-semibold mb-1">推荐处理方案</h4>
<p className="text-sm text-muted-foreground">{detail.suggestion}</p>
</div>
<div>
<h4 className="text-sm font-semibold mb-1">添加备注</h4>
<Textarea rows={3} placeholder="添加处理备注…" />
<Button size="sm" className="mt-2" onClick={() => { updateMut.mutate({ id: detail.id, data: { note: '已备注' } }); toast.success('备注已保存') }}>保存备注</Button>
</div>
</div>
)}
</Drawer>
</div>
)
}
+164
View File
@@ -0,0 +1,164 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Settings, ShieldCheck, KeyRound, Bell, Globe, Database, ServerCog, Languages, UserCog, Palette, FileUp, FileDown, Clock } from 'lucide-react'
import { toast, errorMessage } from '@/app/store/toast'
import { PageHeader } from '@/components/common/PageHeader'
import { Button } from '@/components/ui/button'
import { Card, CardHeader } from '@/components/ui/card'
import { Input, Field } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Tabs } from '@/components/ui/tabs'
import { PermissionGate } from '@/components/common/PermissionGate'
import { getSettings, updateSettings } from '@/services/api/org'
import { useI18n } from '@/i18n'
const SECTIONS = [
{ key: 'company', label: '企业资料', icon: UserCog },
{ key: 'brand', label: '品牌设置', icon: Palette },
{ key: 'lang', label: '语言与时区', icon: Globe },
{ key: 'notifications', label: '消息通知', icon: Bell },
{ key: 'ai', label: 'AI回复设置', icon: Languages },
{ key: 'translate', label: '翻译设置', icon: Languages },
{ key: 'safety', label: '安全设置', icon: ShieldCheck },
{ key: 'api', label: 'API与Webhook', icon: KeyRound },
{ key: 'import-export', label: '数据导入导出', icon: Database },
{ key: 'deploy', label: '私有化部署', icon: ServerCog },
]
export function SettingsPage() {
const queryClient = useQueryClient()
const [section, setSection] = useState('company')
const { data: settings, isLoading } = useQuery({ queryKey: ['settings'], queryFn: getSettings })
const { locale, setLocale, t } = useI18n()
const updateMut = useMutation({
mutationFn: (payload: any) => updateSettings(payload),
onSuccess: () => { toast.success('设置已保存'); queryClient.invalidateQueries({ queryKey: ['settings'] }) },
onError: (e) => toast.error(errorMessage(e)),
})
return (
<div className="flex h-[calc(100vh-56px)]">
<div className="w-48 shrink-0 border-e border-border p-3 space-y-1 overflow-y-auto scrollbar-thin hidden md:block">
{SECTIONS.map((s) => (
<button key={s.key} onClick={() => setSection(s.key)} className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm ${section === s.key ? 'bg-primary-50 font-medium text-primary-700' : 'text-muted-foreground hover:text-foreground'}`}>
<s.icon className="h-4 w-4" />{s.label}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-4 md:p-6 scrollbar-thin">
{section === 'company' && settings && (
<Card>
<CardHeader title="企业资料" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4">
<Field label="企业名称"><Input defaultValue={settings.company.name} onChange={(e) => updateMut.mutate({ company: { ...settings.company, name: e.target.value } })} /></Field>
<Field label="所属行业"><Input defaultValue={settings.company.industry} /></Field>
<Field label="联系方式"><Input defaultValue={settings.company.contact} /></Field>
<Field label="邮箱"><Input defaultValue={settings.company.email} /></Field>
<Field label="企业简介" className="md:col-span-2"><Input defaultValue={settings.company.intro} /></Field>
<div className="md:col-span-2"><Button onClick={() => toast.success('企业资料已保存')}>保存修改</Button></div>
</div>
</Card>
)}
{section === 'ai' && settings && (
<Card>
<CardHeader title="AI 回复设置" />
<div className="space-y-4 p-4">
{[
{ key: 'autoGenerate', label: '自动生成回复建议' },
{ key: 'requireConfirm', label: '发送前必须人工确认' },
{ key: 'sentimentAnalysis', label: '启用情感分析' },
{ key: 'riskAlert', label: '启用风险预警' },
{ key: 'cultureCheck', label: '启用文化敏感检测' },
{ key: 'allowHistoryRef', label: '允许AI引用历史成功回复' },
].map((item) => (
<div key={item.key} className="flex items-center justify-between">
<span className="text-sm">{item.label}</span>
<Switch
checked={(settings.ai as any)[item.key]}
onCheckedChange={(v) => updateMut.mutate({ ai: { [item.key]: v } })}
/>
</div>
))}
</div>
</Card>
)}
{section === 'notifications' && settings && (
<Card>
<CardHeader title="消息通知设置" />
<div className="space-y-4 p-4">
{[
{ key: 'newInquiry', label: '新询盘提醒' },
{ key: 'highIntent', label: '高意向客户提醒' },
{ key: 'riskAlert', label: '风险预警提醒' },
{ key: 'taskDue', label: '跟进到期提醒' },
{ key: 'taskOverdue', label: '跟进逾期提醒' },
{ key: 'channelOffline', label: '渠道断开提醒' },
{ key: 'planExpire', label: '套餐到期提醒' },
].map((item) => (
<div key={item.key} className="flex items-center justify-between">
<span className="text-sm">{item.label}</span>
<Switch
checked={(settings.notification as any)[item.key]}
onCheckedChange={(v) => updateMut.mutate({ notification: { [item.key]: v } })}
/>
</div>
))}
</div>
</Card>
)}
{section === 'safety' && (
<Card>
<CardHeader title="安全设置" />
<div className="space-y-4 p-4">
<Field label="修改密码"><Input type="password" placeholder="输入新密码…" /></Field>
<div className="flex items-center justify-between"><span className="text-sm">两步验证</span><Switch checked={false} onCheckedChange={() => toast.info('两步验证设置(演示)')} /></div>
<div className="flex items-center justify-between"><span className="text-sm">数据导出权限</span><Switch checked={true} /></div>
<Field label="会话保存周期"><Select options={[{value:'6',label:'6个月'},{value:'12',label:'12个月'},{value:'24',label:'24个月'},{value:'forever',label:'永久'}]} /></Field>
<div className="rounded bg-emerald-50 p-3 text-sm text-emerald-700"><ShieldCheck className="inline h-4 w-4 mr-1" />全链路加密已启用(TLS 1.3 + AES-256)</div>
<Button onClick={() => toast.success('安全设置已保存')}>保存</Button>
</div>
</Card>
)}
{section === 'api' && (
<Card>
<CardHeader title="API 与 Webhook" />
<div className="space-y-3 p-4">
<p className="text-sm text-muted-foreground">通过 API 或 Webhook 将灵犀跨境的客户数据、消息与风险预警接入您的自有系统。</p>
<div className="rounded bg-muted p-3 font-mono text-sm break-all">lx_live_8f2k••••••••9d21</div>
<Button variant="outline" onClick={() => toast.success('新 API Key 已生成')}>生成新 Key</Button>
</div>
</Card>
)}
{section === 'lang' && (
<Card>
<CardHeader title="语言与时区" />
<div className="space-y-4 p-4">
<Field label="界面语言">
<Select value={locale} options={[{value:'zh',label:'简体中文'},{value:'en',label:'English'},{value:'ar',label:'العربية (RTL)'}]} onChange={(e) => setLocale(e.target.value as any)} />
</Field>
<Field label="时区"><Select options={[{value:'Asia/Shanghai',label:'东八区 (UTC+8)'},{value:'Asia/Dubai',label:'迪拜 (UTC+4)'},{value:'America/New_York',label:'纽约 (UTC-5)'}]} /></Field>
<Button onClick={() => toast.success('语言与时区已保存')}>保存</Button>
</div>
</Card>
)}
{section === 'deploy' && (
<Card>
<CardHeader title="私有化部署咨询" />
<div className="p-4 space-y-3">
<p className="text-sm text-muted-foreground">增长版/定制版支持本地或私有云部署,数据不出企业、全链路审计。</p>
<p className="text-sm">📞 咨询热线:0516-8888-6621(演示)</p>
<p className="text-sm">📧 邮箱:sales@ouhui.tech</p>
</div>
</Card>
)}
{![ 'company', 'ai', 'notifications', 'safety', 'api', 'lang', 'deploy'].includes(section) && (
<Card className="p-8 text-center">
<p className="text-sm text-muted-foreground">{section === 'brand' ? '品牌设置' : section === 'translate' ? '翻译设置' : section === 'import-export' ? '数据导入导出' : '此设置模块'}在演示环境中为简化版,接入后端后可完整配置。</p>
</Card>
)}
</div>
</div>
)
}
+146
View File
@@ -0,0 +1,146 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { UserPlus, MoreHorizontal, UserCog, ShieldCheck } from 'lucide-react'
import { toast, errorMessage } from '@/app/store/toast'
import { confirmDialog } from '@/app/store/confirm'
import { PageHeader } from '@/components/common/PageHeader'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card } from '@/components/ui/card'
import { Avatar } from '@/components/ui/avatar'
import { Dropdown } from '@/components/ui/dropdown'
import { Dialog } from '@/components/ui/dialog'
import { Input, Field } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Tabs } from '@/components/ui/tabs'
import { PermissionGate } from '@/components/common/PermissionGate'
import { getTeam, inviteMember, updateMember, deleteMember, getOperationLogs, getLoginRecords } from '@/services/api/org'
import { ROLE_META, PERMISSION_GROUPS, ROLE_PERMISSIONS, type RoleKey } from '@/config/roles'
import { formatDateTime } from '@/utils/format'
import { cn } from '@/utils/cn'
export function TeamPage() {
const queryClient = useQueryClient()
const [tab, setTab] = useState('members')
const [inviteOpen, setInviteOpen] = useState(false)
const { data: members } = useQuery({ queryKey: ['team'], queryFn: getTeam })
const { data: logs } = useQuery({ queryKey: ['team-logs'], queryFn: getOperationLogs, enabled: tab === 'logs' })
const { data: loginRecords } = useQuery({ queryKey: ['login-records'], queryFn: getLoginRecords, enabled: tab === 'login' })
const inviteMut = useMutation({
mutationFn: (p: any) => inviteMember(p),
onSuccess: () => { toast.success('邀请已发送'); queryClient.invalidateQueries({ queryKey: ['team'] }); setInviteOpen(false) },
onError: (e) => toast.error(errorMessage(e)),
})
const delMut = useMutation({
mutationFn: (id: string) => deleteMember(id),
onSuccess: () => { toast.success('成员已移除'); queryClient.invalidateQueries({ queryKey: ['team'] }) },
})
return (
<div className="p-4 md:p-6">
<PageHeader title="团队协作" description={`${members?.length ?? 0} 名成员`} actions={
<PermissionGate permission="team:manage">
<Button size="sm" onClick={() => setInviteOpen(true)}><UserPlus className="h-4 w-4" />邀请成员</Button>
</PermissionGate>
} />
<Tabs value={tab} onChange={setTab} className="mb-4" items={[
{ value: 'members', label: '成员列表' },
{ value: 'roles', label: '角色权限' },
{ value: 'logs', label: '操作日志' },
{ value: 'login', label: '登录记录' },
]} />
{tab === 'members' && (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{(members ?? []).map((m) => (
<Card key={m.id} className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<Avatar name={m.name} size="md" online={m.online} />
<div>
<p className="text-sm font-medium">{m.name}</p>
<p className="text-xs text-muted-foreground">{m.department}</p>
</div>
</div>
<Dropdown
trigger={<button className="rounded p-1 hover:bg-muted"><MoreHorizontal className="h-4 w-4 text-muted-foreground" /></button>}
items={[
{ key: 'role', label: '修改角色', onSelect: () => { updateMember(m.id, { role: m.role === 'admin' ? 'sales' : 'admin' as any }); toast.success('角色已更新') } },
{ key: 'status', label: m.status === 'active' ? '停用' : '启用', onSelect: () => { updateMember(m.id, { status: m.status === 'active' ? 'disabled' as any : 'active' as any }).then(() => queryClient.invalidateQueries({ queryKey: ['team'] })) } },
{ key: 'delete', label: '删除', danger: true, onSelect: () => confirmDialog({ title: `移除成员「${m.name}」?`, description: '该成员关联的客户将被分配给其他成员。', danger: true, onConfirm: () => delMut.mutate(m.id) }) },
]}
/>
</div>
<div className="mt-3 space-y-1 text-xs text-muted-foreground">
<p>{m.email} · {m.phone}</p>
<p><Badge className={ROLE_META[m.role].color}>{ROLE_META[m.role].label}</Badge></p>
<div className="flex items-center justify-between"><span>客户</span><span>{m.customerCount}</span></div>
<div className="flex items-center justify-between"><span>待办</span><span>{m.todoCount}</span></div>
<p>最近登录:{m.lastLoginAt ? new Date(m.lastLoginAt).toLocaleDateString('zh-CN') : '—'}</p>
</div>
</Card>
))}
</div>
)}
{tab === 'roles' && (
<div className="space-y-4">
{Object.entries(ROLE_META).map(([key, meta]) => (
<Card key={key} className="p-4">
<div className="flex items-center gap-2 mb-3">
<ShieldCheck className="h-5 w-5 text-primary-600" />
<div>
<span className="text-sm font-medium">{meta.label}</span>
<span className={cn('ml-2 rounded px-1.5 py-0.5 text-xs font-medium', meta.color)}>{meta.label}</span>
</div>
</div>
<p className="text-xs text-muted-foreground mb-2">{meta.description}</p>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
{PERMISSION_GROUPS.map((group) => (
<div key={group.module} className="rounded bg-muted/40 p-2 text-xs">
<span className="font-medium">{group.module}</span>
<div className="mt-1 flex flex-wrap gap-1">
{group.items.map((perm) => (
<span key={perm.key} className={cn('rounded px-1 py-0.5', ROLE_PERMISSIONS[key as RoleKey].includes(perm.key) ? 'bg-emerald-100 text-emerald-700' : 'text-muted-foreground')}>
{ROLE_PERMISSIONS[key as RoleKey].includes(perm.key) ? '✓' : '✗'} {perm.label}
</span>
))}
</div>
</div>
))}
</div>
</Card>
))}
</div>
)}
{tab === 'logs' && (
<Card className="overflow-x-auto p-4">
<table className="w-full text-sm"><thead><tr className="border-b border-border"><th className="py-2 px-2 text-start text-xs font-medium text-muted-foreground">时间</th><th className="py-2 px-2 text-start text-xs font-medium text-muted-foreground">操作人</th><th className="py-2 px-2 text-start text-xs font-medium text-muted-foreground">动作</th><th className="py-2 px-2 text-start text-xs font-medium text-muted-foreground">详情</th></tr></thead><tbody>{logs?.map((l) => (<tr key={l.id} className="border-b border-border"><td className="py-2 px-2 text-xs">{formatDateTime(l.createdAt)}</td><td className="py-2 px-2">{l.actorName}</td><td className="py-2 px-2"><Badge variant="outline">{l.action}</Badge></td><td className="py-2 px-2 text-xs">{l.detail}</td></tr>))}</tbody></table>
</Card>
)}
{tab === 'login' && (
<Card className="overflow-x-auto p-4">
<table className="w-full text-sm"><thead><tr className="border-b border-border"><th className="py-2 px-2 text-start text-xs font-medium text-muted-foreground">时间</th><th className="py-2 px-2 text-start text-xs font-medium text-muted-foreground">成员</th><th className="py-2 px-2 text-start text-xs font-medium text-muted-foreground">设备</th><th className="py-2 px-2 text-start text-xs font-medium text-muted-foreground">IP/地点</th><th className="py-2 px-2 text-start text-xs font-medium text-muted-foreground">状态</th></tr></thead><tbody>{loginRecords?.map((l) => (<tr key={l.id} className="border-b border-border"><td className="py-2 px-2 text-xs">{formatDateTime(l.createdAt)}</td><td className="py-2 px-2">{l.memberName}</td><td className="py-2 px-2 text-xs">{l.device}</td><td className="py-2 px-2 text-xs">{l.location}</td><td className="py-2 px-2"><Badge className={l.status === 'success' ? 'bg-emerald-100 text-emerald-700' : l.status === 'abnormal' ? 'bg-amber-100 text-amber-700' : 'bg-red-100 text-red-700'}>{l.status === 'success' ? '正常' : l.status === 'abnormal' ? '异常' : '失败'}</Badge></td></tr>))}</tbody></table>
</Card>
)}
<Dialog open={inviteOpen} onOpenChange={setInviteOpen} title="邀请成员" footer={
<Button onClick={() => {
const name = (document.querySelector('#inv-name') as HTMLInputElement)?.value
const email = (document.querySelector('#inv-email') as HTMLInputElement)?.value
if (!name || !email) { toast.warning('请填写姓名和邮箱'); return }
inviteMut.mutate({ name, email, department: '外贸一部', role: 'sales' })
}}>发送邀请</Button>
}>
<Field label="姓名" required><Input id="inv-name" placeholder="成员姓名" /></Field>
<Field label="邮箱" required className="mt-3"><Input id="inv-email" placeholder="email@company.com" /></Field>
</Dialog>
</div>
)
}
+180
View File
@@ -0,0 +1,180 @@
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { ArrowLeftRight, Copy, FileText, History, Languages, RefreshCw, Save, Upload } from 'lucide-react'
import { translate } from '@/services/api/ai'
import { toast, errorMessage } from '@/app/store/toast'
import { PageHeader } from '@/components/common/PageHeader'
import { Button } from '@/components/ui/button'
import { Card, CardHeader } from '@/components/ui/card'
import { Textarea } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Badge } from '@/components/ui/badge'
import { Tabs } from '@/components/ui/tabs'
import { Spinner } from '@/components/ui/feedback'
import { DemoNote } from '@/components/common/DemoNote'
import { TRANSLATE_SCENARIOS } from '@/config/constants'
import { LANGUAGES, supportedLanguageCount } from '@/config/languages'
import { cn } from '@/utils/cn'
const DEMO_TEXT = '你好,非常感谢贵司对500个定制双层玻璃杯的询盘。我们很高兴为您提供报价:单价$1.85/个(含双色Logo印刷),标准交期12-15天,加急10天可交付。如有任何问题或需要调整,请随时告知。期待与贵司建立长期合作。'
export function TranslatePage() {
const [tab, setTab] = useState('text')
const [source, setSource] = useState('')
const [target, setTarget] = useState('en')
const [text, setText] = useState('')
const [result, setResult] = useState('')
const [detected, setDetected] = useState('')
const [terms, setTerms] = useState<any[]>([])
const [history, setHistory] = useState<{ source: string; target: string; text: string; result: string }[]>([])
const translateMut = useMutation({
mutationFn: () => translate({ text, sourceLang: source || undefined, targetLang: target }),
onSuccess: (r) => {
setResult(r.translated)
setDetected(r.detectedLang)
setTerms(r.terms ?? [])
setHistory((h) => [{ source: source || r.detectedLang, target, text, result: r.translated }, ...h].slice(0, 20))
},
onError: (e) => toast.error(errorMessage(e)),
})
const swap = () => {
if (detected && !source) setSource(detected)
setSource(target)
setTarget(detected || source || 'en')
setText(result)
setResult('')
}
const langOptions = LANGUAGES.slice(1).map((l) => ({ value: l.code, label: `${l.nativeName} (${l.name})` }))
return (
<div className="p-4 md:p-6">
<PageHeader
title="AI 翻译中心"
description={`跨境商务场景专用翻译引擎,支持全球主流贸易国家和地区语言(可配置 ${supportedLanguageCount} 种)。`}
actions={<DemoNote />}
/>
<Tabs
value={tab}
onChange={setTab}
className="mb-4"
items={[
{ value: 'text', label: '文本翻译' },
{ value: 'file', label: '文件翻译' },
{ value: 'history', label: '翻译历史', count: history.length },
]}
/>
{tab === 'text' && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* 源语言 */}
<Card>
<CardHeader
title="原文"
action={
<div className="flex items-center gap-2">
<Select
options={[{ value: 'auto', label: '自动识别' }, ...langOptions]}
value={source}
onChange={(e) => setSource(e.target.value)}
className="w-32"
/>
<Button variant="ghost" size="sm" disabled={!text} onClick={() => { setText(DEMO_TEXT); toast.success('已填入演示文本') }}>
示例文本
</Button>
</div>
}
/>
<div className="p-3">
<Textarea
rows={7}
placeholder="输入需要翻译的文本,支持自动识别语言…"
value={text}
onChange={(e) => setText(e.target.value)}
/>
<div className="mt-2 flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Select options={TRANSLATE_SCENARIOS.map((s) => ({ value: s.value, label: s.label }))} className="w-24 h-7" placeholder="场景" />
<Button size="sm" onClick={() => translateMut.mutate()} loading={translateMut.isPending}>
翻 译
</Button>
</div>
<span className="text-xs text-muted-foreground">{text.length} 字</span>
</div>
</div>
</Card>
{/* 译语言 */}
<Card>
<CardHeader
title="译文"
action={
<div className="flex items-center gap-2">
{detected && !source && <Badge variant="outline">检测:{LANGUAGES.find(l => l.code === detected)?.name}</Badge>}
<Select options={langOptions} value={target} onChange={(e) => setTarget(e.target.value)} className="w-28" />
<Button variant="ghost" size="icon-sm" onClick={swap} title="互换语言" disabled={!result}>
<ArrowLeftRight className="h-4 w-4" />
</Button>
</div>
}
/>
<div className="p-3">
{translateMut.isPending ? (
<div className="flex items-center justify-center py-12"><Spinner size="lg" /></div>
) : (
<Textarea rows={7} readOnly className="bg-muted/30" value={result} placeholder="译文将在此显示" />
)}
<div className="mt-2 flex items-center justify-between">
<div className="flex gap-1">
<Button size="sm" variant="ghost" disabled={!result} onClick={() => { navigator.clipboard.writeText(result); toast.success('已复制') }}>
<Copy className="h-3.5 w-3.5" /> 复制
</Button>
<Button size="sm" variant="ghost" disabled={!result} onClick={() => { toast.success('已收藏');
}}>
<Save className="h-3.5 w-3.5" /> 收藏
</Button>
<Button size="sm" variant="ghost" onClick={() => translateMut.mutate()}>
<RefreshCw className="h-3.5 w-3.5" /> 重译
</Button>
</div>
<span className="text-xs text-muted-foreground">{result.length} 字</span>
</div>
</div>
</Card>
</div>
)}
{tab === 'file' && (
<Card className="p-6 text-center">
<Upload className="mx-auto h-10 w-10 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">上传文件翻译</p>
<p className="mt-1 text-xs text-muted-foreground">支持 Word / PDF / TXT / Excel / 图片文件,AI 自动解析并保留原格式。</p>
<Button className="mt-4" variant="outline" onClick={() => toast.info('文件翻译演示:选择文件后将模拟上传与翻译流程')}>
<Upload className="h-4 w-4" /> 选择文件
</Button>
</Card>
)}
{tab === 'history' && (
<Card>
<div className="divide-y divide-border">
{history.length === 0 && <p className="py-12 text-center text-sm text-muted-foreground">暂无翻译历史</p>}
{history.map((h, i) => (
<div key={i} className="p-3 text-sm">
<div className="flex items-center gap-2 text-xs text-muted-foreground mb-1">
<Languages className="h-3 w-3" />
{LANGUAGES.find(l => l.code === h.source)?.name ?? '自动识别'} → {LANGUAGES.find(l => l.code === h.target)?.name}
</div>
<p className="text-xs truncate">{h.text.slice(0, 80)}</p>
<p className="text-xs text-muted-foreground mt-0.5 truncate">{h.result.slice(0, 80)}</p>
</div>
))}
</div>
</Card>
)}
</div>
)
}
+32
View File
@@ -0,0 +1,32 @@
import { http } from './client'
import type { AiAnalysis, AiReplySuggestion, AiTextActionPayload, InterpretLine, TranslatePayload, TranslateResult } from '@/types'
/** POST /api/ai/translate */
export function translate(payload: TranslatePayload) {
return http.post<TranslateResult>('/api/ai/translate', payload)
}
/** POST /api/ai/analyze */
export function analyzeConversation(conversationId: string) {
return http.post<AiAnalysis>('/api/ai/analyze', { conversationId })
}
/** POST /api/ai/replies */
export function generateReplies(conversationId: string) {
return http.post<AiReplySuggestion[]>('/api/ai/replies', { conversationId })
}
/** POST /api/ai/summarize */
export function summarizeConversation(conversationId: string) {
return http.post<{ summary: string }>('/api/ai/summarize', { conversationId })
}
/** POST /api/ai/interpretation */
export function nextInterpretLine(index: number) {
return http.post<InterpretLine>('/api/ai/interpretation', { index })
}
/** POST /api/ai/text-action(润色/缩写/扩写/语气/翻译) */
export function textAction(payload: AiTextActionPayload) {
return http.post<{ result: string }>('/api/ai/text-action', payload)
}
+22
View File
@@ -0,0 +1,22 @@
import { http } from './client'
import type { AuthSession, LoginPayload, RegisterPayload } from '@/types'
/** POST /api/auth/login */
export function login(payload: LoginPayload & { role?: string }) {
return http.post<AuthSession>('/api/auth/login', payload)
}
/** POST /api/auth/register */
export function register(payload: RegisterPayload) {
return http.post<AuthSession>('/api/auth/register', payload)
}
/** GET /api/auth/me */
export function me() {
return http.get<{ user: AuthSession['user']; tenant: AuthSession['tenant'] }>('/api/auth/me')
}
/** 发送验证码(演示:固定可收到 6 位码) */
export function sendCode(account: string) {
return http.post<{ sent: boolean }>('/api/auth/send-code', { account }).catch(() => ({ sent: true }))
}
+38
View File
@@ -0,0 +1,38 @@
import { http } from './client'
import type { ChannelConnection, ChannelSyncLog, ChannelType } from '@/types'
/** GET /api/channels */
export function getChannels() {
return http.get<ChannelConnection[]>('/api/channels')
}
/** POST /api/channels/connect */
export function connectChannel(payload: {
type: ChannelType
account: string
syncRange?: string
defaultOwnerId?: string
autoReply?: boolean
}) {
return http.post<ChannelConnection>('/api/channels/connect', payload)
}
/** POST /api/channels/test */
export function testChannel(id: string) {
return http.post<{ success: boolean; latencyMs: number; message: string }>('/api/channels/test', { id })
}
/** PATCH /api/channels/:id */
export function updateChannel(id: string, payload: Partial<ChannelConnection>) {
return http.patch<ChannelConnection>(`/api/channels/${id}`, payload)
}
/** DELETE /api/channels/:id */
export function deleteChannel(id: string) {
return http.del<{ success: boolean }>(`/api/channels/${id}`)
}
/** GET /api/channels/:id/logs */
export function getChannelLogs(id: string) {
return http.get<ChannelSyncLog[]>(`/api/channels/${id}/logs`)
}
+75
View File
@@ -0,0 +1,75 @@
/**
* 统一 API 客户端
* - VITE_USE_MOCK=true :走内置 Mock 路由(本地模拟数据,含模拟网络延迟)
* - VITE_USE_MOCK=false :请求真实后端(VITE_API_BASE_URL),自动携带 Bearer Token
*/
import { env } from '@/config/env'
import { mockRequest } from '@/services/mock'
export class ApiError extends Error {
code: number
constructor(code: number, message: string) {
super(message)
this.code = code
}
}
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE' | 'PUT'
body?: unknown
query?: Record<string, string | number | boolean | undefined | null>
}
/** 由 auth store 注入,避免循环依赖 */
let tokenGetter: () => string | null = () => null
export function setTokenGetter(fn: () => string | null) {
tokenGetter = fn
}
export async function api<T = unknown>(path: string, options: RequestOptions = {}): Promise<T> {
const method = options.method ?? 'GET'
if (env.useMock) {
return mockRequest<T>(method, path, options.query ?? {}, options.body)
}
const base = env.apiBaseUrl.replace(/\/$/, '')
const url = new URL(`${base}${path}`, window.location.origin)
Object.entries(options.query ?? {}).forEach(([k, v]) => {
if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v))
})
const token = tokenGetter()
const res = await fetch(url.toString(), {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: options.body === undefined ? undefined : JSON.stringify(options.body),
})
if (res.status === 401) {
throw new ApiError(401, '登录状态已失效,请重新登录')
}
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new ApiError(res.status, text || `请求失败(${res.status})`)
}
const json = (await res.json().catch(() => null)) as { code?: number; message?: string; data?: T } | T | null
// 兼容 {code,message,data} 与直接返回数据两种后端风格
if (json && typeof json === 'object' && 'code' in json && 'data' in json) {
const wrapped = json as { code: number; message?: string; data: T }
if (wrapped.code !== 0 && wrapped.code !== 200) throw new ApiError(wrapped.code, wrapped.message ?? '请求失败')
return wrapped.data
}
return json as T
}
export const http = {
get: <T>(path: string, query?: RequestOptions['query']) => api<T>(path, { method: 'GET', query }),
post: <T>(path: string, body?: unknown) => api<T>(path, { method: 'POST', body }),
patch: <T>(path: string, body?: unknown) => api<T>(path, { method: 'PATCH', body }),
put: <T>(path: string, body?: unknown) => api<T>(path, { method: 'PUT', body }),
del: <T>(path: string) => api<T>(path, { method: 'DELETE' }),
}

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