From 6304f97bc3d960cc829a84f5451202635f9689ac Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 5 Sep 2026 14:40:14 +0800 Subject: [PATCH] =?UTF-8?q?refactor(web):=20=E6=8B=86=E5=88=86=20Dashboard?= =?UTF-8?q?/Settings/VisitorChat=20=E8=B6=85=E5=A4=A7=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=E4=B8=BA=E5=AD=90=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dashboard 2591→1944 行,抽出 dashboard/ 纯函数、客户资料字段与 5 个状态自持弹窗 - Settings 1602→478 行,抽出 settings/ 16 个分区模块(设置卡片、弹窗与工具函数) - VisitorChat 1343→1045 行,抽出 visitor/ 头部、消息区、输入区与评价浮层 - 均为纯代码搬移:行为、路由入口与导出不变;tsc -b 与 vite build 通过 --- web/src/pages/agent/Dashboard.tsx | 755 +--------- web/src/pages/agent/Settings.tsx | 1328 ++--------------- .../pages/agent/dashboard/CustomerFields.tsx | 221 +++ web/src/pages/agent/dashboard/helpers.ts | 156 ++ web/src/pages/agent/dashboard/modals.tsx | 427 ++++++ .../agent/settings/AssignmentSection.tsx | 113 ++ .../pages/agent/settings/AutoReplySection.tsx | 259 ++++ web/src/pages/agent/settings/BasicSection.tsx | 99 ++ .../pages/agent/settings/ChannelsSection.tsx | 152 ++ .../pages/agent/settings/NotifySection.tsx | 70 + web/src/pages/agent/settings/StaffModal.tsx | 100 ++ web/src/pages/agent/settings/StaffSection.tsx | 157 ++ web/src/pages/agent/settings/TagModal.tsx | 90 ++ web/src/pages/agent/settings/TagsSection.tsx | 107 ++ .../pages/agent/settings/WorktimeSection.tsx | 200 +++ web/src/pages/agent/settings/channelMeta.tsx | 10 + web/src/pages/agent/settings/tabs.tsx | 46 + web/src/pages/agent/settings/tagColors.ts | 17 + web/src/pages/agent/settings/types.ts | 7 + web/src/pages/agent/settings/welcome.ts | 16 + web/src/pages/agent/settings/worktime.ts | 59 + web/src/widgets/VisitorChat.tsx | 472 ++---- web/src/widgets/visitor/ChatComposer.tsx | 138 ++ web/src/widgets/visitor/ChatHeader.tsx | 62 + web/src/widgets/visitor/MessageList.tsx | 147 ++ web/src/widgets/visitor/RatingOverlay.tsx | 112 ++ web/src/widgets/visitor/types.ts | 43 + 27 files changed, 3051 insertions(+), 2312 deletions(-) create mode 100644 web/src/pages/agent/dashboard/CustomerFields.tsx create mode 100644 web/src/pages/agent/dashboard/helpers.ts create mode 100644 web/src/pages/agent/dashboard/modals.tsx create mode 100644 web/src/pages/agent/settings/AssignmentSection.tsx create mode 100644 web/src/pages/agent/settings/AutoReplySection.tsx create mode 100644 web/src/pages/agent/settings/BasicSection.tsx create mode 100644 web/src/pages/agent/settings/ChannelsSection.tsx create mode 100644 web/src/pages/agent/settings/NotifySection.tsx create mode 100644 web/src/pages/agent/settings/StaffModal.tsx create mode 100644 web/src/pages/agent/settings/StaffSection.tsx create mode 100644 web/src/pages/agent/settings/TagModal.tsx create mode 100644 web/src/pages/agent/settings/TagsSection.tsx create mode 100644 web/src/pages/agent/settings/WorktimeSection.tsx create mode 100644 web/src/pages/agent/settings/channelMeta.tsx create mode 100644 web/src/pages/agent/settings/tabs.tsx create mode 100644 web/src/pages/agent/settings/tagColors.ts create mode 100644 web/src/pages/agent/settings/types.ts create mode 100644 web/src/pages/agent/settings/welcome.ts create mode 100644 web/src/pages/agent/settings/worktime.ts create mode 100644 web/src/widgets/visitor/ChatComposer.tsx create mode 100644 web/src/widgets/visitor/ChatHeader.tsx create mode 100644 web/src/widgets/visitor/MessageList.tsx create mode 100644 web/src/widgets/visitor/RatingOverlay.tsx create mode 100644 web/src/widgets/visitor/types.ts diff --git a/web/src/pages/agent/Dashboard.tsx b/web/src/pages/agent/Dashboard.tsx index cad2583..7c9ecfd 100644 --- a/web/src/pages/agent/Dashboard.tsx +++ b/web/src/pages/agent/Dashboard.tsx @@ -1,10 +1,10 @@ import { useState, useEffect, useRef, useCallback } from 'react' -import { Button, Checkbox, Dropdown, Input, Modal, Radio, Select, Spin, message as antMsg, Popover } from 'antd' +import { Button, Dropdown, Input, Modal, Popover, Spin, message as antMsg } from 'antd' import { CheckCircleOutlined, FileTextOutlined, FlagOutlined, PaperClipOutlined, SearchOutlined, SendOutlined, SwapOutlined, FilterOutlined, ExportOutlined, BookOutlined, PictureOutlined, ThunderboltOutlined, - StopOutlined, PlusOutlined, TagsOutlined, UserOutlined, + StopOutlined, UserOutlined, } from '@ant-design/icons' import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker' import { ChatImage } from '@/components/common/ImagePreview' @@ -12,382 +12,19 @@ import MarkdownBody, { stripMarkdown } from '@/components/common/MarkdownBody' import { useAuth } from '@/stores/auth' import { OPEN_AGENT_SESSION_EVENT, useAgentNotifications } from '@/stores/notifications' import { - addSessionNote, claimSession, createBlacklist, endSession, getAvailableAgents, getCustomer, getCustomers, getKnowledgeEntries, - getCustomerTags, getQuickReplies, getSession, getSessionMessages, getSessions, markSessionRead, sendSessionMessage, - suggestQuickReplies, transferSession, updateCustomer, updateSessionPriority, uploadImage, useQuickReply as recordQuickReplyUsage, - type AvailableAgent, type BlacklistDuration, type Customer, type CustomerContact, type CustomerTag, type KnowledgeEntry, type Message, type QuickReply, + addSessionNote, claimSession, getCustomer, getCustomerTags, getCustomers, getSession, getSessionMessages, getSessions, + getQuickReplies, markSessionRead, sendSessionMessage, suggestQuickReplies, updateCustomer, updateSessionPriority, uploadImage, + useQuickReply as recordQuickReplyUsage, + type Customer, type CustomerContact, type CustomerTag, type Message, type QuickReply, type Session, type SessionEvent, type VisitorPageView, } from '@/services/api' - -type CustomerEditableField = 'phone' | 'email' | 'wechat' | 'qq' - -/** 客户侧栏基本信息:点击输入,失焦/回车保存 */ -function CustomerInfoField({ - label, - value, - placeholder, - disabled, - saving, - onSave, -}: { - label: string - value: string - placeholder?: string - disabled?: boolean - saving?: boolean - onSave: (next: string) => Promise | void -}) { - const [editing, setEditing] = useState(false) - const [draft, setDraft] = useState(value) - const inputRef = useRef(null) - - useEffect(() => { - if (!editing) setDraft(value) - }, [value, editing]) - - useEffect(() => { - if (editing) { - requestAnimationFrame(() => { - inputRef.current?.focus() - inputRef.current?.select() - }) - } - }, [editing]) - - const commit = async () => { - const next = draft.trim() - setEditing(false) - if (next === (value || '').trim()) return - await onSave(next) - } - - return ( -
- {label} - {editing ? ( - setDraft(e.target.value)} - onBlur={() => { void commit() }} - onKeyDown={e => { - if (e.key === 'Enter') { - e.preventDefault() - void commit() - } - if (e.key === 'Escape') { - setDraft(value) - setEditing(false) - } - }} - /> - ) : ( - - )} -
- ) -} - -/** 按 id 合并消息,再按 seq / id 排序 */ -function mergeMessagesBySeq(existing: Message[], incoming: Message[]): Message[] { - const map = new Map() - for (const item of existing) map.set(item.id, item) - for (const item of incoming) map.set(item.id, item) - return Array.from(map.values()).sort((a, b) => { - const seqDiff = (a.seq || 0) - (b.seq || 0) - return seqDiff !== 0 ? seqDiff : a.id - b.id - }) -} - -function maxMessageSeq(messages: Message[]): number { - return messages.reduce((acc, item) => Math.max(acc, item.seq || 0), 0) -} - -function normalizeMessage(raw: Partial & { id?: number }, sessionId: number): Message | null { - if (!raw?.id) return null - return { - id: Number(raw.id), - session_id: Number(raw.session_id ?? sessionId), - sender_type: raw.sender_type === 'agent' ? 'agent' : 'visitor', - sender_id: raw.sender_id ?? null, - content: String(raw.content ?? ''), - type: raw.type === 'image' ? 'image' : 'text', - seq: Number(raw.seq ?? 0), - sent_at: raw.sent_at || new Date().toISOString(), - } -} - -const endReasons = [ - { value: 'resolved', label: '已解决' }, - { value: 'no_response', label: '无人回复' }, - { value: 'visitor_left', label: '访客离开' }, - { value: 'transferred', label: '已转接' }, - { value: 'other', label: '其他' }, -] -/** 列表项展示:紧急 / 等待中 / 进行中 */ -function listStatusMeta(session: Session) { - if (session.priority === 'urgent') { - return { label: '紧急', bar: '#dc2626', avatarBg: '#fef2f2', avatarColor: '#dc2626', dot: '#dc2626' } - } - if (session.status === 'waiting') { - return { label: '等待中', bar: '#d97706', avatarBg: '#fffbeb', avatarColor: '#d97706', dot: '#d97706' } - } - return { label: '进行中', bar: '#2563eb', avatarBg: '#eff6ff', avatarColor: '#2563eb', dot: '#2563eb' } -} - -/** 坐席在线状态(转接列表) */ -function agentStatusMeta(status: string) { - switch (status) { - case 'online': - return { text: '在线', dot: '#16a34a', bg: '#ecfdf5', color: '#15803d' } - case 'busy': - return { text: '忙碌', dot: '#d97706', bg: '#fffbeb', color: '#b45309' } - case 'disabled': - return { text: '停用', dot: '#94a3b8', bg: '#f1f5f9', color: '#64748b' } - default: - return { text: '离线', dot: '#94a3b8', bg: '#f1f5f9', color: '#64748b' } - } -} - -function relativeTime(iso?: string | null) { - if (!iso) return '' - const diff = Date.now() - new Date(iso).getTime() - const mins = Math.floor(diff / 60000) - if (mins < 1) return '刚刚' - if (mins < 60) return `${mins}分钟前` - const hours = Math.floor(mins / 60) - if (hours < 24) return `${hours}小时前` - const days = Math.floor(hours / 24) - if (days < 7) return `${days}天前` - return new Date(iso).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }) -} - -function parseTags(tagsStr: string): string[] { - try { - const parsed = JSON.parse(tagsStr) - return Array.isArray(parsed) ? parsed : [] - } catch { - return tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [] - } -} - -const customerTagColorMap: Record = { - amber: { bg: '#fef3c7', color: '#92400e' }, - green: { bg: '#f0fdf4', color: '#16a34a' }, - blue: { bg: '#dbeafe', color: '#2563eb' }, - cyan: { bg: '#ecfeff', color: '#0891b2' }, - violet: { bg: '#f3e8ff', color: '#7c3aed' }, - rose: { bg: '#fff1f2', color: '#e11d48' }, - orange: { bg: '#fffbeb', color: '#d97706' }, - slate: { bg: '#f1f5f9', color: '#475569' }, - // 兼容历史标签名称 - 'VIP客户': { bg: '#fef3c7', color: '#92400e' }, - 'VIP': { bg: '#fef3c7', color: '#92400e' }, - '新客户': { bg: '#f0fdf4', color: '#16a34a' }, - '活跃': { bg: '#dbeafe', color: '#2563eb' }, - '沉默': { bg: '#fffbeb', color: '#d97706' }, - '企业客户': { bg: '#ecfeff', color: '#0891b2' }, -} - -function customerTagStyle(tag: string, catalog: CustomerTag[]) { - const meta = catalog.find(item => item.name === tag) - return (meta?.color ? customerTagColorMap[meta.color] : undefined) - || customerTagColorMap[tag] - || { bg: '#f1f5f9', color: '#475569' } -} - -function customerTagLabel(tag: string) { - return tag === 'VIP客户' ? 'VIP' : tag -} - -function CustomerTagPill({ tag, catalog }: { tag: string; catalog: CustomerTag[] }) { - const s = customerTagStyle(tag, catalog) - return ( - - {customerTagLabel(tag)} - - ) -} - -function CustomerTagsEditor({ - tags, - catalog, - saving, - onChange, -}: { - tags: string - catalog: CustomerTag[] - saving?: boolean - onChange: (next: string[]) => Promise | void -}) { - const [open, setOpen] = useState(false) - const currentTags = parseTags(tags) - const optionNames = Array.from(new Set([ - ...catalog.map(item => item.name), - ...currentTags, - ])) - - const toggleTag = (tag: string) => { - if (saving) return - const selected = currentTags.includes(tag) - if (!selected && currentTags.length >= 10) { - antMsg.warning('每位客户最多 10 个标签') - return - } - const next = selected - ? currentTags.filter(item => item !== tag) - : [...currentTags, tag] - void onChange(next) - } - - const content = ( -
-
- 客户标签 - {currentTags.length}/10 -
- {optionNames.length === 0 ? ( -
- 暂无标签库 -
- ) : ( -
- {optionNames.map(name => { - const selected = currentTags.includes(name) - const s = customerTagStyle(name, catalog) - return ( - - ) - })} -
- )} - {currentTags.length > 0 && ( - - )} -
- ) - - return ( - -
{ - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault() - setOpen(true) - } - }} - > -
-
标签
- {saving ? : } -
-
- {currentTags.length === 0 ? ( - - - 暂无 - - ) : currentTags.map(tag => ( - - ))} -
-
-
- ) -} - -function channelLabel(source?: string) { - if (!source) return '网页' - if (source.includes('微信') || source.toLowerCase().includes('wechat')) return '微信' - if (source.toLowerCase().includes('app')) return 'APP' - if (source.includes('电话')) return '电话' - return source.length > 6 ? '网页' : source -} - -interface SessionDetail { - messages: Message[] - events: SessionEvent[] - pageViews: VisitorPageView[] - pendingCount: number -} - -function formatOnlineDuration(startIso?: string | null, endIso?: string | null, nowMs?: number): string { - if (!startIso) return '—' - const start = new Date(startIso).getTime() - if (!Number.isFinite(start)) return '—' - const end = endIso ? new Date(endIso).getTime() : (nowMs ?? Date.now()) - let sec = Math.max(0, Math.floor((end - start) / 1000)) - const h = Math.floor(sec / 3600) - sec %= 3600 - const m = Math.floor(sec / 60) - const s = sec % 60 - if (h > 0) return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` - return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` -} - -function shortPagePath(url?: string): string { - if (!url) return '—' - try { - const u = new URL(url) - const path = u.pathname + (u.search || '') + (u.hash || '') - return path.length > 56 ? `${path.slice(0, 54)}…` : path || '/' - } catch { - return url.length > 56 ? `${url.slice(0, 54)}…` : url - } -} +import { + channelLabel, formatOnlineDuration, listStatusMeta, maxMessageSeq, mergeMessagesBySeq, normalizeMessage, + relativeTime, shortPagePath, + type CustomerEditableField, type SessionDetail, +} from './dashboard/helpers' +import { CustomerInfoField, CustomerTagsEditor } from './dashboard/CustomerFields' +import { BlacklistModal, EndSessionModal, KnowledgePickerModal, QuickReplyPickerModal, TransferModal } from './dashboard/modals' const Dashboard = () => { const { user } = useAuth() @@ -402,24 +39,10 @@ const Dashboard = () => { const [search, setSearch] = useState('') const [sending, setSending] = useState(false) const [transferOpen, setTransferOpen] = useState(false) - const [availableAgents, setAvailableAgents] = useState([]) - const [targetAgentID, setTargetAgentID] = useState() const [blacklistOpen, setBlacklistOpen] = useState(false) - const [blacklistKind, setBlacklistKind] = useState<'ip' | 'device'>('ip') - const [blacklistDuration, setBlacklistDuration] = useState('7d') - const [blacklistReason, setBlacklistReason] = useState('') - const [blacklistEndSession, setBlacklistEndSession] = useState(true) - const [blacklistSaving, setBlacklistSaving] = useState(false) const [endingOpen, setEndingOpen] = useState(false) - const [endReason, setEndReason] = useState('resolved') const [knowledgeOpen, setKnowledgeOpen] = useState(false) - const [knowledgeKeyword, setKnowledgeKeyword] = useState('') - const [knowledgeEntries, setKnowledgeEntries] = useState([]) - const [knowledgeLoading, setKnowledgeLoading] = useState(false) const [quickOpen, setQuickOpen] = useState(false) - const [quickKeyword, setQuickKeyword] = useState('') - const [quickList, setQuickList] = useState([]) - const [quickLoading, setQuickLoading] = useState(false) /** 右侧栏快捷回复:团队 / 个人 */ const [sidebarQuickScope, setSidebarQuickScope] = useState<'team' | 'personal'>('team') const [sidebarQuickKeyword, setSidebarQuickKeyword] = useState('') @@ -889,26 +512,6 @@ const Dashboard = () => { if (selectedId) loadDetail(selectedId) }, [selectedId, loadDetail]) - useEffect(() => { - if (!knowledgeOpen) return - setKnowledgeLoading(true) - getKnowledgeEntries({ search: knowledgeKeyword, page: 1 }).then(response => { - setKnowledgeEntries(Array.isArray(response.list) ? response.list : []) - }).catch(() => setKnowledgeEntries([])).finally(() => setKnowledgeLoading(false)) - }, [knowledgeOpen, knowledgeKeyword]) - - useEffect(() => { - if (!quickOpen) return - setQuickLoading(true) - getQuickReplies({ - scope: 'all', - q: quickKeyword.trim() || undefined, - page: 1, - pageSize: 50, - }).then(response => { - setQuickList(Array.isArray(response.list) ? response.list : []) - }).catch(() => setQuickList([])).finally(() => setQuickLoading(false)) - }, [quickOpen, quickKeyword]) // 右侧栏快捷回复列表(始终可用) useEffect(() => { @@ -1312,85 +915,30 @@ const Dashboard = () => { } } - const openTransfer = async () => { - if (!selected) return - try { - // 默认返回在线/忙碌坐席(含 status) - const response = await getAvailableAgents() - setAvailableAgents((response.data || []).filter(agent => agent.id !== user?.user_id)) - setTargetAgentID(undefined) - setTransferOpen(true) - } catch { - antMsg.error('加载可转接坐席失败') - } + const clearAndReload = async () => { + setSelectedId(null) + setDetail(null) + await loadAll() } - const handleTransfer = async () => { - if (!selected || !targetAgentID) return - const target = availableAgents.find(a => a.id === targetAgentID) - if (target && target.status !== 'online' && target.status !== 'busy') { - antMsg.warning('只能转接给在线或忙碌的坐席') - return - } - try { - await transferSession(selected.id, targetAgentID) - antMsg.success('会话已转接') - setTransferOpen(false) - setSelectedId(null) - setDetail(null) - await loadAll() - } catch (error) { - antMsg.error(error instanceof Error ? error.message : '转接失败') - } + const openTransfer = () => { + if (!selected) return + setTransferOpen(true) } const openBlacklist = () => { if (!selected) return - setBlacklistKind(selected.visitor_ip ? 'ip' : 'device') - setBlacklistDuration('7d') - setBlacklistReason('') - setBlacklistEndSession(selected.status === 'active' || selected.status === 'waiting') setBlacklistOpen(true) } - const handleBlacklist = async () => { - if (!selected) return - const reason = blacklistReason.trim() - if (!reason) { - antMsg.warning('请填写拉黑原因') - return - } - if (blacklistKind === 'ip' && !selected.visitor_ip) { - antMsg.warning('该会话无有效 IP,请改选「设备」') - return - } - if (blacklistKind === 'device' && !selected.device_key && !selected.user_agent) { - antMsg.warning('该会话无设备标识,请改选「IP」') - return - } - setBlacklistSaving(true) - try { - await createBlacklist({ - session_id: selected.id, - kind: blacklistKind, - duration: blacklistDuration, - reason, - end_session: blacklistEndSession, - }) - antMsg.success(blacklistKind === 'ip' ? '已拉黑该 IP' : '已拉黑该设备') - setBlacklistOpen(false) - if (blacklistEndSession) { - setSelectedId(null) - setDetail(null) - } else if (selectedId) { - await loadDetail(selectedId, false) - } - await loadAll() - } catch (error) { - antMsg.error(error instanceof Error ? error.message : '拉黑失败') - } finally { - setBlacklistSaving(false) + const handleBlacklistDone = async (ended: boolean) => { + if (ended) { + setSelectedId(null) + setDetail(null) + } else if (selectedId) { + await loadDetail(selectedId, false) } + await loadAll() } const handlePriority = async (priority: 'normal' | 'urgent') => { @@ -1404,19 +952,6 @@ const Dashboard = () => { } } - const handleEnd = async () => { - if (!selected) return - try { - await endSession(selected.id, endReason) - antMsg.success('会话已结束') - setEndingOpen(false) - setSelectedId(null) - setDetail(null) - await loadAll() - } catch (error) { - antMsg.error(error instanceof Error ? error.message : '结束会话失败') - } - } const handleAddNote = async () => { if (!selected || !noteInput.trim()) return @@ -2357,150 +1892,25 @@ const Dashboard = () => { )} - setTransferOpen(false)} - onOk={handleTransfer} - okButtonProps={{ - disabled: !targetAgentID || (() => { - const t = availableAgents.find(a => a.id === targetAgentID) - return Boolean(t && t.status !== 'online' && t.status !== 'busy') - })(), - }} - > -

请选择接手坐席(绿色在线 / 橙色忙碌)。

- {availableAgents.length === 0 ? ( -
- 当前没有其他可转接坐席 -
- ) : ( - -
- setTransferOpen(false)} + onDone={clearAndReload} + /> + setEndingOpen(false)} + onDone={clearAndReload} + /> + setBlacklistOpen(false)} - onOk={handleBlacklist} - okText="确认拉黑" - okButtonProps={{ danger: true, loading: blacklistSaving, disabled: !blacklistReason.trim() }} - cancelButtonProps={{ disabled: blacklistSaving }} - destroyOnClose - > -
-
-
拉黑类型
- setBlacklistKind(e.target.value)} - optionType="button" - buttonStyle="solid" - options={[ - { - value: 'ip', - label: selected?.visitor_ip ? `IP(${selected.visitor_ip})` : 'IP(无)', - disabled: !selected?.visitor_ip, - }, - { - value: 'device', - label: selected?.device_key || selected?.user_agent - ? '设备' - : '设备(无)', - disabled: !selected?.device_key && !selected?.user_agent, - }, - ]} - /> - {blacklistKind === 'device' && ( -
- {selected?.device_key - ? `设备标识:${selected.device_key.slice(0, 12)}…` - : selected?.user_agent - ? `将按浏览器指纹:${selected.user_agent.slice(0, 48)}…` - : ''} -
- )} -
-
-
释放时间
- } - placeholder="搜索标题或内容" - value={knowledgeKeyword} - onChange={event => setKnowledgeKeyword(event.target.value)} - allowClear - className="mb-3" - /> - {knowledgeLoading ? ( -
- ) : ( -
- {knowledgeEntries.length === 0 ? ( -
未找到可用知识条目
- ) : knowledgeEntries.map(entry => ( - - ))} -
- )} - - setQuickOpen(false)} footer={null} width={640}> - } - placeholder="搜索标题、内容或输入码" - value={quickKeyword} - onChange={event => setQuickKeyword(event.target.value)} - allowClear - className="mb-3" - /> -

