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,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Vue 3 + TypeScript + Vite
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||||
|
|
||||||
|
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>yunkes</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1851
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "yunkes",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
|
"axios": "^1.19.0",
|
||||||
|
"element-plus": "^2.14.3",
|
||||||
|
"pinia": "^4.0.2",
|
||||||
|
"vue": "^3.5.40",
|
||||||
|
"vue-router": "^4.6.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.13.3",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.2.0",
|
||||||
|
"vue-tsc": "^3.3.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -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>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
//
|
||||||
|
// Dev server runs on :5173. `/api` is proxied to the FastAPI backend on :8000
|
||||||
|
// so the SPA can call relative `/api/...` URLs in both dev (proxy) and prod
|
||||||
|
// (reverse proxy). `/api/media/` streams media; it is matched first and left
|
||||||
|
// un-rewritten so the backend receives the raw path.
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
proxy: {
|
||||||
|
// Streaming media first — no path rewrite, ws off.
|
||||||
|
'/api/media': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user