import { get, post, put, del, getList, postForm, downloadFile } from './request' export interface LoginParams { username: string; password: string } export interface LoginResult { token: string user_id: number tenant_id: number username?: string nickname: string role: string /** online | busy | offline */ status?: string } export interface Session { id: number; tenant_id: number; channel_id: number; customer_id: number; agent_id: number | null status: string; priority: string; unread_count: number; satisfaction_score: number | null last_message?: string; last_message_at?: string | null satisfaction_text?: string end_reason?: string visitor_ip?: string visitor_region?: string user_agent?: string /** 访客设备指纹(用于设备拉黑) */ device_key?: string landing_url?: string landing_title?: string referrer?: string current_url?: string current_title?: string last_seen_at?: string | null /** 访客输入框未发送草稿 */ draft_text?: string draft_updated_at?: string | null created_at: string; ended_at: string | null /** 列表接口补全字段 */ message_count?: number customer_name?: string agent_name?: string channel_name?: string channel_type?: string } export interface CustomerContact { id: number tenant_id?: number customer_id: number kind: string // phone | wechat | email | qq value: string source?: string created_at?: string } export interface VisitorPageView { id: number session_id: number tenant_id?: number url: string title: string entered_at: string } export interface Message { id: number; session_id: number; sender_type: 'visitor' | 'agent'; sender_id: number | null content: string; type: 'text' | 'image'; seq: number; sent_at: string } export interface SessionEvent { id: number; session_id: number; operator_id: number; action: string; detail: string; created_at: string } export interface AvailableAgent { id: number nickname: string /** online | busy | offline | disabled */ status: string role?: string } export interface Customer { id: number tenant_id: number name: string phone: string /** 联系电话/固话 */ tel?: string email: string wechat?: string qq?: string /** 客户备注 */ remark?: string tags: string source: string status: string conversation_count: number last_contact_at: string contacts?: CustomerContact[] } export type SaveCustomerPayload = Partial & { name?: string /** 多个手机号;首项为主手机 */ phones?: string[] } export interface KnowledgeCategory { id: number; tenant_id: number; parent_id: number | null; name: string /** 本分类直属条目数 */ entry_count?: number /** 含子分类汇总 */ total_count?: number } export interface KnowledgeEntry { id: number; title: string; content: string; status: string; usage_count: number category_id: number; updated_at: string category_name?: string } export interface Channel { id: number; tenant_id: number; type: string; name: string; status: string config: string; script_code: string; channel_key: string } export interface Tenant { id: number; name: string; plan_id: number | null; seat_count: number; expire_at: string; status: string contact_name: string; contact_phone: string; contact_email: string created_at?: string } export interface Plan { id: number; name: string; price_monthly: number; seats: number; storage_days: number kb_limit: number; features: string; status: string } export interface OperationLog { id: number; operator_id: number; action: string; detail: string target_type?: string; target_id?: number; ip?: string; created_at: string } export interface Announcement { id: number; title: string; content: string; status: string created_at: string; updated_at?: string } export interface AdminStats { tenant_total: number active_tenant: number suspended_tenant?: number expiring_tenant?: number new_tenants_month?: number monthly_income: number system_uptime: string plan_distribution?: { plan_id: number; name: string; count: number }[] tenant_growth?: { month: string; month_key: string; new_count: number; total_count: number }[] recent_logs?: OperationLog[] } /** 平台运维实时指标(当前 API 实例) */ export interface OpsServiceStatus { name: string status: 'normal' | 'warning' | 'error' | string detail?: string latency_ms?: number connections?: number } export interface OpsMetrics { overall: 'normal' | 'warning' | 'error' | string updated_at: string instance: string uptime_sec: number services: OpsServiceStatus[] load: { cpu_percent: number mem_percent: number disk_percent: number cpu_cores: number mem_total_mb: number mem_used_mb: number disk_total_gb: number disk_used_gb: number go_goroutines: number go_heap_mb: number } api: { requests_last_24h: number[] error_rate_last_24h: number[] hours: number[] qps: number error_rate_now: number window_requests: number window_errors: number total_requests: number total_errors: number total_5xx: number in_flight: number } websocket: { connections: number agents: number visitors: number } } export const getOpsMetrics = () => get('/admin/ops/metrics') export interface StatisticsKpis { total_sessions: number avg_response_time: number satisfaction_avg: number first_resolve_rate: number total_messages: number } export type StatsQuery = { /** today | week | month */ period?: string /** YYYY-MM-DD,与 to 同时传则优先自定义区间 */ from?: string to?: string } function statsQueryString(params?: StatsQuery) { const search = new URLSearchParams() if (params?.from && params?.to) { search.set('from', params.from) search.set('to', params.to) } else if (params?.period) { search.set('period', params.period) } const qs = search.toString() return qs ? `?${qs}` : '' } // Auth export const login = (params: LoginParams) => post('/login', params) /** 以当前登录身份换取新令牌(前端定时续期,防止长挂机后过期掉线) */ export const refreshSession = () => post<{ token: string }>('/auth/refresh', {}) export type AgentPresence = 'online' | 'busy' | 'offline' export const getMe = () => get<{ user_id: number; tenant_id: number; nickname: string; role: string; status: string; username?: string }>('/me') /** 坐席自行切换在线状态 */ export const updateMyStatus = (status: AgentPresence) => put<{ user_id: number; status: string }>('/me/status', { status }) // Sessions export const getSessions = (params?: { status?: string priority?: string agent_id?: number | string channel_id?: number | string search?: string from?: string to?: string page?: number pageSize?: number }) => { const search = new URLSearchParams() if (params?.status) search.set('status', params.status) if (params?.priority) search.set('priority', params.priority) if (params?.agent_id != null && params.agent_id !== '') search.set('agent_id', String(params.agent_id)) if (params?.channel_id != null && params.channel_id !== '') search.set('channel_id', String(params.channel_id)) if (params?.search) search.set('search', params.search) if (params?.from) search.set('from', params.from) if (params?.to) search.set('to', params.to) if (params?.page) search.set('page', String(params.page)) if (params?.pageSize) search.set('pageSize', String(params.pageSize)) return getList(`/sessions?${search}`) } export const getSession = (id: number) => get<{ session: Session messages: Message[] events: SessionEvent[] page_views?: VisitorPageView[] pending_count: number max_seq?: number }>(`/sessions/${id}`) /** 按 seq 增量拉取消息(重连 catch-up):seq > after_seq */ export interface SessionMessagesSync { messages: Message[] after_seq: number max_seq: number has_more: boolean } export const getSessionMessages = (id: number, afterSeq = 0, limit = 200) => { const search = new URLSearchParams() if (afterSeq > 0) search.set('after_seq', String(afterSeq)) if (limit > 0) search.set('limit', String(limit)) const qs = search.toString() return get(`/sessions/${id}/messages${qs ? `?${qs}` : ''}`) } export const assignSession = (id: number, agentId: number) => post(`/sessions/${id}/assign`, { agent_id: agentId }) export const claimSession = (id: number) => post(`/sessions/${id}/assign`, {}) export const transferSession = (id: number, agentId: number) => post(`/sessions/${id}/transfer`, { agent_id: agentId }) export const endSession = (id: number, reason: string) => post(`/sessions/${id}/end?reason=${reason}`, {}) export const archiveSession = (id: number) => post<{ message: string; session: Session }>(`/sessions/${id}/archive`, {}) export const batchArchiveSessions = (ids: number[]) => post<{ message: string; count: number }>('/sessions/batch-archive', { ids }) export const updateSessionPriority = (id: number, priority: 'normal' | 'urgent') => put(`/sessions/${id}/priority?priority=${priority}`, {}) export const markSessionRead = (id: number) => post(`/sessions/${id}/read`, {}) export const addSessionNote = (id: number, content: string) => post(`/sessions/${id}/notes`, { content }) export const sendSessionMessage = (id: number, content: string, type: 'text' | 'image' = 'text') => post(`/sessions/${id}/messages`, { content, type }) /** onlineOnly 默认 true;传 false 返回全部坐席(对话记录筛选) */ export const getAvailableAgents = (onlineOnly = true) => get(`/agents/available${onlineOnly ? '' : '?all=1'}`) export interface UploadImageResult { url: string thumb_url: string content_type: string width: number height: number size: number } /** 客服端上传图片 → 返回对象存储 URL */ export const uploadImage = (file: File) => { const form = new FormData() form.append('file', file) return postForm('/uploads', form) } /** 访客端上传图片 */ export const uploadWidgetImage = async (file: File, sessionId: number, visitorToken: string) => { const form = new FormData() form.append('file', file) form.append('session_id', String(sessionId)) form.append('visitor_token', visitorToken) const res = await fetch('/api/widget/upload', { method: 'POST', headers: { 'X-Visitor-Token': visitorToken }, body: form, }) const json = await res.json() if (json.code !== 0) throw new Error(json.message || '上传失败') return json as { code: number; data: UploadImageResult } } // Customers export const getCustomers = (params?: { search?: string; status?: string; source?: string; page?: number; pageSize?: number }) => { const search = new URLSearchParams() if (params?.search) search.set('search', params.search) if (params?.status) search.set('status', params.status) if (params?.source) search.set('source', params.source) if (params?.page) search.set('page', String(params.page || 1)) if (params?.pageSize) search.set('pageSize', String(params.pageSize)) return getList(`/customers?${search}`) } export const exportCustomersCSV = (params?: { search?: string; status?: string; source?: string }) => { const search = new URLSearchParams() if (params?.search) search.set('search', params.search) if (params?.status) search.set('status', params.status) if (params?.source) search.set('source', params.source) const qs = search.toString() return downloadFile(`/customers/export${qs ? `?${qs}` : ''}`, `customers_${Date.now()}.csv`) } export const exportSessionsCSV = (params?: { status?: string priority?: string agent_id?: number | string channel_id?: number | string search?: string from?: string to?: string }) => { const search = new URLSearchParams() if (params?.status) search.set('status', params.status) if (params?.priority) search.set('priority', params.priority) if (params?.agent_id != null && params.agent_id !== '') search.set('agent_id', String(params.agent_id)) if (params?.channel_id != null && params.channel_id !== '') search.set('channel_id', String(params.channel_id)) if (params?.search) search.set('search', params.search) if (params?.from) search.set('from', params.from) if (params?.to) search.set('to', params.to) const qs = search.toString() return downloadFile(`/sessions/export${qs ? `?${qs}` : ''}`, `sessions_${Date.now()}.csv`) } export const exportStatisticsCSV = (params?: StatsQuery) => downloadFile(`/statistics/export${statsQueryString(params)}`, `statistics_${Date.now()}.csv`) export const getCustomer = (id: number) => get<{ customer: Customer; sessions: Session[]; contacts?: CustomerContact[] }>(`/customers/${id}`) export const createCustomer = (data: SaveCustomerPayload) => post('/customers', data) export const updateCustomer = (id: number, data: SaveCustomerPayload) => put(`/customers/${id}`, data) export const deleteCustomer = (id: number) => del(`/customers/${id}`) // 黑名单 export interface BlacklistEntry { id: number tenant_id: number kind: 'ip' | 'device' | string value: string reason: string expires_at?: string | null operator_id?: number session_id?: number | null customer_id?: number | null created_at?: string } export type BlacklistDuration = '1h' | '6h' | '1d' | '7d' | '30d' | 'permanent' export const createBlacklist = (data: { session_id?: number kind: 'ip' | 'device' value?: string duration: BlacklistDuration | string reason: string end_session?: boolean }) => post('/blacklist', data) export const getBlacklist = (params?: { kind?: string; active?: boolean }) => { const search = new URLSearchParams() if (params?.kind) search.set('kind', params.kind) if (params?.active === false) search.set('active', '0') const qs = search.toString() return get(`/blacklist${qs ? `?${qs}` : ''}`) } export const releaseBlacklist = (id: number) => del(`/blacklist/${id}`) // 客户标签库(管理员维护,全员可选) export interface CustomerTag { id: number tenant_id: number name: string color: string sort_order: number created_at?: string updated_at?: string } export const getCustomerTags = () => get('/customer-tags') export const createCustomerTag = (data: { name: string; color?: string; sort_order?: number }) => post('/customer-tags', data) export const updateCustomerTag = (id: number, data: { name: string; color?: string; sort_order?: number }) => put(`/customer-tags/${id}`, data) export const deleteCustomerTag = (id: number) => del(`/customer-tags/${id}`) // Knowledge export const getKnowledgeCategories = () => get<{ list: KnowledgeCategory[]; total_entries: number }>('/knowledge/categories') export const createKnowledgeCategory = (data: { name: string; parent_id?: number | null }) => post('/knowledge/categories', data) export const updateKnowledgeCategory = (id: number, data: { name: string; parent_id?: number | null }) => put(`/knowledge/categories/${id}`, data) export const deleteKnowledgeCategory = (id: number) => del(`/knowledge/categories/${id}`) export const getKnowledgeEntries = (params?: { category_id?: string search?: string status?: string sort?: 'usage' | 'updated' | 'title' page?: number pageSize?: number }) => { const search = new URLSearchParams() if (params?.category_id) search.set('category_id', params.category_id) if (params?.search) search.set('search', params.search) if (params?.status) search.set('status', params.status) if (params?.sort) search.set('sort', params.sort) if (params?.page) search.set('page', String(params.page || 1)) if (params?.pageSize) search.set('pageSize', String(params.pageSize || 10)) return getList(`/knowledge/entries?${search}`) } export const createKnowledgeEntry = (data: { title: string; content: string; category_id: number; status?: string }) => post('/knowledge/entries', data) export const updateKnowledgeEntry = (id: number, data: Partial) => put(`/knowledge/entries/${id}`, data) export const deleteKnowledgeEntry = (id: number) => del(`/knowledge/entries/${id}`) // 快捷回复(团队 / 个人) export type QuickReplyScope = 'team' | 'personal' export interface QuickReply { id: number tenant_id: number scope: QuickReplyScope owner_user_id?: number | null title: string content: string shortcut: string group_name: string status: 'draft' | 'published' | string sort_order: number usage_count: number /** 当前坐席个人调用次数(建议排序用) */ my_usage_count?: number created_at: string updated_at: string } export const getQuickReplies = (params?: { scope?: 'team' | 'personal' | 'all' status?: string q?: string page?: number pageSize?: number }) => { const search = new URLSearchParams() if (params?.scope) search.set('scope', params.scope) if (params?.status) search.set('status', params.status) if (params?.q) search.set('q', params.q) if (params?.page) search.set('page', String(params.page)) if (params?.pageSize) search.set('pageSize', String(params.pageSize)) const qs = search.toString() return getList(`/quick-replies${qs ? `?${qs}` : ''}`) } /** slash=输入码前缀;keyword=标题/内容关键字。均按个人调用频次排序,最多 10 条 */ export const suggestQuickReplies = (opts?: { mode?: 'slash' | 'keyword' prefix?: string q?: string }) => { const search = new URLSearchParams() search.set('mode', opts?.mode || 'slash') if (opts?.prefix) search.set('prefix', opts.prefix) if (opts?.q) search.set('q', opts.q) return get(`/quick-replies/suggest?${search.toString()}`) } export const createQuickReply = (data: { scope: QuickReplyScope title: string content: string shortcut?: string group_name?: string status?: string sort_order?: number }) => post('/quick-replies', data) export const updateQuickReply = (id: number, data: { title: string content: string shortcut?: string group_name?: string status?: string sort_order?: number }) => put(`/quick-replies/${id}`, data) export const deleteQuickReply = (id: number) => del(`/quick-replies/${id}`) export const publishQuickReply = (id: number) => post(`/quick-replies/${id}/publish`, {}) export const unpublishQuickReply = (id: number) => post(`/quick-replies/${id}/unpublish`, {}) export const useQuickReply = (id: number) => post<{ ok: boolean; usage_count: number }>(`/quick-replies/${id}/use`, {}) export const exportQuickReplies = (scope: QuickReplyScope) => downloadFile(`/quick-replies/export?scope=${scope}`, `quick_replies_${scope}.csv`) export const downloadQuickReplyTemplate = () => downloadFile('/quick-replies/import-template', '快捷回复导入模板.csv') export const importQuickReplies = (file: File, scope: QuickReplyScope, onConflict: 'skip' | 'overwrite' = 'skip') => { const form = new FormData() form.append('file', file) form.append('scope', scope) form.append('on_conflict', onConflict) return postForm<{ created: number; updated: number; skipped: number; errors: string[] }>( `/quick-replies/import?scope=${scope}`, form, ) } // Channels export const getChannels = () => get('/channels') export const createChannel = (data: { type: string; name?: string }) => post('/channels', data) export const updateChannel = (id: number, data: { name?: string; status?: string }) => put(`/channels/${id}`, data) // 坐席账号 export interface StaffUser { id: number username: string nickname: string role: string status: string created_at: string last_online_at?: string } export interface StaffListResult { list: StaffUser[] seat_limit: number seat_used: number } export const getStaff = () => get('/staff') export const createStaff = (data: { username: string; password: string; nickname?: string; role?: string }) => post('/staff', data) export const updateStaff = (id: number, data: { nickname?: string; role?: string; status?: string; password?: string }) => put(`/staff/${id}`, data) export const batchStaff = (data: { ids: number[]; action: string; new_role?: string }) => post('/staff/batch', data) export const deleteStaff = (id: number) => del(`/staff/${id}`) // Tenant settings export type WorkHours = Record export type AssignStrategy = 'least_load' | 'round_robin' /** 欢迎语段落:文本或图片 URL */ export type WelcomeSegment = { type: 'text' | 'image' content: string } export interface TenantSettings { tenant_id: number display_name: string agent_nickname: string timezone: string /** 兼容字段:首段文本欢迎语 */ welcome_message: string /** 多段欢迎语(含图片) */ welcome_messages?: WelcomeSegment[] offline_prompt: string work_hours: WorkHours worktime_prompt: string notify_new_session: boolean notify_offline_leave: boolean notify_daily_report: boolean /** least_load 负载最低 | round_robin 轮询 */ assign_strategy?: AssignStrategy /** 每位坐席最大进行中会话数,0=不限制 */ max_active_per_agent?: number } export const getTenantSettings = () => get('/settings') export const updateTenantSettings = ( data: Partial & { work_hours?: WorkHours; welcome_messages?: WelcomeSegment[] }, ) => put('/settings', data) // Statistics export const getKPIs = (params?: StatsQuery) => get(`/statistics/kpi${statsQueryString(params)}`) export const getSessionTrend = (params?: StatsQuery | 'today' | 'day' | 'month') => { // 兼容旧调用 getSessionTrend('day') if (typeof params === 'string') { return get<{ date: string; count: number }[]>(`/statistics/trend?period=${params}`) } return get<{ date: string; count: number }[]>(`/statistics/trend${statsQueryString(params)}`) } export const getResponseDistribution = (params?: StatsQuery) => get<{ range: string; count: number }[]>(`/statistics/response-distribution${statsQueryString(params)}`) export const getChannelDistribution = (params?: StatsQuery) => get<{ type: string; value: number }[]>(`/statistics/channels${statsQueryString(params)}`) export const getAgentPerformance = (params?: StatsQuery) => get<{ name: string; conversations: number; avg_response: number; satisfaction: number }[]>( `/statistics/performance${statsQueryString(params)}`, ) // Admin export const getTenants = (params?: { search?: string; status?: string; page?: number; pageSize?: number }) => { const search = new URLSearchParams() if (params?.search) search.set('search', params.search) if (params?.status) search.set('status', params.status) if (params?.page) search.set('page', String(params.page || 1)) if (params?.pageSize) search.set('pageSize', String(params.pageSize)) return getList(`/admin/tenants?${search}`) } export const getAdminStats = () => get('/admin/stats') export const createTenant = (data: { name: string; contact_name: string; contact_phone: string; contact_email?: string plan_id?: number; seat_count?: number; duration_months?: number admin_username?: string; admin_password?: string }) => post<{ tenant: Tenant; admin_username: string; admin_password: string }>('/admin/tenants', data) export const updateTenant = (id: number, data: Partial) => put(`/admin/tenants/${id}`, data) export const suspendTenant = (id: number) => post(`/admin/tenants/${id}/suspend`, {}) export const resumeTenant = (id: number) => post(`/admin/tenants/${id}/resume`, {}) export const getPlans = () => get('/admin/plans') export const createPlan = (data: Partial) => post('/admin/plans', data) export const updatePlan = (id: number, data: Partial) => put(`/admin/plans/${id}`, data) export const getAdminLogs = (params?: { page?: number; pageSize?: number }) => { const search = new URLSearchParams() if (params?.page) search.set('page', String(params.page || 1)) if (params?.pageSize) search.set('pageSize', String(params.pageSize || 10)) return getList(`/admin/logs?${search}`) } export const getAnnouncements = () => get('/admin/announcements') export const createAnnouncement = (data: { title: string; content: string; status?: string }) => post('/admin/announcements', data) export const updateAnnouncement = (id: number, data: Partial) => put(`/admin/announcements/${id}`, data) export const deleteAnnouncement = (id: number) => del(`/admin/announcements/${id}`) // 权限管理 export interface Permission { id: number code: string name: string module: string category: string sort_order: number } export interface RoleWithStats { id: number tenant_id: number name: string code: string type: 'builtin' | 'custom' desc: string member_count: number perm_count: number created_at: string updated_at: string } export interface RoleDetail { role: RoleWithStats permissions: string[] data_scopes: Record member_count: number } export interface MePermissions { role: string permissions: string[] } export const getPermissions = () => get('/roles/permissions') export const getRoles = () => get('/roles') export const getRoleDetail = (id: number) => get(`/roles/${id}`) export const createRole = (data: { name: string; desc?: string; permissions: string[]; data_scopes?: Record }) => post('/roles', data) export const updateRole = (id: number, data: { name?: string; desc?: string; permissions: string[]; data_scopes: Record }) => put(`/roles/${id}`, data) export const updateBuiltinRole = (id: number, data: { desc?: string; permissions: string[]; data_scopes: Record }) => put(`/roles/${id}/builtin`, data) export const deleteRole = (id: number) => del(`/roles/${id}`) export const mePermissions = () => get('/me/permissions')