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
+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>