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