含团队已发布 + 我的话术。管理请到侧栏「快捷回复」。

- {quickLoading ? ( -
- ) : ( -
- {quickList.length === 0 ? ( -
暂无快捷回复
- ) : quickList.map(item => ( - - ))} -
- )} -
+ setKnowledgeOpen(false)} + onPick={content => setMessageInput(content)} + /> + setQuickOpen(false)} + onApply={item => { void applyQuickReply(item) }} + />
) } diff --git a/web/src/pages/agent/Settings.tsx b/web/src/pages/agent/Settings.tsx index 07d4ec3..e0f996f 100644 --- a/web/src/pages/agent/Settings.tsx +++ b/web/src/pages/agent/Settings.tsx @@ -1,174 +1,27 @@ -import { useEffect, useState, type ReactNode } from 'react' -import { Form, Input, Switch, Select, Button, message, Spin, Empty, Modal, Popconfirm, TimePicker, Upload } from 'antd' +import { useEffect, useState } from 'react' +import { Button, Form, message } from 'antd' +import { PlusOutlined } from '@ant-design/icons' import { - LinkOutlined, WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, CopyOutlined, - PlusOutlined, SettingOutlined, ApiOutlined, TeamOutlined, UserSwitchOutlined, - MessageOutlined, ClockCircleOutlined, BellOutlined, GlobalOutlined, - EditOutlined, StopOutlined, CheckCircleOutlined, DeleteOutlined, - ArrowUpOutlined, ArrowDownOutlined, PictureOutlined, TagsOutlined, -} from '@ant-design/icons' -import dayjs, { type Dayjs } from 'dayjs' -import customParseFormat from 'dayjs/plugin/customParseFormat' -import { - createChannel, createCustomerTag, createStaff, deleteCustomerTag, deleteStaff, + createChannel, createStaff, deleteStaff, getChannels, getCustomerTags, getStaff, getTenantSettings, - updateChannel, updateCustomerTag, updateStaff, updateTenantSettings, uploadImage, - type Channel, type CustomerTag, type StaffUser, type TenantSettings, type WorkHours, type WelcomeSegment, + updateChannel, updateStaff, updateTenantSettings, + type Channel, type CustomerTag, type StaffUser, type TenantSettings, type WelcomeSegment, } from '@/services/api' import { useAuth, usePermission } from '@/stores/auth' -import MarkdownEditor from '@/components/common/MarkdownEditor' -import RichTextEditor from '@/components/common/RichTextEditor' - -const tagColorPresets: { value: string; label: string; bg: string; color: string }[] = [ - { value: 'amber', label: '琥珀', bg: '#fef3c7', color: '#92400e' }, - { value: 'green', label: '绿色', bg: '#f0fdf4', color: '#16a34a' }, - { value: 'blue', label: '蓝色', bg: '#dbeafe', color: '#2563eb' }, - { value: 'cyan', label: '青色', bg: '#ecfeff', color: '#0891b2' }, - { value: 'violet', label: '紫色', bg: '#f3e8ff', color: '#7c3aed' }, - { value: 'rose', label: '玫红', bg: '#fff1f2', color: '#e11d48' }, - { value: 'orange', label: '橙色', bg: '#fffbeb', color: '#d97706' }, - { value: 'slate', label: '灰蓝', bg: '#f1f5f9', color: '#475569' }, -] - -function tagColorStyle(color: string): { bg: string; color: string } { - const preset = tagColorPresets.find(p => p.value === color) - if (preset) return { bg: preset.bg, color: preset.color } - if (color?.startsWith('#')) return { bg: `${color}22`, color } - return { bg: '#f1f5f9', color: '#475569' } -} - -dayjs.extend(customParseFormat) - -const typeMeta: Record = { - web: { icon: , label: '网页聊天', desc: '嵌入官网或任意网页的在线客服窗口' }, - wechat: { icon: , label: '微信公众号', desc: '对接微信公众号客服消息(预留)' }, - app: { icon: , label: 'APP 内嵌', desc: '移动端 App 内嵌客服(预留)' }, - phone: { icon: , label: '电话客服', desc: '电话进线与回拨(预留)' }, - email: { icon: , label: '邮件工单', desc: '邮件自动创建工单(预留)' }, -} - -const weekDays: { key: string; label: string }[] = [ - { key: 'monday', label: '周一' }, - { key: 'tuesday', label: '周二' }, - { key: 'wednesday', label: '周三' }, - { key: 'thursday', label: '周四' }, - { key: 'friday', label: '周五' }, - { key: 'saturday', label: '周六' }, - { key: 'sunday', label: '周日' }, -] - -/** 工作日模式:休息 / 全天 / 自定义时段 */ -type WorkMode = 'rest' | 'allday' | 'custom' - -const workModeOptions: { value: WorkMode; label: string }[] = [ - { value: 'rest', label: '休息' }, - { value: 'allday', label: '全天' }, - { value: 'custom', label: '自定义时段' }, -] - -/** 快捷时段预设(选中后填入自定义时间范围) */ -const workRangePresets: { label: string; start: string; end: string }[] = [ - { label: '09:00-18:00', start: '09:00', end: '18:00' }, - { label: '08:00-17:00', start: '08:00', end: '17:00' }, - { label: '09:00-12:00', start: '09:00', end: '12:00' }, - { label: '13:00-18:00', start: '13:00', end: '18:00' }, - { label: '10:00-22:00', start: '10:00', end: '22:00' }, -] - -function parseWorkSlot(slot?: string): { mode: WorkMode; range?: [Dayjs, Dayjs] } { - const s = (slot || '').trim() - if (!s || s === '休息') return { mode: 'rest' } - if (s === '全天') return { mode: 'allday' } - const m = s.match(/^(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})$/) - if (m) { - const start = dayjs(m[1], 'HH:mm') - const end = dayjs(m[2], 'HH:mm') - if (start.isValid() && end.isValid()) { - return { mode: 'custom', range: [start, end] } - } - } - return { mode: 'rest' } -} - -function encodeWorkSlot(mode: WorkMode | undefined, range?: [Dayjs, Dayjs] | null): string { - if (mode === 'allday') return '全天' - if (mode === 'custom' && range?.[0]?.isValid() && range?.[1]?.isValid()) { - return `${range[0].format('HH:mm')}-${range[1].format('HH:mm')}` - } - return '' -} - -function defaultCustomRange(): [Dayjs, Dayjs] { - return [dayjs('09:00', 'HH:mm'), dayjs('18:00', 'HH:mm')] -} - -const MAX_WELCOME_SEGMENTS = 10 - -function normalizeWelcomeFromSettings(data: TenantSettings): WelcomeSegment[] { - if (Array.isArray(data.welcome_messages) && data.welcome_messages.length > 0) { - return data.welcome_messages.map(s => ({ - type: s.type === 'image' ? 'image' : 'text', - content: s.content || '', - })) - } - if (data.welcome_message?.trim()) { - return [{ type: 'text', content: data.welcome_message.trim() }] - } - return [{ type: 'text', content: '您好!欢迎咨询,请问有什么可以帮您?' }] -} - -const roleLabel: Record = { - admin: '管理员', - supervisor: '主管', - agent: '客服', -} - -const statusLabel: Record = { - online: { text: '在线', className: 'bg-emerald-50 text-emerald-700' }, - offline: { text: '离线', className: 'bg-neutral-100 text-neutral-600' }, - busy: { text: '忙碌', className: 'bg-amber-50 text-amber-700' }, - disabled: { text: '已禁用', className: 'bg-red-50 text-red-600' }, -} - -export interface TabItem { - key: string - label: string - desc: string - icon: ReactNode -} - -export interface TabGroup { - groupName: string - items: TabItem[] -} - -const tabGroups: TabGroup[] = [ - { - groupName: '企业与渠道', - items: [ - { key: 'basic', label: '基本设置', desc: '租户展示名称、默认昵称与时区', icon: }, - { key: 'channels', label: '渠道管理', desc: '配置客户服务接入渠道与嵌入代码', icon: }, - { key: 'staff', label: '坐席账号', desc: '管理客服/主管账号与坐席配额', icon: }, - ], - }, - { - groupName: '规则与路由', - items: [ - { key: 'assignment', label: '客服分配规则', desc: '自动分配策略与并发上限', icon: }, - { key: 'tags', label: '客户标签', desc: '维护标签库,坐席为客户打标时选择', icon: }, - { key: 'worktime', label: '工作时间', desc: '在线服务时段与非工作时间提示', icon: }, - ], - }, - { - groupName: '自动化与通知', - items: [ - { key: 'autoreply', label: '自动回复', desc: '多段欢迎语(含图片)与离线留言提示', icon: }, - { key: 'notify', label: '通知设置', desc: '新会话、留言与日报偏好', icon: }, - ], - }, -] - -const tabItems: TabItem[] = tabGroups.flatMap(g => g.items) +import { tabGroups, tabItems } from './settings/tabs' +import { typeMeta } from './settings/channelMeta' +import { normalizeWelcomeFromSettings } from './settings/welcome' +import { parseWorkSlot, weekDays } from './settings/worktime' +import BasicSection from './settings/BasicSection' +import ChannelsSection from './settings/ChannelsSection' +import AssignmentSection from './settings/AssignmentSection' +import StaffSection from './settings/StaffSection' +import StaffModal from './settings/StaffModal' +import AutoReplySection from './settings/AutoReplySection' +import WorktimeSection from './settings/WorktimeSection' +import NotifySection from './settings/NotifySection' +import TagsSection from './settings/TagsSection' +import TagModal from './settings/TagModal' const Settings = () => { const { user } = useAuth() @@ -386,19 +239,6 @@ const Settings = () => { } } - const copyScript = async (script: string) => { - try { - const origin = window.location.origin - const text = script.includes('src="/widget.js"') - ? script.replace('src="/widget.js"', `src="${origin}/widget.js"`) - : script - await navigator.clipboard.writeText(text) - message.success('已复制接入代码') - } catch { - message.error('复制失败') - } - } - const toggleChannel = async (ch: Channel, enabled: boolean) => { setTogglingId(ch.id) try { @@ -522,1079 +362,115 @@ const Settings = () => {
{activeTab === 'basic' && ( -
- {loadingSettings && !settings ? ( -
- ) : ( -
savePartial({ - display_name: values.display_name, - agent_nickname: values.agent_nickname, - timezone: values.timezone, - })} - > -
-

企业展示档案

-

配置访客端看到的租户品牌名称与在线客服默认身份

-
- -
- 租户名称} - rules={[{ required: true, message: '请输入租户名称' }, { min: 1, max: 100 }]} - extra={公司或品牌名称,展示于访客端聊天窗口顶部} - > - - - - 客服默认昵称} - extra={未设置个人独立昵称的坐席将默认以此名称展示} - > - - -
- -
-

区域与本地化

-

定义系统的标准服务时区与工作台界面语言

-
- -
- 服务时区} - extra={影响营业时间判断、会话统计与历史报表生成} - > - - -
- -
- 配置保存后将实时对访客端与坐席工作台生效 - -
-
- )} -
+ )} {activeTab === 'channels' && ( -
- {loadingChannels ? ( -
- ) : channels.length === 0 ? ( -
- - - -
- ) : ( -
- {channels.map(ch => { - const meta = typeMeta[ch.type] || { icon: , label: ch.name || ch.type, desc: '' } - const enabled = ch.status === 'enabled' - return ( -
-
-
- {meta.icon} -
-
-
-
-

- {ch.name || meta.label} -

- - {enabled ? '已启用' : '未启用'} - -
- toggleChannel(ch, v)} - /> -
- {meta.desc && ( -

{meta.desc}

- )} - {ch.channel_key && ( -
- 渠道 ID - - {ch.channel_key} - -
- )} - {ch.type === 'web' && ch.script_code && ( -
-
网站嵌入代码
-
- - {ch.script_code.replace( - 'src="/widget.js"', - `src="${window.location.origin}/widget.js"`, - )} - - -
-
- )} - {ch.type !== 'web' && ( -
- 本期仅网页渠道可完整接入,其他类型预留开关。 -
- )} -
-
-
- ) - })} -
- )} - - {missingTypes.length > 0 && channels.length > 0 && ( -
-
可添加其他渠道
-
- {missingTypes.map(t => ( - - ))} -
-
- )} -
+ )} {activeTab === 'assignment' && ( -
- {loadingSettings ? ( -
- ) : ( - <> -
-
-
- -
-
-
自动分配规则
-
- 工作时间内,访客进线会按下列策略自动分配给在线一线客服; - 非工作时间或无可接坐席时进入排队或离线留言。人工领取与内部转接不受此策略限制。 -
-
-
-
- -
-
{ - await savePartial({ - assign_strategy: values.assign_strategy, - max_active_per_agent: Number(values.max_active_per_agent) || 0, - }, '分配规则已保存') - }} - > - 分配策略} - rules={[{ required: true, message: '请选择策略' }]} - extra={负载最低:优先分配给进行中会话最少的坐席;轮询:在可接坐席中均匀依次分发。} - > - - - -
- {isAdmin ? ( - - ) : ( -

仅租户管理员可修改分配规则

- )} -
-
-
- -
-
使用说明
-

· 自动分发候选范围:角色为客服、当前状态为「在线」的账号(忙碌/离线状态不参与自动分发)。

-

· 专属坐席组与渠道绑定策略将在后续版本逐步上线。

-
- - )} -
+ )} {activeTab === 'staff' && ( -
- {!canViewStaff ? ( -
- 仅管理员或主管可查看坐席列表 -
- ) : loadingStaff ? ( -
- ) : ( - <> -
-
-
坐席配额占用
-
- {seatUsed} - / {seatLimit} -
-
-
- 启用中的管理员、主管、客服各占 1 个坐席。禁用账号可即刻释放坐席配额。 -
-
- - {staffList.length === 0 ? ( - - ) : ( -
- - - - - - - - - {isAdmin && ( - - )} - - - - {staffList.map(s => { - const st = statusLabel[s.status] || statusLabel.offline - return ( - - - - - - - {isAdmin && ( - - )} - - ) - })} - -
账号昵称角色状态创建时间操作
{s.username}{s.nickname || '—'} - - {roleLabel[s.role] || s.role} - - - - {st.text} - - - {s.created_at ? s.created_at.slice(0, 16) : '—'} - e.stopPropagation()}> -
- - {s.status === 'disabled' ? ( - - ) : ( - handleDisableStaff(s)} - disabled={s.id === user?.user_id} - > - - - )} -
-
-
- )} - - )} -
+ )} {activeTab === 'autoreply' && ( -
- {loadingSettings && !settings ? ( -
- ) : ( -
{ - if (welcomeSegments.length === 0) { - message.error('至少保留一段欢迎语') - return - } - for (let i = 0; i < welcomeSegments.length; i++) { - const seg = welcomeSegments[i] - if (seg.type === 'text') { - const plain = seg.content.replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim() - if (!plain) { - message.error(`第 ${i + 1} 段文本不能为空`) - return - } - } - if (seg.type === 'image' && !seg.content.trim()) { - message.error(`第 ${i + 1} 段请上传图片`) - return - } - } - await savePartial({ - welcome_messages: welcomeSegments.map(s => ({ - type: s.type, - content: s.content.trim(), - })), - offline_prompt: values.offline_prompt, - }, '自动回复已保存') - }} - > -
-
-
-

欢迎语(多段混合展示)

-

- 访客首次进入聊天窗口时按序自动推送;支持图文混排,最多可添加 {MAX_WELCOME_SEGMENTS} 段。 -

-
-
-
- {welcomeSegments.map((seg, idx) => ( -
-
- - 第 {idx + 1} 段 · {seg.type === 'image' ? '图片消息' : '富文本消息'} - -
-
-
- {seg.type === 'text' ? ( - { - setWelcomeSegments(prev => - prev.map((s, i) => (i === idx ? { ...s, content: v } : s)), - ) - }} - /> - ) : ( -
- {seg.content ? ( - {`欢迎图 - ) : ( -
- 未上传 -
- )} - { - setUploadingWelcomeIdx(idx) - try { - const res = await uploadImage(file as File) - const url = res.data?.url - if (!url) throw new Error('上传未返回地址') - setWelcomeSegments(prev => - prev.map((s, i) => (i === idx ? { ...s, content: url } : s)), - ) - onSuccess?.(res) - message.success('图片已上传') - } catch (e) { - const err = e instanceof Error ? e : new Error('上传失败') - onError?.(err) - message.error(err.message) - } finally { - setUploadingWelcomeIdx(null) - } - }} - > - - -
- )} -
- ))} -
-
- - -
-
- -
- 离线留言提示语} - rules={[{ max: 500 }]} - extra={当座席全部离线或在非工作时段进线时向访客展示} - > - - -
- -
- 更新自动回复后实时对新访客生效 - -
-
- )} -
+ )} {activeTab === 'worktime' && ( -
- {loadingSettings && !settings ? ( -
- ) : ( -
{ - const work_hours: WorkHours = {} - for (const d of weekDays) { - const mode = values[`${d.key}_mode`] as WorkMode | undefined - const range = values[`${d.key}_range`] as [Dayjs, Dayjs] | null | undefined - if (mode === 'custom' && (!range?.[0] || !range?.[1])) { - message.error(`${d.label} 请选择自定义时间范围`) - return - } - work_hours[d.key] = encodeWorkSlot(mode, range) - } - return savePartial({ - work_hours, - worktime_prompt: values.worktime_prompt, - }, '工作时间已保存') - }} - > -
-

营业与服务时间段

-

- 设定每周工作日在线服务时间;非服务时间系统将向访客展示非工作时间提示。 -

-
- -
- - - -
- -
- {weekDays.map((day, i) => ( -
- {day.label} - - - - -
- 时段以系统时区为准 - -
- - )} -
+ )} {activeTab === 'notify' && ( -
- {loadingSettings && !settings ? ( -
- ) : ( -
savePartial({ - notify_new_session: values.notify_new_session, - notify_offline_leave: values.notify_offline_leave, - notify_daily_report: values.notify_daily_report, - }, '通知设置已保存')} - > -
-

通知推送偏好

-

配置各类关键业务事件的系统通知与推送规则

-
- -
- {[ - { name: 'notify_new_session', title: '新会话提醒', desc: '有新访客进线时提醒;浏览器通知与声音可在工作台铃铛中配置' }, - { name: 'notify_offline_leave', title: '离线留言通知', desc: '访客在离线时段提交留言时发送系统提醒' }, - { name: 'notify_daily_report', title: '服务日报推送', desc: '每日定时汇总推送坐席接待与满意度数据摘要' }, - ].map(item => ( -
-
-
{item.title}
-
{item.desc}
-
- - - -
- ))} -
- -
- -
-
- )} -
+ )} {activeTab === 'tags' && ( -
-
-
-

客户标签库

-

- 维护企业标准标签体系,坐席可在客户管理与工作台快捷打标。 -

-
- -
- {loadingTags ? ( -
- ) : customerTags.length === 0 ? ( - - ) : ( -
- {customerTags.map(tag => { - const style = tagColorStyle(tag.color) - return ( -
- - {tag.name} - -
- - { - try { - await deleteCustomerTag(tag.id) - message.success('已删除') - await loadCustomerTags() - } catch (e) { - message.error(e instanceof Error ? e.message : '删除失败') - } - }} - > -
-
- ) - })} -
- )} -
+ )}
- setTagModalOpen(false)} - onOk={() => tagForm.submit()} - confirmLoading={savingTag} - destroyOnHidden - okText="保存" - width={400} - > -
{ - setSavingTag(true) - try { - const payload = { - name: String(values.name || '').trim(), - color: values.color || 'slate', - } - if (editingTag) { - await updateCustomerTag(editingTag.id, payload) - message.success('标签已更新') - } else { - await createCustomerTag(payload) - message.success('标签已创建') - } - setTagModalOpen(false) - await loadCustomerTags() - } catch (e) { - message.error(e instanceof Error ? e.message : '保存失败') - } finally { - setSavingTag(false) - } - }} - > - - - - - - - - - - - )} - - - - - - - - - - - )} -
-
+
) } diff --git a/web/src/pages/agent/dashboard/CustomerFields.tsx b/web/src/pages/agent/dashboard/CustomerFields.tsx new file mode 100644 index 0000000..f6694a6 --- /dev/null +++ b/web/src/pages/agent/dashboard/CustomerFields.tsx @@ -0,0 +1,221 @@ +import { useEffect, useRef, useState } from 'react' +import { Popover, Spin, message as antMsg } from 'antd' +import { CheckCircleOutlined, PlusOutlined, TagsOutlined } from '@ant-design/icons' +import type { CustomerTag } from '@/services/api' +import { customerTagLabel, customerTagStyle, parseTags } from './helpers' + +/** 客户侧栏基本信息:点击输入,失焦/回车保存 */ +export function CustomerInfoField({ + label, + value, + placeholder, + disabled, + saving, + onSave, +}: { + label: string + value: string + placeholder?: string + disabled?: boolean + saving?: boolean + onSave: (next: string) => Promise | void +}) { + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(value) + const inputRef = useRef(null) + + useEffect(() => { + if (!editing) setDraft(value) + }, [value, editing]) + + useEffect(() => { + if (editing) { + requestAnimationFrame(() => { + inputRef.current?.focus() + inputRef.current?.select() + }) + } + }, [editing]) + + const commit = async () => { + const next = draft.trim() + setEditing(false) + if (next === (value || '').trim()) return + await onSave(next) + } + + return ( +
+ {label} + {editing ? ( + setDraft(e.target.value)} + onBlur={() => { void commit() }} + onKeyDown={e => { + if (e.key === 'Enter') { + e.preventDefault() + void commit() + } + if (e.key === 'Escape') { + setDraft(value) + setEditing(false) + } + }} + /> + ) : ( + + )} +
+ ) +} + +function CustomerTagPill({ tag, catalog }: { tag: string; catalog: CustomerTag[] }) { + const s = customerTagStyle(tag, catalog) + return ( + + {customerTagLabel(tag)} + + ) +} + +export function CustomerTagsEditor({ + tags, + catalog, + saving, + onChange, +}: { + tags: string + catalog: CustomerTag[] + saving?: boolean + onChange: (next: string[]) => Promise | void +}) { + const [open, setOpen] = useState(false) + const currentTags = parseTags(tags) + const optionNames = Array.from(new Set([ + ...catalog.map(item => item.name), + ...currentTags, + ])) + + const toggleTag = (tag: string) => { + if (saving) return + const selected = currentTags.includes(tag) + if (!selected && currentTags.length >= 10) { + antMsg.warning('每位客户最多 10 个标签') + return + } + const next = selected + ? currentTags.filter(item => item !== tag) + : [...currentTags, tag] + void onChange(next) + } + + const content = ( +
+
+ 客户标签 + {currentTags.length}/10 +
+ {optionNames.length === 0 ? ( +
+ 暂无标签库 +
+ ) : ( +
+ {optionNames.map(name => { + const selected = currentTags.includes(name) + const s = customerTagStyle(name, catalog) + return ( + + ) + })} +
+ )} + {currentTags.length > 0 && ( + + )} +
+ ) + + return ( + +
{ + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + setOpen(true) + } + }} + > +
+
标签
+ {saving ? : } +
+
+ {currentTags.length === 0 ? ( + + + 暂无 + + ) : currentTags.map(tag => ( + + ))} +
+
+
+ ) +} diff --git a/web/src/pages/agent/dashboard/helpers.ts b/web/src/pages/agent/dashboard/helpers.ts new file mode 100644 index 0000000..47ce025 --- /dev/null +++ b/web/src/pages/agent/dashboard/helpers.ts @@ -0,0 +1,156 @@ +import type { CustomerTag, Message, Session, SessionEvent, VisitorPageView } from '@/services/api' + +export type CustomerEditableField = 'phone' | 'email' | 'wechat' | 'qq' + +/** 按 id 合并消息,再按 seq / id 排序 */ +export function mergeMessagesBySeq(existing: Message[], incoming: Message[]): Message[] { + const map = new Map() + for (const item of existing) map.set(item.id, item) + for (const item of incoming) map.set(item.id, item) + return Array.from(map.values()).sort((a, b) => { + const seqDiff = (a.seq || 0) - (b.seq || 0) + return seqDiff !== 0 ? seqDiff : a.id - b.id + }) +} + +export function maxMessageSeq(messages: Message[]): number { + return messages.reduce((acc, item) => Math.max(acc, item.seq || 0), 0) +} + +export function normalizeMessage(raw: Partial & { id?: number }, sessionId: number): Message | null { + if (!raw?.id) return null + return { + id: Number(raw.id), + session_id: Number(raw.session_id ?? sessionId), + sender_type: raw.sender_type === 'agent' ? 'agent' : 'visitor', + sender_id: raw.sender_id ?? null, + content: String(raw.content ?? ''), + type: raw.type === 'image' ? 'image' : 'text', + seq: Number(raw.seq ?? 0), + sent_at: raw.sent_at || new Date().toISOString(), + } +} + +export const endReasons = [ + { value: 'resolved', label: '已解决' }, + { value: 'no_response', label: '无人回复' }, + { value: 'visitor_left', label: '访客离开' }, + { value: 'transferred', label: '已转接' }, + { value: 'other', label: '其他' }, +] + +/** 列表项展示:紧急 / 等待中 / 进行中 */ +export function listStatusMeta(session: Session) { + if (session.priority === 'urgent') { + return { label: '紧急', bar: '#dc2626', avatarBg: '#fef2f2', avatarColor: '#dc2626', dot: '#dc2626' } + } + if (session.status === 'waiting') { + return { label: '等待中', bar: '#d97706', avatarBg: '#fffbeb', avatarColor: '#d97706', dot: '#d97706' } + } + return { label: '进行中', bar: '#2563eb', avatarBg: '#eff6ff', avatarColor: '#2563eb', dot: '#2563eb' } +} + +/** 坐席在线状态(转接列表) */ +export function agentStatusMeta(status: string) { + switch (status) { + case 'online': + return { text: '在线', dot: '#16a34a', bg: '#ecfdf5', color: '#15803d' } + case 'busy': + return { text: '忙碌', dot: '#d97706', bg: '#fffbeb', color: '#b45309' } + case 'disabled': + return { text: '停用', dot: '#94a3b8', bg: '#f1f5f9', color: '#64748b' } + default: + return { text: '离线', dot: '#94a3b8', bg: '#f1f5f9', color: '#64748b' } + } +} + +export function relativeTime(iso?: string | null) { + if (!iso) return '' + const diff = Date.now() - new Date(iso).getTime() + const mins = Math.floor(diff / 60000) + if (mins < 1) return '刚刚' + if (mins < 60) return `${mins}分钟前` + const hours = Math.floor(mins / 60) + if (hours < 24) return `${hours}小时前` + const days = Math.floor(hours / 24) + if (days < 7) return `${days}天前` + return new Date(iso).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' }) +} + +export function parseTags(tagsStr: string): string[] { + try { + const parsed = JSON.parse(tagsStr) + return Array.isArray(parsed) ? parsed : [] + } catch { + return tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [] + } +} + +export const customerTagColorMap: Record = { + amber: { bg: '#fef3c7', color: '#92400e' }, + green: { bg: '#f0fdf4', color: '#16a34a' }, + blue: { bg: '#dbeafe', color: '#2563eb' }, + cyan: { bg: '#ecfeff', color: '#0891b2' }, + violet: { bg: '#f3e8ff', color: '#7c3aed' }, + rose: { bg: '#fff1f2', color: '#e11d48' }, + orange: { bg: '#fffbeb', color: '#d97706' }, + slate: { bg: '#f1f5f9', color: '#475569' }, + // 兼容历史标签名称 + 'VIP客户': { bg: '#fef3c7', color: '#92400e' }, + 'VIP': { bg: '#fef3c7', color: '#92400e' }, + '新客户': { bg: '#f0fdf4', color: '#16a34a' }, + '活跃': { bg: '#dbeafe', color: '#2563eb' }, + '沉默': { bg: '#fffbeb', color: '#d97706' }, + '企业客户': { bg: '#ecfeff', color: '#0891b2' }, +} + +export function customerTagStyle(tag: string, catalog: CustomerTag[]) { + const meta = catalog.find(item => item.name === tag) + return (meta?.color ? customerTagColorMap[meta.color] : undefined) + || customerTagColorMap[tag] + || { bg: '#f1f5f9', color: '#475569' } +} + +export function customerTagLabel(tag: string) { + return tag === 'VIP客户' ? 'VIP' : tag +} + +export function channelLabel(source?: string) { + if (!source) return '网页' + if (source.includes('微信') || source.toLowerCase().includes('wechat')) return '微信' + if (source.toLowerCase().includes('app')) return 'APP' + if (source.includes('电话')) return '电话' + return source.length > 6 ? '网页' : source +} + +export interface SessionDetail { + messages: Message[] + events: SessionEvent[] + pageViews: VisitorPageView[] + pendingCount: number +} + +export function formatOnlineDuration(startIso?: string | null, endIso?: string | null, nowMs?: number): string { + if (!startIso) return '—' + const start = new Date(startIso).getTime() + if (!Number.isFinite(start)) return '—' + const end = endIso ? new Date(endIso).getTime() : (nowMs ?? Date.now()) + let sec = Math.max(0, Math.floor((end - start) / 1000)) + const h = Math.floor(sec / 3600) + sec %= 3600 + const m = Math.floor(sec / 60) + const s = sec % 60 + if (h > 0) return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` + return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` +} + +export function shortPagePath(url?: string): string { + if (!url) return '—' + try { + const u = new URL(url) + const path = u.pathname + (u.search || '') + (u.hash || '') + return path.length > 56 ? `${path.slice(0, 54)}…` : path || '/' + } catch { + return url.length > 56 ? `${url.slice(0, 54)}…` : url + } +} diff --git a/web/src/pages/agent/dashboard/modals.tsx b/web/src/pages/agent/dashboard/modals.tsx new file mode 100644 index 0000000..a1bfceb --- /dev/null +++ b/web/src/pages/agent/dashboard/modals.tsx @@ -0,0 +1,427 @@ +import { useEffect, useRef, useState } from 'react' +import { Checkbox, Input, Modal, Radio, Select, Spin, message as antMsg } from 'antd' +import { SearchOutlined } from '@ant-design/icons' +import { + createBlacklist, endSession as endSessionApi, getAvailableAgents, getKnowledgeEntries, getQuickReplies, transferSession, + type BlacklistDuration, type KnowledgeEntry, type QuickReply, type Session, +} from '@/services/api' +import { agentStatusMeta, endReasons } from './helpers' + +/** 转接会话弹窗:坐席列表与目标选择状态内聚 */ +export function TransferModal({ + open, + session, + excludeAgentId, + onClose, + onDone, +}: { + open: boolean + session: Session | undefined + excludeAgentId?: number + onClose: () => void + onDone: () => Promise | void +}) { + const [availableAgents, setAvailableAgents] = useState>['data']>([]) + const [targetAgentID, setTargetAgentID] = useState() + const onCloseRef = useRef(onClose) + onCloseRef.current = onClose + + useEffect(() => { + if (!open) return + let cancelled = false + // 默认返回在线/忙碌坐席(含 status) + getAvailableAgents().then(response => { + if (cancelled) return + setAvailableAgents((response.data || []).filter(agent => agent.id !== excludeAgentId)) + setTargetAgentID(undefined) + }).catch(() => { + if (cancelled) return + antMsg.error('加载可转接坐席失败') + onCloseRef.current() + }) + return () => { cancelled = true } + }, [open, excludeAgentId]) + + const handleTransfer = async () => { + if (!session || !targetAgentID) return + const target = (availableAgents || []).find(a => a.id === targetAgentID) + if (target && target.status !== 'online' && target.status !== 'busy') { + antMsg.warning('只能转接给在线或忙碌的坐席') + return + } + try { + await transferSession(session.id, targetAgentID) + antMsg.success('会话已转接') + await onDone() + onClose() + } catch (error) { + antMsg.error(error instanceof Error ? error.message : '转接失败') + } + } + + return ( + { + const t = (availableAgents || []).find(a => a.id === targetAgentID) + return Boolean(t && t.status !== 'online' && t.status !== 'busy') + })(), + }} + > +

请选择接手坐席(绿色在线 / 橙色忙碌)。

+ {(availableAgents || []).length === 0 ? ( +
+ 当前没有其他可转接坐席 +
+ ) : ( + +
+ ) +} + +/** 拉黑访客弹窗:拉黑表单状态内聚,打开时按会话重置默认值 */ +export function BlacklistModal({ + open, + session, + onClose, + onDone, +}: { + open: boolean + session: Session | undefined + onClose: () => void + /** ended=true 表示已同时结束会话(父层清空选中),false 表示仅拉黑(刷新详情) */ + onDone: (ended: boolean) => Promise | void +}) { + const [kind, setKind] = useState<'ip' | 'device'>('ip') + const [duration, setDuration] = useState('7d') + const [reason, setReason] = useState('') + const [endSessionChecked, setEndSessionChecked] = useState(true) + const [saving, setSaving] = useState(false) + + useEffect(() => { + if (!open || !session) return + setKind(session.visitor_ip ? 'ip' : 'device') + setDuration('7d') + setReason('') + setEndSessionChecked(session.status === 'active' || session.status === 'waiting') + }, [open, session]) + + const handleBlacklist = async () => { + if (!session) return + const trimmedReason = reason.trim() + if (!trimmedReason) { + antMsg.warning('请填写拉黑原因') + return + } + if (kind === 'ip' && !session.visitor_ip) { + antMsg.warning('该会话无有效 IP,请改选「设备」') + return + } + if (kind === 'device' && !session.device_key && !session.user_agent) { + antMsg.warning('该会话无设备标识,请改选「IP」') + return + } + setSaving(true) + try { + await createBlacklist({ + session_id: session.id, + kind, + duration, + reason: trimmedReason, + end_session: endSessionChecked, + }) + antMsg.success(kind === 'ip' ? '已拉黑该 IP' : '已拉黑该设备') + onClose() + await onDone(endSessionChecked) + } catch (error) { + antMsg.error(error instanceof Error ? error.message : '拉黑失败') + } finally { + setSaving(false) + } + } + + return ( + +
+
+
拉黑类型
+ setKind(e.target.value)} + optionType="button" + buttonStyle="solid" + options={[ + { + value: 'ip', + label: session?.visitor_ip ? `IP(${session.visitor_ip})` : 'IP(无)', + disabled: !session?.visitor_ip, + }, + { + value: 'device', + label: session?.device_key || session?.user_agent + ? '设备' + : '设备(无)', + disabled: !session?.device_key && !session?.user_agent, + }, + ]} + /> + {kind === 'device' && ( +
+ {session?.device_key + ? `设备标识:${session.device_key.slice(0, 12)}…` + : session?.user_agent + ? `将按浏览器指纹:${session.user_agent.slice(0, 48)}…` + : ''} +
+ )} +
+
+
释放时间
+ } + placeholder="搜索标题或内容" + value={keyword} + onChange={event => setKeyword(event.target.value)} + allowClear + className="mb-3" + /> + {loading ? ( +
+ ) : ( +
+ {entries.length === 0 ? ( +
未找到可用知识条目
+ ) : entries.map(entry => ( + + ))} +
+ )} + + ) +} + +/** 快捷回复选择弹窗:搜索与列表状态内聚;应用后由父层关闭(applyQuickReply 内) */ +export function QuickReplyPickerModal({ + open, + onClose, + onApply, +}: { + open: boolean + onClose: () => void + onApply: (item: QuickReply) => void +}) { + const [keyword, setKeyword] = useState('') + const [list, setList] = useState([]) + const [loading, setLoading] = useState(false) + + useEffect(() => { + if (!open) return + setLoading(true) + getQuickReplies({ + scope: 'all', + q: keyword.trim() || undefined, + page: 1, + pageSize: 50, + }).then(response => { + setList(Array.isArray(response.list) ? response.list : []) + }).catch(() => setList([])).finally(() => setLoading(false)) + }, [open, keyword]) + + return ( + + } + placeholder="搜索标题、内容或输入码" + value={keyword} + onChange={event => setKeyword(event.target.value)} + allowClear + className="mb-3" + /> +

含团队已发布 + 我的话术。管理请到侧栏「快捷回复」。

+ {loading ? ( +
+ ) : ( +
+ {list.length === 0 ? ( +
暂无快捷回复
+ ) : list.map(item => ( + + ))} +
+ )} +
+ ) +} diff --git a/web/src/pages/agent/settings/AssignmentSection.tsx b/web/src/pages/agent/settings/AssignmentSection.tsx new file mode 100644 index 0000000..bb48bd9 --- /dev/null +++ b/web/src/pages/agent/settings/AssignmentSection.tsx @@ -0,0 +1,113 @@ +import { Button, Form, Input, Select, Spin } from 'antd' +import { TeamOutlined } from '@ant-design/icons' +import type { FormInstance } from 'antd' +import type { SaveSettingsFn } from './types' + +interface AssignmentSectionProps { + assignForm: FormInstance + loading: boolean + isAdmin: boolean + saving: boolean + onSave: SaveSettingsFn +} + +/** 客服分配规则:自动分配策略与并发上限 */ +const AssignmentSection = ({ assignForm, loading, isAdmin, saving, onSave }: AssignmentSectionProps) => { + return ( +
+ {loading ? ( +
+ ) : ( + <> +
+
+
+ +
+
+
自动分配规则
+
+ 工作时间内,访客进线会按下列策略自动分配给在线一线客服; + 非工作时间或无可接坐席时进入排队或离线留言。人工领取与内部转接不受此策略限制。 +
+
+
+
+ +
+
{ + await onSave({ + assign_strategy: values.assign_strategy, + max_active_per_agent: Number(values.max_active_per_agent) || 0, + }, '分配规则已保存') + }} + > + 分配策略} + rules={[{ required: true, message: '请选择策略' }]} + extra={负载最低:优先分配给进行中会话最少的坐席;轮询:在可接坐席中均匀依次分发。} + > + + + +
+ {isAdmin ? ( + + ) : ( +

仅租户管理员可修改分配规则

+ )} +
+
+
+ +
+
使用说明
+

· 自动分发候选范围:角色为客服、当前状态为「在线」的账号(忙碌/离线状态不参与自动分发)。

+

· 专属坐席组与渠道绑定策略将在后续版本逐步上线。

+
+ + )} +
+ ) +} + +export default AssignmentSection diff --git a/web/src/pages/agent/settings/AutoReplySection.tsx b/web/src/pages/agent/settings/AutoReplySection.tsx new file mode 100644 index 0000000..429db42 --- /dev/null +++ b/web/src/pages/agent/settings/AutoReplySection.tsx @@ -0,0 +1,259 @@ +import type { Dispatch, SetStateAction } from 'react' +import { Button, Form, Spin, Upload, message } from 'antd' +import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PictureOutlined, PlusOutlined } from '@ant-design/icons' +import type { FormInstance } from 'antd' +import { uploadImage, type WelcomeSegment } from '@/services/api' +import MarkdownEditor from '@/components/common/MarkdownEditor' +import RichTextEditor from '@/components/common/RichTextEditor' +import { MAX_WELCOME_SEGMENTS } from './welcome' +import type { SaveSettingsFn } from './types' + +interface AutoReplySectionProps { + autoReplyForm: FormInstance + loading: boolean + saving: boolean + welcomeSegments: WelcomeSegment[] + setWelcomeSegments: Dispatch> + uploadingWelcomeIdx: number | null + setUploadingWelcomeIdx: Dispatch> + onSave: SaveSettingsFn +} + +/** 自动回复:多段欢迎语(含图片)与离线留言提示 */ +const AutoReplySection = ({ + autoReplyForm, + loading, + saving, + welcomeSegments, + setWelcomeSegments, + uploadingWelcomeIdx, + setUploadingWelcomeIdx, + onSave, +}: AutoReplySectionProps) => { + return ( +
+ {loading ? ( +
+ ) : ( +
{ + if (welcomeSegments.length === 0) { + message.error('至少保留一段欢迎语') + return + } + for (let i = 0; i < welcomeSegments.length; i++) { + const seg = welcomeSegments[i] + if (seg.type === 'text') { + const plain = seg.content.replace(/<[^>]+>/g, '').replace(/ /g, ' ').trim() + if (!plain) { + message.error(`第 ${i + 1} 段文本不能为空`) + return + } + } + if (seg.type === 'image' && !seg.content.trim()) { + message.error(`第 ${i + 1} 段请上传图片`) + return + } + } + await onSave({ + welcome_messages: welcomeSegments.map(s => ({ + type: s.type, + content: s.content.trim(), + })), + offline_prompt: values.offline_prompt, + }, '自动回复已保存') + }} + > +
+
+
+

欢迎语(多段混合展示)

+

+ 访客首次进入聊天窗口时按序自动推送;支持图文混排,最多可添加 {MAX_WELCOME_SEGMENTS} 段。 +

+
+
+
+ {welcomeSegments.map((seg, idx) => ( +
+
+ + 第 {idx + 1} 段 · {seg.type === 'image' ? '图片消息' : '富文本消息'} + +
+
+
+ {seg.type === 'text' ? ( + { + setWelcomeSegments(prev => + prev.map((s, i) => (i === idx ? { ...s, content: v } : s)), + ) + }} + /> + ) : ( +
+ {seg.content ? ( + {`欢迎图 + ) : ( +
+ 未上传 +
+ )} + { + setUploadingWelcomeIdx(idx) + try { + const res = await uploadImage(file as File) + const url = res.data?.url + if (!url) throw new Error('上传未返回地址') + setWelcomeSegments(prev => + prev.map((s, i) => (i === idx ? { ...s, content: url } : s)), + ) + onSuccess?.(res) + message.success('图片已上传') + } catch (e) { + const err = e instanceof Error ? e : new Error('上传失败') + onError?.(err) + message.error(err.message) + } finally { + setUploadingWelcomeIdx(null) + } + }} + > + + +
+ )} +
+ ))} +
+
+ + +
+
+ +
+ 离线留言提示语} + rules={[{ max: 500 }]} + extra={当座席全部离线或在非工作时段进线时向访客展示} + > + + +
+ +
+ 更新自动回复后实时对新访客生效 + +
+
+ )} +
+ ) +} + +export default AutoReplySection diff --git a/web/src/pages/agent/settings/BasicSection.tsx b/web/src/pages/agent/settings/BasicSection.tsx new file mode 100644 index 0000000..9360016 --- /dev/null +++ b/web/src/pages/agent/settings/BasicSection.tsx @@ -0,0 +1,99 @@ +import { Button, Form, Input, Select, Spin } from 'antd' +import type { FormInstance } from 'antd' +import type { SaveSettingsFn } from './types' + +interface BasicSectionProps { + basicForm: FormInstance + loading: boolean + saving: boolean + onSave: SaveSettingsFn +} + +/** 基本设置:租户展示档案与区域本地化 */ +const BasicSection = ({ basicForm, loading, saving, onSave }: BasicSectionProps) => { + return ( +
+ {loading ? ( +
+ ) : ( +
onSave({ + display_name: values.display_name, + agent_nickname: values.agent_nickname, + timezone: values.timezone, + })} + > +
+

企业展示档案

+

配置访客端看到的租户品牌名称与在线客服默认身份

+
+ +
+ 租户名称} + rules={[{ required: true, message: '请输入租户名称' }, { min: 1, max: 100 }]} + extra={公司或品牌名称,展示于访客端聊天窗口顶部} + > + + + + 客服默认昵称} + extra={未设置个人独立昵称的坐席将默认以此名称展示} + > + + +
+ +
+

区域与本地化

+

定义系统的标准服务时区与工作台界面语言

+
+ +
+ 服务时区} + extra={影响营业时间判断、会话统计与历史报表生成} + > + + +
+ +
+ 配置保存后将实时对访客端与坐席工作台生效 + +
+
+ )} +
+ ) +} + +export default BasicSection diff --git a/web/src/pages/agent/settings/ChannelsSection.tsx b/web/src/pages/agent/settings/ChannelsSection.tsx new file mode 100644 index 0000000..bbf483f --- /dev/null +++ b/web/src/pages/agent/settings/ChannelsSection.tsx @@ -0,0 +1,152 @@ +import { Button, Empty, Spin, Switch, message } from 'antd' +import { CopyOutlined, LinkOutlined, PlusOutlined } from '@ant-design/icons' +import type { Channel } from '@/services/api' +import { typeMeta } from './channelMeta' + +/** 复制接入脚本(自动替换为当前站点域名) */ +const copyScript = async (script: string) => { + try { + const origin = window.location.origin + const text = script.includes('src="/widget.js"') + ? script.replace('src="/widget.js"', `src="${origin}/widget.js"`) + : script + await navigator.clipboard.writeText(text) + message.success('已复制接入代码') + } catch { + message.error('复制失败') + } +} + +interface ChannelsSectionProps { + loadingChannels: boolean + channels: Channel[] + missingTypes: string[] + togglingId: number | null + toggleChannel: (ch: Channel, enabled: boolean) => void + ensureWebChannel: () => void + addChannelType: (t: string) => void +} + +/** 渠道管理:接入渠道列表与嵌入代码 */ +const ChannelsSection = ({ + loadingChannels, + channels, + missingTypes, + togglingId, + toggleChannel, + ensureWebChannel, + addChannelType, +}: ChannelsSectionProps) => { + return ( +
+ {loadingChannels ? ( +
+ ) : channels.length === 0 ? ( +
+ + + +
+ ) : ( +
+ {channels.map(ch => { + const meta = typeMeta[ch.type] || { icon: , label: ch.name || ch.type, desc: '' } + const enabled = ch.status === 'enabled' + return ( +
+
+
+ {meta.icon} +
+
+
+
+

+ {ch.name || meta.label} +

+ + {enabled ? '已启用' : '未启用'} + +
+ toggleChannel(ch, v)} + /> +
+ {meta.desc && ( +

{meta.desc}

+ )} + {ch.channel_key && ( +
+ 渠道 ID + + {ch.channel_key} + +
+ )} + {ch.type === 'web' && ch.script_code && ( +
+
网站嵌入代码
+
+ + {ch.script_code.replace( + 'src="/widget.js"', + `src="${window.location.origin}/widget.js"`, + )} + + +
+
+ )} + {ch.type !== 'web' && ( +
+ 本期仅网页渠道可完整接入,其他类型预留开关。 +
+ )} +
+
+
+ ) + })} +
+ )} + + {missingTypes.length > 0 && channels.length > 0 && ( +
+
可添加其他渠道
+
+ {missingTypes.map(t => ( + + ))} +
+
+ )} +
+ ) +} + +export default ChannelsSection diff --git a/web/src/pages/agent/settings/NotifySection.tsx b/web/src/pages/agent/settings/NotifySection.tsx new file mode 100644 index 0000000..def8cbe --- /dev/null +++ b/web/src/pages/agent/settings/NotifySection.tsx @@ -0,0 +1,70 @@ +import { Button, Form, Spin, Switch } from 'antd' +import type { FormInstance } from 'antd' +import type { SaveSettingsFn } from './types' + +interface NotifySectionProps { + notifyForm: FormInstance + loading: boolean + saving: boolean + onSave: SaveSettingsFn +} + +/** 通知设置:新会话、留言与日报偏好 */ +const NotifySection = ({ notifyForm, loading, saving, onSave }: NotifySectionProps) => { + return ( +
+ {loading ? ( +
+ ) : ( +
onSave({ + notify_new_session: values.notify_new_session, + notify_offline_leave: values.notify_offline_leave, + notify_daily_report: values.notify_daily_report, + }, '通知设置已保存')} + > +
+

通知推送偏好

+

配置各类关键业务事件的系统通知与推送规则

+
+ +
+ {[ + { name: 'notify_new_session', title: '新会话提醒', desc: '有新访客进线时提醒;浏览器通知与声音可在工作台铃铛中配置' }, + { name: 'notify_offline_leave', title: '离线留言通知', desc: '访客在离线时段提交留言时发送系统提醒' }, + { name: 'notify_daily_report', title: '服务日报推送', desc: '每日定时汇总推送坐席接待与满意度数据摘要' }, + ].map(item => ( +
+
+
{item.title}
+
{item.desc}
+
+ + + +
+ ))} +
+ +
+ +
+
+ )} +
+ ) +} + +export default NotifySection diff --git a/web/src/pages/agent/settings/StaffModal.tsx b/web/src/pages/agent/settings/StaffModal.tsx new file mode 100644 index 0000000..3cf1cd1 --- /dev/null +++ b/web/src/pages/agent/settings/StaffModal.tsx @@ -0,0 +1,100 @@ +import type { Dispatch, SetStateAction } from 'react' +import { Form, Input, Modal, Select } from 'antd' +import type { FormInstance } from 'antd' +import type { StaffUser } from '@/services/api' + +interface StaffModalProps { + staffModalOpen: boolean + setStaffModalOpen: Dispatch> + editingStaff: StaffUser | null + savingStaff: boolean + isAdmin: boolean + staffForm: FormInstance + onFinish: (values: { + username?: string; password?: string; nickname?: string; role?: string; status?: string + }) => void +} + +/** 新建 / 编辑坐席弹窗 */ +const StaffModal = ({ + staffModalOpen, + setStaffModalOpen, + editingStaff, + savingStaff, + isAdmin, + staffForm, + onFinish, +}: StaffModalProps) => { + return ( + setStaffModalOpen(false)} + onOk={() => staffForm.submit()} + confirmLoading={savingStaff} + destroyOnClose + okText="保存" + width={440} + > +
+ {!editingStaff && ( + <> + + + + + + + + )} + + + + + + + + + + + )} +
+
+ ) +} + +export default StaffModal diff --git a/web/src/pages/agent/settings/StaffSection.tsx b/web/src/pages/agent/settings/StaffSection.tsx new file mode 100644 index 0000000..9c92815 --- /dev/null +++ b/web/src/pages/agent/settings/StaffSection.tsx @@ -0,0 +1,157 @@ +import { Empty, Popconfirm, Spin } from 'antd' +import { CheckCircleOutlined, EditOutlined, StopOutlined } from '@ant-design/icons' +import type { StaffUser } from '@/services/api' + +const roleLabel: Record = { + admin: '管理员', + supervisor: '主管', + agent: '客服', +} + +const statusLabel: Record = { + online: { text: '在线', className: 'bg-emerald-50 text-emerald-700' }, + offline: { text: '离线', className: 'bg-neutral-100 text-neutral-600' }, + busy: { text: '忙碌', className: 'bg-amber-50 text-amber-700' }, + disabled: { text: '已禁用', className: 'bg-red-50 text-red-600' }, +} + +interface StaffSectionProps { + canViewStaff: boolean + loadingStaff: boolean + staffList: StaffUser[] + isAdmin: boolean + seatUsed: number + seatLimit: number + currentUserId?: number + openEditStaff: (s: StaffUser) => void + handleEnableStaff: (s: StaffUser) => void + handleDisableStaff: (s: StaffUser) => void +} + +/** 坐席账号:配额占用与坐席列表 */ +const StaffSection = ({ + canViewStaff, + loadingStaff, + staffList, + isAdmin, + seatUsed, + seatLimit, + currentUserId, + openEditStaff, + handleEnableStaff, + handleDisableStaff, +}: StaffSectionProps) => { + return ( +
+ {!canViewStaff ? ( +
+ 仅管理员或主管可查看坐席列表 +
+ ) : loadingStaff ? ( +
+ ) : ( + <> +
+
+
坐席配额占用
+
+ {seatUsed} + / {seatLimit} +
+
+
+ 启用中的管理员、主管、客服各占 1 个坐席。禁用账号可即刻释放坐席配额。 +
+
+ + {staffList.length === 0 ? ( + + ) : ( +
+ + + + + + + + + {isAdmin && ( + + )} + + + + {staffList.map(s => { + const st = statusLabel[s.status] || statusLabel.offline + return ( + + + + + + + {isAdmin && ( + + )} + + ) + })} + +
账号昵称角色状态创建时间操作
{s.username}{s.nickname || '—'} + + {roleLabel[s.role] || s.role} + + + + {st.text} + + + {s.created_at ? s.created_at.slice(0, 16) : '—'} + e.stopPropagation()}> +
+ + {s.status === 'disabled' ? ( + + ) : ( + handleDisableStaff(s)} + disabled={s.id === currentUserId} + > + + + )} +
+
+
+ )} + + )} +
+ ) +} + +export default StaffSection diff --git a/web/src/pages/agent/settings/TagModal.tsx b/web/src/pages/agent/settings/TagModal.tsx new file mode 100644 index 0000000..f3735b4 --- /dev/null +++ b/web/src/pages/agent/settings/TagModal.tsx @@ -0,0 +1,90 @@ +import type { Dispatch, SetStateAction } from 'react' +import { Form, Input, Modal, Select, message } from 'antd' +import type { FormInstance } from 'antd' +import { createCustomerTag, updateCustomerTag, type CustomerTag } from '@/services/api' +import { tagColorPresets } from './tagColors' + +interface TagModalProps { + tagModalOpen: boolean + setTagModalOpen: Dispatch> + editingTag: CustomerTag | null + savingTag: boolean + setSavingTag: Dispatch> + tagForm: FormInstance + loadCustomerTags: () => Promise +} + +/** 新建 / 编辑客户标签弹窗 */ +const TagModal = ({ + tagModalOpen, + setTagModalOpen, + editingTag, + savingTag, + setSavingTag, + tagForm, + loadCustomerTags, +}: TagModalProps) => { + return ( + setTagModalOpen(false)} + onOk={() => tagForm.submit()} + confirmLoading={savingTag} + destroyOnHidden + okText="保存" + width={400} + > +
{ + setSavingTag(true) + try { + const payload = { + name: String(values.name || '').trim(), + color: values.color || 'slate', + } + if (editingTag) { + await updateCustomerTag(editingTag.id, payload) + message.success('标签已更新') + } else { + await createCustomerTag(payload) + message.success('标签已创建') + } + setTagModalOpen(false) + await loadCustomerTags() + } catch (e) { + message.error(e instanceof Error ? e.message : '保存失败') + } finally { + setSavingTag(false) + } + }} + > + + + + + { + if (mode === 'custom' && !workForm.getFieldValue(`${day.key}_range`)) { + workForm.setFieldValue(`${day.key}_range`, defaultCustomRange()) + } + }} + /> + + + prev[`${day.key}_mode`] !== cur[`${day.key}_mode`] + } + > + {() => + workForm.getFieldValue(`${day.key}_mode`) === 'custom' ? ( +
+ + + +
+ {workRangePresets.map(p => ( + + ))} +
+
+ ) : null + } +
+
+ ))} +
+ + 非工作时间提示语} + rules={[{ max: 500 }]} + > + + + +
+ 时段以系统时区为准 + +
+ + )} +
+ ) +} + +export default WorktimeSection diff --git a/web/src/pages/agent/settings/channelMeta.tsx b/web/src/pages/agent/settings/channelMeta.tsx new file mode 100644 index 0000000..bb75d4b --- /dev/null +++ b/web/src/pages/agent/settings/channelMeta.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from 'react' +import { WechatOutlined, PhoneOutlined, MailOutlined, MobileOutlined, GlobalOutlined } from '@ant-design/icons' + +export const typeMeta: Record = { + web: { icon: , label: '网页聊天', desc: '嵌入官网或任意网页的在线客服窗口' }, + wechat: { icon: , label: '微信公众号', desc: '对接微信公众号客服消息(预留)' }, + app: { icon: , label: 'APP 内嵌', desc: '移动端 App 内嵌客服(预留)' }, + phone: { icon: , label: '电话客服', desc: '电话进线与回拨(预留)' }, + email: { icon: , label: '邮件工单', desc: '邮件自动创建工单(预留)' }, +} diff --git a/web/src/pages/agent/settings/tabs.tsx b/web/src/pages/agent/settings/tabs.tsx new file mode 100644 index 0000000..500a4fc --- /dev/null +++ b/web/src/pages/agent/settings/tabs.tsx @@ -0,0 +1,46 @@ +import type { ReactNode } from 'react' +import { + SettingOutlined, ApiOutlined, UserSwitchOutlined, + TeamOutlined, TagsOutlined, ClockCircleOutlined, + MessageOutlined, BellOutlined, +} from '@ant-design/icons' + +export interface TabItem { + key: string + label: string + desc: string + icon: ReactNode +} + +export interface TabGroup { + groupName: string + items: TabItem[] +} + +export const tabGroups: TabGroup[] = [ + { + groupName: '企业与渠道', + items: [ + { key: 'basic', label: '基本设置', desc: '租户展示名称、默认昵称与时区', icon: }, + { key: 'channels', label: '渠道管理', desc: '配置客户服务接入渠道与嵌入代码', icon: }, + { key: 'staff', label: '坐席账号', desc: '管理客服/主管账号与坐席配额', icon: }, + ], + }, + { + groupName: '规则与路由', + items: [ + { key: 'assignment', label: '客服分配规则', desc: '自动分配策略与并发上限', icon: }, + { key: 'tags', label: '客户标签', desc: '维护标签库,坐席为客户打标时选择', icon: }, + { key: 'worktime', label: '工作时间', desc: '在线服务时段与非工作时间提示', icon: }, + ], + }, + { + groupName: '自动化与通知', + items: [ + { key: 'autoreply', label: '自动回复', desc: '多段欢迎语(含图片)与离线留言提示', icon: }, + { key: 'notify', label: '通知设置', desc: '新会话、留言与日报偏好', icon: }, + ], + }, +] + +export const tabItems: TabItem[] = tabGroups.flatMap(g => g.items) diff --git a/web/src/pages/agent/settings/tagColors.ts b/web/src/pages/agent/settings/tagColors.ts new file mode 100644 index 0000000..e3095e6 --- /dev/null +++ b/web/src/pages/agent/settings/tagColors.ts @@ -0,0 +1,17 @@ +export const tagColorPresets: { value: string; label: string; bg: string; color: string }[] = [ + { value: 'amber', label: '琥珀', bg: '#fef3c7', color: '#92400e' }, + { value: 'green', label: '绿色', bg: '#f0fdf4', color: '#16a34a' }, + { value: 'blue', label: '蓝色', bg: '#dbeafe', color: '#2563eb' }, + { value: 'cyan', label: '青色', bg: '#ecfeff', color: '#0891b2' }, + { value: 'violet', label: '紫色', bg: '#f3e8ff', color: '#7c3aed' }, + { value: 'rose', label: '玫红', bg: '#fff1f2', color: '#e11d48' }, + { value: 'orange', label: '橙色', bg: '#fffbeb', color: '#d97706' }, + { value: 'slate', label: '灰蓝', bg: '#f1f5f9', color: '#475569' }, +] + +export function tagColorStyle(color: string): { bg: string; color: string } { + const preset = tagColorPresets.find(p => p.value === color) + if (preset) return { bg: preset.bg, color: preset.color } + if (color?.startsWith('#')) return { bg: `${color}22`, color } + return { bg: '#f1f5f9', color: '#475569' } +} diff --git a/web/src/pages/agent/settings/types.ts b/web/src/pages/agent/settings/types.ts new file mode 100644 index 0000000..79909e0 --- /dev/null +++ b/web/src/pages/agent/settings/types.ts @@ -0,0 +1,7 @@ +import { updateTenantSettings } from '@/services/api' + +/** 局部保存租户设置(与 Settings 入口文件 savePartial 的签名一致) */ +export type SaveSettingsFn = ( + payload: Parameters[0], + okText?: string, +) => Promise diff --git a/web/src/pages/agent/settings/welcome.ts b/web/src/pages/agent/settings/welcome.ts new file mode 100644 index 0000000..21df4cd --- /dev/null +++ b/web/src/pages/agent/settings/welcome.ts @@ -0,0 +1,16 @@ +import type { TenantSettings, WelcomeSegment } from '@/services/api' + +export const MAX_WELCOME_SEGMENTS = 10 + +export function normalizeWelcomeFromSettings(data: TenantSettings): WelcomeSegment[] { + if (Array.isArray(data.welcome_messages) && data.welcome_messages.length > 0) { + return data.welcome_messages.map(s => ({ + type: s.type === 'image' ? 'image' : 'text', + content: s.content || '', + })) + } + if (data.welcome_message?.trim()) { + return [{ type: 'text', content: data.welcome_message.trim() }] + } + return [{ type: 'text', content: '您好!欢迎咨询,请问有什么可以帮您?' }] +} diff --git a/web/src/pages/agent/settings/worktime.ts b/web/src/pages/agent/settings/worktime.ts new file mode 100644 index 0000000..a6945e7 --- /dev/null +++ b/web/src/pages/agent/settings/worktime.ts @@ -0,0 +1,59 @@ +import dayjs, { type Dayjs } from 'dayjs' +import customParseFormat from 'dayjs/plugin/customParseFormat' + +dayjs.extend(customParseFormat) + +export const weekDays: { key: string; label: string }[] = [ + { key: 'monday', label: '周一' }, + { key: 'tuesday', label: '周二' }, + { key: 'wednesday', label: '周三' }, + { key: 'thursday', label: '周四' }, + { key: 'friday', label: '周五' }, + { key: 'saturday', label: '周六' }, + { key: 'sunday', label: '周日' }, +] + +/** 工作日模式:休息 / 全天 / 自定义时段 */ +export type WorkMode = 'rest' | 'allday' | 'custom' + +export const workModeOptions: { value: WorkMode; label: string }[] = [ + { value: 'rest', label: '休息' }, + { value: 'allday', label: '全天' }, + { value: 'custom', label: '自定义时段' }, +] + +/** 快捷时段预设(选中后填入自定义时间范围) */ +export const workRangePresets: { label: string; start: string; end: string }[] = [ + { label: '09:00-18:00', start: '09:00', end: '18:00' }, + { label: '08:00-17:00', start: '08:00', end: '17:00' }, + { label: '09:00-12:00', start: '09:00', end: '12:00' }, + { label: '13:00-18:00', start: '13:00', end: '18:00' }, + { label: '10:00-22:00', start: '10:00', end: '22:00' }, +] + +export function parseWorkSlot(slot?: string): { mode: WorkMode; range?: [Dayjs, Dayjs] } { + const s = (slot || '').trim() + if (!s || s === '休息') return { mode: 'rest' } + if (s === '全天') return { mode: 'allday' } + const m = s.match(/^(\d{1,2}:\d{2})\s*-\s*(\d{1,2}:\d{2})$/) + if (m) { + const start = dayjs(m[1], 'HH:mm') + const end = dayjs(m[2], 'HH:mm') + if (start.isValid() && end.isValid()) { + return { mode: 'custom', range: [start, end] } + } + } + return { mode: 'rest' } +} + +export function encodeWorkSlot(mode: WorkMode | undefined, range?: [Dayjs, Dayjs] | null): string { + if (mode === 'allday') return '全天' + if (mode === 'custom' && range?.[0]?.isValid() && range?.[1]?.isValid()) { + return `${range[0].format('HH:mm')}-${range[1].format('HH:mm')}` + } + return '' +} + +export function defaultCustomRange(): [Dayjs, Dayjs] { + return [dayjs('09:00', 'HH:mm'), dayjs('18:00', 'HH:mm')] +} diff --git a/web/src/widgets/VisitorChat.tsx b/web/src/widgets/VisitorChat.tsx index 6a3052b..efb9ece 100644 --- a/web/src/widgets/VisitorChat.tsx +++ b/web/src/widgets/VisitorChat.tsx @@ -1,56 +1,17 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react' +import { MessageOutlined } from '@ant-design/icons' +import { insertAtCursor } from '@/components/common/EmojiPicker' import { - CloseOutlined, MessageOutlined, SendOutlined, - StarFilled, MinusOutlined, PictureOutlined, CustomerServiceOutlined, -} from '@ant-design/icons' -import EmojiPicker, { insertAtCursor } from '@/components/common/EmojiPicker' -import { ChatImage } from '@/components/common/ImagePreview' -import MarkdownBody from '@/components/common/MarkdownBody' -import SafeHtml, { looksLikeHtml } from '@/components/common/SafeHtml' + defaultWelcomeSegments, parseWelcomeSegments, + type HostPageInfo, type LayoutMode, type Message, type WelcomeSegment, +} from './visitor/types' +import { ChatHeader } from './visitor/ChatHeader' +import { MessageList } from './visitor/MessageList' +import { ChatComposer } from './visitor/ChatComposer' +import { RatingOverlay } from './visitor/RatingOverlay' -interface Message { - id: number - sender: 'visitor' | 'agent' - content: string - time: string - type?: 'text' | 'image' - /** 服务端单调序号,用于增量同步 */ - seq?: number -} +export type { HostPageInfo } from './visitor/types' -const defaultQuickQuestions = ['查询订单状态', '退换货政策', '配送时效说明'] - -type WelcomeSegment = { type: 'text' | 'image'; content: string } - -const defaultWelcomeSegments: WelcomeSegment[] = [ - { type: 'text', content: '您好!欢迎咨询,请问有什么可以帮您?' }, -] - -function parseWelcomeSegments(data: { - welcome_messages?: unknown - welcome_message?: string -}): WelcomeSegment[] { - if (Array.isArray(data.welcome_messages) && data.welcome_messages.length > 0) { - const segs: WelcomeSegment[] = [] - for (const raw of data.welcome_messages) { - if (!raw || typeof raw !== 'object') continue - const item = raw as { type?: string; content?: string } - const content = typeof item.content === 'string' ? item.content.trim() : '' - if (!content) continue - if (item.type === 'image') segs.push({ type: 'image', content }) - else segs.push({ type: 'text', content }) - } - if (segs.length > 0) return segs - } - if (typeof data.welcome_message === 'string' && data.welcome_message.trim()) { - return [{ type: 'text', content: data.welcome_message.trim() }] - } - return defaultWelcomeSegments -} - -type LayoutMode = 'floating' | 'fill' - -export type HostPageInfo = { url?: string; title?: string; referrer?: string } interface VisitorChatProps { defaultOpen?: boolean @@ -917,154 +878,31 @@ const VisitorChat = ({ const panel = open && (
-
-
- -
-
-

