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