优化快捷回复联想与个人调用统计,数据统计支持自定义日期

/ 按输入码与个人频次建议(最多10条,唯一自动填入),正文关键字联想;统计页可日历选区间并统一中文日历。
This commit is contained in:
yml2213
2026-07-19 01:10:16 +08:00
parent c6532f2ee3
commit 1a3b32868c
9 changed files with 488 additions and 114 deletions
+5
View File
@@ -2,10 +2,15 @@ import React from 'react'
import ReactDOM from 'react-dom/client'
import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import App from './App'
import { AuthProvider } from './stores/auth'
import './index.css'
// 日历 / DatePicker 月份、星期与 Ant Design 中文一致
dayjs.locale('zh-cn')
const theme = {
token: {
colorPrimary: '#2563eb',
+138 -41
View File
@@ -274,11 +274,16 @@ const Dashboard = () => {
const [quickKeyword, setQuickKeyword] = useState('')
const [quickList, setQuickList] = useState<QuickReply[]>([])
const [quickLoading, setQuickLoading] = useState(false)
/** 输入框 / 触发建议 */
const [slashOpen, setSlashOpen] = useState(false)
/** 输入框快捷回复建议:slash=/ 输入码;keyword=正文关键字 */
const [suggestOpen, setSuggestOpen] = useState(false)
const [suggestMode, setSuggestMode] = useState<'slash' | 'keyword'>('slash')
const [slashPrefix, setSlashPrefix] = useState('')
const [slashItems, setSlashItems] = useState<QuickReply[]>([])
const [slashIndex, setSlashIndex] = useState(0)
const [keywordQuery, setKeywordQuery] = useState('')
const [suggestItems, setSuggestItems] = useState<QuickReply[]>([])
const [suggestIndex, setSuggestIndex] = useState(0)
const keywordTimer = useRef<number | null>(null)
/** 避免唯一匹配自动填入后立刻再次触发 */
const autoAppliedRef = useRef('')
const [pendingImage, setPendingImage] = useState<{ file: File; preview: string } | null>(null)
const [noteInput, setNoteInput] = useState('')
const [savingNote, setSavingNote] = useState(false)
@@ -718,25 +723,75 @@ const Dashboard = () => {
}).catch(() => setQuickList([])).finally(() => setQuickLoading(false))
}, [quickOpen, quickKeyword])
const closeSuggest = useCallback(() => {
setSuggestOpen(false)
setSuggestItems([])
setSuggestIndex(0)
setSlashPrefix('')
setKeywordQuery('')
}, [])
// / 模式:拉输入码建议(按个人调用频次)
useEffect(() => {
if (!slashOpen) return
if (!suggestOpen || suggestMode !== 'slash') return
let cancelled = false
suggestQuickReplies(slashPrefix).then(res => {
suggestQuickReplies({ mode: 'slash', prefix: slashPrefix }).then(res => {
if (cancelled) return
const list = Array.isArray(res.data) ? res.data : []
setSlashItems(list)
setSlashIndex(0)
setSuggestItems(list)
setSuggestIndex(0)
// 输入码细化后仅剩 1 条 → 自动填入(纯 / 不自动,避免误触)
if (list.length === 1 && slashPrefix.length > 0) {
const only = list[0]
const key = `slash:${slashPrefix}:${only.id}`
if (autoAppliedRef.current !== key) {
autoAppliedRef.current = key
// 延迟到下一 tick,避免与 onChange 竞态
window.setTimeout(() => {
void applyQuickReplyRef.current(only, 'slash')
}, 0)
}
}
}).catch(() => {
if (!cancelled) setSlashItems([])
if (!cancelled) setSuggestItems([])
})
return () => { cancelled = true }
}, [slashOpen, slashPrefix])
}, [suggestOpen, suggestMode, slashPrefix])
const applyQuickReply = useCallback(async (item: QuickReply) => {
setMessageInput(item.content)
// 关键字模式:防抖搜索标题/内容
useEffect(() => {
if (!suggestOpen || suggestMode !== 'keyword') return
const q = keywordQuery.trim()
if (q.length < 2) {
setSuggestItems([])
return
}
let cancelled = false
suggestQuickReplies({ mode: 'keyword', q }).then(res => {
if (cancelled) return
const list = Array.isArray(res.data) ? res.data : []
setSuggestItems(list)
setSuggestIndex(0)
}).catch(() => {
if (!cancelled) setSuggestItems([])
})
return () => { cancelled = true }
}, [suggestOpen, suggestMode, keywordQuery])
const applyQuickReplyRef = useRef<(item: QuickReply, mode?: 'slash' | 'keyword' | 'panel') => Promise<void>>(async () => {})
const applyQuickReply = useCallback(async (item: QuickReply, mode: 'slash' | 'keyword' | 'panel' = 'panel') => {
if (mode === 'slash') {
// 只替换末尾 /输入码,保留前文
setMessageInput(prev => prev.replace(/(^|[\s\n])\/([a-zA-Z0-9_-]*)$/, `$1${item.content}`))
} else if (mode === 'keyword') {
// 关键字触发:用话术替换当前输入(模板式回复)
setMessageInput(item.content)
} else {
setMessageInput(item.content)
}
setQuickOpen(false)
setSlashOpen(false)
setSlashPrefix('')
closeSuggest()
try {
await useQuickReply(item.id)
} catch { /* 计数失败可忽略 */ }
@@ -744,24 +799,55 @@ const Dashboard = () => {
const el = messageInputRef.current
if (el) {
el.focus()
const len = item.content.length
const len = el.value.length
el.setSelectionRange(len, len)
}
})
}, [])
}, [closeSuggest])
/** 从输入内容解析末尾 /shortcut 触发 */
const syncSlashFromInput = useCallback((value: string) => {
// 匹配末尾未完成的 /xxx(前面是行首或空白)
const m = /(^|[\s\n])\/([a-zA-Z0-9_-]*)$/.exec(value)
if (m) {
setSlashOpen(true)
setSlashPrefix(m[2] || '')
} else {
setSlashOpen(false)
setSlashPrefix('')
applyQuickReplyRef.current = applyQuickReply
/** 解析输入:优先 / 输入码;否则关键字联想 */
const syncSuggestFromInput = useCallback((value: string) => {
// 末尾 /xxx(行首或空白后)
const slashMatch = /(^|[\s\n])\/([a-zA-Z0-9_-]*)$/.exec(value)
if (slashMatch) {
if (keywordTimer.current) {
window.clearTimeout(keywordTimer.current)
keywordTimer.current = null
}
setSuggestMode('slash')
setSuggestOpen(true)
setSlashPrefix(slashMatch[2] || '')
setKeywordQuery('')
return
}
}, [])
// 无 / 时:取最后一段非空白作关键字(至少 2 字)
const trimmed = value.trim()
if (!trimmed) {
closeSuggest()
return
}
// 取末行最后一词/整段
const lastLine = trimmed.split(/\n/).pop() || trimmed
const token = lastLine.trim()
if (token.length < 2) {
if (keywordTimer.current) {
window.clearTimeout(keywordTimer.current)
keywordTimer.current = null
}
closeSuggest()
return
}
if (keywordTimer.current) window.clearTimeout(keywordTimer.current)
keywordTimer.current = window.setTimeout(() => {
setSuggestMode('keyword')
setSuggestOpen(true)
setKeywordQuery(token)
setSlashPrefix('')
}, 220)
}, [closeSuggest])
const selected = sessions.find(session => session.id === selectedId)
const selectedCustomer = selected ? customers[selected.customer_id] : null
@@ -1540,24 +1626,32 @@ const Dashboard = () => {
<span className="text-[11px] text-neutral-400 ml-1"> / </span>
</div>
<div className="relative flex items-end gap-2.5 px-4 pb-3 pt-1">
{slashOpen && (
{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">
{slashItems.length === 0 ? (
<div className="px-3 py-1.5 text-[11px] text-neutral-400 border-b border-neutral-100 flex items-center justify-between">
<span>
{suggestMode === 'slash'
? (slashPrefix ? `输入码 /${slashPrefix}` : '常用话术(按你的调用频率)')
: `关键字「${keywordQuery}`}
</span>
<span> · Enter · Esc </span>
</div>
{suggestItems.length === 0 ? (
<div className="px-3 py-2 text-xs text-neutral-400"></div>
) : (
slashItems.map((item, idx) => (
suggestItems.map((item, idx) => (
<button
key={item.id}
type="button"
className={`w-full text-left px-3 py-2 border-0 cursor-pointer ${
idx === slashIndex ? 'bg-blue-50' : 'bg-white hover:bg-neutral-50'
idx === suggestIndex ? 'bg-blue-50' : 'bg-white hover:bg-neutral-50'
}`}
onMouseDown={e => {
e.preventDefault()
void applyQuickReply(item)
void applyQuickReply(item, suggestMode)
}}
>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm font-medium text-neutral-800 truncate">{item.title}</span>
{item.shortcut && (
<code className="text-[11px] text-blue-600 bg-blue-50 px-1 rounded shrink-0">/{item.shortcut}</code>
@@ -1565,6 +1659,9 @@ const Dashboard = () => {
<span className="text-[10px] text-neutral-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>
)}
</div>
<div className="text-xs text-neutral-500 line-clamp-1 mt-0.5">{item.content}</div>
</button>
@@ -1576,12 +1673,12 @@ const Dashboard = () => {
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="支持 Markdown**加粗** *斜体* 列表 链接)… / 调话术,Enter 发送"
placeholder="输入文字联想话术,/ 调输入码… Enter 发送"
value={messageInput}
onChange={event => {
const v = event.target.value
setMessageInput(v)
syncSlashFromInput(v)
syncSuggestFromInput(v)
emitTyping()
}}
onPaste={event => {
@@ -1592,30 +1689,30 @@ const Dashboard = () => {
}
}}
onKeyDown={event => {
if (slashOpen && slashItems.length > 0) {
if (suggestOpen && suggestItems.length > 0) {
if (event.key === 'ArrowDown') {
event.preventDefault()
setSlashIndex(i => (i + 1) % slashItems.length)
setSuggestIndex(i => (i + 1) % suggestItems.length)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setSlashIndex(i => (i - 1 + slashItems.length) % slashItems.length)
setSuggestIndex(i => (i - 1 + suggestItems.length) % suggestItems.length)
return
}
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault()
void applyQuickReply(slashItems[slashIndex] || slashItems[0])
void applyQuickReply(suggestItems[suggestIndex] || suggestItems[0], suggestMode)
return
}
if (event.key === 'Escape') {
event.preventDefault()
setSlashOpen(false)
closeSuggest()
return
}
if (event.key === 'Tab') {
event.preventDefault()
void applyQuickReply(slashItems[slashIndex] || slashItems[0])
void applyQuickReply(suggestItems[suggestIndex] || suggestItems[0], suggestMode)
return
}
}
+67 -22
View File
@@ -1,31 +1,40 @@
import { useEffect, useMemo, useState } from 'react'
import { Spin, message } from 'antd'
import { DatePicker, Spin, message } from 'antd'
import {
ClockCircleOutlined, SmileOutlined, CheckCircleOutlined, MessageOutlined,
DownloadOutlined, ArrowUpOutlined, ArrowDownOutlined,
} from '@ant-design/icons'
import { Column, Line, Pie } from '@ant-design/charts'
import dayjs, { type Dayjs } from 'dayjs'
import {
exportStatisticsCSV, getAgentPerformance, getChannelDistribution, getKPIs, getResponseDistribution, getSessionTrend,
type StatisticsKpis,
type StatisticsKpis, type StatsQuery,
} from '@/services/api'
type TimeRange = 'today' | 'week' | 'month'
type TimePreset = 'today' | 'week' | 'month' | 'custom'
const rangeOptions: { value: TimeRange; label: string }[] = [
const rangeOptions: { value: TimePreset; label: string }[] = [
{ value: 'today', label: '今日' },
{ value: 'week', label: '本周' },
{ value: 'month', label: '本月' },
]
const trendSubtitle: Record<TimeRange, string> = {
today: '今日会话量',
week: '近7天会话量变化',
month: '近6个月会话量变化',
const { RangePicker } = DatePicker
function buildStatsQuery(preset: TimePreset, custom: [Dayjs, Dayjs] | null): StatsQuery {
if (preset === 'custom' && custom?.[0] && custom?.[1]) {
return {
from: custom[0].format('YYYY-MM-DD'),
to: custom[1].format('YYYY-MM-DD'),
}
}
// 本周 = 近7天(含今天),与后端 period=week 一致
return { period: preset === 'custom' ? 'week' : preset }
}
const Statistics = () => {
const [timeRange, setTimeRange] = useState<TimeRange>('week')
const [timePreset, setTimePreset] = useState<TimePreset>('week')
const [customRange, setCustomRange] = useState<[Dayjs, Dayjs] | null>(null)
const [kpis, setKpis] = useState<StatisticsKpis | null>(null)
const [sessionTrendData, setSessionTrendData] = useState<{ date: string; count: number }[]>([])
const [responseDistribution, setResponseDistribution] = useState<{ range: string; count: number }[]>([])
@@ -36,17 +45,33 @@ const Statistics = () => {
const [loading, setLoading] = useState(true)
const [exporting, setExporting] = useState(false)
const statsQuery = useMemo(
() => buildStatsQuery(timePreset, customRange),
[timePreset, customRange],
)
const trendSubtitle = useMemo(() => {
if (timePreset === 'custom' && customRange) {
return `${customRange[0].format('YYYY-MM-DD')} ~ ${customRange[1].format('YYYY-MM-DD')} 会话量`
}
if (timePreset === 'today') return '今日会话量'
if (timePreset === 'month') return '近6个月会话量变化'
return '近7天会话量变化'
}, [timePreset, customRange])
useEffect(() => {
// 自定义未选完区间时不请求
if (timePreset === 'custom' && (!customRange?.[0] || !customRange?.[1])) return
const load = async () => {
setLoading(true)
try {
const period = timeRange === 'week' ? 'day' : timeRange
const q = statsQuery
const [kpiRes, trendRes, distributionRes, channelRes, performanceRes] = await Promise.all([
getKPIs(),
getSessionTrend(period),
getResponseDistribution(),
getChannelDistribution(),
getAgentPerformance(),
getKPIs(q),
getSessionTrend(q),
getResponseDistribution(q),
getChannelDistribution(q),
getAgentPerformance(q),
])
setKpis(kpiRes.data)
setSessionTrendData(Array.isArray(trendRes.data) ? trendRes.data : [])
@@ -70,8 +95,8 @@ const Statistics = () => {
setLoading(false)
}
}
load()
}, [timeRange])
void load()
}, [statsQuery, timePreset, customRange])
const satPercent = useMemo(() => {
const avg = kpis?.satisfaction_avg ?? 0
@@ -187,15 +212,18 @@ const Statistics = () => {
<div className="min-w-0">
<h1 className="text-base font-semibold text-neutral-900 m-0 truncate"></h1>
</div>
<div className="flex items-center gap-2 shrink-0">
<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">
{rangeOptions.map(opt => {
const active = timeRange === opt.value
const active = timePreset === opt.value
return (
<button
key={opt.value}
type="button"
onClick={() => setTimeRange(opt.value)}
onClick={() => {
setTimePreset(opt.value)
setCustomRange(null)
}}
className={`h-7 px-3 rounded-md text-sm border-0 cursor-pointer transition-colors ${
active
? 'bg-[#2563eb] text-white font-medium shadow-sm'
@@ -207,6 +235,23 @@ const Statistics = () => {
)
})}
</div>
<RangePicker
size="small"
allowClear
value={customRange}
disabledDate={current => current != null && current.isAfter(dayjs().endOf('day'))}
placeholder={['开始日期', '结束日期']}
className={timePreset === 'custom' ? 'ring-1 ring-[#2563eb] rounded-md' : undefined}
onChange={values => {
if (values?.[0] && values?.[1]) {
setCustomRange([values[0], values[1]])
setTimePreset('custom')
} else {
setCustomRange(null)
if (timePreset === 'custom') setTimePreset('week')
}
}}
/>
<button
type="button"
disabled={exporting}
@@ -214,7 +259,7 @@ const Statistics = () => {
onClick={async () => {
setExporting(true)
try {
await exportStatisticsCSV()
await exportStatisticsCSV(statsQuery)
message.success('统计报告已导出')
} catch (e) {
message.error(e instanceof Error ? e.message : '导出失败')
@@ -274,7 +319,7 @@ const Statistics = () => {
<div className="bg-white rounded-xl border border-neutral-200 shadow-sm 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[timeRange]}</p>
<p className="m-0 mt-0.5 text-xs text-neutral-400">{trendSubtitle}</p>
</div>
{sessionTrendData.length === 0 ? (
<div className="h-[260px] flex items-center justify-center text-sm text-neutral-400"></div>
+51 -11
View File
@@ -212,6 +212,26 @@ export interface StatisticsKpis {
total_messages: number
}
export type StatsQuery = {
/** today | week | month */
period?: string
/** YYYY-MM-DD,与 to 同时传则优先自定义区间 */
from?: string
to?: string
}
function statsQueryString(params?: StatsQuery) {
const search = new URLSearchParams()
if (params?.from && params?.to) {
search.set('from', params.from)
search.set('to', params.to)
} else if (params?.period) {
search.set('period', params.period)
}
const qs = search.toString()
return qs ? `?${qs}` : ''
}
// Auth
export const login = (params: LoginParams) => post<LoginResult>('/login', params)
@@ -359,8 +379,8 @@ export const exportSessionsCSV = (params?: {
return downloadFile(`/sessions/export${qs ? `?${qs}` : ''}`, `sessions_${Date.now()}.csv`)
}
export const exportStatisticsCSV = () =>
downloadFile('/statistics/export', `statistics_${Date.now()}.csv`)
export const exportStatisticsCSV = (params?: StatsQuery) =>
downloadFile(`/statistics/export${statsQueryString(params)}`, `statistics_${Date.now()}.csv`)
export const getCustomer = (id: number) =>
get<{ customer: Customer; sessions: Session[]; contacts?: CustomerContact[] }>(`/customers/${id}`)
@@ -465,6 +485,8 @@ export interface QuickReply {
status: 'draft' | 'published' | string
sort_order: number
usage_count: number
/** 当前坐席个人调用次数(建议排序用) */
my_usage_count?: number
created_at: string
updated_at: string
}
@@ -486,11 +508,17 @@ export const getQuickReplies = (params?: {
return getList<QuickReply>(`/quick-replies${qs ? `?${qs}` : ''}`)
}
export const suggestQuickReplies = (prefix = '') => {
/** slash=输入码前缀;keyword=标题/内容关键字。均按个人调用频次排序,最多 10 条 */
export const suggestQuickReplies = (opts?: {
mode?: 'slash' | 'keyword'
prefix?: string
q?: string
}) => {
const search = new URLSearchParams()
if (prefix) search.set('prefix', prefix)
const qs = search.toString()
return get<QuickReply[]>(`/quick-replies/suggest${qs ? `?${qs}` : ''}`)
search.set('mode', opts?.mode || 'slash')
if (opts?.prefix) search.set('prefix', opts.prefix)
if (opts?.q) search.set('q', opts.q)
return get<QuickReply[]>(`/quick-replies/suggest?${search.toString()}`)
}
export const createQuickReply = (data: {
@@ -597,11 +625,23 @@ export const updateTenantSettings = (
) => put<TenantSettings>('/settings', data)
// Statistics
export const getKPIs = () => get<StatisticsKpis>('/statistics/kpi')
export const getSessionTrend = (period: 'today' | 'day' | 'month' = 'day') => get<{ date: string; count: number }[]>(`/statistics/trend?period=${period}`)
export const getResponseDistribution = () => get<{ range: string; count: number }[]>('/statistics/response-distribution')
export const getChannelDistribution = () => get<{ type: string; value: number }[]>('/statistics/channels')
export const getAgentPerformance = () => get<{ name: string; conversations: number; avg_response: number; satisfaction: number }[]>('/statistics/performance')
export const getKPIs = (params?: StatsQuery) =>
get<StatisticsKpis>(`/statistics/kpi${statsQueryString(params)}`)
export const getSessionTrend = (params?: StatsQuery | 'today' | 'day' | 'month') => {
// 兼容旧调用 getSessionTrend('day')
if (typeof params === 'string') {
return get<{ date: string; count: number }[]>(`/statistics/trend?period=${params}`)
}
return get<{ date: string; count: number }[]>(`/statistics/trend${statsQueryString(params)}`)
}
export const getResponseDistribution = (params?: StatsQuery) =>
get<{ range: string; count: number }[]>(`/statistics/response-distribution${statsQueryString(params)}`)
export const getChannelDistribution = (params?: StatsQuery) =>
get<{ type: string; value: number }[]>(`/statistics/channels${statsQueryString(params)}`)
export const getAgentPerformance = (params?: StatsQuery) =>
get<{ name: string; conversations: number; avg_response: number; satisfaction: number }[]>(
`/statistics/performance${statsQueryString(params)}`,
)
// Admin
export const getTenants = (params?: { search?: string; status?: string; page?: number; pageSize?: number }) => {