feat(ui): 所有列表页加CSV导出按钮(全量拉取+中文BOM兼容Excel)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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` }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user