优化整体ui-1

This commit is contained in:
yml2213
2026-08-31 16:25:26 +08:00
parent 2b57bdc0c8
commit 198ff9e1fb
19 changed files with 1273 additions and 959 deletions
+145 -23
View File
@@ -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">
&copy; {new Date().getFullYear()} Kefu Cloud. All Rights Reserved.
</div>
</footer>
</div>
)
}
+54 -54
View File
@@ -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
View File
@@ -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>
+6 -5
View File
@@ -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>
+37 -35
View File
@@ -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"
+41 -31
View File
@@ -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 }}
+65 -59
View File
@@ -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 ? (
+68 -65
View File
@@ -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
View File
@@ -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 ? (
+53 -48
View File
@@ -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>
{/* 条目列表 */}
+62 -67
View File
@@ -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>
+15 -14
View File
@@ -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}
>
+96 -131
View File
@@ -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>