优化快捷回复联想与个人调用统计,数据统计支持自定义日期
/ 按输入码与个人频次建议(最多10条,唯一自动填入),正文关键字联想;统计页可日历选区间并统一中文日历。
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user