feat: release contextual omnichannel customer platform

Add Telegram, WhatsApp and email account management, realtime messaging, contextual multilingual AI, customer intelligence, knowledge workflows, enterprise controls, a unified design system and complete repository documentation.
This commit is contained in:
wmq
2026-07-30 02:29:53 +08:00
parent 9804341463
commit af49d22ca7
66 changed files with 3181 additions and 554 deletions
+24 -13
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useAuthStore } from '@/app/store/auth'
import { toast, errorMessage } from '@/app/store/toast'
@@ -16,6 +17,7 @@ import { CountryFlag } from '@/components/common/CountryFlag'
import { IntentScoreBadge, OpportunityStageBadge, TaskPriorityBadge } from '@/components/common/StatusBadges'
import { TaskFormDialog } from '@/components/crm/TaskFormDialog'
import { getOpportunities, updateOpportunity, getTasks, updateTask } from '@/services/api/crm'
import { getTeam } from '@/services/api/org'
import { OPPORTUNITY_STAGES, taskTypeLabel, TASK_TYPES, TASK_PRIORITIES } from '@/config/constants'
import { formatDateTime, formatMoney } from '@/utils/format'
import { cn } from '@/utils/cn'
@@ -24,7 +26,11 @@ 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 [searchParams, setSearchParams] = useSearchParams()
const requestedView = searchParams.get('tab')
const [view, setView] = useState(
requestedView === 'tasks' || requestedView === 'calendar' ? requestedView : 'kanban',
)
const [taskFilter, setTaskFilter] = useState('all')
const [taskDialog, setTaskDialog] = useState(false)
@@ -36,6 +42,8 @@ export function OpportunitiesPage() {
queryKey: ['tasks', taskFilter],
queryFn: () => getTasks({ filter: taskFilter, ownerId: user?.id }),
})
const { data: team = [] } = useQuery({ queryKey: ['team'], queryFn: getTeam })
const memberNames = new Map(team.map((member) => [member.id, member.name]))
const oppMut = useMutation({
mutationFn: ({ id, stage }: { id: string; stage: string }) => updateOpportunity(id, { stage: stage as any }),
@@ -77,7 +85,7 @@ export function OpportunitiesPage() {
const firstDay = new Date(today.getFullYear(), today.getMonth(), 1).getDay()
return (
<div className="p-4 md:p-6">
<div className="page-shell">
<PageHeader
title="商机与跟进"
description="管理商机流转与团队跟进任务"
@@ -90,8 +98,11 @@ export function OpportunitiesPage() {
<Tabs
value={view}
onChange={setView}
className="mb-4"
onChange={(next) => {
setView(next)
setSearchParams(next === 'kanban' ? {} : { tab: next }, { replace: true })
}}
className="mb-5"
items={[
{ value: 'kanban', label: '商机看板', count: opps.length },
{ value: 'tasks', label: '跟进任务', count: tasks.filter((t) => t.status !== 'done').length },
@@ -100,17 +111,17 @@ export function OpportunitiesPage() {
/>
{view === 'kanban' && (
<div className="flex gap-3 overflow-x-auto pb-2 scrollbar-thin" style={{ minHeight: '60vh' }}>
<div className="flex gap-4 overflow-x-auto pb-3 scrollbar-thin" style={{ minHeight: '60vh' }}>
{groupedOpps.map((col) => (
<div
key={col.value}
className="w-64 shrink-0 rounded-lg bg-muted/40"
className="w-[272px] shrink-0 rounded-[14px] border border-slate-200 bg-slate-100/65"
onDragOver={(e) => e.preventDefault()}
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 className="sticky top-0 z-10 flex items-center justify-between rounded-t-[14px] border-b border-slate-200 bg-white/90 px-3.5 py-3 backdrop-blur">
<span className="text-sm font-semibold text-slate-800">{col.label}</span>
<Badge variant="outline">{col.items.length}</Badge>
</div>
<div className="space-y-2 p-2">
{col.items.map((opp) => (
@@ -118,7 +129,7 @@ export function OpportunitiesPage() {
key={opp.id}
draggable
onDragStart={(e) => handleDragStart(e, opp)}
className="cursor-grab p-3 active:cursor-grabbing hover:shadow-sm"
className="cursor-grab p-3.5 active:cursor-grabbing hover:border-primary-200 hover:shadow-[0_8px_20px_-16px_rgba(37,99,235,.55)]"
>
<p className="text-sm font-medium truncate">{opp.name}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{opp.customerName} <CountryFlag code={opp.country} withName={false} /></p>
@@ -138,13 +149,13 @@ export function OpportunitiesPage() {
{view === 'tasks' && (
<div>
<div className="mb-3 flex flex-wrap gap-1">
<div className="mb-4 flex flex-wrap gap-1 rounded-[12px] border border-slate-200 bg-white p-1.5 shadow-sm">
{[
{ v: 'all', l: '全部' }, { v: 'mine', l: '我的待办' },
{ v: '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>
<button key={f.v} onClick={() => setTaskFilter(f.v)} className={cn('rounded-[8px] px-3 py-1.5 text-xs transition-colors', taskFilter === f.v ? 'bg-slate-900 font-medium text-white' : 'text-slate-500 hover:bg-slate-100 hover:text-slate-900')}>{f.l}</button>
))}
</div>
<Card>
@@ -160,7 +171,7 @@ export function OpportunitiesPage() {
<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><span className="text-xs">{memberNames.get(t.ownerId) ?? '未分配'}</span></Td>
<Td>
{t.status !== 'done' && (
<Button size="sm" variant="ghost" onClick={() => taskMut.mutate({ id: t.id, data: { status: 'done' } })}>完成</Button>