diff --git a/yunkes/src/utils/csvExport.ts b/yunkes/src/utils/csvExport.ts new file mode 100644 index 0000000..8bc92f0 --- /dev/null +++ b/yunkes/src/utils/csvExport.ts @@ -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 { + key: string + label: string + /** Optional formatter — receives the row, returns display string */ + format?: (row: T) => string | number | null | undefined +} + +export interface ExportOptions { + /** 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[] + /** 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(options: ExportOptions): 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)[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` } +} diff --git a/yunkes/src/views/CallList.vue b/yunkes/src/views/CallList.vue index fc55a69..04cebff 100644 --- a/yunkes/src/views/CallList.vue +++ b/yunkes/src/views/CallList.vue @@ -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 { + 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 }} +
+ + 导出 + +
@@ -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; diff --git a/yunkes/src/views/CustomerList.vue b/yunkes/src/views/CustomerList.vue index b6b3475..a8f211e 100644 --- a/yunkes/src/views/CustomerList.vue +++ b/yunkes/src/views/CustomerList.vue @@ -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 { } } +// ---- header 导出:当前 tab 渠道全量客户 CSV(含当前部门/员工筛选) ---- +const exporting = ref(false) + +async function exportCustomers(): Promise { + 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(() => {
+ + 导出 + { } } +// ---- header 导出:当前 tab 渠道全量群 CSV(含当前员工筛选) ---- +const exporting = ref(false) + +async function exportGroups(): Promise { + 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(() => {
+ + 导出 + 清除员工筛选 diff --git a/yunkes/src/views/SmsList.vue b/yunkes/src/views/SmsList.vue index 058064b..7bdaf78 100644 --- a/yunkes/src/views/SmsList.vue +++ b/yunkes/src/views/SmsList.vue @@ -1,10 +1,12 @@