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
+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 ?? '未知'
}
}