Files
yunke/yunkes/src/views/CustomerList.vue
T
浅念 6c4cd92ae6 feat: vue3 frontend scaffold + org tree + customer list + chat timeline
M5b viewing frontend for the yunke data-mirror, under yunkes/.

- Scaffold: Vite + Vue 3 + TS, with element-plus, @element-plus/icons-vue,
  pinia, vue-router@4, axios. Dev server on :5173 with /api and /api/media
  proxied to the FastAPI backend on :8000.
- API client (src/api/): axios instance (baseURL '/api') whose response
  interceptor unwraps the axios envelope; typed wrappers (getCustomers,
  getMessages, getDepartments, getStaff) returning Page<T> / arrays; types
  mirroring the backend *Out schemas (Message typed as a permissive superset
  so file_th_url/duration/transcription_text degrade gracefully today and
  light up once MessageOut exposes them).
- Layout: el-header top bar (title + channel switch) + el-aside department
  tree (built from the flat parent_id list via useOrgStore, with search
  filter) + el-main router-view.
- CustomerList (/customers): el-table with channel filter, department filter
  driven by the tree selection (route query), pagination, and row-click into
  the chat timeline (channel + owner_account_id + talker_id).
- ChatTimeline (/chat/:channel/:ownerId/:talkerId): asc-ordered message
  bubbles keyed by is_send (direction) and type (text/image/video/voice/file/
  divider/system/unknown); lands on the most-recent page and prepends older
  pages via load-more with scroll anchoring.
- Home '/' redirects to '/customers'.

npm run build (vue-tsc -b + vite build) is clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 16:27:22 +08:00

268 lines
7.3 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, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { getCustomers } from '@/api'
import type { Channel, Customer } from '@/api/types'
import { useChannelStore } from '@/stores/channel'
import { useOrgStore } from '@/stores/org'
import { channelLabel, formatTime } from '@/utils/format'
const route = useRoute()
const router = useRouter()
const channelStore = useChannelStore()
const org = useOrgStore()
const customers = ref<Customer[]>([])
const total = ref(0)
const page = ref(1)
const pageSize = ref(20)
const loading = ref(false)
const errorMsg = ref<string | null>(null)
const departmentId = computed<string | null>(() =>
route.query.department_id ? String(route.query.department_id) : null,
)
const departmentName = computed(() => {
if (!departmentId.value) return null
return org.findNode(departmentId.value)?.label ?? departmentId.value
})
const channelOptions = [
{ value: 'all' as const, label: '全部渠道' },
{ value: 'wechat' as const, label: '微信' },
{ value: 'whatsapp' as const, label: 'WhatsApp' },
]
const channelLabelCurrent = computed(() => channelLabel(channelStore.channel === 'all' ? null : channelStore.channel))
function channelTagType(c: Customer['channel']) {
return c === 'wechat' ? 'success' : 'warning'
}
function onChannelChange(value: string | number | boolean | undefined): void {
channelStore.setChannel(value as Channel | 'all')
}
async function load(): Promise<void> {
loading.value = true
errorMsg.value = null
try {
const res = await getCustomers({
channel: channelStore.channel,
page: page.value,
page_size: pageSize.value,
department_id: departmentId.value ?? undefined,
})
customers.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 handlePageChange(p: number): void {
page.value = p
load()
}
function handlePageSizeChange(s: number): void {
pageSize.value = s
page.value = 1
load()
}
// React to top-bar channel + dept tree selection.
watch(
() => [channelStore.channel, departmentId.value] as const,
() => {
page.value = 1
load()
},
)
function openChat(row: Customer): void {
// The chat timeline is keyed by (channel, owner_account_id, talker_id).
// The customer's friend_id is the talker on the staff's side of the DM.
if (!row.owner_account_id) {
ElMessage.warning('该客户缺少归属账号,无法查看聊天记录')
return
}
router.push({
name: 'chat',
params: {
channel: row.channel,
ownerId: row.owner_account_id,
talkerId: row.friend_id,
},
})
}
onMounted(load)
</script>
<template>
<div class="yk-customers">
<div class="yk-customers__header">
<div>
<h2 style="margin: 0 0 4px; font-size: 16px">客户列表</h2>
<span class="yk-customers__sub">
共 {{ total }} 条
<template v-if="departmentName"> · 部门:{{ departmentName }}</template>
· 渠道:{{ channelLabelCurrent }}
</span>
</div>
<div class="yk-customers__filters">
<el-radio-group
:model-value="channelStore.channel"
size="small"
@change="onChannelChange"
>
<el-radio-button
v-for="opt in channelOptions"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</el-radio-button>
</el-radio-group>
<el-button
v-if="departmentId"
size="small"
type="info"
plain
@click="router.replace({ name: 'customers' })"
>
清除部门筛选
</el-button>
</div>
</div>
<el-alert
v-if="errorMsg"
:title="errorMsg"
type="error"
show-icon
:closable="false"
style="margin-bottom: 12px"
/>
<el-table
v-loading="loading"
:data="customers"
stripe
border
row-key="friend_id"
style="width: 100%"
:row-class-name="() => 'yk-clickable-row'"
empty-text="暂无客户数据"
@row-click="openChat"
>
<el-table-column label="头像" width="64" align="center">
<template #default="{ row }">
<el-avatar
:size="36"
:src="row.head_image_url ?? undefined"
shape="square"
>
{{ (row.friend_nickname || row.friend_id || '?').slice(0, 1) }}
</el-avatar>
</template>
</el-table-column>
<el-table-column label="昵称" prop="friend_nickname" min-width="120">
<template #default="{ row }">
{{ row.friend_nickname || '--' }}
</template>
</el-table-column>
<el-table-column label="备注" min-width="120">
<template #default="{ row }">{{ row.friend_remark || '--' }}</template>
</el-table-column>
<el-table-column label="真实姓名" min-width="100">
<template #default="{ row }">{{ row.real_name || '--' }}</template>
</el-table-column>
<el-table-column label="手机" min-width="120">
<template #default="{ row }">{{ row.phone || '--' }}</template>
</el-table-column>
<el-table-column label="渠道" width="100">
<template #default="{ row }">
<el-tag :type="channelTagType(row.channel)" size="small" effect="plain">
{{ channelLabel(row.channel) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="部门" min-width="110">
<template #default="{ row }">
<span v-if="row.dept_name">{{ row.dept_name }}</span>
<span v-else style="color: #c0c4cc">--</span>
<div v-if="row.sale_name" style="font-size: 12px; color: #909399">
销售:{{ row.sale_name }}
</div>
</template>
</el-table-column>
<el-table-column label="最近聊天" width="160">
<template #default="{ row }">
{{ formatTime(row.last_chat_time) }}
</template>
</el-table-column>
<el-table-column label="消息数" prop="message_count" width="90" align="right">
<template #default="{ row }">
{{ row.message_count ?? 0 }}
</template>
</el-table-column>
</el-table>
<div class="yk-customers__pager">
<el-pagination
:current-page="page"
:page-size="pageSize"
:page-sizes="[20, 50, 100]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
background
@current-change="handlePageChange"
@size-change="handlePageSizeChange"
/>
</div>
<p class="yk-customers__hint">点击任意一行查看与该客户的聊天记录。</p>
</div>
</template>
<style scoped>
.yk-customers__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.yk-customers__sub {
color: #909399;
font-size: 13px;
}
.yk-customers__filters {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.yk-customers__pager {
margin-top: 12px;
display: flex;
justify-content: flex-end;
}
.yk-customers__hint {
margin: 8px 2px 0;
font-size: 12px;
color: #c0c4cc;
}
</style>