支持客户、对话记录与统计报告 CSV 导出
- 新增 /customers/export、/sessions/export、/statistics/export - 沿用列表筛选与角色可见范围,UTF-8 BOM 便于 Excel 打开 - 前端三处导出按钮接入真实下载
This commit is contained in:
@@ -7,7 +7,7 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import {
|
||||
archiveSession, batchArchiveSessions, getAvailableAgents, getChannels, getSession, getSessions,
|
||||
archiveSession, batchArchiveSessions, exportSessionsCSV, getAvailableAgents, getChannels, getSession, getSessions,
|
||||
type AvailableAgent, type Channel, type Message, type Session, type SessionEvent,
|
||||
} from '@/services/api'
|
||||
import { ChatImage } from '@/components/common/ImagePreview'
|
||||
@@ -162,6 +162,7 @@ const ChatHistory = () => {
|
||||
const [detailLoading, setDetailLoading] = useState(false)
|
||||
const [checkedIds, setCheckedIds] = useState<number[]>([])
|
||||
const [archiving, setArchiving] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -335,11 +336,29 @@ const ChatHistory = () => {
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1.5 h-8 px-3 rounded-lg text-sm text-neutral-600 border border-neutral-200 bg-white hover:bg-neutral-50 cursor-pointer"
|
||||
onClick={() => message.info('导出功能后续版本提供')}
|
||||
disabled={exporting}
|
||||
className="flex items-center gap-1.5 h-8 px-3 rounded-lg text-sm text-neutral-600 border border-neutral-200 bg-white hover:bg-neutral-50 cursor-pointer disabled:opacity-50"
|
||||
onClick={async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
await exportSessionsCSV({
|
||||
status: statusFilter,
|
||||
agent_id: agentFilter,
|
||||
channel_id: channelFilter,
|
||||
search: search || undefined,
|
||||
from: dateRange?.[0] ? dateRange[0].format('YYYY-MM-DD') : undefined,
|
||||
to: dateRange?.[1] ? dateRange[1].format('YYYY-MM-DD') : undefined,
|
||||
})
|
||||
message.success('对话记录已导出')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '导出失败')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DownloadOutlined />
|
||||
导出
|
||||
{exporting ? '导出中…' : '导出'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
DeleteOutlined, ExportOutlined, CloseOutlined, GlobalOutlined, MessageOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
createCustomer, deleteCustomer, getCustomer, getCustomers, updateCustomer,
|
||||
createCustomer, deleteCustomer, exportCustomersCSV, getCustomer, getCustomers, updateCustomer,
|
||||
type Customer, type Session,
|
||||
} from '@/services/api'
|
||||
import { useAuth } from '@/stores/auth'
|
||||
@@ -140,6 +140,7 @@ const Customers = () => {
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [search, setSearch] = useState('')
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [tagFilter, setTagFilter] = useState('all')
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null)
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
@@ -321,8 +322,19 @@ const Customers = () => {
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Button
|
||||
icon={<ExportOutlined />}
|
||||
loading={exporting}
|
||||
className="!text-[13px] !h-8 !px-3 !font-medium !text-neutral-600 !border-neutral-200"
|
||||
onClick={() => message.info('导出功能后续版本提供')}
|
||||
onClick={async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
await exportCustomersCSV({ search: search || undefined })
|
||||
message.success('客户列表已导出')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '导出失败')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
导出
|
||||
</Button>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import { Column, Line, Pie } from '@ant-design/charts'
|
||||
import {
|
||||
getAgentPerformance, getChannelDistribution, getKPIs, getResponseDistribution, getSessionTrend,
|
||||
exportStatisticsCSV, getAgentPerformance, getChannelDistribution, getKPIs, getResponseDistribution, getSessionTrend,
|
||||
type StatisticsKpis,
|
||||
} from '@/services/api'
|
||||
|
||||
@@ -34,6 +34,7 @@ const Statistics = () => {
|
||||
name: string; conversations: number; avgResponse: number; satisfaction: number
|
||||
}[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
@@ -208,11 +209,22 @@ const Statistics = () => {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="h-8 px-3 rounded-lg text-sm text-neutral-600 border border-neutral-200 bg-white hover:bg-neutral-50 flex items-center gap-1.5 cursor-pointer"
|
||||
onClick={() => message.info('导出报告功能后续版本提供')}
|
||||
disabled={exporting}
|
||||
className="h-8 px-3 rounded-lg text-sm text-neutral-600 border border-neutral-200 bg-white hover:bg-neutral-50 flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
|
||||
onClick={async () => {
|
||||
setExporting(true)
|
||||
try {
|
||||
await exportStatisticsCSV()
|
||||
message.success('统计报告已导出')
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '导出失败')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DownloadOutlined />
|
||||
导出报告
|
||||
{exporting ? '导出中…' : '导出报告'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
+35
-1
@@ -1,4 +1,4 @@
|
||||
import { get, post, put, del, getList, postForm } from './request'
|
||||
import { get, post, put, del, getList, postForm, downloadFile } from './request'
|
||||
|
||||
export interface LoginParams { username: string; password: string }
|
||||
export interface LoginResult { token: string; user_id: number; tenant_id: number; nickname: string; role: string }
|
||||
@@ -253,6 +253,40 @@ export const getCustomers = (params?: { search?: string; status?: string; source
|
||||
if (params?.pageSize) search.set('pageSize', String(params.pageSize))
|
||||
return getList<Customer>(`/customers?${search}`)
|
||||
}
|
||||
|
||||
export const exportCustomersCSV = (params?: { search?: string; status?: string; source?: string }) => {
|
||||
const search = new URLSearchParams()
|
||||
if (params?.search) search.set('search', params.search)
|
||||
if (params?.status) search.set('status', params.status)
|
||||
if (params?.source) search.set('source', params.source)
|
||||
const qs = search.toString()
|
||||
return downloadFile(`/customers/export${qs ? `?${qs}` : ''}`, `customers_${Date.now()}.csv`)
|
||||
}
|
||||
|
||||
export const exportSessionsCSV = (params?: {
|
||||
status?: string
|
||||
priority?: string
|
||||
agent_id?: number | string
|
||||
channel_id?: number | string
|
||||
search?: string
|
||||
from?: string
|
||||
to?: string
|
||||
}) => {
|
||||
const search = new URLSearchParams()
|
||||
if (params?.status) search.set('status', params.status)
|
||||
if (params?.priority) search.set('priority', params.priority)
|
||||
if (params?.agent_id != null && params.agent_id !== '') search.set('agent_id', String(params.agent_id))
|
||||
if (params?.channel_id != null && params.channel_id !== '') search.set('channel_id', String(params.channel_id))
|
||||
if (params?.search) search.set('search', params.search)
|
||||
if (params?.from) search.set('from', params.from)
|
||||
if (params?.to) search.set('to', params.to)
|
||||
const qs = search.toString()
|
||||
return downloadFile(`/sessions/export${qs ? `?${qs}` : ''}`, `sessions_${Date.now()}.csv`)
|
||||
}
|
||||
|
||||
export const exportStatisticsCSV = () =>
|
||||
downloadFile('/statistics/export', `statistics_${Date.now()}.csv`)
|
||||
|
||||
export const getCustomer = (id: number) => get<{ customer: Customer; sessions: Session[] }>(`/customers/${id}`)
|
||||
export const createCustomer = (data: Partial<Customer>) => post<Customer>('/customers', data)
|
||||
export const updateCustomer = (id: number, data: Partial<Customer>) => put<Customer>(`/customers/${id}`, data)
|
||||
|
||||
@@ -53,3 +53,38 @@ export const getList = <T>(url: string) => request<ListResponse<T>>(url)
|
||||
export const postForm = <T>(url: string, form: FormData) =>
|
||||
request<Response<T>>(url, { method: 'POST', body: form })
|
||||
|
||||
/** 下载 CSV 等二进制响应(带鉴权) */
|
||||
export async function downloadFile(path: string, fallbackName: string) {
|
||||
const headers: Record<string, string> = {}
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
const res = await fetch(`${BASE}${path}`, { headers })
|
||||
const ct = res.headers.get('Content-Type') || ''
|
||||
if (!res.ok || ct.includes('application/json')) {
|
||||
let msg = '导出失败'
|
||||
try {
|
||||
const json = await res.json()
|
||||
if (json?.message) msg = json.message
|
||||
} catch { /* ignore */ }
|
||||
throw new Error(msg)
|
||||
}
|
||||
const blob = await res.blob()
|
||||
let filename = fallbackName
|
||||
const cd = res.headers.get('Content-Disposition') || ''
|
||||
const m = /filename\*=UTF-8''([^;]+)|filename="?([^";]+)"?/i.exec(cd)
|
||||
if (m) {
|
||||
try {
|
||||
filename = decodeURIComponent(m[1] || m[2])
|
||||
} catch {
|
||||
filename = m[1] || m[2] || fallbackName
|
||||
}
|
||||
}
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user