支持客户、对话记录与统计报告 CSV 导出

- 新增 /customers/export、/sessions/export、/statistics/export
- 沿用列表筛选与角色可见范围,UTF-8 BOM 便于 Excel 打开
- 前端三处导出按钮接入真实下载
This commit is contained in:
yml2213
2026-07-15 15:10:46 +08:00
parent 216ab2c85a
commit c43f420875
8 changed files with 571 additions and 11 deletions
+23 -4
View File
@@ -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"
+14 -2
View File
@@ -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>
+16 -4
View File
@@ -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
View File
@@ -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)
+35
View File
@@ -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)
}