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:
@@ -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>
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user