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
+64 -30
View File
@@ -29,20 +29,28 @@ export function KnowledgePage() {
const [createOpen, setCreateOpen] = useState(false)
const [editItem, setEditItem] = useState<KnowledgeItem | null>(null)
const [searchQ, setSearchQ] = useState('')
const [createDraft, setCreateDraft] = useState({ title: '', content: '' })
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 { data: stats } = useQuery({ queryKey: ['knowledge-stats'], queryFn: getKnowledgeStats })
const [searchResult, setSearchResult] = useState<any>(null)
const [searchLoading, setSearchLoading] = useState(false)
const [uploadFiles, setUploadFiles] = useState<File[]>([])
const [uploadDialog, setUploadDialog] = useState(false)
const [uploadLoading, setUploadLoading] = useState(false)
const createMut = useMutation({
mutationFn: (p: any) => createKnowledge(p),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['knowledge'] }); toast.success('知识已创建'); setCreateOpen(false) },
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['knowledge'] })
queryClient.invalidateQueries({ queryKey: ['knowledge-stats'] })
toast.success('知识已创建并完成索引')
setCreateDraft({ title: '', content: '' })
setCreateOpen(false)
},
onError: (e) => toast.error(errorMessage(e)),
})
@@ -65,7 +73,7 @@ export function KnowledgePage() {
<div className="p-4 md:p-6">
<PageHeader
title="企业知识库"
description={`${list.length} 条知识 · 用于回复建议与翻译参考`}
description="把产品、价格、物流和售后资料变成可检索、可引用的企业答案"
actions={
<>
<Button size="sm" variant="outline" onClick={() => setUploadDialog(true)}><Upload className="h-4 w-4" />上传文件</Button>
@@ -74,6 +82,20 @@ export function KnowledgePage() {
}
/>
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
{[
['当前条目', data?.total || 0],
['已分段索引', list.reduce((sum, item) => sum + Number(item.chunkCount || 0), 0)],
['真实引用', stats?.totalCitations || 0],
['待优化内容', (stats?.lowQuality?.length || 0) + (stats?.outdated?.length || 0)],
].map(([label, value]) => (
<Card key={String(label)} className="px-4 py-3">
<p className="text-[11px] font-medium text-slate-400">{label}</p>
<p className="mt-1 text-xl font-semibold tracking-tight text-slate-900">{value}</p>
</Card>
))}
</div>
<Tabs value={tab} onChange={setTab} className="mb-4" items={[
{ value: 'list', label: '知识列表' },
{ value: 'search', label: '检索测试' },
@@ -109,10 +131,14 @@ export function KnowledgePage() {
<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>{item.chunkCount || 0} 个检索片段</span>
<span>·</span>
<span>{item.status === 'parsing' ? '解析中…' : item.status === 'failed' ? '解析失败' : ''}</span>
<span>{Number(item.characterCount || item.content?.length || 0).toLocaleString('zh-CN')} 字符</span>
<span>·</span>
<span>{formatRelative(item.updatedAt)}</span>
{item.status !== 'ready' && <><span>·</span><span>{item.status === 'parsing' ? '解析中…' : '解析失败'}</span></>}
</div>
{item.parseMessage && <p className="mt-1 text-[11px] text-emerald-600">{item.parseMessage}</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
<Switch checked={item.enabled} onCheckedChange={(v) => toggleMut.mutate({ id: item.id, enabled: v })} />
@@ -148,8 +174,9 @@ export function KnowledgePage() {
<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>
<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>
<p className="mt-1 text-[10px] text-slate-400">命中片段 #{Number(h.chunkIndex || 0) + 1}</p>
</div>
))}
</div>
@@ -175,34 +202,41 @@ export function KnowledgePage() {
<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>
if (!createDraft.title.trim()) { toast.warning('请输入标题'); return }
if (createDraft.content.trim().length < 20) { toast.warning('知识内容至少需要 20 个字符'); return }
createMut.mutate({ ...createDraft, category: category || 'faq' })
}} loading={createMut.isPending}>创建并索引</Button>
}>
<Field label="标题" required><Input id="k-title" placeholder="知识条目标题" /></Field>
<Field label="内容" className="mt-3"><Textarea id="k-content" rows={5} placeholder="知识内容…" /></Field>
<Field label="标题" required><Input value={createDraft.title} onChange={(event) => setCreateDraft((value) => ({ ...value, title: event.target.value }))} placeholder="例如:欧洲地区退换货政策" /></Field>
<Field label="内容" className="mt-3"><Textarea value={createDraft.content} onChange={(event) => setCreateDraft((value) => ({ ...value, content: event.target.value }))} rows={8} placeholder="写明适用范围、条件、时效、例外和处理方式。系统会自动分段并用于回复建议。" /></Field>
</Dialog>
<Dialog open={uploadDialog} onOpenChange={setUploadDialog} title="上传文件" footer={
<Button disabled={!uploadFiles.length} onClick={async () => {
const payload = await Promise.all(uploadFiles.map(async (file) => ({
name: file.name,
type: file.type,
base64: await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(reader.error)
reader.onload = () => resolve(String(reader.result).split(',')[1] || '')
reader.readAsDataURL(file)
}),
})))
await uploadKnowledgeFiles(payload)
toast.success(`${payload.length} 个知识文件解析完成`)
queryClient.invalidateQueries({ queryKey: ['knowledge'] })
setUploadFiles([])
setUploadDialog(false)
}}>上传并解析</Button>
<Button disabled={!uploadFiles.length} loading={uploadLoading} onClick={async () => {
setUploadLoading(true)
try {
const payload = await Promise.all(uploadFiles.map(async (file) => ({
name: file.name,
type: file.type,
base64: await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onerror = () => reject(reader.error)
reader.onload = () => resolve(String(reader.result).split(',')[1] || '')
reader.readAsDataURL(file)
}),
})))
const result = await uploadKnowledgeFiles(payload)
toast.success(`${result.items.length} 个文件解析并建立检索索引`)
queryClient.invalidateQueries({ queryKey: ['knowledge'] })
queryClient.invalidateQueries({ queryKey: ['knowledge-stats'] })
setUploadFiles([])
setUploadDialog(false)
} catch (error) {
toast.error(errorMessage(error))
} finally {
setUploadLoading(false)
}
}}>上传、解析并索引</Button>
}>
<input
type="file"