Files
yunke/yunkes/src/components/VoiceClipModal.vue
T
浅念 6880ff1b97 feat(auth): 登录页(用户名/密码换 API token)+ 前端全链路登录态
后端:
- POST /api/auth/login:admin 账密(Settings 默认 admin/admin123,
  ADMIN_USER/ADMIN_PASSWORD 可覆盖)换取共享 api_token,免 token 中间件
- 防爆破:同 IP 15min 窗口内 5 次失败锁 15min(429),成功登录清零
- 错误信息统一「用户名或密码错误」(防用户名枚举)
- 测试 7 个新增(TDD),全量 500 绿

前端:
- LoginView + 路由守卫(未登录跳 /login,回跳 redirect)
- axios 请求拦截器自动带 Bearer;401 清登录态回登录页
- 媒体直链统一 mediaUrl() 带 ?token=(img/audio 标签发不了 header),
  替换 4 处裸拼;downloadBlob 手动带头
- isLoggedIn 按 key 存在判定 — 后端鉴权关闭时空 token 不锁死登录页
- MainLayout 侧栏底部退出登录

compose:两份编排透传 ADMIN_USER/ADMIN_PASSWORD(默认 admin/admin123)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-03 15:03:20 +08:00

595 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getCustomers, getVoiceClips, matchVoiceClip, search } from '@/api'
import { mediaUrl } from '@/api/client'
import type { Customer, VoiceClip } from '@/api/types'
import { formatCallDuration, formatTime } from '@/utils/format'
/**
* 长语音记录 modal —— 复刻云客「微信长语音列表」modal 的展示部分 +
* 本地手动匹配(voice_clip ↔ customer)。
*
* 调用方传 `userId`(从 CallList 行传入,精确限定员工)。
*
* 匹配是本地写(PATCH /voice-clips/{id}/match):把 voice_clip.matched_friend_id
* 关联到 customer.friend_id,不回写云客。matched_friend_name 由后端在 GET 时
* 经 Customer join 解析(remark > nickname > real_name > friend_id)。
*
* 录音播放采用 local-first:优先 `/api/media/{media_hash}`(本地化音频),
* 回退到 `oss_file_name` 的 OSS 直链(mirrors CallList.playRecord)。
*/
const props = withDefaults(
defineProps<{
modelValue: boolean
userId: string
/** 本地通道标记(wechat / whatsapp)。 */
channel?: string
title?: string
/** 该员工姓名,用于标题展示(可选)。 */
staffName?: string | null
}>(),
{
channel: 'wechat',
title: '',
staffName: null,
},
)
const emit = defineEmits<{ 'update:modelValue': [boolean] }>()
const loading = ref(false)
const errorMsg = ref<string | null>(null)
const items = ref<VoiceClip[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
// 录音结束时间窗(el-date-picker datetimerange,clearable)。
const dateRange = ref<[Date, Date] | null>(null)
// 当前内联播放的片段。
const playingUrl = ref<string | null>(null)
const playingLabel = ref('')
// ----- 手动匹配 picker 状态 -----
// picker 对话框是否打开。
const pickerOpen = ref(false)
// 正在为其挑选客户的 voice_clip 行(picker 打开期间非空)。
const pickerRow = ref<VoiceClip | null>(null)
const pickerLoading = ref(false)
const pickerQuery = ref('')
// picker 当前展示的客户候选(默认 = 该员工最近活跃客户;输入查询后 = 全通道名搜索)。
const pickerResults = ref<Customer[]>([])
// PATCH 提交中(避免重复点击)。
const matching = ref(false)
const dialogTitle = computed(() => {
if (props.title) return props.title
const who = props.staffName || props.userId
return `长语音记录 · ${who}`
})
const pickerTitle = computed(() => {
const who = pickerRow.value?.user_real_name || pickerRow.value?.user_id || ''
return who ? `选择匹配客户 · ${who}` : '选择匹配客户'
})
async function load(): Promise<void> {
if (!props.userId) {
items.value = []
total.value = 0
return
}
loading.value = true
errorMsg.value = null
try {
let start: number | undefined
let end: number | undefined
if (dateRange.value && dateRange.value.length === 2) {
start = dateRange.value[0].getTime()
end = dateRange.value[1].getTime()
}
const res = await getVoiceClips({
user_id: props.userId,
channel: props.channel,
start,
end,
page: page.value,
page_size: pageSize.value,
})
items.value = res.items
total.value = res.total
} catch (e) {
errorMsg.value = (e as Error).message
ElMessage.error(`长语音记录加载失败:${(e as Error).message}`)
} finally {
loading.value = false
}
}
function durationDisplay(row: VoiceClip): string {
return row.duration_format || formatCallDuration(row.mp3_duration)
}
// ----- 录音异常检查(本地元数据判定,无网络/无后端) -----
interface Anomaly {
type: string
label: string
}
/**
* 基于本地元数据判定单条 voice_clip 的异常:
* - 无录音文件(oss_file_name 缺失)
* - 时长为 0(mp3_duration 缺失或为 0)
* - 时间缺失(start_time / end_time 缺失)
* 纯前端计算,仅作用于当前已加载分页(≤50 条),不涉及服务端查询。
*/
function clipAnomalies(clip: VoiceClip): Anomaly[] {
const anomalies: Anomaly[] = []
if (!clip.oss_file_name) anomalies.push({ type: 'no_file', label: '无录音文件' })
if (!clip.mp3_duration || clip.mp3_duration === 0) {
anomalies.push({ type: 'zero_duration', label: '时长为0' })
}
if (!clip.start_time || !clip.end_time) {
anomalies.push({ type: 'no_time', label: '时间缺失' })
}
return anomalies
}
// 「仅看异常」开关:开启时仅展示当前分页内命中异常的片段。
const onlyAnomaly = ref(false)
// 当前分页命中异常的片段数量(用于"N 条异常"汇总,不受开关影响)。
const anomalyCount = computed(() => items.value.filter((c) => clipAnomalies(c).length > 0).length)
// 表格实际渲染的数据:开关 ON 时过滤为仅异常项,OFF 时原样展示。
const displayItems = computed(() =>
onlyAnomaly.value ? items.value.filter((c) => clipAnomalies(c).length > 0) : items.value,
)
function playClip(row: VoiceClip): void {
// Local-first: 优先本地化的 /api/media/{media_hash},回退到 OSS 直链
// (mirrors CallList.playRecord)。
const local = row.media_hash ? mediaUrl(row.media_hash) : null
const url = local ?? row.oss_file_name
if (!url) {
ElMessage.info('该片段无录音文件')
return
}
playingUrl.value = url
const who = row.user_real_name || row.user_id
const when = formatTime(row.end_time)
playingLabel.value = `${who}${when !== '--' ? ` · ${when}` : ''}`
}
function closePlaying(): void {
playingUrl.value = null
playingLabel.value = ''
}
function onFilterChange(): void {
page.value = 1
load()
}
function resetFilters(): void {
dateRange.value = null
page.value = 1
load()
}
function handlePageChange(p: number): void {
page.value = p
load()
}
function handlePageSizeChange(s: number): void {
pageSize.value = s
page.value = 1
load()
}
// ----- 手动匹配 picker 逻辑 -----
/** 客户显示名(与后端 matched_friend_name 解析口径一致:remark > nickname > real_name > friend_id)。 */
function customerName(c: Customer): string {
return c.friend_remark || c.friend_nickname || c.real_name || c.friend_id
}
/** 打开 picker:先按员工+通道加载默认候选(最近活跃客户),清空查询框。 */
async function openPicker(row: VoiceClip): Promise<void> {
pickerRow.value = row
pickerQuery.value = ''
pickerOpen.value = true
pickerResults.value = []
await loadPickerDefaults()
}
/** 默认候选:该员工、该通道下的客户(最近活跃在前)。失败不阻塞(用户可走名搜索)。 */
async function loadPickerDefaults(): Promise<void> {
if (!pickerRow.value) return
pickerLoading.value = true
try {
const res = await getCustomers({
user_id: pickerRow.value.user_id,
channel: props.channel as 'wechat' | 'whatsapp',
page: 1,
page_size: 20,
})
pickerResults.value = res.items
} catch {
// 默认列表加载失败时静默——用户仍可在搜索框输入查询走 /search 路径。
pickerResults.value = []
} finally {
pickerLoading.value = false
}
}
/** 名字查询:走 /search(跨该通道全员工),覆盖默认按员工范围。 */
async function runPickerSearch(): Promise<void> {
const q = pickerQuery.value.trim()
if (!q) {
await loadPickerDefaults()
return
}
pickerLoading.value = true
try {
const res = await search({
q,
type: 'customers',
channel: props.channel as 'wechat' | 'whatsapp',
page: 1,
page_size: 20,
})
pickerResults.value = res.customers?.items ?? []
} catch (e) {
ElMessage.error(`客户搜索失败:${(e as Error).message}`)
pickerResults.value = []
} finally {
pickerLoading.value = false
}
}
function onPickerQueryEnter(): void {
runPickerSearch()
}
function clearPickerQuery(): void {
pickerQuery.value = ''
loadPickerDefaults()
}
/** 选定一个客户:PATCH 本地匹配 → 关闭 picker → 刷新主表(拿回 matched_friend_name)。 */
async function confirmMatch(c: Customer): Promise<void> {
if (!pickerRow.value) return
const row = pickerRow.value
matching.value = true
try {
await matchVoiceClip(row.voice_clip_id, c.friend_id)
ElMessage.success(`已匹配:${customerName(c)}`)
pickerOpen.value = false
pickerRow.value = null
await load()
} catch (e) {
ElMessage.error(`匹配失败:${(e as Error).message}`)
} finally {
matching.value = false
}
}
/** 取消匹配:二次确认 → PATCH friend_id=null → 刷新主表。 */
async function unmatch(row: VoiceClip): Promise<void> {
try {
await ElMessageBox.confirm(
`确定取消该片段与「${row.matched_friend_name ?? row.matched_friend_id}」的匹配?`,
'取消匹配',
{ type: 'warning', confirmButtonText: '取消匹配', cancelButtonText: '保留' },
)
} catch {
return // 用户放弃
}
matching.value = true
try {
await matchVoiceClip(row.voice_clip_id, null)
ElMessage.success('已取消匹配')
await load()
} catch (e) {
ElMessage.error(`取消匹配失败:${(e as Error).message}`)
} finally {
matching.value = false
}
}
watch(
() => props.modelValue,
(open) => {
if (open) {
page.value = 1
dateRange.value = null
playingUrl.value = null
playingLabel.value = ''
load()
}
},
)
</script>
<template>
<el-dialog
:model-value="modelValue"
:title="dialogTitle"
width="900px"
align-center
@update:model-value="emit('update:modelValue', $event)"
@closed="closePlaying"
>
<!-- Filters: 录音结束时间范围(对齐云客 modal 三筛之一,本期只做时间窗) -->
<div class="yk-voice-clip__filters">
<span class="yk-voice-clip__filter-label">录音结束时间</span>
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
size="small"
format="YYYY-MM-DD HH:mm:ss"
@change="onFilterChange"
/>
<el-button link type="primary" size="small" @click="resetFilters">重置</el-button>
<span class="yk-voice-clip__filter-spacer" />
<el-switch v-model="onlyAnomaly" size="small" />
<span class="yk-voice-clip__filter-label">仅看异常</span>
<el-tag
:type="anomalyCount > 0 ? 'danger' : 'success'"
size="small"
effect="plain"
class="yk-voice-clip__anomaly-count"
>
{{ anomalyCount }} 条异常
</el-tag>
</div>
<el-alert
v-if="errorMsg"
:title="errorMsg"
type="error"
show-icon
:closable="false"
style="margin-bottom: 8px"
/>
<!-- Inline playback bar -->
<div v-if="playingUrl" class="yk-voice-clip__player">
<div class="yk-voice-clip__player-label">{{ playingLabel }}</div>
<audio controls :src="playingUrl" preload="metadata" style="width: 100%">
您的浏览器不支持音频播放。
</audio>
<a :href="playingUrl" target="_blank" rel="noopener" class="yk-voice-clip__player-link">
在新窗口打开
</a>
</div>
<el-table
v-loading="loading"
:data="displayItems"
border
size="small"
style="width: 100%"
empty-text="暂无长语音记录"
>
<el-table-column label="员工姓名" min-width="100">
<template #default="{ row }">{{ row.user_real_name || row.user_id || '--' }}</template>
</el-table-column>
<el-table-column label="企业串码" prop="company_code" width="110">
<template #default="{ row }">{{ row.company_code || '--' }}</template>
</el-table-column>
<el-table-column label="录音开始" min-width="150">
<template #default="{ row }">{{ formatTime(row.start_time) }}</template>
</el-table-column>
<el-table-column label="录音结束" min-width="150">
<template #default="{ row }">{{ formatTime(row.end_time) }}</template>
</el-table-column>
<el-table-column label="录音时长" width="100" align="right">
<template #default="{ row }">{{ durationDisplay(row) }}</template>
</el-table-column>
<el-table-column label="录音异常" width="140" align="center">
<template #default="{ row }">
<template v-if="clipAnomalies(row).length">
<el-tag
v-for="a in clipAnomalies(row)"
:key="a.type"
type="danger"
size="small"
effect="light"
style="margin: 2px"
>
{{ a.label }}
</el-tag>
</template>
<span v-else style="color: #67c23a">✓</span>
</template>
</el-table-column>
<el-table-column label="已匹配客户" min-width="120">
<template #default="{ row }">
<span v-if="row.matched_friend_id" :title="row.matched_friend_id">
{{ row.matched_friend_name ?? row.matched_friend_id }}
</span>
<span v-else style="color: #c0c4cc">--</span>
</template>
</el-table-column>
<el-table-column label="文件地址" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.oss_file_name" class="yk-voice-clip__url" :title="row.oss_file_name">
{{ row.oss_file_name }}
</span>
<span v-else style="color: #c0c4cc">--</span>
</template>
</el-table-column>
<el-table-column label="操作" width="170" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openPicker(row)">匹配</el-button>
<el-button
v-if="row.matched_friend_id"
link
type="warning"
size="small"
:disabled="matching"
@click="unmatch(row)"
>
取消匹配
</el-button>
<el-button
v-if="row.oss_file_name"
link
type="primary"
size="small"
@click="playClip(row)"
>
播放
</el-button>
</template>
</el-table-column>
</el-table>
<div class="yk-voice-clip__pager">
<el-pagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next"
background
size="small"
@current-change="handlePageChange"
@size-change="handlePageSizeChange"
/>
</div>
<!-- 客户选择 picker(嵌套 dialog) -->
<el-dialog
v-model="pickerOpen"
:title="pickerTitle"
width="640px"
append-to-body
align-center
@closed="pickerRow = null"
>
<div class="yk-voice-clip__picker-search">
<el-input
v-model="pickerQuery"
placeholder="按姓名/备注搜索客户;留空显示该员工最近客户"
size="small"
clearable
@keyup.enter="onPickerQueryEnter"
@clear="clearPickerQuery"
/>
<el-button type="primary" size="small" @click="runPickerSearch">搜索</el-button>
</div>
<el-table
v-loading="pickerLoading"
:data="pickerResults"
border
size="small"
style="width: 100%"
max-height="360"
empty-text="无候选客户,请尝试搜索"
>
<el-table-column label="名称" min-width="160">
<template #default="{ row: c }">{{ customerName(c) }}</template>
</el-table-column>
<el-table-column label="备注" min-width="120">
<template #default="{ row: c }">{{ c.friend_remark || '--' }}</template>
</el-table-column>
<el-table-column label="昵称" min-width="120">
<template #default="{ row: c }">{{ c.friend_nickname || '--' }}</template>
</el-table-column>
<el-table-column label="所属员工" min-width="110">
<template #default="{ row: c }">{{ c.sale_name || c.owner_account_id || '--' }}</template>
</el-table-column>
<el-table-column label="操作" width="90" align="center" fixed="right">
<template #default="{ row: c }">
<el-button
type="primary"
link
size="small"
:disabled="matching"
@click="confirmMatch(c)"
>
选择
</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
<template #footer>
<el-button @click="emit('update:modelValue', false)">关闭</el-button>
</template>
</el-dialog>
</template>
<style scoped>
.yk-voice-clip__filters {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.yk-voice-clip__filter-label {
font-size: 13px;
color: #606266;
}
.yk-voice-clip__filter-spacer {
flex: 1 1 auto;
}
.yk-voice-clip__anomaly-count {
margin-left: 4px;
}
.yk-voice-clip__player {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 10px;
padding: 8px 10px;
background: #f5f7fa;
border: 1px solid #e4e7ed;
border-radius: 4px;
}
.yk-voice-clip__player-label {
font-size: 12px;
color: #606266;
}
.yk-voice-clip__player-link {
font-size: 12px;
color: var(--el-color-primary, #409eff);
align-self: flex-end;
}
.yk-voice-clip__url {
color: #909399;
font-size: 12px;
word-break: break-all;
}
.yk-voice-clip__pager {
margin-top: 10px;
display: flex;
justify-content: flex-end;
}
.yk-voice-clip__picker-search {
display: flex;
gap: 8px;
margin-bottom: 10px;
}
</style>