feat(staffchat): 统计分析7tab(分型明细列+计数徽标+近7天窗+msgid定位,对齐云客)
- StatsPane 占位替换: 7 tab(label 带 (N) 计数)+daterange 默认近7天(清空后端兜底) - 明细列配置驱动(KIND_COLS);link 列跳 single/group 聊天;红包/转账行带 ?msgid 定位 - 语音通话列对齐 CallList(通话人/类型/呼入呼出/接通状态/时长/录音播放 dialog/时间/操作) - 敏感词 tab 空态卡(词库未配置,待 C1 接入,零请求) - 员工微信列用资料卡信息(明细行无 staff 字段,数据本属该员工) - counts 与明细解耦;双 seq 竞态守卫(兄弟组件惯例) - api: getStaffStatsDetail kind 收紧为 StaffStatsKind 联合(后端 Literal 6 值) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+11
-2
@@ -378,10 +378,19 @@ export function getMoments(query: MomentsQuery): Promise<MomentsList> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** staff-stats 明细 kind(与后端 KindParam Literal 6 值一致;无 sensitive——词库未接入)。 */
|
||||||
|
export type StaffStatsKind =
|
||||||
|
| 'new_customers'
|
||||||
|
| 'new_groups'
|
||||||
|
| 'red_transfer'
|
||||||
|
| 'del_customers'
|
||||||
|
| 'blocked_customers'
|
||||||
|
| 'voice_calls'
|
||||||
|
|
||||||
export interface StaffStatsQuery {
|
export interface StaffStatsQuery {
|
||||||
wechat_id: string
|
wechat_id: string
|
||||||
/** 传 kind 走明细模式,缺省返回 7 数字 counts。 */
|
/** 传 kind 走明细模式,缺省返回 7 数字 counts。 */
|
||||||
kind?: string
|
kind?: StaffStatsKind
|
||||||
start?: number
|
start?: number
|
||||||
end?: number
|
end?: number
|
||||||
}
|
}
|
||||||
@@ -400,7 +409,7 @@ export function getStaffStatsCounts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getStaffStatsDetail(
|
export function getStaffStatsDetail(
|
||||||
query: StaffStatsQuery & { kind: string },
|
query: StaffStatsQuery & { kind: StaffStatsKind },
|
||||||
): Promise<StaffStatsDetail> {
|
): Promise<StaffStatsDetail> {
|
||||||
return getJson<StaffStatsDetail>(
|
return getJson<StaffStatsDetail>(
|
||||||
'/staff-stats',
|
'/staff-stats',
|
||||||
|
|||||||
@@ -1,8 +1,623 @@
|
|||||||
<!-- 占位组件: Task 14 实现统计分析视图,签名锁定如下。 -->
|
<!-- 员工聊天窗口右列·统计分析(对齐云客 wechat-window 统计 tab): 7 个计数 tab
|
||||||
|
(label 带 (N) 徽标) + 近 7 天日期窗 + 分型明细表。明细列配置驱动
|
||||||
|
(KIND_COLS);红包/转账行带 msgid 定位跳转;语音通话列对齐 CallList。
|
||||||
|
根元素必须保持单 div——父组件 StaffChat 靠 attrs fallthrough 挂
|
||||||
|
sc-root__pane 列宽。 -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{ ownerId: string }>()
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { getAccounts, getStaffStatsCounts, getStaffStatsDetail } from '@/api'
|
||||||
|
import type { StaffStatsKind } from '@/api'
|
||||||
|
import type { Account, StaffStatsCounts } from '@/api/types'
|
||||||
|
import {
|
||||||
|
callDirectionLabel,
|
||||||
|
formatCallDuration,
|
||||||
|
formatTime,
|
||||||
|
} from '@/utils/format'
|
||||||
|
|
||||||
|
const props = defineProps<{ ownerId: string }>()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// ---- tab 定义(顺序/文案对齐云客;sensitive 是前端专属空态,后端无该 kind) ----
|
||||||
|
|
||||||
|
/** tab key = 后端 kind + 前端 sensitive 空态。 */
|
||||||
|
type KindKey = StaffStatsKind | 'sensitive'
|
||||||
|
|
||||||
|
const TABS: { key: KindKey; label: string }[] = [
|
||||||
|
{ key: 'new_customers', label: '新的客户' },
|
||||||
|
{ key: 'new_groups', label: '新的群' },
|
||||||
|
{ key: 'red_transfer', label: '红包转账' },
|
||||||
|
{ key: 'sensitive', label: '单聊/群聊敏感词' },
|
||||||
|
{ key: 'del_customers', label: '删除客户' },
|
||||||
|
{ key: 'blocked_customers', label: '拉黑客户' },
|
||||||
|
{ key: 'voice_calls', label: '语音通话' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** tab key → counts 响应字段(sensitive 对应后端恒 0 占位字段)。 */
|
||||||
|
const COUNT_KEY: Record<KindKey, keyof StaffStatsCounts> = {
|
||||||
|
new_customers: 'new_customers',
|
||||||
|
new_groups: 'new_groups',
|
||||||
|
red_transfer: 'red_transfer',
|
||||||
|
sensitive: 'sensitive_words',
|
||||||
|
del_customers: 'del_customers',
|
||||||
|
blocked_customers: 'blocked_customers',
|
||||||
|
voice_calls: 'voice_calls',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 通用明细行(后端每 kind 字段不同,统一宽行;取值见 staff_stats.py) ----
|
||||||
|
|
||||||
|
interface StatsRow {
|
||||||
|
talker_id?: string | null
|
||||||
|
room_id?: string | null
|
||||||
|
nickname?: string | null
|
||||||
|
avatar?: string | null
|
||||||
|
remark?: string | null
|
||||||
|
room_name?: string | null
|
||||||
|
op_type?: string | null
|
||||||
|
amount?: number | null
|
||||||
|
time?: number | null
|
||||||
|
msg_id?: string | null
|
||||||
|
/** voice_calls 专属 */
|
||||||
|
id?: number | string | null
|
||||||
|
peer?: string | null
|
||||||
|
incoming?: number | null
|
||||||
|
connected?: boolean | null
|
||||||
|
duration?: number | null
|
||||||
|
start_time?: number | null
|
||||||
|
media_url?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 明细列配置(语音通话列富模板 + sensitive 空态走专属分支,不入表) ----
|
||||||
|
|
||||||
|
interface Col {
|
||||||
|
prop: string
|
||||||
|
label: string
|
||||||
|
/** link=客户/群点击跳聊天;staff=员工微信(资料卡);缺省按 prop 特判文本。 */
|
||||||
|
kind?: 'link' | 'staff'
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_COLS: Partial<Record<KindKey, Col[]>> = {
|
||||||
|
new_customers: [
|
||||||
|
{ prop: 'nickname', label: '客户微信', kind: 'link' },
|
||||||
|
{ prop: 'remark', label: '客户备注' },
|
||||||
|
{ prop: 'staff', label: '员工微信', kind: 'staff' },
|
||||||
|
{ prop: 'time', label: '时间' },
|
||||||
|
],
|
||||||
|
new_groups: [
|
||||||
|
{ prop: 'room_name', label: '微信群信息', kind: 'link' },
|
||||||
|
{ prop: 'staff', label: '员工微信', kind: 'staff' },
|
||||||
|
{ prop: 'time', label: '时间' },
|
||||||
|
],
|
||||||
|
red_transfer: [
|
||||||
|
{ prop: 'op_type', label: '操作类型' },
|
||||||
|
{ prop: 'nickname', label: '客户微信', kind: 'link' },
|
||||||
|
{ prop: 'staff', label: '员工微信', kind: 'staff' },
|
||||||
|
{ prop: 'amount', label: '金额' },
|
||||||
|
{ prop: 'time', label: '发生时间' },
|
||||||
|
{ prop: '_action', label: '操作' },
|
||||||
|
],
|
||||||
|
del_customers: [
|
||||||
|
{ prop: 'nickname', label: '客户微信', kind: 'link' },
|
||||||
|
{ prop: 'staff', label: '员工微信', kind: 'staff' },
|
||||||
|
{ prop: 'time', label: '发生时间' },
|
||||||
|
],
|
||||||
|
blocked_customers: [
|
||||||
|
{ prop: 'nickname', label: '客户微信', kind: 'link' },
|
||||||
|
{ prop: 'staff', label: '员工微信', kind: 'staff' },
|
||||||
|
{ prop: 'time', label: '发生时间' },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTab = ref<KindKey>('new_customers')
|
||||||
|
/** 当前 tab 列配置(voice_calls/sensitive 渲染专属分支,取空数组)。 */
|
||||||
|
const activeCols = computed<Col[]>(() => KIND_COLS[activeTab.value] ?? [])
|
||||||
|
|
||||||
|
// ---- 日期窗(默认近 7 天:今天-6天 00:00:00 ~ 今天 23:59:59) ----
|
||||||
|
|
||||||
|
function defaultRange(): [Date, Date] {
|
||||||
|
const start = new Date()
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
start.setDate(start.getDate() - 6)
|
||||||
|
const end = new Date()
|
||||||
|
end.setHours(23, 59, 59, 999)
|
||||||
|
return [start, end]
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateRange = ref<[Date, Date] | null>(defaultRange())
|
||||||
|
|
||||||
|
/** 日期窗 → start/end ms(闭区间,整日对齐);清空返回空对象让后端兜底近 7 天。 */
|
||||||
|
function windowParams(): { start?: number; end?: number } {
|
||||||
|
const r = dateRange.value
|
||||||
|
if (!r || !r[0] || !r[1]) return {}
|
||||||
|
const start = new Date(r[0])
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
const end = new Date(r[1])
|
||||||
|
end.setHours(23, 59, 59, 999)
|
||||||
|
return { start: start.getTime(), end: end.getTime() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 员工微信列数据: 后端明细行无 staff 字段——数据本就是该员工的统计,
|
||||||
|
// 用资料卡信息渲染(nickname/avatar,与 StaffChat 同款 getAccounts 手法) ----
|
||||||
|
|
||||||
|
const profile = ref<Account | null>(null)
|
||||||
|
|
||||||
|
async function loadProfile(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await getAccounts({ channel: 'wechat', page: 1, page_size: 100 })
|
||||||
|
profile.value = res.items.find(a => a.account_id === props.ownerId) ?? null
|
||||||
|
} catch {
|
||||||
|
profile.value = null // 后端未起: 员工列退化为 ownerId 兜底
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const staffName = computed(() => profile.value?.nickname || props.ownerId)
|
||||||
|
|
||||||
|
// ---- counts(一次拉全 7 类,徽标与明细解耦) ----
|
||||||
|
|
||||||
|
const counts = ref<StaffStatsCounts | null>(null)
|
||||||
|
const countsLoading = ref(false)
|
||||||
|
let countsSeq = 0
|
||||||
|
|
||||||
|
async function loadCounts(): Promise<void> {
|
||||||
|
const mySeq = ++countsSeq
|
||||||
|
countsLoading.value = true
|
||||||
|
try {
|
||||||
|
const res = await getStaffStatsCounts({
|
||||||
|
wechat_id: props.ownerId,
|
||||||
|
...windowParams(),
|
||||||
|
})
|
||||||
|
if (mySeq !== countsSeq) return
|
||||||
|
counts.value = res
|
||||||
|
} catch {
|
||||||
|
if (mySeq !== countsSeq) return
|
||||||
|
counts.value = null // 失败仅退化为无徽标,不阻塞明细
|
||||||
|
} finally {
|
||||||
|
if (mySeq === countsSeq) countsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** tab label: label (N)(counts 未到位/失败时无徽标)。 */
|
||||||
|
function tabLabel(key: KindKey): string {
|
||||||
|
const t = TABS.find(x => x.key === key)
|
||||||
|
if (!t) return key
|
||||||
|
const n = counts.value ? counts.value[COUNT_KEY[key]] : null
|
||||||
|
return n == null ? t.label : `${t.label} (${n})`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 明细(按当前 tab 拉;sensitive 不拉) ----
|
||||||
|
|
||||||
|
const rows = ref<StatsRow[]>([])
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const detailError = ref<string | null>(null)
|
||||||
|
let detailSeq = 0
|
||||||
|
|
||||||
|
async function loadDetail(): Promise<void> {
|
||||||
|
const kind = activeTab.value
|
||||||
|
if (kind === 'sensitive') {
|
||||||
|
// 词库未接入(spec 决策): 空态卡分支——作废在途请求并清表
|
||||||
|
detailSeq++
|
||||||
|
rows.value = []
|
||||||
|
detailLoading.value = false
|
||||||
|
detailError.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const mySeq = ++detailSeq
|
||||||
|
rows.value = []
|
||||||
|
detailLoading.value = true
|
||||||
|
detailError.value = null
|
||||||
|
try {
|
||||||
|
const res = await getStaffStatsDetail({
|
||||||
|
wechat_id: props.ownerId,
|
||||||
|
kind,
|
||||||
|
...windowParams(),
|
||||||
|
})
|
||||||
|
if (mySeq !== detailSeq) return
|
||||||
|
rows.value = res.rows as StatsRow[]
|
||||||
|
} catch (e) {
|
||||||
|
if (mySeq !== detailSeq) return
|
||||||
|
detailError.value = (e as Error).message
|
||||||
|
rows.value = []
|
||||||
|
} finally {
|
||||||
|
if (mySeq === detailSeq) detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 加载编排: 进入(ownerId 变更)拉 counts+明细+资料卡;切 tab 只拉明细;
|
||||||
|
// 日期窗变更 counts+明细都重拉 ----
|
||||||
|
|
||||||
|
watch(activeTab, () => void loadDetail())
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.ownerId,
|
||||||
|
() => {
|
||||||
|
dateRange.value = defaultRange()
|
||||||
|
void loadProfile()
|
||||||
|
void loadCounts()
|
||||||
|
void loadDetail()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function onRangeChange(): void {
|
||||||
|
void loadCounts()
|
||||||
|
void loadDetail()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 跳转: 客户/群 link 列 + red_transfer 操作列(msgid 定位) ----
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跳本员工聊天窗: room_id 优先走 group(talkerId 语义为 roomId),否则 single。
|
||||||
|
* withMsgid 用于红包/转账「查看聊天记录」——带 ?msgid= 让 ChatMain 定位消息。
|
||||||
|
*/
|
||||||
|
function goChat(row: StatsRow, withMsgid = false): void {
|
||||||
|
const query =
|
||||||
|
withMsgid && row.msg_id ? { msgid: String(row.msg_id) } : undefined
|
||||||
|
if (row.room_id) {
|
||||||
|
router.push({
|
||||||
|
name: 'staff-chat',
|
||||||
|
params: { ownerId: props.ownerId, tab: 'group', talkerId: row.room_id },
|
||||||
|
query,
|
||||||
|
})
|
||||||
|
} else if (row.talker_id) {
|
||||||
|
router.push({
|
||||||
|
name: 'staff-chat',
|
||||||
|
params: { ownerId: props.ownerId, tab: 'single', talkerId: row.talker_id },
|
||||||
|
query,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 语音通话行跳聊天: peer 是对方 wxid(@chatroom 后缀判群,同 CallList 手法)。 */
|
||||||
|
function goVoiceChat(row: StatsRow): void {
|
||||||
|
const peer = row.peer
|
||||||
|
if (!peer) return
|
||||||
|
if (peer.endsWith('@chatroom')) {
|
||||||
|
router.push({
|
||||||
|
name: 'staff-chat',
|
||||||
|
params: { ownerId: props.ownerId, tab: 'group', talkerId: peer },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
router.push({
|
||||||
|
name: 'staff-chat',
|
||||||
|
params: { ownerId: props.ownerId, tab: 'single', talkerId: peer },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- cell helpers ----
|
||||||
|
|
||||||
|
/** link 列显示名: 昵称空时回退 wxid/群名/群ID。 */
|
||||||
|
function linkText(row: StatsRow): string {
|
||||||
|
return row.nickname || row.room_name || row.talker_id || row.room_id || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文本列: 空显 -。 */
|
||||||
|
function cellText(row: StatsRow, prop: string): string {
|
||||||
|
const v = (row as Record<string, unknown>)[prop]
|
||||||
|
if (typeof v === 'string') return v.trim() ? v : '-'
|
||||||
|
if (typeof v === 'number') return String(v)
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 接通状态(后端 connected 布尔,语义同 callStatusLabel 的 已接通/未接通)。 */
|
||||||
|
function connectLabel(connected?: boolean | null): string {
|
||||||
|
if (connected == null) return '--'
|
||||||
|
return connected ? '已接通' : '未接通'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 录音播放(手法抄 CallList: dialog + audio + 新窗口链接) ----
|
||||||
|
|
||||||
|
const recordDialogVisible = ref(false)
|
||||||
|
const recordSrc = ref<string | null>(null)
|
||||||
|
const recordTitle = ref('')
|
||||||
|
|
||||||
|
function playRecord(row: StatsRow): void {
|
||||||
|
if (!row.media_url) return
|
||||||
|
recordSrc.value = row.media_url
|
||||||
|
const when = formatTime(row.start_time)
|
||||||
|
recordTitle.value = `录音 · ${row.peer || '语音通话'}${when !== '--' ? ` · ${when}` : ''}`
|
||||||
|
recordDialogVisible.value = true
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="yk-empty">统计分析 — Task 14 实现</div>
|
<div class="sc-stats yk-panel-bg yk-panel-sep">
|
||||||
|
<!-- ── 顶部: 7 计数 tab + 日期窗 ── -->
|
||||||
|
<div class="sc-stats__top">
|
||||||
|
<el-tabs v-model="activeTab" class="sc-stats__tabs">
|
||||||
|
<el-tab-pane
|
||||||
|
v-for="t in TABS"
|
||||||
|
:key="t.key"
|
||||||
|
:label="tabLabel(t.key)"
|
||||||
|
:name="t.key"
|
||||||
|
/>
|
||||||
|
</el-tabs>
|
||||||
|
<div class="sc-stats__row">
|
||||||
|
<span class="sc-stats__range-label">统计时间</span>
|
||||||
|
<el-date-picker
|
||||||
|
v-model="dateRange"
|
||||||
|
type="daterange"
|
||||||
|
size="small"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始日期"
|
||||||
|
end-placeholder="结束日期"
|
||||||
|
clearable
|
||||||
|
class="sc-stats__date"
|
||||||
|
@change="onRangeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 明细区(列内滚动) ── -->
|
||||||
|
<div class="sc-stats__body">
|
||||||
|
<!-- 敏感词: 词库未接入,空态卡(spec 决策,对齐云客但词库缺失) -->
|
||||||
|
<div v-if="activeTab === 'sensitive'" class="sc-stats__sensitive">
|
||||||
|
<div class="sc-stats__sensitive-title">单聊/群聊敏感词</div>
|
||||||
|
<div class="sc-stats__sensitive-text">词库未配置,待 C1 敏感词功能接入</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<el-alert
|
||||||
|
v-if="detailError"
|
||||||
|
:title="detailError"
|
||||||
|
type="error"
|
||||||
|
:closable="false"
|
||||||
|
style="margin-bottom: 10px"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
v-loading="detailLoading"
|
||||||
|
:data="rows"
|
||||||
|
stripe
|
||||||
|
empty-text="暂无数据"
|
||||||
|
>
|
||||||
|
<!-- ── 语音通话: 列对齐 CallList(通话人/类型/呼入呼出/接通状态/时长/录音/时间/操作) ── -->
|
||||||
|
<template v-if="activeTab === 'voice_calls'">
|
||||||
|
<el-table-column label="通话人" min-width="160">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-if="row.peer"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="goVoiceChat(row)"
|
||||||
|
>{{ row.peer }}</el-button>
|
||||||
|
<span v-else>--</span>
|
||||||
</template>
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="通话类型" width="100">
|
||||||
|
<template #default>语音通话</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="呼入呼出" width="90">
|
||||||
|
<template #default="{ row }">{{ callDirectionLabel(row.incoming) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="接通状态" width="90">
|
||||||
|
<template #default="{ row }">{{ connectLabel(row.connected) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="通话时长" width="100">
|
||||||
|
<template #default="{ row }">{{ formatCallDuration(row.duration) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="通话录音" width="100" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
v-if="row.media_url"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
@click="playRecord(row)"
|
||||||
|
>播放</el-button>
|
||||||
|
<span v-else style="color: #c0c4cc">--</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="通话时间" width="160">
|
||||||
|
<template #default="{ row }">{{ formatTime(row.start_time) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="110" fixed="right" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:disabled="!row.peer"
|
||||||
|
@click="goVoiceChat(row)"
|
||||||
|
>查看聊天记录</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ── 其余 5 类: KIND_COLS 配置驱动 ── -->
|
||||||
|
<template v-else>
|
||||||
|
<el-table-column
|
||||||
|
v-for="col in activeCols"
|
||||||
|
:key="col.prop"
|
||||||
|
:label="col.label"
|
||||||
|
:min-width="col.kind === 'link' || col.kind === 'staff' ? 150 : col.prop === '_action' ? 110 : 110"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
<!-- link 列: 客户/群 → 跳本员工聊天窗 -->
|
||||||
|
<template v-if="col.kind === 'link'">
|
||||||
|
<div class="sc-stats__user">
|
||||||
|
<el-avatar
|
||||||
|
v-if="row.avatar"
|
||||||
|
:size="28"
|
||||||
|
:src="row.avatar"
|
||||||
|
shape="square"
|
||||||
|
/>
|
||||||
|
<el-button
|
||||||
|
v-if="linkText(row)"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
class="sc-stats__link"
|
||||||
|
@click="goChat(row)"
|
||||||
|
>{{ linkText(row) }}</el-button>
|
||||||
|
<span v-else>--</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- staff 列: 明细行无该字段,渲染资料卡信息(数据本就是该员工) -->
|
||||||
|
<template v-else-if="col.kind === 'staff'">
|
||||||
|
<div class="sc-stats__user">
|
||||||
|
<el-avatar
|
||||||
|
v-if="profile?.avatar"
|
||||||
|
:size="28"
|
||||||
|
:src="profile.avatar"
|
||||||
|
shape="square"
|
||||||
|
/>
|
||||||
|
<div class="sc-stats__meta">
|
||||||
|
<div class="sc-stats__name" :title="staffName">{{ staffName }}</div>
|
||||||
|
<div class="sc-stats__sub" :title="props.ownerId">{{ props.ownerId }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 时间/金额/操作列特判,其余纯文本 -->
|
||||||
|
<template v-else-if="col.prop === 'time'">{{ formatTime(row.time) }}</template>
|
||||||
|
<template v-else-if="col.prop === 'amount'">{{ row.amount ?? '—' }}</template>
|
||||||
|
<template v-else-if="col.prop === '_action'">
|
||||||
|
<el-button link type="primary" size="small" @click="goChat(row, true)">
|
||||||
|
查看聊天记录
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
<template v-else>{{ cellText(row, col.prop) }}</template>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</template>
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── 录音播放 dialog(手法抄 CallList) ── -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="recordDialogVisible"
|
||||||
|
:title="recordTitle"
|
||||||
|
width="480px"
|
||||||
|
align-center
|
||||||
|
@closed="recordSrc = null"
|
||||||
|
>
|
||||||
|
<audio
|
||||||
|
v-if="recordSrc"
|
||||||
|
controls
|
||||||
|
:src="recordSrc"
|
||||||
|
style="width: 100%"
|
||||||
|
preload="metadata"
|
||||||
|
>
|
||||||
|
您的浏览器不支持音频播放。
|
||||||
|
</audio>
|
||||||
|
<div v-if="recordSrc" class="sc-stats__record-link">
|
||||||
|
<a :href="recordSrc" target="_blank" rel="noopener">在新窗口打开录音文件</a>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ══ 统计分析右列(墨玉主题,手法沿用 ContactsPane/MomentsPane) ══
|
||||||
|
点阵底/右缘玉绿线走全局 yk-panel-bg/yk-panel-sep(style.css);这里只写布局。 */
|
||||||
|
.sc-stats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 顶部区(tab + 日期窗,随列固定不滚动) ── */
|
||||||
|
.sc-stats__top {
|
||||||
|
flex: none;
|
||||||
|
padding: 4px 12px 0;
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border-bottom: 1px solid rgba(23, 29, 43, 0.05);
|
||||||
|
}
|
||||||
|
.sc-stats__tabs :deep(.el-tabs__header) {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
}
|
||||||
|
/* 7 个 tab 一排放不下时滚动而非换行挤爆列宽 */
|
||||||
|
.sc-stats__tabs :deep(.el-tabs__nav-scroll) {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.sc-stats__tabs :deep(.el-tabs__nav) {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.sc-stats__row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
.sc-stats__range-label {
|
||||||
|
flex: none;
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: #5a6472;
|
||||||
|
}
|
||||||
|
.sc-stats__date {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 明细区(列内滚动) ── */
|
||||||
|
.sc-stats__body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 敏感词空态卡 */
|
||||||
|
.sc-stats__sensitive {
|
||||||
|
margin-top: 60px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.sc-stats__sensitive-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2b3240;
|
||||||
|
}
|
||||||
|
.sc-stats__sensitive-text {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #8a94a4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* link/staff 富单元格(头像 + 名称/微信号,手法对齐 ContactsPane) */
|
||||||
|
.sc-stats__user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.sc-stats__user .el-avatar {
|
||||||
|
flex: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(23, 29, 43, 0.05);
|
||||||
|
box-shadow: 0 1px 3px rgba(23, 29, 43, 0.06);
|
||||||
|
}
|
||||||
|
.sc-stats__link {
|
||||||
|
max-width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.sc-stats__meta {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.sc-stats__name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2b3240;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.sc-stats__sub {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: #8a94a4;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 录音 dialog 底部链接 */
|
||||||
|
.sc-stats__record-link {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user