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:
浅念
2026-08-01 16:27:22 +08:00
parent ff7e9cb1d9
commit 6c4cd92ae6
27 changed files with 3551 additions and 0 deletions
+24
View File
@@ -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?
+5
View File
@@ -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).
+13
View File
@@ -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>
+1851
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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

+24
View File
@@ -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

+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
import MainLayout from '@/layouts/MainLayout.vue'
</script>
<template>
<MainLayout class="yk-app" />
</template>
+43
View File
@@ -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>
}
+97
View File
@@ -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 }
+88
View File
@@ -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

+124
View File
@@ -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>
+193
View File
@@ -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>
+21
View File
@@ -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')
+42
View File
@@ -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} · 云客数据镜像` : '云客数据镜像'
})
+36
View File
@@ -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 }
})
+49
View File
@@ -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 }
})
+249
View File
@@ -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;
}
+47
View File
@@ -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 ?? '未知'
}
}
+54
View File
@@ -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
}
+207
View File
@@ -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>
+267
View File
@@ -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>
+18
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -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"]
}
+33
View File
@@ -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,
},
},
},
})