优化整体ui-1
This commit is contained in:
+145
-23
@@ -1,6 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { Form, Input, Button, Card, message } from 'antd'
|
||||
import { MessageOutlined } from '@ant-design/icons'
|
||||
import { Form, Input, Button, message } from 'antd'
|
||||
import {
|
||||
MessageOutlined, UserOutlined, LockOutlined,
|
||||
SafetyCertificateOutlined, CrownOutlined, ArrowRightOutlined,
|
||||
CheckCircleFilled,
|
||||
} from '@ant-design/icons'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '@/stores/auth'
|
||||
|
||||
@@ -23,30 +27,148 @@ const Login = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const fillCredentials = (u: string, p: string) => {
|
||||
form.setFieldsValue({ username: u, password: p })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-neutral-100 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-[400px] shadow-lg !rounded-xl" bordered={false}>
|
||||
<div className="text-center mb-6">
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-500 flex items-center justify-center mx-auto mb-3">
|
||||
<MessageOutlined className="text-white text-xl" />
|
||||
<div className="min-h-screen relative flex flex-col justify-between bg-gradient-to-br from-slate-900 via-slate-950 to-indigo-950 p-4 sm:p-6 overflow-hidden select-none">
|
||||
{/* 动态光晕与网格背景 */}
|
||||
<div className="absolute top-[-10%] left-[-10%] w-[600px] h-[600px] rounded-full bg-blue-500/15 blur-[140px] pointer-events-none" />
|
||||
<div className="absolute bottom-[-10%] right-[-10%] w-[700px] h-[700px] rounded-full bg-indigo-500/15 blur-[160px] pointer-events-none" />
|
||||
<div className="absolute top-[30%] right-[10%] w-[400px] h-[400px] rounded-full bg-cyan-400/10 blur-[120px] pointer-events-none" />
|
||||
|
||||
{/* 顶部品牌微标 */}
|
||||
<header className="relative z-10 w-full max-w-5xl mx-auto flex items-center justify-between py-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-xl bg-gradient-to-tr from-blue-600 to-indigo-500 flex items-center justify-center text-white shadow-md shadow-blue-500/30">
|
||||
<MessageOutlined className="text-base" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold text-neutral-800">客服云</h1>
|
||||
<p className="text-sm text-neutral-400 mt-1">在线客服系统</p>
|
||||
<span className="text-base font-bold text-white tracking-tight">Kefu Cloud</span>
|
||||
</div>
|
||||
<Form form={form} layout="vertical" onFinish={onFinish} autoComplete="off">
|
||||
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input placeholder="kefu_admin" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password placeholder="kefu_admin123" size="large" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" block size="large" loading={loading}>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-white/10 text-slate-300 border border-white/10 backdrop-blur-md">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
系统服务正常
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 核心登录卡片 */}
|
||||
<main className="relative z-10 flex-1 flex items-center justify-center my-6">
|
||||
<div className="w-full max-w-[430px]">
|
||||
<div className="relative rounded-3xl bg-slate-900/70 border border-slate-700/60 p-8 sm:p-9 shadow-2xl backdrop-blur-2xl">
|
||||
{/* 顶端光效描边 */}
|
||||
<div className="absolute inset-x-0 -top-px h-px bg-gradient-to-r from-transparent via-blue-500/60 to-transparent" />
|
||||
|
||||
{/* 卡片头部 */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-gradient-to-tr from-blue-600 to-indigo-500 text-white shadow-xl shadow-blue-500/30 mb-4 border border-white/20">
|
||||
<MessageOutlined className="text-2xl" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white tracking-tight m-0">欢迎登录客服云</h1>
|
||||
<p className="text-xs text-slate-400 mt-2 m-0 font-normal">全渠道多租户智能客服工作台</p>
|
||||
</div>
|
||||
|
||||
{/* 登录表单 */}
|
||||
<Form form={form} layout="vertical" onFinish={onFinish} autoComplete="off" size="large">
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: '请输入用户名' }]}
|
||||
className="!mb-4"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-semibold text-slate-300">账号 / 用户名</label>
|
||||
<Input
|
||||
prefix={<UserOutlined className="text-slate-400 mr-1.5 text-sm" />}
|
||||
placeholder="请输入登录账号"
|
||||
className="!h-11 !rounded-xl !bg-slate-800/80 !border-slate-700 !text-slate-100 placeholder:!text-slate-500 hover:!border-blue-500 focus:!border-blue-500 focus:!ring-2 focus:!ring-blue-500/20 transition-all text-sm"
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: '请输入密码' }]}
|
||||
className="!mb-6"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-semibold text-slate-300">密码</label>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined className="text-slate-400 mr-1.5 text-sm" />}
|
||||
placeholder="请输入登录密码"
|
||||
className="!h-11 !rounded-xl !bg-slate-800/80 !border-slate-700 !text-slate-100 placeholder:!text-slate-500 hover:!border-blue-500 focus:!border-blue-500 focus:!ring-2 focus:!ring-blue-500/20 transition-all text-sm"
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item className="!mb-5">
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
block
|
||||
loading={loading}
|
||||
className="!h-11 !rounded-xl !bg-gradient-to-r !from-blue-600 !to-indigo-600 hover:!from-blue-500 hover:!to-indigo-500 !border-0 !text-sm !font-semibold shadow-lg shadow-blue-600/30 transition-all duration-200 cursor-pointer flex items-center justify-center gap-2"
|
||||
>
|
||||
<span>立即登录</span>
|
||||
<ArrowRightOutlined className="text-xs" />
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
{/* 快捷体验账号 */}
|
||||
<div className="pt-5 border-t border-slate-800/90">
|
||||
<div className="flex items-center justify-between text-[11px] font-medium text-slate-400 mb-2.5">
|
||||
<span>一键填入演示账号</span>
|
||||
<span className="text-slate-500">点击自动填充</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fillCredentials('kefu_admin', 'kefu_admin123')}
|
||||
className="group relative flex items-center justify-between p-2.5 rounded-xl bg-slate-800/60 hover:bg-slate-800 border border-slate-700/70 hover:border-blue-500/60 text-left transition-all cursor-pointer shadow-2xs"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-200 group-hover:text-blue-400 transition-colors">
|
||||
<CrownOutlined className="text-amber-400 text-xs" />
|
||||
<span>租户管理员</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 mt-0.5">kefu_admin</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 group-hover:text-slate-300 font-mono">填入</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fillCredentials('admin', 'admin123')}
|
||||
className="group relative flex items-center justify-between p-2.5 rounded-xl bg-slate-800/60 hover:bg-slate-800 border border-slate-700/70 hover:border-indigo-500/60 text-left transition-all cursor-pointer shadow-2xs"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-200 group-hover:text-indigo-400 transition-colors">
|
||||
<SafetyCertificateOutlined className="text-blue-400 text-xs" />
|
||||
<span>平台管理员</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 mt-0.5">admin</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 group-hover:text-slate-300 font-mono">填入</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* 底部功能特性与版权 */}
|
||||
<footer className="relative z-10 w-full max-w-5xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-2 text-xs text-slate-500 py-2 border-t border-white/5">
|
||||
<div className="flex items-center gap-4 text-[11px]">
|
||||
<span className="inline-flex items-center gap-1"><CheckCircleFilled className="text-emerald-500 text-xs" /> 多租户数据隔离</span>
|
||||
<span className="inline-flex items-center gap-1"><CheckCircleFilled className="text-emerald-500 text-xs" /> WebSocket 实时长连</span>
|
||||
<span className="inline-flex items-center gap-1"><CheckCircleFilled className="text-emerald-500 text-xs" /> 全渠道客服集成</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-500">
|
||||
© {new Date().getFullYear()} Kefu Cloud. All Rights Reserved.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ const AdminDashboard = () => {
|
||||
hint: `本月新增 ${newMonth} 家`,
|
||||
badge: newMonth > 0 ? `+${newMonth}` : null,
|
||||
icon: <TeamOutlined />,
|
||||
iconBg: '#dbeafe',
|
||||
iconBg: '#eff6ff',
|
||||
iconColor: '#2563eb',
|
||||
},
|
||||
{
|
||||
@@ -123,14 +123,14 @@ const AdminDashboard = () => {
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col min-h-0 overflow-hidden bg-neutral-50">
|
||||
<div className="h-full flex flex-col min-h-0 overflow-hidden bg-slate-50/60">
|
||||
<header
|
||||
className="shrink-0 px-6 flex items-center justify-between border-b border-neutral-200 bg-white"
|
||||
className="shrink-0 px-6 flex items-center justify-between border-b border-slate-200/80 bg-white"
|
||||
style={{ height: 'var(--header-height)' }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<h1 className="text-base font-semibold text-neutral-900 m-0 truncate">运营概览</h1>
|
||||
<span className="text-sm text-neutral-400 whitespace-nowrap hidden sm:inline">{formatDate()}</span>
|
||||
<h1 className="text-sm font-bold text-slate-900 m-0 truncate">运营概览</h1>
|
||||
<span className="text-xs text-slate-400 whitespace-nowrap hidden sm:inline">{formatDate()}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -140,10 +140,10 @@ const AdminDashboard = () => {
|
||||
) : (
|
||||
<div className="flex flex-col gap-5 w-full">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-neutral-900 m-0">
|
||||
<h2 className="text-lg font-bold text-slate-900 m-0">
|
||||
欢迎回来,{user?.nickname || '管理员'}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-500 m-0 mt-1">
|
||||
<p className="text-xs text-slate-400 m-0 mt-1">
|
||||
以下是平台当前运营数据概览
|
||||
{stats?.expiring_tenant ? ` · ${stats.expiring_tenant} 家租户即将到期` : ''}
|
||||
</p>
|
||||
@@ -154,56 +154,56 @@ const AdminDashboard = () => {
|
||||
{kpis.map(k => (
|
||||
<div
|
||||
key={k.label}
|
||||
className="rounded-xl bg-white border border-neutral-200 px-4 py-4 shadow-sm"
|
||||
className="rounded-2xl bg-white border border-slate-200/80 p-5 shadow-xs hover:shadow-md transition-all duration-200 group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center text-lg shrink-0"
|
||||
className="w-9 h-9 rounded-xl flex items-center justify-center text-sm shrink-0 transition-transform group-hover:scale-105"
|
||||
style={{ backgroundColor: k.iconBg, color: k.iconColor }}
|
||||
>
|
||||
{k.icon}
|
||||
</div>
|
||||
{k.badge && (
|
||||
<span className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-[11px] font-medium bg-[#f0fdf4] text-[#16a34a]">
|
||||
<span className="inline-flex items-center gap-0.5 px-2 py-0.5 rounded-md text-[11px] font-semibold bg-emerald-50 text-emerald-700 border border-emerald-100">
|
||||
{k.badge.startsWith('+') && <RiseOutlined className="text-[10px]" />}
|
||||
{k.badge}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-1 flex items-baseline gap-1">
|
||||
<span className="text-3xl font-bold text-neutral-900 tabular-nums leading-none">
|
||||
<span className="text-3xl font-extrabold text-slate-900 tabular-nums leading-none tracking-tight">
|
||||
{k.value}
|
||||
</span>
|
||||
{k.unit && <span className="text-sm text-neutral-500">{k.unit}</span>}
|
||||
{k.unit && <span className="text-sm text-slate-400 font-medium">{k.unit}</span>}
|
||||
</div>
|
||||
<p className="text-sm text-neutral-500 m-0">{k.label}</p>
|
||||
<p className="text-xs text-neutral-400 m-0 mt-1 truncate">{k.hint}</p>
|
||||
<p className="text-xs font-semibold text-slate-600 m-0">{k.label}</p>
|
||||
<p className="text-[11px] text-slate-400 m-0 mt-1 truncate">{k.hint}</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* 图表区 */}
|
||||
<section className="grid grid-cols-1 lg:grid-cols-[3fr_2fr] gap-4">
|
||||
<div className="rounded-xl bg-white border border-neutral-200 shadow-sm">
|
||||
<div className="flex items-center justify-between px-4 pt-4 pb-3">
|
||||
<div className="rounded-2xl bg-white border border-slate-200/80 shadow-xs p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="m-0 text-[15px] font-semibold text-neutral-800">租户增长趋势</h3>
|
||||
<p className="m-0 mt-0.5 text-xs text-neutral-400">近6个月新增租户数量</p>
|
||||
<h3 className="m-0 text-sm font-bold text-slate-800">租户增长趋势</h3>
|
||||
<p className="m-0 mt-0.5 text-xs text-slate-400">近6个月新增租户数量</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0 text-xs text-neutral-500">
|
||||
<div className="flex items-center gap-3 shrink-0 text-xs text-slate-500">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-sm bg-[#2563eb]" />
|
||||
<span className="w-2.5 h-2.5 rounded-sm bg-blue-600" />
|
||||
新增租户
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-sm bg-neutral-200" />
|
||||
<span className="w-2.5 h-2.5 rounded-sm bg-slate-200" />
|
||||
累计(缩放)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 pb-4">
|
||||
<div>
|
||||
{growth.length === 0 ? (
|
||||
<div className="h-[180px] flex items-center justify-center text-sm text-neutral-400">暂无数据</div>
|
||||
<div className="h-[180px] flex items-center justify-center text-xs text-slate-400">暂无数据</div>
|
||||
) : (
|
||||
<div className="flex items-end gap-3 h-[180px]">
|
||||
{growth.map(g => {
|
||||
@@ -213,17 +213,17 @@ const AdminDashboard = () => {
|
||||
<div key={g.month_key || g.month} className="flex-1 flex flex-col items-center gap-1 min-w-0">
|
||||
<div className="w-full flex flex-col items-center justify-end gap-0.5" style={{ height: 150 }}>
|
||||
<div
|
||||
className="w-full max-w-[40px] rounded-t-sm bg-neutral-200"
|
||||
className="w-full max-w-[36px] rounded-t-sm bg-slate-200"
|
||||
style={{ height: totH }}
|
||||
title={`累计 ${g.total_count}`}
|
||||
/>
|
||||
<div
|
||||
className="w-full max-w-[40px] rounded-t-sm bg-[#2563eb]"
|
||||
className="w-full max-w-[36px] rounded-t-sm bg-gradient-to-t from-blue-600 to-blue-500 shadow-xs"
|
||||
style={{ height: newH }}
|
||||
title={`新增 ${g.new_count}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[11px] text-neutral-400 whitespace-nowrap">{g.month}</span>
|
||||
<span className="text-[11px] text-slate-400 whitespace-nowrap">{g.month}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -232,21 +232,21 @@ const AdminDashboard = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-white border border-neutral-200 shadow-sm">
|
||||
<div className="px-4 pt-4 pb-3">
|
||||
<h3 className="m-0 text-[15px] font-semibold text-neutral-800">套餐分布占比</h3>
|
||||
<p className="m-0 mt-0.5 text-xs text-neutral-400">当前租户套餐类型分布</p>
|
||||
<div className="rounded-2xl bg-white border border-slate-200/80 shadow-xs p-5">
|
||||
<div className="mb-4">
|
||||
<h3 className="m-0 text-sm font-bold text-slate-800">套餐分布占比</h3>
|
||||
<p className="m-0 mt-0.5 text-xs text-slate-400">当前租户套餐类型分布</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-6 px-4 pb-5">
|
||||
<div className="flex items-center gap-6 pb-2">
|
||||
<div className="shrink-0 w-[120px] h-[120px] relative flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 rounded-full"
|
||||
className="absolute inset-0 rounded-full shadow-inner"
|
||||
style={{ background: `conic-gradient(${conic})` }}
|
||||
/>
|
||||
<div className="absolute w-[70px] h-[70px] rounded-full bg-white" />
|
||||
<div className="absolute w-[72px] h-[72px] rounded-full bg-white shadow-xs" />
|
||||
<div className="relative z-[1] flex flex-col items-center">
|
||||
<span className="text-xl font-bold text-neutral-900 tabular-nums">{planTotal || stats?.tenant_total || 0}</span>
|
||||
<span className="text-[11px] text-neutral-400">总计</span>
|
||||
<span className="text-xl font-bold text-slate-900 tabular-nums">{planTotal || stats?.tenant_total || 0}</span>
|
||||
<span className="text-[10px] text-slate-400">总计</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-2.5">
|
||||
@@ -262,9 +262,9 @@ const AdminDashboard = () => {
|
||||
className="w-2.5 h-2.5 rounded-sm shrink-0"
|
||||
style={{ backgroundColor: planColors[i % planColors.length] }}
|
||||
/>
|
||||
<span className="text-sm text-neutral-600 truncate">{item.name}</span>
|
||||
<span className="text-xs text-slate-600 truncate">{item.name}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-neutral-800 tabular-nums shrink-0">
|
||||
<span className="text-xs font-semibold text-slate-800 tabular-nums shrink-0">
|
||||
{item.count} · {pct}%
|
||||
</span>
|
||||
</div>
|
||||
@@ -277,25 +277,25 @@ const AdminDashboard = () => {
|
||||
</section>
|
||||
|
||||
{/* 最近操作 */}
|
||||
<section className="rounded-xl bg-white border border-neutral-200 shadow-sm overflow-hidden">
|
||||
<div className="px-5 pt-4 pb-2">
|
||||
<h3 className="m-0 text-[15px] font-semibold text-neutral-800">最近操作记录</h3>
|
||||
<p className="m-0 mt-0.5 text-xs text-neutral-400">平台管理操作审计</p>
|
||||
<section className="rounded-2xl bg-white border border-slate-200/80 shadow-xs overflow-hidden">
|
||||
<div className="px-6 pt-5 pb-2">
|
||||
<h3 className="m-0 text-sm font-bold text-slate-800">最近操作记录</h3>
|
||||
<p className="m-0 mt-0.5 text-xs text-slate-400">平台管理操作审计</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<div className="overflow-x-auto px-4 pb-4">
|
||||
<table className="w-full min-w-[640px] border-collapse">
|
||||
<thead>
|
||||
<tr className="text-[11px] font-medium text-neutral-500 bg-neutral-50">
|
||||
<th className="text-left px-4 py-2.5 border-b border-neutral-200 w-36">时间</th>
|
||||
<th className="text-left px-4 py-2.5 border-b border-neutral-200 w-28">操作</th>
|
||||
<th className="text-left px-4 py-2.5 border-b border-neutral-200">详情</th>
|
||||
<th className="text-left px-4 py-2.5 border-b border-neutral-200 w-28">IP</th>
|
||||
<tr className="text-[11px] font-semibold text-slate-400 bg-slate-50/80">
|
||||
<th className="text-left px-4 py-2.5 rounded-l-lg w-36">时间</th>
|
||||
<th className="text-left px-4 py-2.5 w-28">操作</th>
|
||||
<th className="text-left px-4 py-2.5">详情</th>
|
||||
<th className="text-left px-4 py-2.5 rounded-r-lg w-28">IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-center text-sm text-neutral-400 py-12">
|
||||
<td colSpan={4} className="text-center text-xs text-slate-400 py-12">
|
||||
暂无操作记录
|
||||
</td>
|
||||
</tr>
|
||||
@@ -305,22 +305,22 @@ const AdminDashboard = () => {
|
||||
text: log.action, bg: '#f1f5f9', color: '#64748b',
|
||||
}
|
||||
return (
|
||||
<tr key={log.id} className="hover:bg-neutral-50">
|
||||
<td className="px-4 py-3 border-b border-neutral-100 text-xs text-neutral-500 whitespace-nowrap">
|
||||
<tr key={log.id} className="hover:bg-slate-50/80 transition-colors border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-3 text-xs text-slate-500 whitespace-nowrap">
|
||||
{formatTime(log.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 border-b border-neutral-100">
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className="inline-flex px-2 py-0.5 rounded text-[11px] font-medium whitespace-nowrap"
|
||||
className="inline-flex px-2 py-0.5 rounded-md text-[11px] font-semibold whitespace-nowrap"
|
||||
style={{ backgroundColor: meta.bg, color: meta.color }}
|
||||
>
|
||||
{meta.text}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 border-b border-neutral-100 text-sm text-neutral-600 max-w-md truncate">
|
||||
<td className="px-4 py-3 text-xs text-slate-700 max-w-md truncate">
|
||||
{log.detail || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 border-b border-neutral-100 text-xs text-neutral-400 whitespace-nowrap">
|
||||
<td className="px-4 py-3 text-xs text-slate-400 whitespace-nowrap">
|
||||
{log.ip || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
+43
-42
@@ -265,54 +265,55 @@ const Ops = () => {
|
||||
const errThresholdY = 100 - (2 / maxErr) * (100 - 8) - 4 // map 2% to viewBox y
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col min-h-0 overflow-hidden bg-neutral-50">
|
||||
<div className="shrink-0 px-6 pt-5 pb-4 bg-white border-b border-neutral-200">
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-xl font-semibold text-neutral-900 m-0 tracking-tight">系统运维</h1>
|
||||
<p className="text-sm text-neutral-500 m-0 mt-1">
|
||||
监控平台运行状态、查看操作日志、管理平台公告
|
||||
<span className="text-neutral-400"> · 指标来自当前 API 实例</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs border"
|
||||
style={{ background: overall.bg, color: overall.color, borderColor: `${overall.dot}33` }}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full" style={{ background: overall.dot }} />
|
||||
{metrics
|
||||
? (metrics.overall === 'normal' ? '系统正常运行' : metrics.overall === 'warning' ? '存在告警' : '存在异常')
|
||||
: '加载中…'}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-400 whitespace-nowrap">
|
||||
最后更新:{formatRelative(refreshedAt)}
|
||||
{metrics ? ` · 运行 ${formatUptime(metrics.uptime_sec)}` : ''}
|
||||
</span>
|
||||
<Button
|
||||
icon={<ReloadOutlined spin={loadingMetrics} />}
|
||||
size="small"
|
||||
className="!h-8"
|
||||
onClick={() => { void refreshAll() }}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<div className="h-full flex flex-col min-h-0 overflow-hidden bg-slate-50/60">
|
||||
<header
|
||||
className="shrink-0 px-6 flex items-center justify-between border-b border-slate-200/80 bg-white"
|
||||
style={{ height: 'var(--header-height)' }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-sm font-bold text-slate-900 m-0 truncate">系统运维</h1>
|
||||
<p className="text-[11px] text-slate-400 m-0 leading-tight">
|
||||
平台运行监控、服务状态与操作审计日志
|
||||
</p>
|
||||
</div>
|
||||
{metricsError && (
|
||||
<div className="mt-2 text-xs text-red-500">{metricsError}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-semibold border"
|
||||
style={{ background: overall.bg, color: overall.color, borderColor: `${overall.dot}33` }}
|
||||
>
|
||||
<span className="w-1.5 h-1.5 rounded-full" style={{ background: overall.dot }} />
|
||||
{metrics
|
||||
? (metrics.overall === 'normal' ? '系统正常运行' : metrics.overall === 'warning' ? '存在告警' : '存在异常')
|
||||
: '加载中…'}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400 whitespace-nowrap hidden sm:inline">
|
||||
最后更新:{formatRelative(refreshedAt)}
|
||||
{metrics ? ` · 运行 ${formatUptime(metrics.uptime_sec)}` : ''}
|
||||
</span>
|
||||
<Button
|
||||
icon={<ReloadOutlined spin={loadingMetrics} />}
|
||||
size="small"
|
||||
className="!h-8 !rounded-lg !text-xs !font-medium !text-slate-700"
|
||||
onClick={() => { void refreshAll() }}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{metricsError && (
|
||||
<div className="px-6 py-2 bg-red-50 text-xs text-red-600 border-b border-red-100">{metricsError}</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto px-6 py-5">
|
||||
<div className="flex flex-col gap-5 w-full">
|
||||
|
||||
<section className="rounded-xl bg-white border border-neutral-200 p-5 shadow-sm">
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
<CloudServerOutlined className="text-[#2563eb]" />
|
||||
<span className="font-semibold text-[15px] text-neutral-800">服务状态</span>
|
||||
<section className="rounded-2xl bg-white border border-slate-200/80 p-5 shadow-xs">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<CloudServerOutlined className="text-blue-600 text-base" />
|
||||
<span className="font-bold text-sm text-slate-800">服务状态</span>
|
||||
{metrics?.websocket != null && (
|
||||
<span className="text-[11px] text-neutral-400 ml-2">
|
||||
<span className="text-[11px] text-slate-400 ml-2 font-medium">
|
||||
WS 连接 {metrics.websocket.connections}
|
||||
(坐席 {metrics.websocket.agents} / 访客 {metrics.websocket.visitors})
|
||||
</span>
|
||||
|
||||
@@ -272,18 +272,19 @@ const Plans = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col min-h-0 overflow-hidden bg-[#f4f6f9]">
|
||||
<div className="h-full flex flex-col min-h-0 overflow-hidden bg-slate-50/60">
|
||||
<header
|
||||
className="shrink-0 px-6 flex items-center justify-between border-b border-neutral-200/80 bg-white/90 backdrop-blur-sm"
|
||||
className="shrink-0 px-6 flex items-center justify-between border-b border-slate-200/80 bg-white"
|
||||
style={{ height: 'var(--header-height)' }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-[15px] font-semibold text-neutral-900 m-0 tracking-tight">套餐定价</h1>
|
||||
<h1 className="text-sm font-bold text-slate-900 m-0 truncate">套餐定价</h1>
|
||||
<p className="text-[11px] text-slate-400 m-0 leading-tight">全平台订阅档位配置与功能权益矩阵</p>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
className="!h-8 !rounded-lg !px-3.5 !font-medium !shadow-none"
|
||||
className="!h-8 !rounded-lg !px-3.5 !text-xs !font-semibold !bg-gradient-to-r !from-blue-600 !to-blue-500 shadow-sm shadow-blue-500/20"
|
||||
onClick={openCreate}
|
||||
>
|
||||
新建套餐
|
||||
@@ -295,7 +296,7 @@ const Plans = () => {
|
||||
{loading ? (
|
||||
<div className="h-64 flex items-center justify-center"><Spin size="large" /></div>
|
||||
) : displayPlans.length === 0 ? (
|
||||
<div className="bg-white rounded-2xl border border-neutral-200/80 py-16">
|
||||
<div className="bg-white rounded-2xl border border-slate-200/80 py-16 text-center">
|
||||
<Empty description="还没有套餐">
|
||||
<Button type="primary" onClick={openCreate}>创建第一个套餐</Button>
|
||||
</Empty>
|
||||
|
||||
@@ -243,29 +243,27 @@ const Tenants = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col min-h-0 overflow-hidden bg-neutral-50">
|
||||
<div className="h-full flex flex-col min-h-0 bg-slate-50/60 overflow-hidden">
|
||||
{/* 顶栏 56px */}
|
||||
<header
|
||||
className="shrink-0 px-6 flex items-center justify-between border-b border-neutral-200 bg-white"
|
||||
className="shrink-0 px-6 flex items-center justify-between border-b border-slate-200/80 bg-white"
|
||||
style={{ height: 'var(--header-height)' }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<h1 className="text-base font-semibold text-neutral-900 m-0">租户管理</h1>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-neutral-100 text-neutral-500 tabular-nums">
|
||||
共 {total} 个租户
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-sm font-bold text-slate-900 m-0 truncate">租户管理</h1>
|
||||
<p className="text-[11px] text-slate-400 m-0 leading-tight">全平台企业租户全生命周期管理与授权配额</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 工具栏 */}
|
||||
<div className="shrink-0 px-6 py-3 border-b border-neutral-200 bg-white">
|
||||
<div className="shrink-0 px-6 py-3.5 bg-white border-b border-slate-100">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 h-8 px-3 rounded-md border border-neutral-200 bg-white w-64">
|
||||
<SearchOutlined className="text-neutral-400 text-xs shrink-0" />
|
||||
<div className="flex items-center gap-3 flex-1 min-w-[280px] flex-wrap">
|
||||
<div className="flex items-center gap-2 px-3 h-8.5 rounded-xl max-w-sm w-full bg-slate-100/80 border border-slate-200/70 focus-within:bg-white focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-50 transition-all">
|
||||
<SearchOutlined className="text-slate-400 text-xs shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索公司名/联系人..."
|
||||
placeholder="搜索租户名称、联系人、手机号..."
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
@@ -274,10 +272,10 @@ const Tenants = () => {
|
||||
setPage(1)
|
||||
}
|
||||
}}
|
||||
className="flex-1 min-w-0 bg-transparent border-0 outline-none text-sm text-neutral-800 placeholder:text-neutral-400"
|
||||
className="flex-1 min-w-0 bg-transparent border-0 outline-none text-xs text-slate-800 placeholder:text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 p-0.5 rounded-md border border-neutral-200 bg-white">
|
||||
<div className="flex items-center gap-0.5 p-0.5 rounded-xl bg-slate-100 border border-slate-200/60">
|
||||
{statusFilters.map(f => {
|
||||
const active = statusFilter === f.key || (!statusFilter && f.key === undefined)
|
||||
return (
|
||||
@@ -285,10 +283,10 @@ const Tenants = () => {
|
||||
key={f.label}
|
||||
type="button"
|
||||
onClick={() => { setStatusFilter(f.key); setPage(1) }}
|
||||
className={`px-3 py-1 text-xs rounded border-0 cursor-pointer transition-colors ${
|
||||
className={`px-3 py-1 text-xs rounded-lg border-0 cursor-pointer transition-all ${
|
||||
active
|
||||
? 'bg-[#2563eb] text-white font-medium'
|
||||
: 'bg-transparent text-neutral-600 hover:bg-neutral-50'
|
||||
? 'bg-white text-blue-600 font-semibold shadow-xs'
|
||||
: 'bg-transparent text-slate-500 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
@@ -297,15 +295,20 @@ const Tenants = () => {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />} className="!h-8" onClick={openCreate}>
|
||||
开通新账号
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
className="!h-8 !px-3.5 !text-xs !font-semibold !bg-gradient-to-r !from-blue-600 !to-blue-500 !rounded-lg shadow-sm shadow-blue-500/20"
|
||||
onClick={openCreate}
|
||||
>
|
||||
开通新租户
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 表格 */}
|
||||
<div className="flex-1 min-h-0 overflow-auto px-6 py-4">
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
<div className="flex-1 min-h-0 overflow-auto px-6 py-5">
|
||||
<div className="rounded-2xl border border-slate-200/80 bg-white shadow-xs overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="py-20 flex justify-center"><Spin size="large" /></div>
|
||||
) : tenants.length === 0 ? (
|
||||
@@ -314,11 +317,11 @@ const Tenants = () => {
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" style={{ minWidth: 960 }}>
|
||||
<thead>
|
||||
<tr className="bg-neutral-50 border-b border-neutral-200">
|
||||
<tr className="bg-slate-50/80 border-b border-slate-100">
|
||||
{['公司名称', '联系人', '手机号', '套餐类型', '坐席', '开通日期', '到期日期', '状态', '操作'].map((h, i) => (
|
||||
<th
|
||||
key={h}
|
||||
className={`px-4 py-3 text-[11px] font-semibold text-neutral-500 whitespace-nowrap ${
|
||||
className={`px-4 py-3 text-[11px] font-semibold uppercase tracking-wider text-slate-400 whitespace-nowrap ${
|
||||
i === 4 || i === 8 ? 'text-center' : 'text-left'
|
||||
}`}
|
||||
>
|
||||
@@ -335,46 +338,46 @@ const Tenants = () => {
|
||||
return (
|
||||
<tr
|
||||
key={record.id}
|
||||
className="border-t border-neutral-100 hover:bg-neutral-50 cursor-pointer"
|
||||
className="border-t border-slate-100 hover:bg-slate-50/80 cursor-pointer transition-colors"
|
||||
onClick={() => openDetail(record)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div
|
||||
className="w-8 h-8 rounded-md flex items-center justify-center text-xs font-bold shrink-0"
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold shrink-0 shadow-2xs"
|
||||
style={{ backgroundColor: pal.bg, color: pal.color }}
|
||||
>
|
||||
{record.name.slice(0, 1)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-neutral-900 truncate">{record.name}</div>
|
||||
<div className="text-[11px] text-neutral-400 truncate">
|
||||
<div className="text-xs font-semibold text-slate-800 truncate">{record.name}</div>
|
||||
<div className="text-[10px] text-slate-400 truncate">
|
||||
ID: T{String(record.id).padStart(4, '0')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-neutral-700 whitespace-nowrap">
|
||||
<td className="px-4 py-3 text-xs text-slate-700 whitespace-nowrap">
|
||||
{record.contact_name || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-neutral-600 whitespace-nowrap font-mono">
|
||||
<td className="px-4 py-3 text-xs text-slate-600 whitespace-nowrap font-mono">
|
||||
{maskPhone(record.contact_phone)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className="inline-flex px-2 py-0.5 rounded text-[11px] font-medium whitespace-nowrap"
|
||||
className="inline-flex px-2 py-0.5 rounded-md text-[11px] font-medium whitespace-nowrap"
|
||||
style={{ backgroundColor: pstyle.bg, color: pstyle.color }}
|
||||
>
|
||||
{pname}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-center text-neutral-700 tabular-nums">
|
||||
<td className="px-4 py-3 text-xs text-center text-slate-800 font-bold tabular-nums">
|
||||
{record.seat_count}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-neutral-500 whitespace-nowrap">
|
||||
<td className="px-4 py-3 text-xs text-slate-400 whitespace-nowrap">
|
||||
{fmtDate(record.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-neutral-700 whitespace-nowrap">
|
||||
<td className="px-4 py-3 text-xs text-slate-700 font-medium whitespace-nowrap">
|
||||
{fmtDate(record.expire_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -384,12 +387,11 @@ const Tenants = () => {
|
||||
<div className="flex items-center justify-center gap-1 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
className="px-1.5 py-1 rounded text-[#2563eb] hover:bg-blue-50 border-0 bg-transparent cursor-pointer"
|
||||
className="px-2 py-1 rounded-md font-semibold text-blue-600 hover:bg-blue-50 border-0 bg-transparent cursor-pointer transition-colors"
|
||||
onClick={() => openDetail(record)}
|
||||
>
|
||||
查看
|
||||
</button>
|
||||
<span className="text-neutral-200">|</span>
|
||||
<button
|
||||
type="button"
|
||||
className="px-1.5 py-1 rounded text-[#2563eb] hover:bg-blue-50 border-0 bg-transparent cursor-pointer"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Button, Input, Popconfirm, Select, Spin, Table, Tag, message } from 'antd'
|
||||
import { Button, Popconfirm, Select, Spin, Table, Tag, message } from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { ReloadOutlined, StopOutlined } from '@ant-design/icons'
|
||||
import { ReloadOutlined } from '@ant-design/icons'
|
||||
import { getBlacklist, releaseBlacklist, type BlacklistEntry } from '@/services/api'
|
||||
import { usePermission } from '@/stores/auth'
|
||||
|
||||
@@ -162,55 +162,63 @@ const Blacklist = () => {
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-[#f8fafc]">
|
||||
<div className="shrink-0 bg-white border-b border-neutral-200 px-6 py-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StopOutlined className="text-[#dc2626]" />
|
||||
<h1 className="m-0 text-lg font-semibold text-neutral-900">黑名单</h1>
|
||||
</div>
|
||||
<p className="m-0 mt-1 text-sm text-neutral-500">
|
||||
查看已拉黑的 IP / 设备,可手动解除。在工作台会话顶栏可拉黑当前访客。
|
||||
</p>
|
||||
</div>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void load()} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
<div className="h-full flex flex-col min-h-0 bg-slate-50/60 overflow-hidden">
|
||||
{/* 顶栏 56px */}
|
||||
<header
|
||||
className="shrink-0 px-6 flex items-center justify-between gap-3 border-b border-slate-200/80 bg-white"
|
||||
style={{ height: 'var(--header-height)' }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-sm font-bold text-slate-900 m-0 truncate">黑名单管理</h1>
|
||||
<p className="text-[11px] text-slate-400 m-0 leading-tight">拦截恶意访客、封禁 IP 与设备管理</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-4">
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => void load()}
|
||||
loading={loading}
|
||||
className="!h-8 !rounded-lg !text-xs !font-medium !text-slate-700"
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{/* 筛选工具栏 */}
|
||||
<div className="shrink-0 px-6 py-3.5 bg-white border-b border-slate-100">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2 max-w-xs w-full sm:w-64 h-8.5 px-3 rounded-xl bg-slate-100/80 border border-slate-200/70 focus-within:bg-white focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-50 transition-all">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索 IP / 设备 / 原因..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="flex-1 min-w-0 bg-transparent border-0 outline-none text-xs text-slate-800 placeholder:text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部类型"
|
||||
className="w-32"
|
||||
className="!w-28 !h-8.5"
|
||||
value={kind}
|
||||
onChange={v => setKind(v)}
|
||||
options={[
|
||||
{ value: 'ip', label: 'IP' },
|
||||
{ value: 'device', label: '设备' },
|
||||
{ value: 'ip', label: 'IP 地址' },
|
||||
{ value: 'device', label: '设备特征' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
className="w-36"
|
||||
className="!w-32 !h-8.5"
|
||||
value={showExpired ? 'all' : 'active'}
|
||||
onChange={v => setShowExpired(v === 'all')}
|
||||
options={[
|
||||
{ value: 'active', label: '仅生效中' },
|
||||
{ value: 'all', label: '含已过期' },
|
||||
{ value: 'all', label: '包含已过期' },
|
||||
]}
|
||||
/>
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder="搜索 IP / 设备 / 原因"
|
||||
className="w-64"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 p-6 overflow-auto">
|
||||
<div className="bg-white rounded-xl border border-neutral-200 overflow-hidden">
|
||||
<div className="bg-white rounded-2xl border border-slate-200/80 shadow-xs overflow-hidden p-4">
|
||||
{loading && list.length === 0 ? (
|
||||
<div className="py-20 flex justify-center"><Spin /></div>
|
||||
) : (
|
||||
@@ -223,6 +231,8 @@ const Blacklist = () => {
|
||||
pageSize: 20,
|
||||
showTotal: t => `共 ${t} 条`,
|
||||
showSizeChanger: false,
|
||||
size: 'small',
|
||||
className: '!mt-4',
|
||||
}}
|
||||
locale={{ emptyText: '暂无黑名单记录' }}
|
||||
scroll={{ x: 900 }}
|
||||
|
||||
@@ -330,114 +330,120 @@ const ChatHistory = () => {
|
||||
}, [events])
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col min-h-0 bg-neutral-50 overflow-hidden">
|
||||
{/* 顶栏 — 全宽 */}
|
||||
<header className="shrink-0 h-14 px-6 flex items-center justify-between bg-white border-b border-neutral-200">
|
||||
<h1 className="text-lg font-semibold text-neutral-900 m-0">对话记录</h1>
|
||||
<div className="h-full flex flex-col min-h-0 bg-slate-50/60 overflow-hidden">
|
||||
{/* 顶栏 — 56px */}
|
||||
<header
|
||||
className="shrink-0 px-6 flex items-center justify-between bg-white border-b border-slate-200/80"
|
||||
style={{ height: 'var(--header-height)' }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-sm font-bold text-slate-900 m-0 truncate">对话记录</h1>
|
||||
<p className="text-[11px] text-slate-400 m-0 leading-tight">历史服务会话回溯、满意度评价与归档</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canExport && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={exporting}
|
||||
className="flex items-center gap-1.5 h-8 px-3 rounded-lg text-sm text-neutral-600 border border-neutral-200 bg-white hover:bg-neutral-50 cursor-pointer disabled:opacity-50"
|
||||
onClick={async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
await exportSessionsCSV({
|
||||
status: statusFilter,
|
||||
agent_id: agentFilter,
|
||||
channel_id: channelFilter,
|
||||
search: search || undefined,
|
||||
from: dateRange?.[0] ? dateRange[0].format('YYYY-MM-DD') : undefined,
|
||||
to: dateRange?.[1] ? dateRange[1].format('YYYY-MM-DD') : undefined,
|
||||
})
|
||||
message.success('对话记录已导出')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '导出失败')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DownloadOutlined />
|
||||
{exporting ? '导出中…' : '导出'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={exporting}
|
||||
className="flex items-center gap-1.5 h-8 px-3 rounded-xl text-xs font-medium text-slate-700 border border-slate-200 bg-white hover:bg-slate-50 cursor-pointer disabled:opacity-50 transition-all shadow-2xs"
|
||||
onClick={async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
await exportSessionsCSV({
|
||||
status: statusFilter,
|
||||
agent_id: agentFilter,
|
||||
channel_id: channelFilter,
|
||||
search: search || undefined,
|
||||
from: dateRange?.[0] ? dateRange[0].format('YYYY-MM-DD') : undefined,
|
||||
to: dateRange?.[1] ? dateRange[1].format('YYYY-MM-DD') : undefined,
|
||||
})
|
||||
message.success('对话记录已导出')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '导出失败')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DownloadOutlined className="text-slate-400" />
|
||||
{exporting ? '导出中…' : '导出'}
|
||||
</button>
|
||||
)}
|
||||
{canArchive && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={archiving}
|
||||
className="flex items-center gap-1.5 h-8 px-3 rounded-lg text-sm text-neutral-600 border border-neutral-200 bg-white hover:bg-neutral-50 cursor-pointer disabled:opacity-50"
|
||||
onClick={handleBatchArchive}
|
||||
>
|
||||
<InboxOutlined />
|
||||
批量归档
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={archiving}
|
||||
className="flex items-center gap-1.5 h-8 px-3 rounded-xl text-xs font-medium text-slate-700 border border-slate-200 bg-white hover:bg-slate-50 cursor-pointer disabled:opacity-50 transition-all shadow-2xs"
|
||||
onClick={handleBatchArchive}
|
||||
>
|
||||
<InboxOutlined className="text-slate-400" />
|
||||
批量归档
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 筛选栏 — 全宽,左右分栏顶边对齐 */}
|
||||
<div className="shrink-0 px-6 py-3 bg-white border-b border-neutral-200">
|
||||
<div className="flex items-center gap-3 mb-2 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-[200px] max-w-xl h-8 px-3 rounded-md border border-neutral-200 bg-white">
|
||||
<SearchOutlined className="text-neutral-400 text-sm shrink-0" />
|
||||
{/* 筛选栏 */}
|
||||
<div className="shrink-0 px-6 py-3.5 bg-white border-b border-slate-100">
|
||||
<div className="flex items-center gap-3 mb-2.5 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-[200px] max-w-md h-8.5 px-3 rounded-xl bg-slate-100/80 border border-slate-200/70 focus-within:bg-white focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-50 transition-all">
|
||||
<SearchOutlined className="text-slate-400 text-xs shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索客户名称、会话内容..."
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSearch() }}
|
||||
className="flex-1 min-w-0 bg-transparent border-0 outline-none text-sm text-neutral-800 placeholder:text-neutral-400"
|
||||
className="flex-1 min-w-0 bg-transparent border-0 outline-none text-xs text-slate-800 placeholder:text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={v => { setDateRange(v); setPage(1) }}
|
||||
className="!h-8"
|
||||
className="!h-8.5 !rounded-xl"
|
||||
allowClear
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSearch}
|
||||
className="h-8 px-4 rounded-lg text-sm font-medium bg-[#2563eb] text-white border-0 cursor-pointer hover:bg-[#1d4ed8] shrink-0"
|
||||
className="h-8.5 px-4 rounded-xl text-xs font-semibold bg-gradient-to-r from-blue-600 to-blue-500 text-white border-0 cursor-pointer hover:from-blue-500 hover:to-blue-600 shrink-0 shadow-sm shadow-blue-500/20 transition-all"
|
||||
>
|
||||
搜索
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm text-neutral-500 whitespace-nowrap">渠道:</span>
|
||||
<span className="text-xs text-slate-400 font-medium whitespace-nowrap">渠道:</span>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部"
|
||||
value={channelFilter}
|
||||
onChange={v => { setChannelFilter(v); setPage(1) }}
|
||||
className="!w-[120px]"
|
||||
className="!w-[110px]"
|
||||
size="small"
|
||||
options={channels.map(ch => ({ value: ch.id, label: ch.name || ch.type }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm text-neutral-500 whitespace-nowrap">客服:</span>
|
||||
<span className="text-xs text-slate-400 font-medium whitespace-nowrap">客服:</span>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部"
|
||||
value={agentFilter}
|
||||
onChange={v => { setAgentFilter(v); setPage(1) }}
|
||||
className="!w-[120px]"
|
||||
className="!w-[110px]"
|
||||
size="small"
|
||||
options={agents.map(a => ({ value: a.id, label: a.nickname }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm text-neutral-500 whitespace-nowrap">状态:</span>
|
||||
<span className="text-xs text-slate-400 font-medium whitespace-nowrap">状态:</span>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部"
|
||||
value={statusFilter}
|
||||
onChange={v => { setStatusFilter(v); setPage(1) }}
|
||||
className="!w-[120px]"
|
||||
className="!w-[110px]"
|
||||
size="small"
|
||||
options={[
|
||||
{ value: 'active', label: '进行中' },
|
||||
@@ -447,17 +453,17 @@ const ChatHistory = () => {
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<span className="ml-auto text-sm text-neutral-400 whitespace-nowrap">
|
||||
共 {total} 条记录
|
||||
<span className="ml-auto text-xs text-slate-400 whitespace-nowrap font-medium">
|
||||
共 <span className="font-bold text-slate-800">{total}</span> 条记录
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 列表 | 详情 — 顶边同一基线 */}
|
||||
{/* 列表 | 详情 */}
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
{/* 会话列表 */}
|
||||
<div className="flex-1 min-w-0 flex flex-col overflow-hidden border-r border-neutral-200">
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 bg-neutral-50">
|
||||
<div className="flex-1 min-w-0 flex flex-col overflow-hidden border-r border-slate-200/80">
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 bg-slate-50/60 space-y-2">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-20"><Spin size="large" /></div>
|
||||
) : sessions.length === 0 ? (
|
||||
|
||||
@@ -366,29 +366,32 @@ const Customers = () => {
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col min-h-0 bg-neutral-50 overflow-hidden">
|
||||
{/* 顶栏标题 — 对齐设计稿 header */}
|
||||
<header className="shrink-0 h-14 px-6 flex items-center justify-between bg-white border-b border-neutral-200">
|
||||
<h1 className="text-base font-semibold text-neutral-800 m-0">客户管理</h1>
|
||||
<div className="h-full flex flex-col min-h-0 bg-slate-50/60 overflow-hidden">
|
||||
{/* 顶栏标题 — 56px */}
|
||||
<header
|
||||
className="shrink-0 px-6 flex items-center justify-between bg-white border-b border-slate-200/80"
|
||||
style={{ height: 'var(--header-height)' }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-sm font-bold text-slate-900 m-0 truncate">客户管理</h1>
|
||||
<p className="text-[11px] text-slate-400 m-0 leading-tight">客户档案、联络信息与标签资产</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 min-h-0 overflow-hidden">
|
||||
{/* 主内容区 */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{/* 工具栏:搜索 + 筛选 + 操作 同一行 */}
|
||||
<div className="shrink-0 px-6 py-4 bg-white">
|
||||
{/* 工具栏:搜索 + 筛选 + 操作 */}
|
||||
<div className="shrink-0 px-6 py-3.5 bg-white border-b border-slate-100">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 h-9 rounded-lg max-w-xs w-full sm:w-64 border border-neutral-200"
|
||||
style={{ backgroundColor: 'var(--neutral-100)' }}
|
||||
>
|
||||
<SearchOutlined className="text-neutral-400 shrink-0 text-sm" />
|
||||
<div className="flex items-center gap-2 px-3 h-8.5 rounded-xl max-w-xs w-full sm:w-64 bg-slate-100/80 border border-slate-200/70 focus-within:bg-white focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-50 transition-all">
|
||||
<SearchOutlined className="text-slate-400 shrink-0 text-xs" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索客户名称、手机号、邮箱..."
|
||||
placeholder="搜索名称、手机号、邮箱..."
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(1) }}
|
||||
className="flex-1 bg-transparent outline-none text-[13px] min-w-0 text-neutral-700 placeholder:text-neutral-400 border-0"
|
||||
className="flex-1 bg-transparent outline-none text-xs min-w-0 text-slate-800 placeholder:text-slate-400 border-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -400,10 +403,10 @@ const Customers = () => {
|
||||
key={chip.key}
|
||||
type="button"
|
||||
onClick={() => setTagFilter(chip.key)}
|
||||
className={`px-3 py-1.5 rounded-md text-[13px] font-medium whitespace-nowrap border-0 cursor-pointer transition-colors ${
|
||||
className={`px-3 py-1 rounded-lg text-xs font-medium whitespace-nowrap border-0 cursor-pointer transition-all ${
|
||||
active
|
||||
? 'bg-[#2563eb] text-white'
|
||||
: 'bg-transparent text-neutral-600 hover:bg-neutral-100'
|
||||
? 'bg-blue-50 text-blue-600 font-semibold shadow-2xs'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200/70'
|
||||
}`}
|
||||
>
|
||||
{chip.label}
|
||||
@@ -414,42 +417,42 @@ const Customers = () => {
|
||||
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
{canExport && (
|
||||
<Button
|
||||
icon={<ExportOutlined />}
|
||||
loading={exporting}
|
||||
className="!text-[13px] !h-8 !px-3 !font-medium !text-neutral-600 !border-neutral-200"
|
||||
onClick={async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
await exportCustomersCSV({ search: search || undefined })
|
||||
message.success('客户列表已导出')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '导出失败')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
导出
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ExportOutlined />}
|
||||
loading={exporting}
|
||||
className="!text-xs !h-8 !px-3 !font-medium !text-slate-700 !border-slate-200 !rounded-lg"
|
||||
onClick={async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
await exportCustomersCSV({ search: search || undefined })
|
||||
message.success('客户列表已导出')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '导出失败')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
导出
|
||||
</Button>
|
||||
)}
|
||||
{canCreate && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
className="!text-[13px] !h-8 !px-3 !font-medium !bg-[#2563eb]"
|
||||
onClick={openCreate}
|
||||
>
|
||||
添加客户
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
className="!text-xs !h-8 !px-3.5 !font-medium !bg-gradient-to-r !from-blue-600 !to-blue-500 !rounded-lg shadow-sm shadow-blue-500/20"
|
||||
onClick={openCreate}
|
||||
>
|
||||
添加客户
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 表格 + 分页 */}
|
||||
<div className="flex-1 min-h-0 overflow-auto px-6 pb-4">
|
||||
<div className="rounded-lg overflow-hidden border border-neutral-200 bg-white">
|
||||
<div className="flex-1 min-h-0 overflow-auto px-6 py-5">
|
||||
<div className="rounded-2xl overflow-hidden border border-slate-200/80 bg-white shadow-xs">
|
||||
{loading ? (
|
||||
<div className="py-24 flex justify-center"><Spin /></div>
|
||||
) : displayed.length === 0 ? (
|
||||
@@ -458,11 +461,11 @@ const Customers = () => {
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full" style={{ minWidth: 960 }}>
|
||||
<thead>
|
||||
<tr className="bg-neutral-50">
|
||||
<tr className="bg-slate-50/80 border-b border-slate-100">
|
||||
{['客户', '手机号', '邮箱', '来源渠道', '标签', '最后对话', '状态', '操作'].map((h, i) => (
|
||||
<th
|
||||
key={h}
|
||||
className={`py-3 px-3 text-[12px] font-medium uppercase tracking-wide whitespace-nowrap text-neutral-500 ${
|
||||
className={`py-3 px-4 text-[11px] font-semibold uppercase tracking-wider whitespace-nowrap text-slate-400 ${
|
||||
i === 7 ? 'text-right' : 'text-left'
|
||||
}`}
|
||||
style={{
|
||||
@@ -482,64 +485,64 @@ const Customers = () => {
|
||||
<tr
|
||||
key={record.id}
|
||||
onClick={() => openDetail(record)}
|
||||
className={`group cursor-pointer border-t border-neutral-100 transition-colors ${
|
||||
selected ? 'bg-blue-50/50' : 'hover:bg-neutral-50'
|
||||
className={`group cursor-pointer border-t border-slate-100 transition-colors ${
|
||||
selected ? 'bg-blue-50/60' : 'hover:bg-slate-50/80'
|
||||
}`}
|
||||
>
|
||||
<td className="py-3 px-3 relative">
|
||||
<td className="py-3 px-4 relative">
|
||||
<div
|
||||
className="absolute left-0 top-0 bottom-0 w-[3px]"
|
||||
style={{ background: activityBar(record) }}
|
||||
/>
|
||||
<div className="flex items-center gap-2.5 min-w-0 pl-1">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold shrink-0 shadow-2xs"
|
||||
style={{ backgroundColor: pal.bg, color: pal.color }}
|
||||
>
|
||||
{(record.name || '?').slice(0, 1)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[13px] font-medium text-neutral-800 truncate">{record.name}</div>
|
||||
<div className="text-[11px] text-neutral-400 truncate">
|
||||
<div className="text-xs font-semibold text-slate-800 truncate">{record.name}</div>
|
||||
<div className="text-[10px] text-slate-400 truncate">
|
||||
ID: C{String(record.id).padStart(5, '0')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-3 text-[13px] text-neutral-600 whitespace-nowrap">
|
||||
<td className="py-3 px-4 text-xs text-slate-600 whitespace-nowrap">
|
||||
{maskPhone(record.phone)}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-[13px] text-neutral-600 max-w-[160px] truncate">
|
||||
<td className="py-3 px-4 text-xs text-slate-600 max-w-[160px] truncate">
|
||||
{record.email || '—'}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-[13px] text-neutral-600 whitespace-nowrap">
|
||||
<td className="py-3 px-4 text-xs text-slate-600 whitespace-nowrap">
|
||||
{record.source || '—'}
|
||||
</td>
|
||||
<td className="py-3 px-3">
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{parseTags(record.tags).length === 0
|
||||
? <span className="text-neutral-300 text-xs">—</span>
|
||||
? <span className="text-slate-300 text-xs">—</span>
|
||||
: parseTags(record.tags).map(t => <TagPill key={t} tag={t} catalog={tagCatalog} />)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-3 text-[13px] text-neutral-500 whitespace-nowrap">
|
||||
<td className="py-3 px-4 text-xs text-slate-400 whitespace-nowrap">
|
||||
{relativeTime(record.last_contact_at)}
|
||||
</td>
|
||||
<td className="py-3 px-3">
|
||||
<td className="py-3 px-4">
|
||||
<StatusDot status={record.status} />
|
||||
</td>
|
||||
<td className="py-3 px-3 text-right" onClick={e => e.stopPropagation()}>
|
||||
<td className="py-3 px-4 text-right" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="px-2 py-1 rounded text-[12px] font-medium text-[#2563eb] hover:bg-blue-50 border-0 bg-transparent cursor-pointer"
|
||||
className="px-2 py-1 rounded-md text-xs font-semibold text-blue-600 hover:bg-blue-50 border-0 bg-transparent cursor-pointer transition-colors"
|
||||
onClick={() => openDetail(record)}
|
||||
>
|
||||
查看
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="px-2 py-1 rounded text-[12px] font-medium text-neutral-500 hover:bg-neutral-100 border-0 bg-transparent cursor-pointer"
|
||||
className="px-2 py-1 rounded-md text-xs font-medium text-slate-500 hover:bg-slate-100 border-0 bg-transparent cursor-pointer transition-colors"
|
||||
onClick={() => openEdit(record)}
|
||||
>
|
||||
编辑
|
||||
@@ -555,10 +558,10 @@ const Customers = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分页 — 在表格卡片外 */}
|
||||
{/* 分页 */}
|
||||
<div className="flex items-center justify-between mt-4 flex-wrap gap-3">
|
||||
<div className="text-[13px] text-neutral-500">
|
||||
共 <span className="font-medium text-neutral-700">{total}</span> 位客户
|
||||
<div className="text-xs text-slate-500 font-medium">
|
||||
共 <span className="font-bold text-slate-800">{total}</span> 位客户
|
||||
</div>
|
||||
<Pagination
|
||||
current={page}
|
||||
|
||||
+172
-183
@@ -438,7 +438,7 @@ const Dashboard = () => {
|
||||
const [pendingImage, setPendingImage] = useState<{ file: File; preview: string } | null>(null)
|
||||
const [noteInput, setNoteInput] = useState('')
|
||||
const [savingNote, setSavingNote] = useState(false)
|
||||
const [customerHistory, setCustomerHistory] = useState<Session[]>([])
|
||||
const [, setCustomerHistory] = useState<Session[]>([])
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'waiting' | 'active'>('all')
|
||||
const [priorityFilter, setPriorityFilter] = useState<'all' | 'urgent'>('all')
|
||||
const [filterOpen, setFilterOpen] = useState(false)
|
||||
@@ -1438,22 +1438,22 @@ const Dashboard = () => {
|
||||
/>
|
||||
|
||||
{/* 会话列表面板 320px — 顶栏固定 56px,与中/右对齐 */}
|
||||
<section className="w-[320px] shrink-0 flex flex-col h-full border-r border-neutral-200 bg-white">
|
||||
<section className="w-[320px] shrink-0 flex flex-col h-full border-r border-slate-200/80 bg-white">
|
||||
<div
|
||||
className="shrink-0 px-3 flex items-center gap-2 border-b border-neutral-200"
|
||||
className="shrink-0 px-3 flex items-center gap-2 border-b border-slate-100"
|
||||
style={{ height: 'var(--header-height)' }}
|
||||
>
|
||||
<div className="flex items-center flex-1 min-w-0 rounded-lg px-2.5 h-8 bg-neutral-100 border border-neutral-200">
|
||||
<SearchOutlined className="text-neutral-400 text-xs mr-1.5 shrink-0" />
|
||||
<div className="flex items-center flex-1 min-w-0 rounded-xl px-2.5 h-8.5 bg-slate-100/80 border border-slate-200/70 focus-within:bg-white focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-50 transition-all">
|
||||
<SearchOutlined className="text-slate-400 text-xs mr-2 shrink-0" />
|
||||
<input
|
||||
className="bg-transparent border-none outline-none flex-1 min-w-0 text-sm text-neutral-900 placeholder:text-neutral-400"
|
||||
placeholder="搜索访客或会话"
|
||||
className="bg-transparent border-none outline-none flex-1 min-w-0 text-xs text-slate-800 placeholder:text-slate-400"
|
||||
placeholder="搜索访客或会话..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className="inline-flex items-center h-8 px-2 rounded-md text-xs font-medium bg-[#dbeafe] text-[#2563eb] whitespace-nowrap shrink-0"
|
||||
className="inline-flex items-center h-7 px-2 rounded-lg text-xs font-semibold bg-blue-50 text-blue-600 border border-blue-100/80 whitespace-nowrap shrink-0"
|
||||
title={`当前会话 ${filteredSessions.length} 个`}
|
||||
>
|
||||
{filteredSessions.length}
|
||||
@@ -1464,9 +1464,9 @@ const Dashboard = () => {
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
content={(
|
||||
<div className="w-52 space-y-3">
|
||||
<div className="w-56 p-1 space-y-3">
|
||||
<div>
|
||||
<div className="text-xs text-neutral-400 mb-1.5">会话状态</div>
|
||||
<div className="text-xs font-medium text-slate-400 mb-1.5">会话状态</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{([
|
||||
{ key: 'all', label: '全部' },
|
||||
@@ -1477,10 +1477,10 @@ const Dashboard = () => {
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => setStatusFilter(item.key)}
|
||||
className={`px-2 py-0.5 rounded-md text-xs border ${
|
||||
className={`px-2.5 py-1 rounded-lg text-xs border transition-all cursor-pointer ${
|
||||
statusFilter === item.key
|
||||
? 'bg-[#dbeafe] border-[#2563eb] text-[#2563eb]'
|
||||
: 'bg-white border-neutral-200 text-neutral-600'
|
||||
? 'bg-blue-50 border-blue-400 text-blue-600 font-medium'
|
||||
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
@@ -1489,7 +1489,7 @@ const Dashboard = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-neutral-400 mb-1.5">优先级</div>
|
||||
<div className="text-xs font-medium text-slate-400 mb-1.5">优先级</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{([
|
||||
{ key: 'all', label: '全部' },
|
||||
@@ -1499,10 +1499,10 @@ const Dashboard = () => {
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => setPriorityFilter(item.key)}
|
||||
className={`px-2 py-0.5 rounded-md text-xs border ${
|
||||
className={`px-2.5 py-1 rounded-lg text-xs border transition-all cursor-pointer ${
|
||||
priorityFilter === item.key
|
||||
? 'bg-[#dbeafe] border-[#2563eb] text-[#2563eb]'
|
||||
: 'bg-white border-neutral-200 text-neutral-600'
|
||||
? 'bg-blue-50 border-blue-400 text-blue-600 font-medium'
|
||||
: 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
@@ -1513,7 +1513,7 @@ const Dashboard = () => {
|
||||
{filterActive && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700"
|
||||
className="text-xs text-slate-500 hover:text-blue-600 font-medium cursor-pointer border-0 bg-transparent p-0"
|
||||
onClick={() => { setStatusFilter('all'); setPriorityFilter('all') }}
|
||||
>
|
||||
清除筛选
|
||||
@@ -1524,31 +1524,31 @@ const Dashboard = () => {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={`w-8 h-8 rounded-lg border flex items-center justify-center shrink-0 relative ${
|
||||
className={`w-8 h-8 rounded-lg border flex items-center justify-center shrink-0 relative transition-all cursor-pointer ${
|
||||
filterActive
|
||||
? 'bg-[#dbeafe] border-[#2563eb] text-[#2563eb]'
|
||||
: 'bg-neutral-100 border-neutral-200 text-neutral-500'
|
||||
? 'bg-blue-50 border-blue-400 text-blue-600'
|
||||
: 'bg-white border-slate-200/80 text-slate-500 hover:bg-slate-50 hover:text-slate-800'
|
||||
}`}
|
||||
title="筛选"
|
||||
>
|
||||
<FilterOutlined className="text-xs" />
|
||||
{filterActive && <span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-[#2563eb]" />}
|
||||
{filterActive && <span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-blue-600 ring-2 ring-white" />}
|
||||
</button>
|
||||
</Popover>
|
||||
<a
|
||||
href="/widget/preview"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="w-8 h-8 rounded-lg border border-neutral-200 bg-neutral-100 text-[#2563eb] hover:bg-[#eff6ff] flex items-center justify-center shrink-0"
|
||||
className="w-8 h-8 rounded-lg border border-slate-200/80 bg-white text-slate-500 hover:text-blue-600 hover:bg-blue-50 hover:border-blue-200 flex items-center justify-center shrink-0 transition-all"
|
||||
title="预览访客窗口"
|
||||
>
|
||||
<ExportOutlined className="text-xs" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto no-scrollbar">
|
||||
<div className="flex-1 overflow-y-auto p-1.5 space-y-1">
|
||||
{filteredSessions.length === 0 ? (
|
||||
<div className="text-center text-sm text-neutral-400 py-16">暂无待处理会话</div>
|
||||
<div className="text-center text-xs text-slate-400 py-16">暂无待处理会话</div>
|
||||
) : filteredSessions.map(session => {
|
||||
const customer = customers[session.customer_id]
|
||||
const name = customer?.name || `客户${session.customer_id}`
|
||||
@@ -1559,52 +1559,60 @@ const Dashboard = () => {
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(session.id)}
|
||||
className={`w-full flex items-stretch text-left border-b border-neutral-100 transition-colors ${
|
||||
selected ? 'bg-[#eff6ff]' : 'hover:bg-neutral-50'
|
||||
className={`w-full flex items-stretch text-left rounded-xl transition-all duration-150 relative border cursor-pointer ${
|
||||
selected
|
||||
? 'bg-blue-50/80 border-blue-200/80 shadow-xs'
|
||||
: 'bg-white border-transparent hover:bg-slate-50/80 hover:border-slate-100'
|
||||
}`}
|
||||
>
|
||||
<div className="w-[3px] shrink-0" style={{ background: selected || session.priority === 'urgent' || session.status === 'waiting' ? meta.bar : 'transparent' }} />
|
||||
<div className="flex-1 min-w-0 px-3 py-3">
|
||||
{/* 活跃/紧急侧边标记 */}
|
||||
{(selected || session.priority === 'urgent' || session.status === 'waiting') && (
|
||||
<div
|
||||
className="absolute left-0 top-2 bottom-2 w-1 rounded-r-full"
|
||||
style={{ background: meta.bar }}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 px-3 py-2.5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center shrink-0 text-sm font-semibold"
|
||||
className="w-8.5 h-8.5 rounded-full flex items-center justify-center shrink-0 text-xs font-bold shadow-2xs"
|
||||
style={{ background: meta.avatarBg, color: meta.avatarColor }}
|
||||
>
|
||||
{name.slice(0, 1)}
|
||||
</div>
|
||||
<span className="truncate font-medium text-sm text-neutral-800">{name}</span>
|
||||
<span className="truncate font-semibold text-xs text-slate-800">{name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0 ml-2">
|
||||
<span className="whitespace-nowrap text-xs text-neutral-400">
|
||||
<span className="whitespace-nowrap text-[11px] text-slate-400">
|
||||
{relativeTime(session.last_message_at || session.created_at)}
|
||||
</span>
|
||||
{session.unread_count > 0 && (
|
||||
<span className="min-w-[18px] h-[18px] px-[5px] rounded-full bg-[#dc2626] text-white text-[11px] font-semibold flex items-center justify-center">
|
||||
<span className="min-w-[17px] h-[17px] px-1 rounded-full bg-rose-500 text-white text-[10px] font-bold flex items-center justify-center shadow-xs">
|
||||
{session.unread_count > 99 ? '99+' : session.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pl-0">
|
||||
<p className="truncate flex-1 min-w-0 mr-2 text-xs text-neutral-500">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="truncate flex-1 min-w-0 mr-2 text-xs text-slate-500 m-0">
|
||||
{session.last_message || '暂无消息'}
|
||||
</p>
|
||||
<span className="flex items-center gap-1 shrink-0 text-xs text-neutral-500">
|
||||
<span className="flex items-center gap-1 shrink-0 text-[11px] text-slate-400 font-medium">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full" style={{ background: meta.dot }} />
|
||||
{meta.label}
|
||||
</span>
|
||||
</div>
|
||||
{session.status === 'waiting' && user?.role === 'agent' && (
|
||||
<div className="mt-1.5 pl-11">
|
||||
<div className="mt-1.5 pt-1 border-t border-slate-100/80 flex justify-end">
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="text-xs text-[#2563eb] hover:underline"
|
||||
className="text-xs font-semibold text-blue-600 hover:text-blue-700 hover:underline"
|
||||
onClick={e => { e.stopPropagation(); handleClaim(session.id) }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.stopPropagation(); handleClaim(session.id) } }}
|
||||
>
|
||||
领取会话
|
||||
领取会话 →
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -1616,16 +1624,16 @@ const Dashboard = () => {
|
||||
</section>
|
||||
|
||||
{/* 主聊天区 */}
|
||||
<section className="flex-1 flex flex-col min-w-0 h-full bg-neutral-50">
|
||||
<section className="flex-1 flex flex-col min-w-0 h-full bg-slate-50/60">
|
||||
{selected && selectedCustomer ? (
|
||||
<>
|
||||
<div
|
||||
className="shrink-0 flex items-center justify-between px-5 bg-white border-b border-neutral-200"
|
||||
className="shrink-0 flex items-center justify-between px-5 bg-white border-b border-slate-200/80"
|
||||
style={{ height: 'var(--header-height)' }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center shrink-0 text-sm font-semibold"
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center shrink-0 text-sm font-bold shadow-2xs"
|
||||
style={{
|
||||
background: listStatusMeta(selected).avatarBg,
|
||||
color: listStatusMeta(selected).avatarColor,
|
||||
@@ -1635,25 +1643,25 @@ const Dashboard = () => {
|
||||
</div>
|
||||
<div className="min-w-0 leading-tight">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-semibold text-[15px] text-neutral-900">{selectedCustomer.name}</span>
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs bg-[#ecfeff] text-[#0891b2]">
|
||||
<span className="truncate font-bold text-[14px] text-slate-900">{selectedCustomer.name}</span>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-md text-[11px] font-medium bg-cyan-50 text-cyan-700 border border-cyan-100">
|
||||
{channelLabel(selectedCustomer.source)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-xs bg-[#f0fdf4] text-[#16a34a]">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-[#16a34a]" />
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] font-medium bg-emerald-50 text-emerald-700 border border-emerald-100">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-emerald-500" />
|
||||
{selectedCustomer.status === 'online' ? '在线' : selectedCustomer.status === 'busy' ? '忙碌' : '离线'}
|
||||
</span>
|
||||
</div>
|
||||
{/* IP | 地区 — 压缩行高,保证三栏顶栏等高 */}
|
||||
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-neutral-400 min-w-0">
|
||||
{/* IP | 地区 | 在线时长 */}
|
||||
<div className="flex items-center gap-2 mt-1 text-[11px] text-slate-400 min-w-0">
|
||||
<span className="truncate">
|
||||
IP: {selected.visitor_ip || '—'}
|
||||
</span>
|
||||
<span className="shrink-0 text-neutral-300">|</span>
|
||||
<span className="text-slate-300">·</span>
|
||||
<span className="truncate">
|
||||
{selected.visitor_region || '未知'}
|
||||
{selected.visitor_region || '未知地区'}
|
||||
</span>
|
||||
<span className="shrink-0 text-neutral-300">|</span>
|
||||
<span className="text-slate-300">·</span>
|
||||
<span className="tabular-nums shrink-0" title="访客在线时长(自进线起)">
|
||||
在线 {formatOnlineDuration(
|
||||
selected.created_at,
|
||||
@@ -1664,7 +1672,7 @@ const Dashboard = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0 ml-4">
|
||||
<div className="flex items-center gap-1 shrink-0 ml-4">
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
@@ -1674,41 +1682,41 @@ const Dashboard = () => {
|
||||
}}
|
||||
disabled={!canOperate}
|
||||
>
|
||||
<button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-neutral-500 hover:bg-neutral-100 disabled:opacity-40" title="优先级" disabled={!canOperate}>
|
||||
<button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-500 hover:bg-slate-100 hover:text-slate-800 disabled:opacity-40 transition-colors border-0 bg-transparent cursor-pointer" title="优先级" disabled={!canOperate}>
|
||||
<FlagOutlined />
|
||||
</button>
|
||||
</Dropdown>
|
||||
<button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-neutral-500 hover:bg-neutral-100 disabled:opacity-40" title="快捷回复" disabled={!canOperate} onClick={() => setQuickOpen(true)}>
|
||||
<button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-500 hover:bg-slate-100 hover:text-slate-800 disabled:opacity-40 transition-colors border-0 bg-transparent cursor-pointer" title="快捷回复" disabled={!canOperate} onClick={() => setQuickOpen(true)}>
|
||||
<ThunderboltOutlined />
|
||||
</button>
|
||||
<button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-neutral-500 hover:bg-neutral-100 disabled:opacity-40" title="知识库" disabled={!canOperate} onClick={() => setKnowledgeOpen(true)}>
|
||||
<button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-500 hover:bg-slate-100 hover:text-slate-800 disabled:opacity-40 transition-colors border-0 bg-transparent cursor-pointer" title="知识库" disabled={!canOperate} onClick={() => setKnowledgeOpen(true)}>
|
||||
<BookOutlined />
|
||||
</button>
|
||||
<button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-neutral-500 hover:bg-neutral-100 disabled:opacity-40" title="转接" disabled={!canOperate} onClick={openTransfer}>
|
||||
<button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-500 hover:bg-slate-100 hover:text-slate-800 disabled:opacity-40 transition-colors border-0 bg-transparent cursor-pointer" title="转接" disabled={!canOperate} onClick={openTransfer}>
|
||||
<SwapOutlined />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-neutral-500 hover:bg-red-50 hover:text-red-600 disabled:opacity-40"
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-500 hover:bg-rose-50 hover:text-rose-600 disabled:opacity-40 transition-colors border-0 bg-transparent cursor-pointer"
|
||||
title="拉黑"
|
||||
disabled={!selected}
|
||||
onClick={openBlacklist}
|
||||
>
|
||||
<StopOutlined />
|
||||
</button>
|
||||
<button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-red-500 hover:bg-red-50 disabled:opacity-40" title="结束会话" disabled={!canOperate} onClick={() => setEndingOpen(true)}>
|
||||
<button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-rose-500 hover:bg-rose-50 disabled:opacity-40 transition-colors border-0 bg-transparent cursor-pointer" title="结束会话" disabled={!canOperate} onClick={() => setEndingOpen(true)}>
|
||||
<CheckCircleOutlined />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4 min-h-0">
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 min-h-0">
|
||||
{detailLoading ? (
|
||||
<div className="h-full flex items-center justify-center"><Spin /></div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-center mb-4">
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-lg text-xs text-neutral-400 bg-white border border-neutral-100">
|
||||
<div className="flex justify-center mb-5">
|
||||
<span className="inline-flex items-center px-3 py-1 rounded-full text-[11px] text-slate-400 bg-white border border-slate-200/80 shadow-2xs">
|
||||
会话开始 — {new Date(selected.created_at).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1717,10 +1725,10 @@ const Dashboard = () => {
|
||||
const { event } = item
|
||||
return (
|
||||
<div key={`ev-${event.id}`} className="flex justify-center mb-4">
|
||||
<span className="inline-flex flex-col items-center max-w-[90%] px-3 py-1.5 rounded-lg text-xs text-violet-700 bg-violet-50 border border-violet-100">
|
||||
<span className="font-medium text-violet-800">{eventLabel(event.action)}</span>
|
||||
<span className="inline-flex flex-col items-center max-w-[90%] px-3.5 py-1.5 rounded-xl text-xs text-indigo-700 bg-indigo-50/80 border border-indigo-100/80">
|
||||
<span className="font-semibold text-indigo-800">{eventLabel(event.action)}</span>
|
||||
<span className="text-center leading-relaxed mt-0.5">{event.detail || eventLabel(event.action)}</span>
|
||||
<span className="text-[11px] text-violet-500/80 mt-0.5">
|
||||
<span className="text-[10px] text-indigo-400 mt-0.5">
|
||||
{new Date(event.created_at).toLocaleString('zh-CN', {
|
||||
month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
})}
|
||||
@@ -1738,33 +1746,33 @@ const Dashboard = () => {
|
||||
className={`flex items-start gap-2.5 mb-4 ${isAgent ? 'flex-row-reverse' : ''}`}
|
||||
>
|
||||
<div
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center shrink-0 text-sm font-semibold"
|
||||
className="w-8.5 h-8.5 rounded-full flex items-center justify-center shrink-0 text-xs font-bold shadow-2xs"
|
||||
style={isAgent
|
||||
? { background: '#2563eb', color: '#fff' }
|
||||
? { background: 'linear-gradient(135deg, #2563eb, #3b82f6)', color: '#fff' }
|
||||
: { background: listStatusMeta(selected).avatarBg, color: listStatusMeta(selected).avatarColor }}
|
||||
>
|
||||
{isAgent ? agentInitial : visitorInitial}
|
||||
</div>
|
||||
<div className={`min-w-0 max-w-[65%] ${isAgent ? 'items-end' : ''}`}>
|
||||
<div className={`min-w-0 max-w-[68%] ${isAgent ? 'items-end' : ''}`}>
|
||||
<div
|
||||
className={`px-3.5 py-2.5 shadow-sm ${
|
||||
className={`px-4 py-2.5 ${
|
||||
isAgent
|
||||
? 'rounded-xl rounded-tr-sm bg-[#2563eb] text-white'
|
||||
: 'rounded-xl rounded-tl-sm bg-white text-neutral-800 border border-neutral-100'
|
||||
? 'rounded-2xl rounded-tr-xs bg-gradient-to-r from-blue-600 to-blue-500 text-white shadow-xs shadow-blue-500/15'
|
||||
: 'rounded-2xl rounded-tl-xs bg-white text-slate-800 border border-slate-200/80 shadow-xs'
|
||||
}`}
|
||||
>
|
||||
{message.type === 'image' ? (
|
||||
<ChatImage src={message.content} alt="聊天图片" className="max-w-64 max-h-64" />
|
||||
<ChatImage src={message.content} alt="聊天图片" className="max-w-64 max-h-64 rounded-lg overflow-hidden" />
|
||||
) : (
|
||||
<MarkdownBody
|
||||
tone={isAgent ? 'inverse' : 'default'}
|
||||
className="text-sm"
|
||||
className="text-[13.5px] leading-relaxed"
|
||||
>
|
||||
{message.content}
|
||||
</MarkdownBody>
|
||||
)}
|
||||
</div>
|
||||
<div className={`mt-1 text-xs text-neutral-400 ${isAgent ? 'text-right' : ''}`}>
|
||||
<div className={`mt-1 text-[11px] text-slate-400 ${isAgent ? 'text-right' : ''}`}>
|
||||
{new Date(message.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1772,12 +1780,12 @@ const Dashboard = () => {
|
||||
)
|
||||
})}
|
||||
{chatTimeline.length === 0 && (
|
||||
<div className="text-center text-sm text-neutral-400 py-10">暂无消息,开始对话吧</div>
|
||||
<div className="text-center text-xs text-slate-400 py-10">暂无消息,开始对话吧</div>
|
||||
)}
|
||||
{visitorTyping && selectedCustomer && (
|
||||
<div className="flex items-start gap-2.5 mb-2">
|
||||
<div
|
||||
className="w-9 h-9 rounded-full flex items-center justify-center shrink-0 text-sm font-semibold"
|
||||
className="w-8.5 h-8.5 rounded-full flex items-center justify-center shrink-0 text-xs font-bold shadow-2xs"
|
||||
style={{
|
||||
background: listStatusMeta(selected).avatarBg,
|
||||
color: listStatusMeta(selected).avatarColor,
|
||||
@@ -1786,12 +1794,12 @@ const Dashboard = () => {
|
||||
{selectedCustomer.name.slice(0, 1)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="px-3.5 py-2.5 rounded-xl rounded-tl-sm bg-white border border-neutral-100 inline-flex items-center gap-1">
|
||||
<div className="px-3.5 py-2.5 rounded-2xl rounded-tl-xs bg-white border border-slate-200/80 shadow-xs inline-flex items-center gap-1.5">
|
||||
<span className="typing-dot" style={{ animationDelay: '0s' }} />
|
||||
<span className="typing-dot" style={{ animationDelay: '0.2s' }} />
|
||||
<span className="typing-dot" style={{ animationDelay: '0.4s' }} />
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-neutral-400">访客正在输入…</div>
|
||||
<div className="mt-1 text-[11px] text-slate-400">访客正在输入…</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1800,12 +1808,12 @@ const Dashboard = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 bg-white border-t border-neutral-200">
|
||||
{/* 访客实时输入草稿:展示在工具栏/输入框上方 */}
|
||||
<div className="shrink-0 bg-white border-t border-slate-200/80">
|
||||
{/* 访客实时输入草稿 */}
|
||||
{(visitorDraft.trim() || visitorTyping) && (
|
||||
<div className="mx-4 mt-2 mb-0 rounded-md border border-amber-200/80 bg-amber-50 px-3 py-2">
|
||||
<div className="flex items-start gap-1.5 min-w-0">
|
||||
<span className="shrink-0 text-[12px] font-medium text-amber-700 leading-5">
|
||||
<div className="mx-4 mt-2.5 mb-0 rounded-xl border border-amber-200/90 bg-amber-50/90 px-3.5 py-2 shadow-2xs">
|
||||
<div className="flex items-start gap-2 min-w-0">
|
||||
<span className="shrink-0 text-xs font-semibold text-amber-800 leading-5">
|
||||
对方正在输入
|
||||
{visitorTyping && (
|
||||
<span className="inline-flex items-center gap-0.5 ml-1 align-middle">
|
||||
@@ -1817,48 +1825,52 @@ const Dashboard = () => {
|
||||
{visitorDraft.trim() ? ':' : ''}
|
||||
</span>
|
||||
{visitorDraft.trim() ? (
|
||||
<span className="min-w-0 flex-1 text-[13px] text-neutral-800 whitespace-pre-wrap break-words max-h-20 overflow-y-auto leading-5">
|
||||
<span className="min-w-0 flex-1 text-xs text-slate-800 whitespace-pre-wrap break-words max-h-20 overflow-y-auto leading-5">
|
||||
{visitorDraft.trim()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[12px] text-amber-600/70 leading-5">…</span>
|
||||
<span className="text-xs text-amber-600/80 leading-5">…</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selected.status !== 'active' || !canOperate ? (
|
||||
<div className="text-center text-sm text-neutral-400 py-4">
|
||||
<div className="text-center text-xs text-slate-400 py-4 font-medium">
|
||||
{selected.status === 'waiting' ? '领取会话后即可回复' : '会话已结束,无法继续发送消息'}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-1 px-4 pt-2.5 pb-1">
|
||||
<div className="flex items-center gap-1.5 px-4 pt-2.5 pb-1">
|
||||
<EmojiPicker onSelect={insertEmoji} disabled={!canOperate} />
|
||||
<button type="button" className="w-8 h-8 rounded-md flex items-center justify-center text-neutral-500 hover:bg-neutral-100" title="图片" onClick={() => fileInputRef.current?.click()}>
|
||||
<button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-500 hover:text-slate-800 hover:bg-slate-100 transition-colors border-0 bg-transparent cursor-pointer" title="图片" onClick={() => fileInputRef.current?.click()}>
|
||||
<PictureOutlined />
|
||||
</button>
|
||||
<button type="button" className="w-8 h-8 rounded-md flex items-center justify-center text-neutral-500 hover:bg-neutral-100" title="附件" onClick={() => fileInputRef.current?.click()}>
|
||||
<button type="button" className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-500 hover:text-slate-800 hover:bg-slate-100 transition-colors border-0 bg-transparent cursor-pointer" title="附件" onClick={() => fileInputRef.current?.click()}>
|
||||
<PaperClipOutlined />
|
||||
</button>
|
||||
<div className="w-px h-5 bg-neutral-200 mx-1" />
|
||||
<div className="w-px h-4 bg-slate-200 mx-1" />
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 px-2 rounded-md text-xs text-neutral-500 hover:bg-neutral-100 flex items-center gap-1"
|
||||
className="h-7.5 px-2 rounded-lg text-xs font-medium text-slate-600 hover:text-blue-600 hover:bg-blue-50 flex items-center gap-1 transition-all border-0 bg-transparent cursor-pointer"
|
||||
onClick={() => setQuickOpen(true)}
|
||||
>
|
||||
<ThunderboltOutlined />
|
||||
快捷回复
|
||||
</button>
|
||||
<button type="button" className="h-8 px-2 rounded-md text-xs text-neutral-500 hover:bg-neutral-100 flex items-center gap-1" onClick={() => setKnowledgeOpen(true)}>
|
||||
<button
|
||||
type="button"
|
||||
className="h-7.5 px-2 rounded-lg text-xs font-medium text-slate-600 hover:text-blue-600 hover:bg-blue-50 flex items-center gap-1 transition-all border-0 bg-transparent cursor-pointer"
|
||||
onClick={() => setKnowledgeOpen(true)}
|
||||
>
|
||||
<FileTextOutlined />
|
||||
知识库
|
||||
</button>
|
||||
<span className="text-[11px] text-neutral-400 ml-1">输入 / 调用话术</span>
|
||||
<span className="text-[11px] text-slate-400 ml-auto font-normal">按 Enter 发送,Shift+Enter 换行</span>
|
||||
</div>
|
||||
<div className="relative flex items-end gap-2.5 px-4 pb-3 pt-1">
|
||||
{suggestOpen && (suggestMode === 'slash' || keywordQuery.trim().length >= 2) && (
|
||||
<div className="absolute bottom-full left-4 right-16 mb-1 z-20 max-h-56 overflow-auto rounded-lg border border-neutral-200 bg-white shadow-lg">
|
||||
<div className="px-3 py-1.5 text-[11px] text-neutral-400 border-b border-neutral-100 flex items-center justify-between">
|
||||
<div className="absolute bottom-full left-4 right-16 mb-1.5 z-20 max-h-56 overflow-auto rounded-xl border border-slate-200 bg-white shadow-xl">
|
||||
<div className="px-3 py-1.5 text-[11px] font-medium text-slate-400 border-b border-slate-100 flex items-center justify-between bg-slate-50/60">
|
||||
<span>
|
||||
{suggestMode === 'slash'
|
||||
? (slashPrefix ? `输入码 /${slashPrefix}` : '常用话术(按你的调用频率)')
|
||||
@@ -1867,14 +1879,14 @@ const Dashboard = () => {
|
||||
<span>↑↓ 选择 · Enter 填入 · Esc 关闭</span>
|
||||
</div>
|
||||
{suggestItems.length === 0 ? (
|
||||
<div className="px-3 py-2 text-xs text-neutral-400">无匹配话术</div>
|
||||
<div className="px-3 py-2 text-xs text-slate-400">无匹配话术</div>
|
||||
) : (
|
||||
suggestItems.map((item, idx) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`w-full text-left px-3 py-2 border-0 cursor-pointer ${
|
||||
idx === suggestIndex ? 'bg-blue-50' : 'bg-white hover:bg-neutral-50'
|
||||
className={`w-full text-left px-3 py-2 border-0 cursor-pointer transition-colors ${
|
||||
idx === suggestIndex ? 'bg-blue-50/80 text-blue-600' : 'bg-white hover:bg-slate-50 text-slate-800'
|
||||
}`}
|
||||
onMouseDown={e => {
|
||||
e.preventDefault()
|
||||
@@ -1882,18 +1894,18 @@ const Dashboard = () => {
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm font-medium text-neutral-800 truncate">{item.title}</span>
|
||||
<span className="text-xs font-semibold truncate">{item.title}</span>
|
||||
{item.shortcut && (
|
||||
<code className="text-[11px] text-blue-600 bg-blue-50 px-1 rounded shrink-0">/{item.shortcut}</code>
|
||||
<code className="text-[10px] text-blue-600 bg-blue-100/80 px-1 py-0.5 rounded shrink-0">/{item.shortcut}</code>
|
||||
)}
|
||||
<span className="text-[10px] text-neutral-400 shrink-0">
|
||||
<span className="text-[10px] text-slate-400 shrink-0">
|
||||
{item.scope === 'team' ? '团队' : '个人'}
|
||||
</span>
|
||||
{(item.my_usage_count || 0) > 0 && (
|
||||
<span className="text-[10px] text-neutral-400 shrink-0 ml-auto">用过 {item.my_usage_count} 次</span>
|
||||
<span className="text-[10px] text-slate-400 shrink-0 ml-auto">用过 {item.my_usage_count} 次</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 line-clamp-1 mt-0.5">{item.content}</div>
|
||||
<div className="text-xs text-slate-500 line-clamp-1 mt-0.5">{item.content}</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
@@ -1902,8 +1914,8 @@ const Dashboard = () => {
|
||||
<textarea
|
||||
ref={messageInputRef}
|
||||
rows={3}
|
||||
className="flex-1 min-w-0 min-h-[88px] max-h-[160px] rounded-xl px-3.5 py-3 bg-neutral-50 border border-neutral-200 text-sm leading-6 text-neutral-800 placeholder:text-neutral-400 outline-none resize-y focus:border-[#2563eb] transition-colors"
|
||||
placeholder="输入文字联想话术,/ 调输入码… Enter 发送"
|
||||
className="flex-1 min-w-0 min-h-[84px] max-h-[160px] rounded-xl px-3.5 py-2.5 bg-slate-50/80 border border-slate-200 text-xs leading-relaxed text-slate-800 placeholder:text-slate-400 outline-none resize-y focus:bg-white focus:border-blue-500 focus:ring-2 focus:ring-blue-50 transition-all"
|
||||
placeholder="输入文字联想话术,/ 快捷码… Enter 发送"
|
||||
value={messageInput}
|
||||
onChange={event => {
|
||||
const v = event.target.value
|
||||
@@ -1956,9 +1968,9 @@ const Dashboard = () => {
|
||||
type="button"
|
||||
disabled={!messageInput.trim() || sending}
|
||||
onClick={() => sendMessage(messageInput)}
|
||||
className="w-11 h-11 rounded-xl bg-[#2563eb] hover:bg-[#1d4ed8] disabled:opacity-40 text-white flex items-center justify-center shrink-0 shadow-sm"
|
||||
className="w-10 h-10 rounded-xl bg-gradient-to-r from-blue-600 to-blue-500 hover:from-blue-500 hover:to-blue-600 disabled:opacity-40 text-white flex items-center justify-center shrink-0 shadow-sm shadow-blue-500/25 transition-all border-0 cursor-pointer"
|
||||
>
|
||||
{sending ? <Spin size="small" /> : <SendOutlined className="text-base" />}
|
||||
{sending ? <Spin size="small" /> : <SendOutlined className="text-sm" />}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
@@ -1966,30 +1978,31 @@ const Dashboard = () => {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-neutral-400 gap-2">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white border border-neutral-200 flex items-center justify-center text-2xl text-neutral-300">
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-slate-400 gap-2">
|
||||
<div className="w-16 h-16 rounded-2xl bg-white border border-slate-200/80 shadow-xs flex items-center justify-center text-2xl text-slate-300">
|
||||
<SearchOutlined />
|
||||
</div>
|
||||
<div className="text-sm">选择一个会话开始处理</div>
|
||||
<div className="text-xs font-medium text-slate-400">选择左侧会话开始处理</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 右侧:上客户信息 + 下快捷回复 */}
|
||||
<aside className="w-[320px] shrink-0 flex flex-col h-full border-l border-neutral-200 bg-white min-h-0">
|
||||
<aside className="w-[320px] shrink-0 flex flex-col h-full border-l border-slate-200/80 bg-white min-h-0">
|
||||
{selectedCustomer ? (
|
||||
<>
|
||||
{/* —— 上:客户信息(精简) —— */}
|
||||
<div
|
||||
className="shrink-0 flex items-center justify-between px-4 border-b border-neutral-200"
|
||||
className="shrink-0 flex items-center justify-between px-4 border-b border-slate-100"
|
||||
style={{ height: 'var(--header-height)' }}
|
||||
>
|
||||
<span className="font-semibold text-base text-neutral-800">客户信息</span>
|
||||
<span className="font-bold text-sm text-slate-800">客户信息</span>
|
||||
<span className="text-[11px] text-slate-400 font-normal">ID: {selectedCustomer.id}</span>
|
||||
</div>
|
||||
<div className="shrink-0 max-h-[42%] overflow-y-auto px-4 py-3 no-scrollbar border-b border-neutral-100">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="shrink-0 max-h-[44%] overflow-y-auto px-4 py-3 border-b border-slate-100 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center text-[16px] font-semibold shrink-0"
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center text-sm font-bold shrink-0 shadow-2xs"
|
||||
style={{
|
||||
background: selected ? listStatusMeta(selected).avatarBg : '#eff6ff',
|
||||
color: selected ? listStatusMeta(selected).avatarColor : '#2563eb',
|
||||
@@ -1998,21 +2011,21 @@ const Dashboard = () => {
|
||||
{selectedCustomer.name.slice(0, 1)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-[15px] text-neutral-900 truncate">{selectedCustomer.name}</div>
|
||||
<div className="flex items-center gap-1 mt-0.5 text-[11px] text-neutral-400">
|
||||
<div className="font-bold text-[14px] text-slate-900 truncate">{selectedCustomer.name}</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5 text-[11px] text-slate-400">
|
||||
<span
|
||||
className="inline-block w-1.5 h-1.5 rounded-full"
|
||||
style={{ background: selectedCustomer.status === 'online' ? '#16a34a' : '#94a3b8' }}
|
||||
/>
|
||||
{selectedCustomer.status === 'online' ? '在线' : selectedCustomer.status === 'busy' ? '忙碌' : '离线'}
|
||||
{selectedCustomer.source ? (
|
||||
<span className="text-neutral-300">· {selectedCustomer.source}</span>
|
||||
<span className="text-slate-300">· {selectedCustomer.source}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1 text-sm">
|
||||
<div className="flex flex-col gap-1 text-xs bg-slate-50/60 p-2.5 rounded-xl border border-slate-100">
|
||||
<CustomerInfoField
|
||||
label="手机"
|
||||
value={selectedCustomer.phone || ''}
|
||||
@@ -2053,12 +2066,12 @@ const Dashboard = () => {
|
||||
})
|
||||
if (extra.length === 0) return null
|
||||
return (
|
||||
<div className="flex items-start gap-2 pt-0.5">
|
||||
<span className="shrink-0 text-neutral-400 min-w-12 leading-5 text-sm">更多</span>
|
||||
<div className="flex items-start gap-2 pt-1 border-t border-slate-100">
|
||||
<span className="shrink-0 text-slate-400 min-w-12 leading-5 text-xs">更多</span>
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
{extra.map(c => (
|
||||
<span key={c.id || `${c.kind}-${c.value}`} className="text-neutral-800 text-xs break-all">
|
||||
<span className="text-neutral-400 mr-1">{kindLabel(c.kind)}</span>
|
||||
<span key={c.id || `${c.kind}-${c.value}`} className="text-slate-700 text-xs break-all">
|
||||
<span className="text-slate-400 mr-1">{kindLabel(c.kind)}</span>
|
||||
{c.value}
|
||||
</span>
|
||||
))}
|
||||
@@ -2068,7 +2081,7 @@ const Dashboard = () => {
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div>
|
||||
<CustomerTagsEditor
|
||||
tags={selectedCustomer.tags}
|
||||
catalog={tagCatalog}
|
||||
@@ -2077,32 +2090,32 @@ const Dashboard = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 mb-3">
|
||||
<div className="mb-1.5 text-[11px] font-semibold text-neutral-500">浏览轨迹</div>
|
||||
<div>
|
||||
<div className="mb-1.5 text-[11px] font-bold text-slate-500">浏览轨迹</div>
|
||||
{!detail?.pageViews?.length ? (
|
||||
<div className="text-xs text-neutral-400">暂无页面记录(需嵌入 widget 并打开会话)</div>
|
||||
<div className="text-xs text-slate-400">暂无页面记录</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0 max-h-36 overflow-y-auto rounded-lg border border-neutral-100">
|
||||
<div className="flex flex-col gap-0 max-h-32 overflow-y-auto rounded-xl border border-slate-100">
|
||||
{detail.pageViews.map((pv, idx) => (
|
||||
<div
|
||||
key={pv.id || `${pv.url}-${pv.entered_at}`}
|
||||
className={`px-2.5 py-2 text-xs ${idx > 0 ? 'border-t border-neutral-50' : ''} ${
|
||||
className={`px-2.5 py-1.5 text-xs ${idx > 0 ? 'border-t border-slate-100' : ''} ${
|
||||
idx === 0 ? 'bg-blue-50/50' : 'bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-0.5">
|
||||
<span className="text-neutral-400 tabular-nums shrink-0">
|
||||
<span className="text-slate-400 tabular-nums shrink-0 text-[10px]">
|
||||
{new Date(pv.entered_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
|
||||
</span>
|
||||
{idx === 0 && (
|
||||
<span className="text-[10px] text-[#2563eb] font-medium">当前</span>
|
||||
<span className="text-[10px] text-blue-600 font-semibold">当前</span>
|
||||
)}
|
||||
</div>
|
||||
<a
|
||||
href={pv.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-neutral-800 hover:text-[#2563eb] break-all leading-snug"
|
||||
className="text-slate-700 hover:text-blue-600 break-all leading-snug text-xs no-underline"
|
||||
title={pv.url}
|
||||
>
|
||||
{pv.title || shortPagePath(pv.url)}
|
||||
@@ -2113,37 +2126,11 @@ const Dashboard = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="mb-1.5 text-[11px] font-semibold text-neutral-500">历史会话</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{customerHistory.length === 0 ? (
|
||||
<div className="text-xs text-neutral-400">暂无其他会话</div>
|
||||
) : customerHistory.map(s => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedId(s.id)}
|
||||
className="rounded-lg px-2.5 py-2 text-left bg-neutral-50 border border-neutral-100 hover:border-blue-200"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<span className="truncate text-sm font-medium text-neutral-700">会话 #{s.id}</span>
|
||||
<span className="whitespace-nowrap text-xs text-neutral-400">
|
||||
{new Date(s.created_at).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<p className="line-clamp-1 text-xs text-neutral-400 m-0">
|
||||
{s.last_message || (s.status === 'ended' ? '已结束' : '进行中')}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1">
|
||||
<div className="mb-1.5 text-[11px] font-semibold text-neutral-500">内部备注</div>
|
||||
<div className="space-y-1.5 max-h-32 overflow-auto">
|
||||
<div>
|
||||
<div className="mb-1.5 text-[11px] font-bold text-slate-500">内部备注</div>
|
||||
<div className="space-y-1.5 max-h-28 overflow-auto">
|
||||
{notes.length === 0 ? (
|
||||
<div className="text-xs text-neutral-400">暂无内部备注</div>
|
||||
<div className="text-xs text-slate-400">暂无内部备注</div>
|
||||
) : notes.map(note => {
|
||||
const isLeave = note.action === 'offline_leave'
|
||||
const isAuto = note.action === 'auto_assign'
|
||||
@@ -2151,22 +2138,22 @@ const Dashboard = () => {
|
||||
const isAssign = note.action === 'assign'
|
||||
const isEnd = note.action === 'end'
|
||||
const box = isLeave
|
||||
? 'bg-orange-50 text-orange-900 border-orange-100'
|
||||
? 'bg-amber-50 text-amber-900 border-amber-200/80'
|
||||
: isTransfer
|
||||
? 'bg-violet-50 text-violet-900 border-violet-100'
|
||||
? 'bg-violet-50 text-violet-900 border-violet-200/80'
|
||||
: (isAuto || isAssign)
|
||||
? 'bg-blue-50 text-blue-900 border-blue-100'
|
||||
? 'bg-blue-50 text-blue-900 border-blue-200/80'
|
||||
: isEnd
|
||||
? 'bg-neutral-100 text-neutral-700 border-neutral-200'
|
||||
: 'bg-amber-50 text-amber-900 border-amber-100'
|
||||
? 'bg-slate-100 text-slate-700 border-slate-200'
|
||||
: 'bg-amber-50 text-amber-900 border-amber-200/80'
|
||||
const timeCls = isLeave
|
||||
? 'text-orange-600/70'
|
||||
? 'text-amber-600/70'
|
||||
: isTransfer
|
||||
? 'text-violet-600/70'
|
||||
: (isAuto || isAssign)
|
||||
? 'text-blue-600/70'
|
||||
: isEnd
|
||||
? 'text-neutral-500'
|
||||
? 'text-slate-400'
|
||||
: 'text-amber-600/70'
|
||||
const title = isLeave
|
||||
? '离线留言'
|
||||
@@ -2180,25 +2167,26 @@ const Dashboard = () => {
|
||||
? '结束会话'
|
||||
: null
|
||||
return (
|
||||
<div key={note.id} className={`rounded-lg p-2 text-xs whitespace-pre-wrap border ${box}`}>
|
||||
{title && <div className="font-medium mb-0.5">{title}</div>}
|
||||
<div key={note.id} className={`rounded-xl p-2 text-xs whitespace-pre-wrap border ${box}`}>
|
||||
{title && <div className="font-semibold mb-0.5">{title}</div>}
|
||||
{note.detail}
|
||||
<div className={`mt-1 ${timeCls}`}>{new Date(note.created_at).toLocaleString('zh-CN')}</div>
|
||||
<div className={`mt-1 text-[10px] ${timeCls}`}>{new Date(note.created_at).toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{canOperate && (
|
||||
<div className="mt-2 flex gap-1">
|
||||
<div className="mt-2 flex gap-1.5">
|
||||
<Input
|
||||
size="small"
|
||||
maxLength={500}
|
||||
placeholder="添加仅客服可见的备注"
|
||||
placeholder="添加客服可见备注"
|
||||
value={noteInput}
|
||||
onChange={e => setNoteInput(e.target.value)}
|
||||
onPressEnter={handleAddNote}
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
<Button size="small" loading={savingNote} onClick={handleAddNote}>保存</Button>
|
||||
<Button size="small" type="primary" loading={savingNote} onClick={handleAddNote} className="!rounded-lg !px-3">保存</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2206,10 +2194,10 @@ const Dashboard = () => {
|
||||
|
||||
{/* —— 下:快捷回复(团队 / 个人) —— */}
|
||||
<div className="flex-1 min-h-0 flex flex-col">
|
||||
<div className="shrink-0 px-3 pt-2.5 pb-2 border-b border-neutral-100">
|
||||
<div className="shrink-0 px-3 pt-2.5 pb-2 border-b border-slate-100">
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<span className="text-sm font-semibold text-neutral-800">快捷回复</span>
|
||||
<div className="flex items-center gap-0.5 p-0.5 rounded-md bg-neutral-100">
|
||||
<span className="text-xs font-bold text-slate-800">快捷回复</span>
|
||||
<div className="flex items-center gap-0.5 p-0.5 rounded-lg bg-slate-100">
|
||||
{([
|
||||
{ key: 'team' as const, label: '团队' },
|
||||
{ key: 'personal' as const, label: '个人' },
|
||||
@@ -2218,10 +2206,10 @@ const Dashboard = () => {
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setSidebarQuickScope(tab.key)}
|
||||
className={`h-6 px-2 rounded text-xs border-0 cursor-pointer transition-colors ${
|
||||
className={`h-5.5 px-2 rounded-md text-[11px] border-0 cursor-pointer transition-all ${
|
||||
sidebarQuickScope === tab.key
|
||||
? 'bg-white text-[#2563eb] font-medium shadow-sm'
|
||||
: 'bg-transparent text-neutral-500 hover:text-neutral-700'
|
||||
? 'bg-white text-blue-600 font-semibold shadow-2xs'
|
||||
: 'bg-transparent text-slate-500 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
@@ -2232,13 +2220,14 @@ const Dashboard = () => {
|
||||
<Input
|
||||
size="small"
|
||||
allowClear
|
||||
prefix={<SearchOutlined className="text-neutral-400" />}
|
||||
prefix={<SearchOutlined className="text-slate-400" />}
|
||||
placeholder="搜索标题、内容或输入码"
|
||||
value={sidebarQuickKeyword}
|
||||
onChange={e => setSidebarQuickKeyword(e.target.value)}
|
||||
className="!rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-2 no-scrollbar">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto px-2 py-2">
|
||||
{sidebarQuickLoading ? (
|
||||
<div className="py-8 text-center"><Spin size="small" /></div>
|
||||
) : sidebarQuickList.length === 0 ? (
|
||||
|
||||
@@ -396,40 +396,40 @@ const Knowledge = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex min-h-0 overflow-hidden bg-neutral-50">
|
||||
{/* 左侧分类 280px — 顶栏固定 56px 与右侧对齐 */}
|
||||
<aside className="w-[280px] shrink-0 flex flex-col bg-white border-r border-neutral-200">
|
||||
<div className="h-full flex min-h-0 overflow-hidden bg-slate-50/60">
|
||||
{/* 左侧分类 260px — 顶栏固定 56px */}
|
||||
<aside className="w-[260px] shrink-0 flex flex-col bg-white border-r border-slate-200/80">
|
||||
<div
|
||||
className="shrink-0 px-3 flex items-center gap-2 border-b border-neutral-200 bg-white"
|
||||
className="shrink-0 px-3.5 flex items-center gap-2 border-b border-slate-100 bg-white"
|
||||
style={{ height: 'var(--header-height)' }}
|
||||
>
|
||||
<span className="text-sm font-semibold text-neutral-900 whitespace-nowrap shrink-0">知识分类</span>
|
||||
<div className="flex items-center flex-1 min-w-0 h-8 px-2.5 rounded-md bg-neutral-50 border border-neutral-200">
|
||||
<SearchOutlined className="text-neutral-400 text-xs mr-1.5 shrink-0" />
|
||||
<span className="text-xs font-bold text-slate-800 whitespace-nowrap shrink-0">知识分类</span>
|
||||
<div className="flex items-center flex-1 min-w-0 h-8 px-2.5 rounded-xl bg-slate-100/80 border border-slate-200/70 focus-within:bg-white focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-50 transition-all">
|
||||
<SearchOutlined className="text-slate-400 text-xs mr-1.5 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索分类..."
|
||||
value={catSearch}
|
||||
onChange={e => setCatSearch(e.target.value)}
|
||||
className="flex-1 min-w-0 bg-transparent border-0 outline-none text-xs text-neutral-700 placeholder:text-neutral-400"
|
||||
className="flex-1 min-w-0 bg-transparent border-0 outline-none text-xs text-slate-800 placeholder:text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto py-2">
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSelectedCategory(''); setPage(1) }}
|
||||
className={`w-full flex items-center gap-2 px-4 py-2 text-sm border-0 cursor-pointer text-left ${
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-xl text-xs border-0 cursor-pointer text-left transition-all ${
|
||||
!selectedCategory
|
||||
? 'bg-[#eff6ff] text-[#2563eb] font-medium'
|
||||
: 'bg-transparent text-neutral-700 hover:bg-neutral-50'
|
||||
? 'bg-blue-50/80 text-blue-600 font-semibold shadow-2xs'
|
||||
: 'bg-transparent text-slate-700 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<AppstoreOutlined className="shrink-0" />
|
||||
<AppstoreOutlined className="shrink-0 text-sm" />
|
||||
<span className="flex-1 truncate">全部知识库</span>
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded-full ${
|
||||
!selectedCategory ? 'bg-[#dbeafe] text-[#2563eb]' : 'text-neutral-400'
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-md font-bold ${
|
||||
!selectedCategory ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-400'
|
||||
}`}>
|
||||
{totalEntries}
|
||||
</span>
|
||||
@@ -447,28 +447,28 @@ const Knowledge = () => {
|
||||
tabIndex={0}
|
||||
onClick={() => { setSelectedCategory(String(root.id)); setPage(1) }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { setSelectedCategory(String(root.id)); setPage(1) } }}
|
||||
className={`group w-full flex items-center gap-1.5 px-3 py-2 text-sm cursor-pointer ${
|
||||
className={`group w-full flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs cursor-pointer transition-all ${
|
||||
active
|
||||
? 'bg-[#eff6ff] text-[#2563eb] font-medium'
|
||||
: 'text-neutral-700 hover:bg-neutral-50'
|
||||
? 'bg-blue-50/80 text-blue-600 font-semibold shadow-2xs'
|
||||
: 'text-slate-700 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="w-4 h-4 flex items-center justify-center shrink-0 border-0 bg-transparent p-0 cursor-pointer text-neutral-400"
|
||||
className="w-4 h-4 flex items-center justify-center shrink-0 border-0 bg-transparent p-0 cursor-pointer text-slate-400"
|
||||
onClick={e => {
|
||||
if (hasChildren) toggleExpand(root.id, e)
|
||||
else e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
{hasChildren
|
||||
? (expanded ? <DownOutlined className="text-[10px]" /> : <RightOutlined className="text-[10px]" />)
|
||||
? (expanded ? <DownOutlined className="text-[9px]" /> : <RightOutlined className="text-[9px]" />)
|
||||
: <span className="w-2" />}
|
||||
</button>
|
||||
<span className="shrink-0 text-neutral-400">{catIcon(root.name)}</span>
|
||||
<span className="shrink-0 text-slate-400">{catIcon(root.name)}</span>
|
||||
<span className="flex-1 truncate text-left">{root.name}</span>
|
||||
{renderCatActions(root, true)}
|
||||
<span className={`text-xs tabular-nums ${active ? 'text-[#2563eb]' : 'text-neutral-400'}`}>
|
||||
<span className={`text-[11px] tabular-nums font-medium ${active ? 'text-blue-600' : 'text-slate-400'}`}>
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
@@ -481,16 +481,16 @@ const Knowledge = () => {
|
||||
tabIndex={0}
|
||||
onClick={() => { setSelectedCategory(String(child.id)); setPage(1) }}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { setSelectedCategory(String(child.id)); setPage(1) } }}
|
||||
className={`group w-full flex items-center gap-1.5 pl-9 pr-3 py-1.5 text-xs cursor-pointer ${
|
||||
className={`group w-full flex items-center gap-1.5 pl-8 pr-3 py-1.5 rounded-lg text-xs cursor-pointer transition-all ${
|
||||
childActive
|
||||
? 'bg-[#eff6ff] text-[#2563eb] font-medium'
|
||||
: 'text-neutral-600 hover:bg-neutral-50'
|
||||
? 'bg-blue-50/80 text-blue-600 font-semibold'
|
||||
: 'text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<span className="shrink-0 w-3.5 text-neutral-300">·</span>
|
||||
<span className="shrink-0 w-3 text-slate-300">·</span>
|
||||
<span className="flex-1 truncate text-left">{child.name}</span>
|
||||
{renderCatActions(child, false)}
|
||||
<span className={`tabular-nums ${childActive ? 'text-[#2563eb]' : 'text-neutral-400'}`}>
|
||||
<span className={`text-[10px] tabular-nums ${childActive ? 'text-blue-600 font-semibold' : 'text-slate-400'}`}>
|
||||
{child.entry_count ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
@@ -501,18 +501,18 @@ const Knowledge = () => {
|
||||
})}
|
||||
|
||||
{filteredTree.length === 0 && (
|
||||
<div className="text-center text-xs text-neutral-400 py-8">暂无分类</div>
|
||||
<div className="text-center text-xs text-slate-400 py-8">暂无分类</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canCreate && (
|
||||
<div className="shrink-0 p-3 border-t border-neutral-200">
|
||||
{canCreate && (
|
||||
<div className="shrink-0 p-3 border-t border-slate-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openCreateCategory(null)}
|
||||
className="w-full h-8 flex items-center justify-center gap-1.5 rounded-md border border-neutral-200 bg-white text-xs text-neutral-600 hover:bg-neutral-50 cursor-pointer"
|
||||
className="w-full h-8 flex items-center justify-center gap-1.5 rounded-xl border border-slate-200 bg-white text-xs font-medium text-slate-700 hover:bg-slate-50 hover:border-slate-300 cursor-pointer shadow-2xs transition-all"
|
||||
>
|
||||
<FolderAddOutlined />
|
||||
<FolderAddOutlined className="text-slate-500" />
|
||||
新建分类
|
||||
</button>
|
||||
</div>
|
||||
@@ -521,30 +521,30 @@ const Knowledge = () => {
|
||||
|
||||
{/* 右侧列表 */}
|
||||
<section className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{/* 工具栏 — 与左侧顶栏同高 56px */}
|
||||
{/* 工具栏 — 56px */}
|
||||
<div
|
||||
className="shrink-0 px-5 flex items-center gap-3 border-b border-neutral-200 bg-white"
|
||||
className="shrink-0 px-6 flex items-center gap-3 border-b border-slate-200/80 bg-white"
|
||||
style={{ height: 'var(--header-height)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 max-w-md h-8 px-3 rounded-md border border-neutral-200 bg-white min-w-0">
|
||||
<SearchOutlined className="text-neutral-400 text-xs shrink-0" />
|
||||
<div className="flex items-center gap-2 flex-1 max-w-sm h-8.5 px-3 rounded-xl bg-slate-100/80 border border-slate-200/70 focus-within:bg-white focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-50 transition-all min-w-0">
|
||||
<SearchOutlined className="text-slate-400 text-xs shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索知识条目..."
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSearch() }}
|
||||
className="flex-1 min-w-0 bg-transparent border-0 outline-none text-sm text-neutral-800 placeholder:text-neutral-400"
|
||||
className="flex-1 min-w-0 bg-transparent border-0 outline-none text-xs text-slate-800 placeholder:text-slate-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<span className="text-xs text-neutral-500 whitespace-nowrap">状态:</span>
|
||||
<span className="text-xs text-slate-400 font-medium whitespace-nowrap">状态:</span>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部"
|
||||
value={statusFilter}
|
||||
onChange={v => { setStatusFilter(v); setPage(1) }}
|
||||
className="!w-[100px]"
|
||||
className="!w-[90px]"
|
||||
size="small"
|
||||
options={[
|
||||
{ value: 'published', label: '已发布' },
|
||||
@@ -553,11 +553,11 @@ const Knowledge = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<span className="text-xs text-neutral-500 whitespace-nowrap">排序:</span>
|
||||
<span className="text-xs text-slate-400 font-medium whitespace-nowrap">排序:</span>
|
||||
<Select
|
||||
value={sortBy}
|
||||
onChange={v => { setSortBy(v); setPage(1) }}
|
||||
className="!w-[120px]"
|
||||
className="!w-[110px]"
|
||||
size="small"
|
||||
options={[
|
||||
{ value: 'usage', label: '按使用次数' },
|
||||
@@ -567,22 +567,27 @@ const Knowledge = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-2" />
|
||||
<span className="text-xs text-neutral-400 whitespace-nowrap shrink-0">共 {total} 条</span>
|
||||
{canCreate && (
|
||||
<Button type="primary" icon={<PlusOutlined />} className="!h-8 !text-sm shrink-0" onClick={openCreate}>
|
||||
<span className="text-xs text-slate-400 whitespace-nowrap shrink-0 font-medium">共 {total} 条</span>
|
||||
{canCreate && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
className="!h-8 !text-xs !font-semibold !px-3.5 !bg-gradient-to-r !from-blue-600 !to-blue-500 !rounded-lg shadow-sm shadow-blue-500/20 shrink-0"
|
||||
onClick={openCreate}
|
||||
>
|
||||
新建知识条目
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 表头 — 与分类列表顶对齐 */}
|
||||
<div className="shrink-0 flex items-center px-5 h-9 bg-neutral-100 border-b border-neutral-200 text-[11px] font-medium text-neutral-500">
|
||||
<div className="w-[40%] pl-3">标题</div>
|
||||
{/* 表头 */}
|
||||
<div className="shrink-0 flex items-center px-6 h-9 bg-slate-50/80 border-b border-slate-100 text-[11px] font-semibold text-slate-400">
|
||||
<div className="w-[40%] pl-2">标题</div>
|
||||
<div className="w-[12%] text-center">分类</div>
|
||||
<div className="w-[10%] text-center">状态</div>
|
||||
<div className="w-[14%] text-center">使用频率</div>
|
||||
<div className="w-[12%] text-center">更新时间</div>
|
||||
<div className="w-[12%] text-right pr-3">操作</div>
|
||||
<div className="w-[12%] text-right pr-2">操作</div>
|
||||
</div>
|
||||
|
||||
{/* 条目列表 */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
Button, Form, Input, Modal, Popconfirm, Select, Spin, Table, Tabs, Upload, message, Tag, Space,
|
||||
Button, Form, Input, Modal, Popconfirm, Select, Table, Tabs, Upload, message, Tag, Space,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import {
|
||||
@@ -263,20 +263,21 @@ const QuickReplies = () => {
|
||||
const effectiveTab = canManageTeam ? tab : 'personal'
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col min-h-0 overflow-hidden bg-neutral-50">
|
||||
{/* 顶栏 56px — 与侧栏 Logo 区底边对齐(其它页统一) */}
|
||||
<div className="h-full flex flex-col min-h-0 overflow-hidden bg-slate-50/60">
|
||||
{/* 顶栏 56px */}
|
||||
<header
|
||||
className="shrink-0 px-6 flex items-center justify-between gap-3 border-b border-neutral-200 bg-white"
|
||||
className="shrink-0 px-6 flex items-center justify-between gap-3 border-b border-slate-200/80 bg-white"
|
||||
style={{ height: 'var(--header-height)' }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-base font-semibold text-neutral-900 m-0 truncate">快捷回复</h1>
|
||||
<h1 className="text-sm font-bold text-slate-900 m-0 truncate">快捷回复</h1>
|
||||
<p className="text-[11px] text-slate-400 m-0 leading-tight">高频话术模板、输入码快捷调用与分类管理</p>
|
||||
</div>
|
||||
<Space wrap size="small" className="shrink-0">
|
||||
<Button size="small" icon={<DownloadOutlined />} onClick={() => void handleTemplate()} title="下载 CSV 模板(一级分类/二级分类/输入码/内容)">
|
||||
<Button size="small" icon={<DownloadOutlined />} onClick={() => void handleTemplate()} title="下载 CSV 模板" className="!rounded-lg !text-xs">
|
||||
导入模板
|
||||
</Button>
|
||||
<Button size="small" icon={<ExportOutlined />} onClick={() => void handleExport()}>
|
||||
<Button size="small" icon={<ExportOutlined />} onClick={() => void handleExport()} className="!rounded-lg !text-xs">
|
||||
导出
|
||||
</Button>
|
||||
<Upload
|
||||
@@ -304,88 +305,82 @@ const QuickReplies = () => {
|
||||
return false
|
||||
}}
|
||||
>
|
||||
<Button size="small" icon={<ImportOutlined />}>导入 CSV</Button>
|
||||
<Button size="small" icon={<ImportOutlined />} className="!rounded-lg !text-xs">导入 CSV</Button>
|
||||
</Upload>
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建
|
||||
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={openCreate} className="!rounded-lg !text-xs !bg-gradient-to-r !from-blue-600 !to-blue-500 shadow-sm shadow-blue-500/20">
|
||||
新建话术
|
||||
</Button>
|
||||
</Space>
|
||||
</header>
|
||||
|
||||
{/* 工具栏:Tab + 搜索 */}
|
||||
<div className="shrink-0 px-6 pt-2 pb-3 bg-white border-b border-neutral-200">
|
||||
<div className="shrink-0 px-6 pt-2 pb-3.5 bg-white border-b border-slate-100">
|
||||
<Tabs
|
||||
size="small"
|
||||
className="!mb-0 quick-reply-tabs"
|
||||
activeKey={effectiveTab}
|
||||
onChange={k => {
|
||||
setTab(k as QuickReplyScope)
|
||||
setPage(1)
|
||||
setStatusFilter(undefined)
|
||||
if (k === 'team' || k === 'personal') {
|
||||
setTab(k)
|
||||
setPage(1)
|
||||
}
|
||||
}}
|
||||
items={tabItems}
|
||||
tabBarStyle={{ marginBottom: 8 }}
|
||||
/>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
<Input.Search
|
||||
<div className="flex items-center gap-3 pt-2.5 flex-wrap">
|
||||
<Input
|
||||
placeholder="搜索话术标题、内容、分组或输入码..."
|
||||
allowClear
|
||||
placeholder="搜索标题、内容、输入码"
|
||||
className="!w-64"
|
||||
onSearch={v => {
|
||||
setQ(v)
|
||||
value={q}
|
||||
onChange={e => {
|
||||
setQ(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
className="!max-w-md !h-8.5 !rounded-xl !text-xs"
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="状态"
|
||||
className="!w-28"
|
||||
value={statusFilter}
|
||||
onChange={v => {
|
||||
setStatusFilter(v)
|
||||
setPage(1)
|
||||
}}
|
||||
options={[
|
||||
{ value: 'published', label: '已发布' },
|
||||
{ value: 'draft', label: '暂存' },
|
||||
]}
|
||||
/>
|
||||
{effectiveTab === 'team' && canManageTeam && (
|
||||
<span className="text-xs text-neutral-400">
|
||||
暂存仅管理可见;「发布同步」后全员可用 · 工作台输入 / 调用
|
||||
</span>
|
||||
)}
|
||||
{effectiveTab === 'personal' && (
|
||||
<span className="text-xs text-neutral-400">
|
||||
个人话术仅自己可见 · 工作台输入 / 或输入码调用
|
||||
</span>
|
||||
{effectiveTab === 'team' && (
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="状态筛选"
|
||||
value={statusFilter}
|
||||
onChange={v => {
|
||||
setStatusFilter(v)
|
||||
setPage(1)
|
||||
}}
|
||||
className="!w-28 !h-8.5 !rounded-xl"
|
||||
size="small"
|
||||
options={[
|
||||
{ value: 'published', label: '已发布' },
|
||||
{ value: 'draft', label: '草稿' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-slate-400 ml-auto font-medium">共 <span className="font-bold text-slate-800">{total}</span> 条话术</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-auto p-4">
|
||||
<div className="bg-white rounded-xl border border-neutral-200 p-2">
|
||||
{loading && list.length === 0 ? (
|
||||
<div className="py-16 text-center"><Spin /></div>
|
||||
) : (
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="middle"
|
||||
columns={columns}
|
||||
dataSource={list}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p)
|
||||
setPageSize(ps)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-h-0 overflow-auto px-6 py-5">
|
||||
<div className="rounded-2xl border border-slate-200/80 bg-white shadow-xs overflow-hidden p-4">
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={list}
|
||||
columns={columns}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50],
|
||||
onChange: (p, ps) => {
|
||||
setPage(p)
|
||||
setPageSize(ps)
|
||||
},
|
||||
size: 'small',
|
||||
className: '!mt-4',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -416,17 +416,17 @@ const Settings = () => {
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className="h-full flex min-h-0 overflow-hidden bg-neutral-50">
|
||||
{/* 左侧设置导航 — 顶栏 56px 对齐 */}
|
||||
<aside className="w-[220px] shrink-0 flex flex-col bg-white border-r border-neutral-200">
|
||||
<div className="h-full flex min-h-0 overflow-hidden bg-slate-50/60">
|
||||
{/* 左侧设置导航 — 顶栏 56px */}
|
||||
<aside className="w-[210px] shrink-0 flex flex-col bg-white border-r border-slate-200/80">
|
||||
<div
|
||||
className="shrink-0 px-4 flex flex-col justify-center border-b border-neutral-200"
|
||||
className="shrink-0 px-4 flex flex-col justify-center border-b border-slate-100"
|
||||
style={{ height: 'var(--header-height)' }}
|
||||
>
|
||||
<div className="text-sm font-semibold text-neutral-900 leading-tight">系统设置</div>
|
||||
<div className="text-[11px] text-neutral-400 truncate leading-tight mt-0.5">配置与渠道接入</div>
|
||||
<div className="text-xs font-bold text-slate-900 leading-tight">系统设置</div>
|
||||
<div className="text-[10px] text-slate-400 truncate leading-tight mt-0.5 font-medium">配置与渠道接入</div>
|
||||
</div>
|
||||
<nav className="flex-1 overflow-y-auto py-2 px-2">
|
||||
<nav className="flex-1 overflow-y-auto p-2 space-y-0.5">
|
||||
{tabItems.map(item => {
|
||||
const active = activeTab === item.key
|
||||
return (
|
||||
@@ -434,13 +434,13 @@ const Settings = () => {
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(item.key)}
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-md text-sm text-left border-0 cursor-pointer transition-colors mb-0.5 ${
|
||||
className={`w-full flex items-center gap-2.5 px-3 py-2 rounded-xl text-xs text-left border-0 cursor-pointer transition-all ${
|
||||
active
|
||||
? 'bg-[#eff6ff] text-[#2563eb] font-medium'
|
||||
: 'bg-transparent text-neutral-600 hover:bg-neutral-50'
|
||||
? 'bg-blue-50/80 text-blue-600 font-semibold shadow-2xs'
|
||||
: 'bg-transparent text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<span className={`text-base leading-none ${active ? 'text-[#2563eb]' : 'text-neutral-400'}`}>
|
||||
<span className={`text-sm leading-none ${active ? 'text-blue-600' : 'text-slate-400'}`}>
|
||||
{item.icon}
|
||||
</span>
|
||||
<span className="truncate">{item.label}</span>
|
||||
@@ -453,11 +453,12 @@ const Settings = () => {
|
||||
{/* 右侧内容 */}
|
||||
<section className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<div
|
||||
className="shrink-0 px-6 flex items-center justify-between gap-3 border-b border-neutral-200 bg-white"
|
||||
className="shrink-0 px-6 flex items-center justify-between gap-3 border-b border-slate-200/80 bg-white"
|
||||
style={{ height: 'var(--header-height)' }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-base font-semibold text-neutral-900 m-0 truncate">{currentTab.label}</h1>
|
||||
<h1 className="text-sm font-bold text-slate-900 m-0 truncate">{currentTab.label}</h1>
|
||||
<p className="text-[11px] text-slate-400 m-0 leading-tight">{currentTab.desc}</p>
|
||||
</div>
|
||||
{panelHeaderAction}
|
||||
{activeTab === 'staff' && isAdmin && (
|
||||
@@ -465,7 +466,7 @@ const Settings = () => {
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
className="!h-8"
|
||||
className="!h-8 !rounded-lg !text-xs !font-semibold !bg-gradient-to-r !from-blue-600 !to-blue-500 shadow-sm shadow-blue-500/20"
|
||||
disabled={seatLimit > 0 && seatUsed >= seatLimit}
|
||||
onClick={openCreateStaff}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { DatePicker, Spin, message } from 'antd'
|
||||
import {
|
||||
ClockCircleOutlined, SmileOutlined, CheckCircleOutlined, MessageOutlined,
|
||||
DownloadOutlined, ArrowUpOutlined, ArrowDownOutlined,
|
||||
DownloadOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { Column, Line, Pie } from '@ant-design/charts'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
@@ -28,7 +28,6 @@ function buildStatsQuery(preset: TimePreset, custom: [Dayjs, Dayjs] | null): Sta
|
||||
to: custom[1].format('YYYY-MM-DD'),
|
||||
}
|
||||
}
|
||||
// 本周 = 近7天(含今天),与后端 period=week 一致
|
||||
return { period: preset === 'custom' ? 'week' : preset }
|
||||
}
|
||||
|
||||
@@ -60,7 +59,6 @@ const Statistics = () => {
|
||||
}, [timePreset, customRange])
|
||||
|
||||
useEffect(() => {
|
||||
// 自定义未选完区间时不请求
|
||||
if (timePreset === 'custom' && (!customRange?.[0] || !customRange?.[1])) return
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
@@ -118,45 +116,37 @@ const Statistics = () => {
|
||||
label: '总会话量',
|
||||
value: String(kpis?.total_sessions ?? 0),
|
||||
suffix: '',
|
||||
hint: '基于已记录会话',
|
||||
hint: '已建立并处理的客户会话总数',
|
||||
icon: <MessageOutlined />,
|
||||
iconBg: '#eff6ff',
|
||||
iconColor: '#2563eb',
|
||||
delta: null as string | null,
|
||||
up: true,
|
||||
},
|
||||
{
|
||||
label: '平均响应时长',
|
||||
value: String(Math.round(kpis?.avg_response_time ?? 0)),
|
||||
suffix: '秒',
|
||||
hint: '响应越短越优',
|
||||
hint: '从访客发消息到客服首响平均耗时',
|
||||
icon: <ClockCircleOutlined />,
|
||||
iconBg: '#ecfeff',
|
||||
iconColor: '#0891b2',
|
||||
delta: null,
|
||||
up: false,
|
||||
},
|
||||
{
|
||||
label: '客户满意度',
|
||||
value: satPercent > 0 ? satPercent.toFixed(1) : '0',
|
||||
suffix: '%',
|
||||
hint: `评分 ${(kpis?.satisfaction_avg ?? 0).toFixed(1)}/5`,
|
||||
hint: `平均评分 ${(kpis?.satisfaction_avg ?? 0).toFixed(1)} / 5.0`,
|
||||
icon: <SmileOutlined />,
|
||||
iconBg: '#f0fdf4',
|
||||
iconColor: '#16a34a',
|
||||
delta: null,
|
||||
up: true,
|
||||
},
|
||||
{
|
||||
label: '首次解决率',
|
||||
value: (kpis?.first_resolve_rate ?? 0).toFixed(1),
|
||||
suffix: '%',
|
||||
hint: '24h 内未再打开',
|
||||
hint: '首通会话即完成诉求解决的比例',
|
||||
icon: <CheckCircleOutlined />,
|
||||
iconBg: '#fffbeb',
|
||||
iconColor: '#d97706',
|
||||
delta: null,
|
||||
up: true,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -165,7 +155,7 @@ const Statistics = () => {
|
||||
xField: 'date',
|
||||
yField: 'count',
|
||||
smooth: true,
|
||||
height: 260,
|
||||
height: 240,
|
||||
color: '#2563eb',
|
||||
point: { size: 3 },
|
||||
style: { lineWidth: 2 },
|
||||
@@ -180,7 +170,7 @@ const Statistics = () => {
|
||||
data: responseDistribution,
|
||||
xField: 'range',
|
||||
yField: 'count',
|
||||
height: 260,
|
||||
height: 240,
|
||||
color: '#0891b2',
|
||||
axis: { y: { grid: true, gridStroke: '#f1f5f9' } },
|
||||
tooltip: { channel: 'y' as const },
|
||||
@@ -190,7 +180,7 @@ const Statistics = () => {
|
||||
data: channelData,
|
||||
angleField: 'value',
|
||||
colorField: 'type',
|
||||
height: 260,
|
||||
height: 240,
|
||||
radius: 0.85,
|
||||
innerRadius: 0.55,
|
||||
legend: { color: { position: 'right' as const } },
|
||||
@@ -203,17 +193,18 @@ const Statistics = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col min-h-0 overflow-hidden bg-neutral-50">
|
||||
{/* 顶栏 56px — 与其它页面对齐 */}
|
||||
<div className="h-full flex flex-col min-h-0 overflow-hidden bg-slate-50/60">
|
||||
{/* 顶栏 56px */}
|
||||
<header
|
||||
className="shrink-0 px-6 flex items-center justify-between gap-3 border-b border-neutral-200 bg-white"
|
||||
className="shrink-0 px-6 flex items-center justify-between gap-3 border-b border-slate-200/80 bg-white"
|
||||
style={{ height: 'var(--header-height)' }}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-base font-semibold text-neutral-900 m-0 truncate">数据统计</h1>
|
||||
<h1 className="text-sm font-bold text-slate-900 m-0 truncate">数据统计</h1>
|
||||
<p className="text-[11px] text-slate-400 m-0 leading-tight">服务效能与接待指标分析</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 flex-wrap justify-end">
|
||||
<div className="flex items-center gap-0.5 p-0.5 rounded-lg bg-neutral-100">
|
||||
<div className="flex items-center gap-0.5 p-0.5 rounded-xl bg-slate-100 border border-slate-200/60">
|
||||
{rangeOptions.map(opt => {
|
||||
const active = timePreset === opt.value
|
||||
return (
|
||||
@@ -224,10 +215,10 @@ const Statistics = () => {
|
||||
setTimePreset(opt.value)
|
||||
setCustomRange(null)
|
||||
}}
|
||||
className={`h-7 px-3 rounded-md text-sm border-0 cursor-pointer transition-colors ${
|
||||
className={`h-7 px-3 rounded-lg text-xs border-0 cursor-pointer transition-all ${
|
||||
active
|
||||
? 'bg-[#2563eb] text-white font-medium shadow-sm'
|
||||
: 'bg-transparent text-neutral-500 hover:text-neutral-700'
|
||||
? 'bg-white text-blue-600 font-semibold shadow-xs'
|
||||
: 'bg-transparent text-slate-500 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
@@ -241,7 +232,7 @@ const Statistics = () => {
|
||||
value={customRange}
|
||||
disabledDate={current => current != null && current.isAfter(dayjs().endOf('day'))}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
className={timePreset === 'custom' ? 'ring-1 ring-[#2563eb] rounded-md' : undefined}
|
||||
className={`!rounded-lg ${timePreset === 'custom' ? 'ring-2 ring-blue-100 !border-blue-500' : ''}`}
|
||||
onChange={values => {
|
||||
if (values?.[0] && values?.[1]) {
|
||||
setCustomRange([values[0], values[1]])
|
||||
@@ -255,7 +246,7 @@ const Statistics = () => {
|
||||
<button
|
||||
type="button"
|
||||
disabled={exporting}
|
||||
className="h-8 px-3 rounded-lg text-sm text-neutral-600 border border-neutral-200 bg-white hover:bg-neutral-50 flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
|
||||
className="h-8 px-3.5 rounded-xl text-xs font-medium text-slate-700 border border-slate-200 bg-white hover:bg-slate-50 hover:border-slate-300 flex items-center gap-1.5 cursor-pointer disabled:opacity-50 transition-all shadow-2xs"
|
||||
onClick={async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
@@ -268,7 +259,7 @@ const Statistics = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DownloadOutlined />
|
||||
<DownloadOutlined className="text-slate-500" />
|
||||
{exporting ? '导出中…' : '导出报告'}
|
||||
</button>
|
||||
</div>
|
||||
@@ -279,186 +270,160 @@ const Statistics = () => {
|
||||
<div className="h-64 flex items-center justify-center"><Spin size="large" /></div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-5 w-full">
|
||||
{/* KPI */}
|
||||
{/* KPI 指标卡片 */}
|
||||
<section className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4">
|
||||
{kpiCards.map(kpi => (
|
||||
<div
|
||||
key={kpi.label}
|
||||
className="relative bg-white rounded-xl border border-neutral-200 shadow-sm p-5"
|
||||
className="relative bg-white rounded-2xl border border-slate-200/80 shadow-xs hover:shadow-md transition-all duration-200 p-5 group"
|
||||
>
|
||||
{kpi.delta && (
|
||||
<div className="absolute top-4 right-4 flex items-center gap-0.5 text-xs font-medium text-green-600 opacity-80">
|
||||
{kpi.up ? <ArrowUpOutlined /> : <ArrowDownOutlined />}
|
||||
{kpi.delta}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs font-semibold text-slate-500">{kpi.label}</span>
|
||||
<div
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center text-base shrink-0"
|
||||
className="w-9 h-9 rounded-xl flex items-center justify-center text-sm shrink-0 transition-transform group-hover:scale-105"
|
||||
style={{ backgroundColor: kpi.iconBg, color: kpi.iconColor }}
|
||||
>
|
||||
{kpi.icon}
|
||||
</div>
|
||||
<span className="text-sm text-neutral-500 whitespace-nowrap">{kpi.label}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-3xl font-bold text-neutral-900 leading-none tabular-nums">
|
||||
<div className="flex items-baseline gap-1 mt-1">
|
||||
<span className="text-3xl font-extrabold text-slate-900 leading-none tabular-nums tracking-tight">
|
||||
{loading ? '—' : kpi.value}
|
||||
</span>
|
||||
{kpi.suffix && (
|
||||
<span className="text-base font-normal text-neutral-400">{kpi.suffix}</span>
|
||||
<span className="text-sm font-medium text-slate-400">{kpi.suffix}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1.5 mb-0 text-xs text-neutral-400">{kpi.hint}</p>
|
||||
<p className="mt-2 mb-0 text-[11px] text-slate-400 font-normal">{kpi.hint}</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* 图表 2×2 */}
|
||||
<section className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-5">
|
||||
<div className="bg-white rounded-2xl border border-slate-200/80 shadow-xs p-5">
|
||||
<div className="mb-4">
|
||||
<h3 className="m-0 text-[15px] font-semibold text-neutral-900">会话量趋势</h3>
|
||||
<p className="m-0 mt-0.5 text-xs text-neutral-400">{trendSubtitle}</p>
|
||||
<h3 className="text-sm font-bold text-slate-800 m-0">会话趋势</h3>
|
||||
<p className="text-xs text-slate-400 m-0 mt-0.5">{trendSubtitle}</p>
|
||||
</div>
|
||||
{sessionTrendData.length === 0 ? (
|
||||
<div className="h-[260px] flex items-center justify-center text-sm text-neutral-400">暂无数据</div>
|
||||
<div className="h-[240px] flex items-center justify-center text-xs text-slate-400">暂无趋势数据</div>
|
||||
) : (
|
||||
<Line {...lineConfig} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-5">
|
||||
<div className="bg-white rounded-2xl border border-slate-200/80 shadow-xs p-5">
|
||||
<div className="mb-4">
|
||||
<h3 className="m-0 text-[15px] font-semibold text-neutral-900">响应时长分布</h3>
|
||||
<p className="m-0 mt-0.5 text-xs text-neutral-400">首次响应耗时区间</p>
|
||||
<h3 className="text-sm font-bold text-slate-800 m-0">响应时长分布</h3>
|
||||
<p className="text-xs text-slate-400 m-0 mt-0.5">首次响应时长区间占比</p>
|
||||
</div>
|
||||
{responseDistribution.every(d => !d.count) ? (
|
||||
<div className="h-[260px] flex items-center justify-center text-sm text-neutral-400">暂无数据</div>
|
||||
{responseDistribution.length === 0 ? (
|
||||
<div className="h-[240px] flex items-center justify-center text-xs text-slate-400">暂无响应时长数据</div>
|
||||
) : (
|
||||
<Column {...columnConfig} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-5">
|
||||
<div className="bg-white rounded-2xl border border-slate-200/80 shadow-xs p-5">
|
||||
<div className="mb-4">
|
||||
<h3 className="m-0 text-[15px] font-semibold text-neutral-900">渠道来源占比</h3>
|
||||
<p className="m-0 mt-0.5 text-xs text-neutral-400">各渠道会话量分布</p>
|
||||
<h3 className="text-sm font-bold text-slate-800 m-0">渠道来源占比</h3>
|
||||
<p className="text-xs text-slate-400 m-0 mt-0.5">
|
||||
各渠道进线分布(共 {channelTotal} 次)
|
||||
</p>
|
||||
</div>
|
||||
{channelData.length === 0 ? (
|
||||
<div className="h-[260px] flex items-center justify-center text-sm text-neutral-400">暂无数据</div>
|
||||
{channelData.length === 0 || channelTotal === 0 ? (
|
||||
<div className="h-[240px] flex items-center justify-center text-xs text-slate-400">暂无渠道数据</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Pie {...pieConfig} />
|
||||
{channelTotal > 0 && (
|
||||
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center pr-16">
|
||||
<span className="text-xl font-bold text-neutral-900 tabular-nums">{channelTotal}</span>
|
||||
<span className="text-[10px] text-neutral-400">总会话</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Pie {...pieConfig} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-neutral-200 shadow-sm p-5">
|
||||
<div className="bg-white rounded-2xl border border-slate-200/80 shadow-xs p-5">
|
||||
<div className="mb-4">
|
||||
<h3 className="m-0 text-[15px] font-semibold text-neutral-900">客服绩效排行</h3>
|
||||
<p className="m-0 mt-0.5 text-xs text-neutral-400">按接待会话量排序</p>
|
||||
<h3 className="text-sm font-bold text-slate-800 m-0">客服效能排名</h3>
|
||||
<p className="text-xs text-slate-400 m-0 mt-0.5">按接待会话量与满意度统计</p>
|
||||
</div>
|
||||
{agentPerformance.length === 0 ? (
|
||||
<div className="h-[260px] flex items-center justify-center text-sm text-neutral-400">暂无数据</div>
|
||||
<div className="h-[240px] flex items-center justify-center text-xs text-slate-400">暂无客服效能数据</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 min-h-[240px]">
|
||||
{agentPerformance.slice(0, 8).map((agent, idx) => {
|
||||
const pct = Math.round((agent.conversations / maxConversations) * 100)
|
||||
const rankColor = idx < 3 ? '#2563eb' : '#94a3b8'
|
||||
return (
|
||||
<div key={agent.name + idx} className="flex items-center gap-3">
|
||||
<span
|
||||
className="w-5 text-center text-xs font-bold shrink-0 tabular-nums"
|
||||
style={{ color: rankColor }}
|
||||
>
|
||||
{idx + 1}
|
||||
</span>
|
||||
<span className="w-14 text-sm text-neutral-700 truncate shrink-0" title={agent.name}>
|
||||
{agent.name || '—'}
|
||||
</span>
|
||||
<div className="flex-1 h-5 rounded bg-neutral-100 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded bg-[#2563eb] transition-all"
|
||||
style={{ width: `${Math.max(pct, agent.conversations > 0 ? 4 : 0)}%`, opacity: 1 - idx * 0.08 }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-right text-xs font-medium text-neutral-700 tabular-nums shrink-0">
|
||||
{agent.conversations}
|
||||
</span>
|
||||
<div className="space-y-3 max-h-64 overflow-y-auto">
|
||||
{agentPerformance.map(agent => (
|
||||
<div key={agent.name} className="p-3 rounded-xl bg-slate-50 border border-slate-100">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-xs font-semibold text-slate-800">{agent.name}</span>
|
||||
<span className="text-xs font-bold text-blue-600">{agent.conversations} 会话</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="w-full bg-slate-200/70 h-1.5 rounded-full overflow-hidden mb-2">
|
||||
<div
|
||||
className="bg-blue-600 h-full rounded-full transition-all duration-300"
|
||||
style={{ width: `${(agent.conversations / maxConversations) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px] text-slate-400">
|
||||
<span>平均响应: {Math.round(agent.avgResponse)}s</span>
|
||||
<span>满意度: {agent.satisfaction > 0 ? (agent.satisfaction * 20).toFixed(0) : '0'}%</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 客服效率明细表 */}
|
||||
<section className="bg-white rounded-xl border border-neutral-200 shadow-sm overflow-hidden">
|
||||
<div className="px-5 pt-5 pb-0">
|
||||
<h3 className="m-0 text-[15px] font-semibold text-neutral-900">客服效率明细</h3>
|
||||
<p className="m-0 mt-0.5 text-xs text-neutral-400">接待量 · 平均响应 · 满意度</p>
|
||||
<section className="bg-white rounded-2xl border border-slate-200/80 shadow-xs overflow-hidden">
|
||||
<div className="px-6 pt-5 pb-2">
|
||||
<h3 className="m-0 text-sm font-bold text-slate-800">客服效率明细</h3>
|
||||
<p className="m-0 mt-0.5 text-xs text-slate-400">接待量 · 平均响应 · 满意度</p>
|
||||
</div>
|
||||
<div className="overflow-x-auto px-2 pb-4 pt-3">
|
||||
<div className="overflow-x-auto px-4 pb-4">
|
||||
<table className="w-full min-w-[640px] border-collapse">
|
||||
<thead>
|
||||
<tr className="text-[11px] font-medium text-neutral-400">
|
||||
<th className="text-left px-4 py-2 border-b border-neutral-200 w-16">排名</th>
|
||||
<th className="text-left px-4 py-2 border-b border-neutral-200">客服名称</th>
|
||||
<th className="text-right px-4 py-2 border-b border-neutral-200">处理量</th>
|
||||
<th className="text-right px-4 py-2 border-b border-neutral-200">平均响应时长</th>
|
||||
<th className="text-right px-4 py-2 border-b border-neutral-200">满意度</th>
|
||||
<tr className="text-[11px] font-semibold text-slate-400 bg-slate-50/80">
|
||||
<th className="text-left px-4 py-2.5 rounded-l-lg w-16">排名</th>
|
||||
<th className="text-left px-4 py-2.5">客服名称</th>
|
||||
<th className="text-right px-4 py-2.5">处理量</th>
|
||||
<th className="text-right px-4 py-2.5">平均响应时长</th>
|
||||
<th className="text-right px-4 py-2.5 rounded-r-lg">满意度</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{agentPerformance.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center text-sm text-neutral-400 py-10">
|
||||
<td colSpan={5} className="text-center text-xs text-slate-400 py-10">
|
||||
暂无客服绩效数据
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
agentPerformance.map((agent, idx) => {
|
||||
const pal = ['#dbeafe', '#ecfeff', '#f0fdf4', '#fffbeb', '#f3e8ff'][idx % 5]
|
||||
const col = ['#2563eb', '#0891b2', '#16a34a', '#d97706', '#7c3aed'][idx % 5]
|
||||
return (
|
||||
<tr key={agent.name + idx} className="hover:bg-neutral-50">
|
||||
<td className="px-4 py-3 border-b border-neutral-100">
|
||||
<tr key={agent.name + idx} className="hover:bg-slate-50/80 transition-colors border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-[22px] h-[22px] rounded-full text-[11px] font-bold"
|
||||
style={{
|
||||
backgroundColor: idx < 3 ? '#dbeafe' : '#f1f5f9',
|
||||
color: idx < 3 ? '#2563eb' : '#94a3b8',
|
||||
}}
|
||||
className={`inline-flex items-center justify-center w-5.5 h-5.5 rounded-full text-[10px] font-bold ${
|
||||
idx === 0
|
||||
? 'bg-amber-100 text-amber-700'
|
||||
: idx === 1
|
||||
? 'bg-slate-200 text-slate-700'
|
||||
: idx === 2
|
||||
? 'bg-orange-100 text-orange-700'
|
||||
: 'bg-slate-100 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{idx + 1}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 border-b border-neutral-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-semibold shrink-0"
|
||||
style={{ backgroundColor: pal, color: col }}
|
||||
>
|
||||
{(agent.name || '?').slice(0, 1)}
|
||||
</div>
|
||||
<span className="text-sm text-neutral-800">{agent.name || '—'}</span>
|
||||
</div>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs font-semibold text-slate-800">{agent.name || '—'}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 border-b border-neutral-100 text-right text-sm font-medium text-neutral-800 tabular-nums">
|
||||
<td className="px-4 py-3 text-right text-xs font-bold text-slate-900 tabular-nums">
|
||||
{agent.conversations}
|
||||
</td>
|
||||
<td className="px-4 py-3 border-b border-neutral-100 text-right text-sm text-neutral-700 tabular-nums">
|
||||
<td className="px-4 py-3 text-right text-xs text-slate-600 tabular-nums">
|
||||
{Math.round(agent.avgResponse)}s
|
||||
</td>
|
||||
<td className="px-4 py-3 border-b border-neutral-100 text-right text-sm text-neutral-700 tabular-nums">
|
||||
<td className="px-4 py-3 text-right text-xs text-slate-600 tabular-nums">
|
||||
{agent.satisfaction > 0 ? `${agent.satisfaction.toFixed(1)}/5` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
Reference in New Issue
Block a user