feat(ui): 所有列表页加CSV导出按钮(全量拉取+中文BOM兼容Excel)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
浅念
2026-08-15 10:31:21 +08:00
parent 9bec4dd7c8
commit edd6a27371
6 changed files with 327 additions and 11 deletions
+85
View File
@@ -0,0 +1,85 @@
/**
* CSV export utility — fetches all pages of a paginated endpoint and exports as CSV.
*
* Usage:
* const result = await exportListToCsv({
* fetchPage: (page, size) => getCustomers({ channel: 'wechat', page, page_size: size }),
* columns: [
* { key: 'friend_nickname', label: '客户微信' },
* { key: 'friend_id', label: '微信号' },
* ],
* filename: '微信客户列表',
* })
*/
export interface CsvColumn<T> {
key: string
label: string
/** Optional formatter — receives the row, returns display string */
format?: (row: T) => string | number | null | undefined
}
export interface ExportOptions<T> {
/** Fetch one page of data. Return items array. */
fetchPage: (page: number, pageSize: number) => Promise<{ items: T[]; total: number }>
/** Column definitions (key + display label) */
columns: CsvColumn<T>[]
/** Download filename (without extension) */
filename: string
/** Page size for fetching (default 100) */
pageSize?: number
/** Max rows to export (safety cap, default 10000) */
maxRows?: number
}
function escapeCsv(value: string | number | null | undefined): string {
if (value === null || value === undefined) return ''
const s = String(value)
// Escape quotes and wrap in quotes if contains comma, quote, or newline
if (s.includes('"') || s.includes(',') || s.includes('\n') || s.includes('\r')) {
return `"${s.replace(/"/g, '""')}"`
}
return s
}
export async function exportListToCsv<T>(options: ExportOptions<T>): Promise<{ rows: number; filename: string }> {
const { fetchPage, columns, filename, pageSize = 100, maxRows = 10000 } = options
// Fetch all pages
const allRows: T[] = []
let page = 1
let total = Infinity
while (allRows.length < Math.min(total, maxRows)) {
const result = await fetchPage(page, pageSize)
total = result.total
allRows.push(...result.items)
if (result.items.length < pageSize) break // last page
page++
}
const limited = allRows.slice(0, maxRows)
// Build CSV
const header = columns.map(c => escapeCsv(c.label)).join(',')
const lines = limited.map(row =>
columns.map(col => {
const val = col.format ? col.format(row) : (row as Record<string, unknown>)[col.key]
return escapeCsv(val as string | number | null | undefined)
}).join(',')
)
const csv = '' + header + '\n' + lines.join('\n') // BOM for Excel Chinese support
// Trigger download
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${filename}.csv`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
return { rows: limited.length, filename: `${filename}.csv` }
}
+46
View File
@@ -2,6 +2,7 @@
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, type TabPaneName } from 'element-plus'
import { Download } from '@element-plus/icons-vue'
import { getCalls } from '@/api'
import type { CallChannel, PhoneCall } from '@/api/types'
import {
@@ -12,6 +13,7 @@ import {
formatTime,
} from '@/utils/format'
import { buildCallSections, readField } from '@/utils/callFields'
import { exportListToCsv } from '@/utils/csvExport'
import VoiceClipModal from '@/components/VoiceClipModal.vue'
// Calls aren't tied to a messaging channel — `channel` on a call is the call
@@ -91,6 +93,38 @@ function onTabChange(_name: TabPaneName): void {
load()
}
// ---- header 导出:当前 tab 通话类型全量记录 CSV ----
const exporting = ref(false)
async function exportCalls(): Promise<void> {
if (total.value === 0) {
ElMessage.warning('当前列表为空,无数据可导出')
return
}
exporting.value = true
try {
const channel = callType.value
const result = await exportListToCsv({
fetchPage: (page, pageSize) => getCalls({ channel, page, page_size: pageSize }),
columns: [
{ key: 'user_id_name', label: '员工', format: (r) => r.user_id_name ?? r.user_id ?? '' },
{ key: 'call_number', label: '客户', format: (r) => r.call_number ?? '' },
{ key: 'call_status', label: '状态', format: (r) => callStatusLabel(r.call_status, r.channel) },
{ key: 'incoming_call', label: '方向', format: (r) => callDirectionLabel(r.incoming_call) },
{ key: 'call_duration', label: '通话时长', format: (r) => formatCallDuration(r.call_duration) },
{ key: 'start_call_time', label: '通话时间', format: (r) => (r.start_call_time ? formatTime(r.start_call_time) : '') },
{ key: 'record', label: '有无录音', format: (r) => (r.record_media_hash || r.record_file ? '有' : '无') },
],
filename: `${tabs.find((t) => t.value === callType.value)?.label ?? '通话'}记录`,
})
ElMessage.success(`已导出 ${result.rows} 条`)
} catch (e) {
ElMessage.error(`导出失败:${(e as Error).message}`)
} finally {
exporting.value = false
}
}
/**
* 当前表格要渲染的行。非 voice_call tab 原样返回;voice_call tab 按 voiceScope
* 依 isRoom(0=好友, 1=群)做页内过滤。云客 isRoom 可能是 number/string/boolean,
@@ -217,6 +251,11 @@ onMounted(load)
共 {{ total }} 条 · 类型:{{ tabs.find(t => t.value === callType)?.label }}
</span>
</div>
<div class="yk-calls__actions">
<el-button size="small" :loading="exporting" @click="exportCalls">
<el-icon><Download /></el-icon> 导出
</el-button>
</div>
</div>
<el-tabs v-model="callType" class="yk-calls__tabs" @tab-change="onTabChange">
@@ -490,6 +529,13 @@ onMounted(load)
font-size: 13px;
}
.yk-calls__actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.yk-cell__sub {
color: #909399;
font-size: 12px;
+44
View File
@@ -2,12 +2,14 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Download } from '@element-plus/icons-vue'
import { getCustomers } from '@/api'
import { downloadBlob } from '@/api/client'
import type { Channel, Customer } from '@/api/types'
import { useChannelStore } from '@/stores/channel'
import { useOrgStore } from '@/stores/org'
import { channelLabel, formatTime } from '@/utils/format'
import { exportListToCsv } from '@/utils/csvExport'
import { buildCustomerSections, fromTypeLabel, readField } from '@/utils/customerFields'
/**
@@ -205,6 +207,45 @@ async function confirmExport(): Promise<void> {
}
}
// ---- header 导出:当前 tab 渠道全量客户 CSV(含当前部门/员工筛选) ----
const exporting = ref(false)
async function exportCustomers(): Promise<void> {
const channel = activeTab.value // 'wechat' | 'whatsapp'
if (tabState[channel].total === 0) {
ElMessage.warning('当前列表为空,无数据可导出')
return
}
exporting.value = true
try {
const result = await exportListToCsv({
fetchPage: (page, pageSize) =>
getCustomers({
channel,
department_id: departmentId.value ?? undefined,
owner_account_id: ownerId.value ?? undefined,
page,
page_size: pageSize,
}),
columns: [
{ key: 'friend_nickname', label: '客户昵称' },
{ key: 'friend_id', label: '客户微信号' },
{ key: 'friend_remark', label: '备注名' },
{ key: 'real_name', label: '姓名' },
{ key: 'phone', label: '手机号' },
{ key: 'owner_account_id', label: '归属员工' },
{ key: 'last_chat_time', label: '最近沟通时间', format: (r) => (r.last_chat_time ? formatTime(r.last_chat_time) : '') },
],
filename: `${channel === 'wechat' ? '微信客户' : 'WA客户'}列表`,
})
ElMessage.success(`已导出 ${result.rows} 条`)
} catch (e) {
ElMessage.error(`导出失败:${(e as Error).message}`)
} finally {
exporting.value = false
}
}
/** 清除 owner_account_id query(保留其余 query)。 */
function clearOwnerFilter(): void {
const { owner_account_id: _drop, ...rest } = route.query
@@ -272,6 +313,9 @@ onMounted(() => {
</span>
</div>
<div class="yk-customers__filters">
<el-button size="small" :loading="exporting" @click="exportCustomers">
<el-icon><Download /></el-icon> 导出
</el-button>
<el-button
v-if="departmentId"
size="small"
+43
View File
@@ -2,11 +2,13 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Download } from '@element-plus/icons-vue'
import { getGroups } from '@/api'
import { downloadBlob } from '@/api/client'
import type { Channel, Group } from '@/api/types'
import { useChannelStore } from '@/stores/channel'
import { channelLabel, formatTime } from '@/utils/format'
import { exportListToCsv } from '@/utils/csvExport'
import { buildGroupSections, readField } from '@/utils/groupFields'
import { fromTypeLabel } from '@/utils/customerFields'
@@ -174,6 +176,44 @@ async function exportGroupHtml(row: Group): Promise<void> {
}
}
// ---- header 导出:当前 tab 渠道全量群 CSV(含当前员工筛选) ----
const exporting = ref(false)
async function exportGroups(): Promise<void> {
const channel = activeTab.value // 'wechat' | 'whatsapp'
if (tabState[channel].total === 0) {
ElMessage.warning('当前列表为空,无数据可导出')
return
}
exporting.value = true
try {
const result = await exportListToCsv({
fetchPage: (page, pageSize) =>
getGroups({
channel,
owner_account_id: ownerId.value ?? undefined,
page,
page_size: pageSize,
}),
columns: [
{ key: 'room_name', label: '群名' },
{ key: 'room_id', label: '群ID' },
{ key: 'owner_account_nickname', label: '群主', format: (r) => r.owner_account_nickname ?? r.owner_account_id ?? '' },
{ key: 'member_count', label: '成员数' },
{ key: 'message_count', label: '消息数' },
{ key: 'last_chat_time', label: '最新消息时间', format: (r) => (r.last_chat_time ? formatTime(r.last_chat_time) : '') },
{ key: 'owner_account_id', label: '归属员工' },
],
filename: `${channel === 'wechat' ? '微信群' : 'WA群'}列表`,
})
ElMessage.success(`已导出 ${result.rows} 条`)
} catch (e) {
ElMessage.error(`导出失败:${(e as Error).message}`)
} finally {
exporting.value = false
}
}
/** 清除 owner_account_id query(保留其余 query)。 */
function clearOwnerFilter(): void {
const { owner_account_id: _drop, ...rest } = route.query
@@ -237,6 +277,9 @@ onMounted(() => {
</span>
</div>
<div class="yk-groups__filters">
<el-button size="small" :loading="exporting" @click="exportGroups">
<el-icon><Download /></el-icon> 导出
</el-button>
<el-button v-if="ownerId" size="small" type="info" plain @click="clearOwnerFilter">
清除员工筛选
</el-button>
+60 -11
View File
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Download } from '@element-plus/icons-vue'
import { getSms } from '@/api'
import type { Sms } from '@/api/types'
import { formatTime, smsDirectionLabel } from '@/utils/format'
import { buildSmsSections, readField } from '@/utils/smsFields'
import { exportListToCsv } from '@/utils/csvExport'
const list = ref<Sms[]>([])
const total = ref(0)
@@ -57,6 +59,41 @@ function onDateChange(): void {
load()
}
// ---- header 导出:全量短信 CSV(含当前日期筛选) ----
const exporting = ref(false)
async function exportSms(): Promise<void> {
if (total.value === 0) {
ElMessage.warning('当前列表为空,无数据可导出')
return
}
exporting.value = true
try {
const result = await exportListToCsv({
fetchPage: (p, pageSize) =>
getSms({
page: p,
page_size: pageSize,
start: dateRange.value?.[0]?.getTime(),
end: dateRange.value?.[1]?.getTime(),
}),
columns: [
{ key: 'create_time', label: '时间', format: (r) => (r.create_time ? formatTime(r.create_time) : '') },
{ key: 'receive_name', label: '员工', format: (r) => r.receive_name ?? r.user_id ?? '' },
{ key: 'customer_phone', label: '号码', format: (r) => r.customer_phone ?? r.sender ?? '' },
{ key: 'direction', label: '方向', format: (r) => smsDirectionLabel(r.direction) },
{ key: 'content', label: '内容', format: (r) => r.content ?? r.content_preview ?? '' },
],
filename: '短信记录',
})
ElMessage.success(`已导出 ${result.rows} 条`)
} catch (e) {
ElMessage.error(`导出失败:${(e as Error).message}`)
} finally {
exporting.value = false
}
}
/**
* Read a raw_json camelCase key off an SMS, preferring the structured
* snake_case column when it is populated. The first argument is the row so the
@@ -107,17 +144,22 @@ onMounted(load)
<h2 style="margin: 0 0 4px; font-size: 16px">短信记录</h2>
<span class="yk-sms__sub">共 {{ total }} 条</span>
</div>
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
size="small"
clearable
style="width: 260px"
@change="onDateChange"
/>
<div class="yk-sms__actions">
<el-date-picker
v-model="dateRange"
type="daterange"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
size="small"
clearable
style="width: 260px"
@change="onDateChange"
/>
<el-button size="small" :loading="exporting" @click="exportSms">
<el-icon><Download /></el-icon> 导出
</el-button>
</div>
</div>
<el-alert
@@ -224,6 +266,13 @@ onMounted(load)
font-size: 13px;
}
.yk-sms__actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.yk-sms__pager {
margin-top: 12px;
display: flex;
+49
View File
@@ -2,11 +2,13 @@
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Download } from '@element-plus/icons-vue'
import { getAccounts, getCustomers, getStaffChatStats } from '@/api'
import type { Account, Channel, Page, StaffChatCustomer, StaffChatStats } from '@/api/types'
import YkStaffDetailModal from '@/components/YkStaffDetailModal.vue'
import { useChannelStore } from '@/stores/channel'
import { channelLabel, formatTime } from '@/utils/format'
import { exportListToCsv } from '@/utils/csvExport'
import { buildAccountSections, formatBytes, readField } from '@/utils/accountFields'
/**
@@ -502,6 +504,41 @@ function onTabChange(name: string | number): void {
if (!tabState[ch].loaded) loadTab(ch)
}
// ---------------------------------------------------------------------------
// Header 导出:当前 tab 渠道全量员工账号 CSV
// ---------------------------------------------------------------------------
const exporting = ref(false)
async function exportStaff(): Promise<void> {
const channel = activeTab.value // 'wechat' | 'whatsapp'
if (tabState[channel].total === 0) {
ElMessage.warning('当前列表为空,无数据可导出')
return
}
exporting.value = true
try {
const result = await exportListToCsv({
fetchPage: (page, pageSize) => getAccounts({ channel, page, page_size: pageSize }),
columns: [
{ key: 'user_name', label: '员工姓名' },
{ key: 'nickname', label: '微信昵称' },
{ key: 'account_id', label: '微信号' },
{ key: 'phone', label: '手机' },
{ key: 'contacts_num', label: '好友数' },
{ key: 'group_num', label: '群数' },
{ key: 'channel', label: '渠道', format: (r) => channelLabel(r.channel) },
],
filename: `${channel === 'wechat' ? '微信员工' : 'WhatsApp员工'}列表`,
})
ElMessage.success(`已导出 ${result.rows} 条`)
} catch (e) {
ElMessage.error(`导出失败:${(e as Error).message}`)
} finally {
exporting.value = false
}
}
// ---------------------------------------------------------------------------
// Drawer
// ---------------------------------------------------------------------------
@@ -550,6 +587,11 @@ onMounted(() => {
按渠道展示员工账号(微信号 / WhatsApp 号),列布局对齐云客后台,点击行查看全部字段。
</span>
</div>
<div class="yk-staff__filters">
<el-button size="small" :loading="exporting" @click="exportStaff">
<el-icon><Download /></el-icon> 导出
</el-button>
</div>
</div>
<el-tabs v-model="activeTab" class="yk-staff__tabs" @tab-change="onTabChange">
@@ -955,6 +997,13 @@ onMounted(() => {
font-size: 13px;
}
.yk-staff__filters {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.yk-staff__tabs {
margin-top: 4px;
}