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>
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import MainLayout from '@/layouts/MainLayout.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MainLayout class="yk-app" />
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
import axios from 'axios'
|
||||
|
||||
/**
|
||||
* Pre-configured axios instance for the yunke data-mirror API.
|
||||
*
|
||||
* `baseURL: '/api'` keeps calls relative so Vite's dev proxy (→ :8000) and a
|
||||
* production reverse proxy both work without env-specific code.
|
||||
*
|
||||
* The response interceptor unwraps the axios envelope: callers receive the
|
||||
* response body directly (e.g. a `Page<T>` or `Department[]`) rather than the
|
||||
* full `AxiosResponse`. The typed wrappers below (`getJson`) restore the
|
||||
* concrete generic type.
|
||||
*/
|
||||
export const http = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => {
|
||||
// Surface a readable message upstream; views render `error.message`.
|
||||
if (axios.isCancel(error)) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
const status = error?.response?.status
|
||||
const detail = error?.response?.data?.detail ?? error?.response?.data
|
||||
const message =
|
||||
typeof detail === 'string' && detail
|
||||
? detail
|
||||
: `请求失败 (${status ?? error?.code ?? 'unknown'})`
|
||||
return Promise.reject(new Error(message))
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Typed GET helper. Because the response interceptor returns the body, we cast
|
||||
* the resolved value to `T` — this is where the `Page<T>` / array shapes from
|
||||
* `types.ts` are reattached.
|
||||
*/
|
||||
export function getJson<T>(url: string, params?: Record<string, unknown>): Promise<T> {
|
||||
return http.get(url, { params }) as unknown as Promise<T>
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Typed endpoint wrappers for the M5b browse experience.
|
||||
//
|
||||
// Mirrors the FastAPI routes in yunke-server/app/api/routers/. Each wrapper
|
||||
// drops `undefined`/empty params so the query string only carries set filters
|
||||
// (the backend treats missing as "no filter").
|
||||
import { getJson } from './client'
|
||||
import type {
|
||||
Channel,
|
||||
Customer,
|
||||
Department,
|
||||
Message,
|
||||
Page,
|
||||
Staff,
|
||||
} from './types'
|
||||
|
||||
/** Drop entries whose value is `undefined`, `null`, or `''`. */
|
||||
function clean(params: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === undefined || v === null || v === '') continue
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export interface CustomerQuery {
|
||||
channel?: Channel | 'all'
|
||||
page?: number
|
||||
page_size?: number
|
||||
owner_account_id?: string
|
||||
department_id?: string
|
||||
}
|
||||
|
||||
export function getCustomers(query: CustomerQuery = {}): Promise<Page<Customer>> {
|
||||
return getJson<Page<Customer>>(
|
||||
'/customers',
|
||||
clean({
|
||||
page: query.page ?? 1,
|
||||
page_size: query.page_size ?? 20,
|
||||
channel: query.channel === 'all' ? undefined : query.channel,
|
||||
owner_account_id: query.owner_account_id,
|
||||
department_id: query.department_id,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export interface MessageQuery {
|
||||
channel?: Channel
|
||||
owner_account_id: string
|
||||
talker_id?: string
|
||||
room_id?: string
|
||||
start?: number
|
||||
end?: number
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export function getMessages(query: MessageQuery): Promise<Page<Message>> {
|
||||
return getJson<Page<Message>>(
|
||||
'/messages',
|
||||
clean({
|
||||
channel: query.channel,
|
||||
owner_account_id: query.owner_account_id,
|
||||
talker_id: query.talker_id,
|
||||
room_id: query.room_id,
|
||||
start: query.start,
|
||||
end: query.end,
|
||||
page: query.page ?? 1,
|
||||
page_size: query.page_size ?? 50,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export function getDepartments(): Promise<Department[]> {
|
||||
return getJson<Department[]>('/departments')
|
||||
}
|
||||
|
||||
export interface StaffQuery {
|
||||
department_id?: string
|
||||
is_on_job?: boolean
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
export function getStaff(query: StaffQuery = {}): Promise<Page<Staff>> {
|
||||
return getJson<Page<Staff>>(
|
||||
'/staff',
|
||||
clean({
|
||||
department_id: query.department_id,
|
||||
is_on_job: query.is_on_job,
|
||||
page: query.page ?? 1,
|
||||
page_size: query.page_size ?? 20,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export type { Channel, Customer, Department, Message, Page, Staff }
|
||||
@@ -0,0 +1,88 @@
|
||||
// Types mirroring the backend `*Out` pydantic schemas (see
|
||||
// yunke-server/app/api/schemas.py). All fields the API might omit are optional.
|
||||
//
|
||||
// Note on Message: the DB has more columns (file_th_url, file_hd_url, md5,
|
||||
// duration, transcription_text, is_del, ...) than the current MessageOut
|
||||
// exposes. They are declared optional here so rendering degrades gracefully
|
||||
// today and lights up automatically once the backend exposes them.
|
||||
|
||||
/** Generic pagination envelope returned by all list endpoints. */
|
||||
export interface Page<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
export type Channel = 'wechat' | 'whatsapp'
|
||||
|
||||
export interface Customer {
|
||||
friend_id: string
|
||||
channel: Channel
|
||||
owner_account_id?: string | null
|
||||
friend_nickname?: string | null
|
||||
friend_remark?: string | null
|
||||
real_name?: string | null
|
||||
phone?: string | null
|
||||
region?: string | null
|
||||
sale_name?: string | null
|
||||
dept_name?: string | null
|
||||
head_image_url?: string | null
|
||||
/** ms-epoch of most recent message */
|
||||
last_chat_time?: number | null
|
||||
message_count?: number | null
|
||||
version?: string | null
|
||||
}
|
||||
|
||||
export interface Department {
|
||||
dept_id: string
|
||||
parent_id?: string | null
|
||||
name?: string | null
|
||||
path?: string | null
|
||||
}
|
||||
|
||||
export interface Staff {
|
||||
user_id: string
|
||||
name?: string | null
|
||||
phone?: string | null
|
||||
sale_phone?: string | null
|
||||
dept_id?: string | null
|
||||
is_on_job?: boolean | null
|
||||
}
|
||||
|
||||
/** Normalized message type label (text/image/video/voice/divider/system/unknown). */
|
||||
export type MessageType =
|
||||
| 'text'
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'voice'
|
||||
| 'file'
|
||||
| 'divider'
|
||||
| 'system'
|
||||
| 'unknown'
|
||||
| (string & {})
|
||||
|
||||
export interface Message {
|
||||
msg_id: string
|
||||
channel: Channel
|
||||
owner_account_id?: string | null
|
||||
talker_id?: string | null
|
||||
room_id?: string | null
|
||||
/** true = sent by staff (right), false = received from customer (left). */
|
||||
is_send?: boolean | null
|
||||
type?: MessageType | null
|
||||
type_raw?: number | null
|
||||
text?: string | null
|
||||
file_url?: string | null
|
||||
// Forward-compat: present in the DB, not yet in MessageOut.
|
||||
file_th_url?: string | null
|
||||
file_hd_url?: string | null
|
||||
md5?: string | null
|
||||
duration?: number | null
|
||||
transcription_text?: string | null
|
||||
sender_name?: string | null
|
||||
sender_head_url?: string | null
|
||||
/** ms-epoch */
|
||||
timestamp?: number | null
|
||||
is_del?: boolean | null
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Microphone, VideoPlay, Document, Picture } from '@element-plus/icons-vue'
|
||||
import type { Message } from '@/api/types'
|
||||
import { formatDuration, formatTime } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{ message: Message }>()
|
||||
|
||||
/**
|
||||
* One chat row. Layout/direction is driven by `is_send`; content by `type`.
|
||||
* Media URLs are rendered straight from the OSS `file_*_url` fields — the
|
||||
* `/api/media/{hash}` streaming endpoint exists for cases where the OSS URL is
|
||||
* missing, but the current MessageOut schema exposes `file_url` only, so we
|
||||
* degrade gracefully via `file_th_url || file_url` (forward-compatible).
|
||||
*/
|
||||
const isSend = computed(() => !!props.message.is_send)
|
||||
|
||||
// Centered (system/divider) rows ignore direction.
|
||||
const isCentered = computed(
|
||||
() => props.message.type === 'divider' || props.message.type === 'system',
|
||||
)
|
||||
|
||||
const senderLabel = computed(
|
||||
() => props.message.sender_name || (isSend.value ? '我方' : '对方'),
|
||||
)
|
||||
|
||||
const timeLabel = computed(() => formatTime(props.message.timestamp))
|
||||
|
||||
// Image preview list for el-image (full-size click-to-zoom).
|
||||
const imageFullUrl = computed(() => props.message.file_hd_url || props.message.file_url)
|
||||
const imageThumbUrl = computed(
|
||||
() => props.message.file_th_url || props.message.file_url,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="yk-msg"
|
||||
:class="{
|
||||
'yk-msg--send': isSend && !isCentered,
|
||||
'yk-msg--center': isCentered,
|
||||
}"
|
||||
>
|
||||
<!-- Centered: divider / system -->
|
||||
<div v-if="isCentered" class="yk-msg__divider">
|
||||
{{ message.text || (message.type === 'divider' ? '—— 分割线 ——' : '系统消息') }}
|
||||
</div>
|
||||
|
||||
<!-- Normal bubble row -->
|
||||
<div v-else class="yk-msg__row">
|
||||
<div class="yk-msg__meta">
|
||||
<span>{{ senderLabel }}</span>
|
||||
<span v-if="timeLabel">{{ timeLabel }}</span>
|
||||
</div>
|
||||
|
||||
<!-- text -->
|
||||
<div v-if="message.type === 'text'" class="yk-msg__bubble">
|
||||
{{ message.text || '(空消息)' }}
|
||||
</div>
|
||||
|
||||
<!-- image -->
|
||||
<div v-else-if="message.type === 'image'">
|
||||
<el-image
|
||||
v-if="imageThumbUrl"
|
||||
class="yk-msg__image"
|
||||
:src="imageThumbUrl"
|
||||
:preview-src-list="imageFullUrl ? [imageFullUrl] : []"
|
||||
:preview-teleported="true"
|
||||
fit="cover"
|
||||
hide-on-click-modal
|
||||
/>
|
||||
<span v-else class="yk-msg__bubble">[图片]</span>
|
||||
</div>
|
||||
|
||||
<!-- video -->
|
||||
<div v-else-if="message.type === 'video'">
|
||||
<video
|
||||
v-if="message.file_url"
|
||||
class="yk-msg__video"
|
||||
:src="message.file_url"
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
<span v-else class="yk-msg__bubble">
|
||||
<el-icon><VideoPlay /></el-icon> [视频]
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- voice -->
|
||||
<div v-else-if="message.type === 'voice'" class="yk-msg__voice">
|
||||
<span class="yk-msg__chip">
|
||||
<el-icon><Microphone /></el-icon>
|
||||
语音{{ message.duration ? ` (${formatDuration(message.duration)})` : '' }}
|
||||
</span>
|
||||
<div v-if="message.transcription_text" class="yk-msg__transcript">
|
||||
{{ message.transcription_text }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- file -->
|
||||
<div v-else-if="message.type === 'file'">
|
||||
<a
|
||||
v-if="message.file_url"
|
||||
class="yk-msg__chip yk-msg__chip--download"
|
||||
:href="message.file_url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<el-icon><Document /></el-icon>
|
||||
下载文件
|
||||
</a>
|
||||
<span v-else class="yk-msg__bubble">
|
||||
<el-icon><Document /></el-icon> [文件]
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- unknown / fallback -->
|
||||
<div v-else class="yk-msg__bubble">
|
||||
<el-icon v-if="message.type === 'unknown'"><Picture /></el-icon>
|
||||
{{ message.text || `[未知消息${message.type_raw != null ? ` (${message.type_raw})` : ''}]` }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ChatLineSquare, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { useOrgStore } from '@/stores/org'
|
||||
import { useChannelStore } from '@/stores/channel'
|
||||
import type { DeptTreeNode } from '@/utils/tree'
|
||||
import type { Channel } from '@/api/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const org = useOrgStore()
|
||||
const channelStore = useChannelStore()
|
||||
|
||||
const filterText = ref('')
|
||||
const treeRef = ref()
|
||||
|
||||
// Currently selected dept id drives the customer list via route query.
|
||||
const selectedDeptId = computed<string | null>(
|
||||
() => (route.query.department_id as string | undefined) ?? null,
|
||||
)
|
||||
|
||||
const treeProps = { label: 'label', children: 'children' }
|
||||
|
||||
// el-tree's `filter-node-method` is called for every node on each keystroke.
|
||||
function filterNode(value: string, data: DeptTreeNode): boolean {
|
||||
if (!value) return true
|
||||
return data.label.toLowerCase().includes(value.toLowerCase())
|
||||
}
|
||||
|
||||
watch(filterText, (val) => {
|
||||
treeRef.value?.filter(val)
|
||||
})
|
||||
|
||||
function handleNodeClick(node: DeptTreeNode): void {
|
||||
// Toggling a dept navigates to the list with the dept filter applied. The
|
||||
// ChatTimeline view ignores this query — only CustomerList reads it.
|
||||
if (route.name !== 'customers') {
|
||||
router.push({ name: 'customers', query: { department_id: node.dept_id } })
|
||||
} else {
|
||||
router.replace({ name: 'customers', query: { department_id: node.dept_id } })
|
||||
}
|
||||
}
|
||||
|
||||
const channelOptions: { value: Channel | 'all'; label: string }[] = [
|
||||
{ value: 'all', label: '全部渠道' },
|
||||
{ value: 'wechat', label: '微信' },
|
||||
{ value: 'whatsapp', label: 'WhatsApp' },
|
||||
]
|
||||
|
||||
function onChannelChange(value: string | number | boolean | undefined): void {
|
||||
channelStore.setChannel(value as Channel | 'all')
|
||||
}
|
||||
|
||||
function goCustomers(): void {
|
||||
router.push({ name: 'customers' })
|
||||
}
|
||||
|
||||
async function reloadTree(): Promise<void> {
|
||||
try {
|
||||
await org.ensureLoaded(true)
|
||||
ElMessage.success('部门已刷新')
|
||||
} catch (e) {
|
||||
ElMessage.error((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
// The tree's `current-key` highlights the active department. We re-apply it
|
||||
// whenever the underlying dept id changes (e.g. browser back/forward).
|
||||
watch(selectedDeptId, (id) => {
|
||||
treeRef.value?.setCurrentKey(id ?? undefined)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await org.ensureLoaded()
|
||||
} catch {
|
||||
/* error surfaced in the aside placeholder */
|
||||
}
|
||||
treeRef.value?.setCurrentKey(selectedDeptId.value ?? undefined)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="yk-app">
|
||||
<el-header class="yk-header">
|
||||
<h1 class="yk-header__title">云客数据镜像</h1>
|
||||
<el-radio-group
|
||||
:model-value="channelStore.channel"
|
||||
size="default"
|
||||
@change="onChannelChange"
|
||||
>
|
||||
<el-radio-button
|
||||
v-for="opt in channelOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
<span class="yk-header__spacer" />
|
||||
<el-button text @click="goCustomers">
|
||||
<el-icon><ChatLineSquare /></el-icon>
|
||||
<span style="margin-left: 4px">客户列表</span>
|
||||
</el-button>
|
||||
</el-header>
|
||||
|
||||
<el-container>
|
||||
<el-aside class="yk-aside" :width="'var(--yk-aside-width)'">
|
||||
<div class="yk-aside__search">
|
||||
<el-input
|
||||
v-model="filterText"
|
||||
size="small"
|
||||
clearable
|
||||
placeholder="搜索部门"
|
||||
:prefix-icon="Search"
|
||||
/>
|
||||
<div
|
||||
style="
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 6px;
|
||||
"
|
||||
>
|
||||
<span style="color: #909399; font-size: 12px">部门</span>
|
||||
<el-button
|
||||
text
|
||||
size="small"
|
||||
:loading="org.loading"
|
||||
title="刷新"
|
||||
@click="reloadTree"
|
||||
>
|
||||
<el-icon><Refresh /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="yk-aside__tree">
|
||||
<el-tree
|
||||
ref="treeRef"
|
||||
:data="org.tree"
|
||||
:props="treeProps"
|
||||
node-key="dept_id"
|
||||
:current-node-key="selectedDeptId ?? undefined"
|
||||
:filter-node-method="filterNode"
|
||||
:expand-on-click-node="false"
|
||||
:default-expand-all="false"
|
||||
highlight-current
|
||||
empty-text="暂无部门"
|
||||
@node-click="handleNodeClick"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<span class="yk-tree-node">
|
||||
{{ data.label }}
|
||||
<el-tag
|
||||
v-if="data.dept_id === selectedDeptId"
|
||||
size="small"
|
||||
type="primary"
|
||||
effect="plain"
|
||||
style="margin-left: 4px"
|
||||
>
|
||||
筛选中
|
||||
</el-tag>
|
||||
</span>
|
||||
</template>
|
||||
</el-tree>
|
||||
|
||||
<div
|
||||
v-if="org.error"
|
||||
class="yk-empty"
|
||||
style="font-size: 12px; padding: 12px"
|
||||
>
|
||||
加载失败:{{ org.error }}
|
||||
</div>
|
||||
</div>
|
||||
</el-aside>
|
||||
|
||||
<el-main class="yk-main">
|
||||
<router-view />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.yk-tree-node {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import 'element-plus/dist/index.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// Register every Element Plus icon globally (used by `<el-icon><ChatDotRound/>`).
|
||||
for (const [name, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(name, component)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
|
||||
// `/` redirects to `/customers`. Chat timeline params are path segments so the
|
||||
// URL is shareable + survives refresh:
|
||||
// /chat/:channel/:ownerId/:talkerId
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: { name: 'customers' },
|
||||
},
|
||||
{
|
||||
path: '/customers',
|
||||
name: 'customers',
|
||||
component: () => import('@/views/CustomerList.vue'),
|
||||
meta: { title: '客户列表' },
|
||||
},
|
||||
{
|
||||
path: '/chat/:channel/:ownerId/:talkerId',
|
||||
name: 'chat',
|
||||
component: () => import('@/views/ChatTimeline.vue'),
|
||||
props: true,
|
||||
meta: { title: '聊天记录' },
|
||||
},
|
||||
{
|
||||
// Anything else → back to the list.
|
||||
path: '/:pathMatch(.*)*',
|
||||
redirect: { name: 'customers' },
|
||||
},
|
||||
]
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
scrollBehavior() {
|
||||
return { top: 0 }
|
||||
},
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
const title = (to.meta.title as string | undefined) ?? ''
|
||||
document.title = title ? `${title} · 云客数据镜像` : '云客数据镜像'
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import type { Channel } from '@/api/types'
|
||||
|
||||
/**
|
||||
* Global channel filter for the top-bar radio group. Persisted to localStorage
|
||||
* so a refresh keeps the user's last selection. `Channel | 'all'` — `all` means
|
||||
* "no channel filter" (the wrapper drops the param).
|
||||
*/
|
||||
const STORAGE_KEY = 'yunkes.channel'
|
||||
const VALID: (Channel | 'all')[] = ['wechat', 'whatsapp', 'all']
|
||||
|
||||
function readInitial(): Channel | 'all' {
|
||||
try {
|
||||
const v = localStorage.getItem(STORAGE_KEY)
|
||||
if (v && (VALID as string[]).includes(v)) return v as Channel | 'all'
|
||||
} catch {
|
||||
/* ignore — private mode etc. */
|
||||
}
|
||||
return 'all'
|
||||
}
|
||||
|
||||
export const useChannelStore = defineStore('channel', () => {
|
||||
const channel = ref<Channel | 'all'>(readInitial())
|
||||
|
||||
function setChannel(value: Channel | 'all'): void {
|
||||
channel.value = value
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, value)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
return { channel, setChannel }
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { getDepartments } from '@/api'
|
||||
import type { Department } from '@/api/types'
|
||||
import { buildDeptTree, type DeptTreeNode } from '@/utils/tree'
|
||||
|
||||
/**
|
||||
* Loads the flat department list once and exposes both the raw list and the
|
||||
* el-tree-ready forest. Views select a department by `dept_id`; the tree node
|
||||
* for a given id is resolved via `findNode`.
|
||||
*/
|
||||
export const useOrgStore = defineStore('org', () => {
|
||||
const departments = ref<Department[]>([])
|
||||
const tree = computed<DeptTreeNode[]>(() => buildDeptTree(departments.value))
|
||||
const loaded = ref(false)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function ensureLoaded(force = false): Promise<void> {
|
||||
if (loaded.value && !force) return
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
departments.value = await getDepartments()
|
||||
loaded.value = true
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function findNode(deptId?: string | null): DeptTreeNode | undefined {
|
||||
if (!deptId) return undefined
|
||||
const visit = (list: DeptTreeNode[]): DeptTreeNode | undefined => {
|
||||
for (const n of list) {
|
||||
if (n.dept_id === deptId) return n
|
||||
const hit = visit(n.children)
|
||||
if (hit) return hit
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
return visit(tree.value)
|
||||
}
|
||||
|
||||
return { departments, tree, loaded, loading, error, ensureLoaded, findNode }
|
||||
})
|
||||
@@ -0,0 +1,249 @@
|
||||
/* App-level layout + resets. Element Plus ships its own component styles. */
|
||||
|
||||
:root {
|
||||
--yk-header-height: 56px;
|
||||
--yk-aside-width: 260px;
|
||||
--yk-bg: #f5f7fa;
|
||||
--yk-border: #e4e7ed;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
background: var(--yk-bg);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--el-color-primary, #409eff);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Layout shell ----------------------------------------------------------- */
|
||||
|
||||
.yk-app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.yk-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
height: var(--yk-header-height);
|
||||
padding: 0 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--yk-border);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.yk-header__title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.yk-header__spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.yk-aside {
|
||||
background: #fff;
|
||||
border-right: 1px solid var(--yk-border);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.yk-aside__search {
|
||||
padding: 10px 12px 6px;
|
||||
}
|
||||
|
||||
.yk-aside__tree {
|
||||
padding: 4px 6px 16px;
|
||||
}
|
||||
|
||||
.yk-main {
|
||||
padding: 16px;
|
||||
background: var(--yk-bg);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* Empty / loading placeholders */
|
||||
.yk-empty {
|
||||
padding: 40px 0;
|
||||
color: #909399;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Chat timeline ---------------------------------------------------------- */
|
||||
|
||||
.yk-chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.yk-chat__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.yk-chat__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.yk-chat__sub {
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.yk-chat__stream {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--yk-border);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.yk-chat__load {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 8px 0 12px;
|
||||
}
|
||||
|
||||
.yk-chat__end {
|
||||
text-align: center;
|
||||
color: #c0c4cc;
|
||||
font-size: 12px;
|
||||
padding: 6px 0 2px;
|
||||
}
|
||||
|
||||
/* Message bubble */
|
||||
.yk-msg {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.yk-msg--send {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.yk-msg__row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
.yk-msg--send .yk-msg__row {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.yk-msg__meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
padding: 0 4px 2px;
|
||||
}
|
||||
|
||||
.yk-msg--send .yk-msg__meta {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.yk-msg__bubble {
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
background: #f4f4f5;
|
||||
color: #303133;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.yk-msg--send .yk-msg__bubble {
|
||||
background: var(--el-color-primary-light-8, #d9ecff);
|
||||
color: #1d2129;
|
||||
}
|
||||
|
||||
.yk-msg__image {
|
||||
max-width: 220px;
|
||||
max-height: 220px;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.yk-msg__video {
|
||||
max-width: 280px;
|
||||
max-height: 280px;
|
||||
border-radius: 8px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.yk-msg__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 16px;
|
||||
background: #ecf5ff;
|
||||
color: var(--el-color-primary, #409eff);
|
||||
font-size: 13px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.yk-msg__chip--download {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.yk-msg__transcript {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
font-style: italic;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* Centered faint row for divider / system messages */
|
||||
.yk-msg--center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.yk-msg__divider {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
background: transparent;
|
||||
text-align: center;
|
||||
padding: 2px 8px;
|
||||
}
|
||||
|
||||
.yk-clickable-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.yk-channel-tag {
|
||||
margin-right: 4px;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Formatting helpers for chat timeline + customer list. */
|
||||
|
||||
/** ms-epoch → `YYYY-MM-DD HH:mm:ss` in local time. `--` for missing/invalid. */
|
||||
export function formatTime(ts?: number | null): string {
|
||||
if (!ts || !Number.isFinite(ts) || ts <= 0) return '--'
|
||||
const d = new Date(ts)
|
||||
if (Number.isNaN(d.getTime())) return '--'
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return (
|
||||
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
|
||||
`${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
)
|
||||
}
|
||||
|
||||
/** ms-epoch → short `MM-DD HH:mm` for dense timelines. */
|
||||
export function formatTimeShort(ts?: number | null): string {
|
||||
if (!ts || !Number.isFinite(ts) || ts <= 0) return ''
|
||||
const d = new Date(ts)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
/**
|
||||
* For voice messages the backend stores duration in seconds (best-effort).
|
||||
* Render as `N"` or `M'SS"` to mirror the messaging-app look.
|
||||
*/
|
||||
export function formatDuration(seconds?: number | null): string {
|
||||
if (!seconds || !Number.isFinite(seconds) || seconds <= 0) return ''
|
||||
const s = Math.round(seconds)
|
||||
if (s < 60) return `${s}"`
|
||||
const m = Math.floor(s / 60)
|
||||
const rem = s % 60
|
||||
return `${m}'${String(rem).padStart(2, '0')}"`
|
||||
}
|
||||
|
||||
/** Friendly label for a Channel value. */
|
||||
export function channelLabel(channel?: string | null): string {
|
||||
switch (channel) {
|
||||
case 'wechat':
|
||||
return '微信'
|
||||
case 'whatsapp':
|
||||
return 'WhatsApp'
|
||||
default:
|
||||
return channel ?? '未知'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Department } from '@/api/types'
|
||||
|
||||
/**
|
||||
* Tree node consumed by `<el-tree :data>`. Element Plus looks at `label` for
|
||||
* the text and `children` for sub-nodes (both configurable via props).
|
||||
*/
|
||||
export interface DeptTreeNode {
|
||||
dept_id: string
|
||||
label: string
|
||||
raw: Department
|
||||
children: DeptTreeNode[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a department forest from the backend's flat `parent_id` list.
|
||||
*
|
||||
* - Roots = departments with no `parent_id` (or whose parent is missing).
|
||||
* - Children are sorted by `path` then `dept_id` to mirror the backend ordering
|
||||
* (the API already returns ordered by path, but orphans re-ordering is cheap
|
||||
* insurance).
|
||||
* - Falls back gracefully on cycles / dangling parent_ids (orphans become roots
|
||||
* rather than disappearing).
|
||||
*/
|
||||
export function buildDeptTree(departments: Department[]): DeptTreeNode[] {
|
||||
const nodes = new Map<string, DeptTreeNode>()
|
||||
for (const d of departments) {
|
||||
nodes.set(d.dept_id, {
|
||||
dept_id: d.dept_id,
|
||||
label: d.name?.trim() || d.dept_id,
|
||||
raw: d,
|
||||
children: [],
|
||||
})
|
||||
}
|
||||
|
||||
const roots: DeptTreeNode[] = []
|
||||
for (const d of departments) {
|
||||
const node = nodes.get(d.dept_id)!
|
||||
const parent = d.parent_id ? nodes.get(d.parent_id) : undefined
|
||||
if (parent) {
|
||||
parent.children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
}
|
||||
|
||||
const sortRec = (list: DeptTreeNode[]) => {
|
||||
list.sort((a, b) =>
|
||||
(a.raw.path ?? a.dept_id).localeCompare(b.raw.path ?? b.dept_id),
|
||||
)
|
||||
for (const n of list) sortRec(n.children)
|
||||
}
|
||||
sortRec(roots)
|
||||
return roots
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||
import { getMessages } from '@/api'
|
||||
import type { Channel, Message } from '@/api/types'
|
||||
import MessageBubble from '@/components/MessageBubble.vue'
|
||||
import { channelLabel } from '@/utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
channel: string
|
||||
ownerId: string
|
||||
talkerId: string
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
const messages = ref<Message[]>([])
|
||||
const loading = ref(false)
|
||||
const loadingOlder = ref(false)
|
||||
const errorMsg = ref<string | null>(null)
|
||||
|
||||
// Asc-ordered pagination: page 1 = oldest 50, last page = most recent 50.
|
||||
// We land on the last page (most recent activity) and walk backwards to load
|
||||
// older history. `currentPage` is the smallest page number currently loaded.
|
||||
const currentPage = ref(1)
|
||||
const totalPages = ref(1)
|
||||
const total = ref(0)
|
||||
|
||||
const channelTyped = computed(() => props.channel as Channel)
|
||||
|
||||
const hasMoreOlder = computed(() => currentPage.value > 1)
|
||||
|
||||
const conversationTitle = computed(
|
||||
() => `与 ${props.talkerId} 的聊天记录`,
|
||||
)
|
||||
|
||||
const subtitle = computed(
|
||||
() =>
|
||||
`渠道:${channelLabel(channelTyped.value)} · 归属账号:${props.ownerId}` +
|
||||
(total.value > 0 ? ` · 共 ${total.value} 条` : ''),
|
||||
)
|
||||
|
||||
const streamRef = ref<HTMLElement | null>(null)
|
||||
|
||||
function scrollToBottom(): void {
|
||||
nextTick(() => {
|
||||
const el = streamRef.value
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the last page (most recent messages) on entry. Page 1 is fetched first
|
||||
* purely to learn `total` so we can compute `lastPage` — the messages API
|
||||
* returns asc, so the last page holds the newest slice.
|
||||
*/
|
||||
async function loadInitial(): Promise<void> {
|
||||
loading.value = true
|
||||
errorMsg.value = null
|
||||
try {
|
||||
const first = await getMessages({
|
||||
channel: channelTyped.value,
|
||||
owner_account_id: props.ownerId,
|
||||
talker_id: props.talkerId,
|
||||
page: 1,
|
||||
page_size: PAGE_SIZE,
|
||||
})
|
||||
total.value = first.total
|
||||
totalPages.value = Math.max(1, Math.ceil(first.total / PAGE_SIZE))
|
||||
|
||||
if (totalPages.value <= 1) {
|
||||
messages.value = first.items
|
||||
} else {
|
||||
const last = await getMessages({
|
||||
channel: channelTyped.value,
|
||||
owner_account_id: props.ownerId,
|
||||
talker_id: props.talkerId,
|
||||
page: totalPages.value,
|
||||
page_size: PAGE_SIZE,
|
||||
})
|
||||
messages.value = last.items
|
||||
}
|
||||
currentPage.value = totalPages.value
|
||||
scrollToBottom()
|
||||
} catch (e) {
|
||||
errorMsg.value = (e as Error).message
|
||||
messages.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Prepend the next older page (currentPage - 1). No-op on page 1. */
|
||||
async function loadOlder(): Promise<void> {
|
||||
if (loadingOlder.value || !hasMoreOlder.value) return
|
||||
loadingOlder.value = true
|
||||
const prevHeight = streamRef.value?.scrollHeight ?? 0
|
||||
try {
|
||||
const prevPage = currentPage.value - 1
|
||||
const res = await getMessages({
|
||||
channel: channelTyped.value,
|
||||
owner_account_id: props.ownerId,
|
||||
talker_id: props.talkerId,
|
||||
page: prevPage,
|
||||
page_size: PAGE_SIZE,
|
||||
})
|
||||
// Backend returns asc, so res.items are older than everything currently
|
||||
// shown — prepend them.
|
||||
messages.value = [...res.items, ...messages.value]
|
||||
currentPage.value = prevPage
|
||||
// Keep the user's scroll anchored near where they were.
|
||||
nextTick(() => {
|
||||
const el = streamRef.value
|
||||
if (el) el.scrollTop = el.scrollHeight - prevHeight
|
||||
})
|
||||
} catch (e) {
|
||||
ElMessage.error(`加载更多失败:${(e as Error).message}`)
|
||||
} finally {
|
||||
loadingOlder.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goBack(): void {
|
||||
// Prefer history back when there's somewhere to go; else the customer list.
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
} else {
|
||||
router.push({ name: 'customers' })
|
||||
}
|
||||
}
|
||||
|
||||
// Reload when the route params change (navigating between conversations).
|
||||
watch(
|
||||
() => [props.channel, props.ownerId, props.talkerId] as const,
|
||||
() => {
|
||||
loadInitial()
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(loadInitial)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="yk-chat">
|
||||
<div class="yk-chat__header">
|
||||
<div>
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<el-button text size="small" @click="goBack">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
返回
|
||||
</el-button>
|
||||
<h2 class="yk-chat__title">{{ conversationTitle }}</h2>
|
||||
</div>
|
||||
<div class="yk-chat__sub">{{ subtitle }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="errorMsg"
|
||||
:title="`聊天记录加载失败:${errorMsg}`"
|
||||
type="error"
|
||||
show-icon
|
||||
:closable="false"
|
||||
style="margin-bottom: 0"
|
||||
/>
|
||||
|
||||
<div v-loading="loading" class="yk-chat__stream" ref="streamRef">
|
||||
<div class="yk-chat__load">
|
||||
<el-button
|
||||
v-if="hasMoreOlder"
|
||||
size="small"
|
||||
plain
|
||||
:loading="loadingOlder"
|
||||
@click="loadOlder"
|
||||
>
|
||||
加载更多 / 上一页
|
||||
</el-button>
|
||||
<span v-else-if="!loading && messages.length > 0" class="yk-chat__end">
|
||||
—— 已加载全部历史 ——
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<template v-if="messages.length === 0 && !loading">
|
||||
<div class="yk-empty">暂无聊天记录</div>
|
||||
</template>
|
||||
|
||||
<MessageBubble
|
||||
v-for="m in messages"
|
||||
:key="m.msg_id"
|
||||
:message="m"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Make the stream its own scroll region so scrollToBottom + load-older scroll
|
||||
* anchoring target the right element. Height reserves space for the app
|
||||
* header + chat header + main padding. */
|
||||
.yk-chat__stream {
|
||||
max-height: calc(100vh - var(--yk-header-height) - 140px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,267 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user