{displayName || '在线客服'}

-

- - {sessionEnded - ? '会话已结束' - : agentsOnline - ? (agentName ? `${agentName} 为您服务` : '客服在线,随时解答') - : '暂无在线客服 · 欢迎留言'} -

-
-
- {!isFill && ( - - )} - -
-
+
- {showWelcome && ( -
- {agentsOnline ? ( - welcomeSegments.map((seg, i) => - seg.type === 'image' ? ( -
- -
- ) : ( -
- {looksLikeHtml(seg.content) ? ( - - ) : ( - {seg.content} - )} -
- ), - ) - ) : ( -
- {looksLikeHtml(offlinePrompt) ? ( - - ) : ( - {offlinePrompt} - )} -
- )} -
- )} - - {showQuick && ( -
- {defaultQuickQuestions.map((q, i) => ( - - ))} -
- )} - - {messages.map(msg => ( - msg.sender === 'visitor' ? ( -
-
- {msg.type === 'image' ? ( - - ) : ( - {msg.content} - )} -
- {msg.time && ( - {msg.time} - )} -
- ) : ( -
-
- -
-
-
- {msg.type === 'image' ? ( - - ) : ( - {msg.content} - )} -
- {msg.time && ( - {msg.time} - )} -
-
- ) - ))} - - {agentTyping && ( - <> -
-
- -
-
- - - -
-
-

客服正在输入...

- - )} -
+ sendMessage(q)} + chatEndRef={chatEndRef} + />
@@ -1128,198 +966,62 @@ const VisitorChat = ({
)} - {!sessionEnded && pendingImage && agentsOnline && ( -
- 预览 -
确认上传并发送?(自动转 WebP)
- - -
- )} - - {!sessionEnded && agentsOnline && ( -
- - - { handleImageFile(e.target.files?.[0]); e.currentTarget.value = '' }} - /> -
- )} - - {!sessionEnded && ( -
- { - const v = e.target.value - setInput(v) - emitInputDraft(v, v === '') - }} - onPaste={e => { - if (!agentsOnline) return - const item = Array.from(e.clipboardData.items).find(i => i.type.startsWith('image/')) - if (item) { - e.preventDefault() - handleImageFile(item.getAsFile()) - } - }} - onKeyDown={e => { - if (e.key === 'Enter') { - if (agentsOnline) sendMessage(input) - } - }} - disabled={sending || !sessionId} - /> - {agentsOnline ? ( - - ) : ( - - )} -
- )} + { + if (!pendingImage) return + URL.revokeObjectURL(pendingImage.preview) + setPendingImage(null) + }} + onSendImage={sendImage} + onPickFile={handleImageFile} + onInsertEmoji={insertEmoji} + onInputChange={v => { + setInput(v) + emitInputDraft(v, v === '') + }} + onSend={() => sendMessage(input)} + onSubmitLeave={submitLeaveMessage} + /> - {showRating && ( -
-
-
本次服务如何?
-
先点亮星级,可补充感受后再提交
-
- {[1, 2, 3, 4, 5].map(star => { - const active = star <= (hoverStar || ratingScore) - return ( - setHoverStar(star)} - onMouseLeave={() => setHoverStar(0)} - onClick={() => { - setRatingScore(star) - setSendError('') - }} - /> - ) - })} -
-
- {ratingScore > 0 ? `${ratingScore} 星` : '请选择 1~5 星'} -
-
- {ratingPresets.map(preset => { - const selected = ratingText === preset - return ( - - ) - })} -
-