- 后端:JWT 有效期可配(JWT_EXPIRE_HOURS,默认 24h);令牌寿命过半时经 X-Renewed-Token 响应头滑动续期;新增 POST /api/auth/refresh 以数据库真实身份签发 - 前端 request 层:静默接管续期头并同步 localStorage;任何 401(登录/续期除外) 统一广播会话过期事件 - auth store:监听过期事件清理登录态并跳转 /login?expired=1(不再卡 403 页); 每 10 分钟定时续期,避免 WS 重连时令牌已过期 - 新增滑动续期单元测试(回签过期时间戳),go test -race 通过
732 lines
26 KiB
TypeScript
732 lines
26 KiB
TypeScript
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<Customer> & {
|
|
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<OpsMetrics>('/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<LoginResult>('/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<Session>(`/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<SessionMessagesSync>(`/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<SessionEvent>(`/sessions/${id}/notes`, { content })
|
|
export const sendSessionMessage = (id: number, content: string, type: 'text' | 'image' = 'text') => post<Message>(`/sessions/${id}/messages`, { content, type })
|
|
/** onlineOnly 默认 true;传 false 返回全部坐席(对话记录筛选) */
|
|
export const getAvailableAgents = (onlineOnly = true) =>
|
|
get<AvailableAgent[]>(`/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<UploadImageResult>('/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<Customer>(`/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<Customer>('/customers', data)
|
|
export const updateCustomer = (id: number, data: SaveCustomerPayload) => put<Customer>(`/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<BlacklistEntry>('/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<BlacklistEntry[]>(`/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<CustomerTag[]>('/customer-tags')
|
|
export const createCustomerTag = (data: { name: string; color?: string; sort_order?: number }) =>
|
|
post<CustomerTag>('/customer-tags', data)
|
|
export const updateCustomerTag = (id: number, data: { name: string; color?: string; sort_order?: number }) =>
|
|
put<CustomerTag>(`/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<KnowledgeCategory>('/knowledge/categories', data)
|
|
export const updateKnowledgeCategory = (id: number, data: { name: string; parent_id?: number | null }) =>
|
|
put<KnowledgeCategory>(`/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<KnowledgeEntry>(`/knowledge/entries?${search}`)
|
|
}
|
|
export const createKnowledgeEntry = (data: { title: string; content: string; category_id: number; status?: string }) =>
|
|
post<KnowledgeEntry>('/knowledge/entries', data)
|
|
export const updateKnowledgeEntry = (id: number, data: Partial<KnowledgeEntry>) =>
|
|
put<KnowledgeEntry>(`/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<QuickReply>(`/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<QuickReply[]>(`/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<QuickReply>('/quick-replies', data)
|
|
|
|
export const updateQuickReply = (id: number, data: {
|
|
title: string
|
|
content: string
|
|
shortcut?: string
|
|
group_name?: string
|
|
status?: string
|
|
sort_order?: number
|
|
}) => put<QuickReply>(`/quick-replies/${id}`, data)
|
|
|
|
export const deleteQuickReply = (id: number) => del(`/quick-replies/${id}`)
|
|
export const publishQuickReply = (id: number) => post<QuickReply>(`/quick-replies/${id}/publish`, {})
|
|
export const unpublishQuickReply = (id: number) => post<QuickReply>(`/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<Channel[]>('/channels')
|
|
export const createChannel = (data: { type: string; name?: string }) => post<Channel>('/channels', data)
|
|
export const updateChannel = (id: number, data: { name?: string; status?: string }) => put<Channel>(`/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<StaffListResult>('/staff')
|
|
export const createStaff = (data: { username: string; password: string; nickname?: string; role?: string }) =>
|
|
post<StaffUser>('/staff', data)
|
|
export const updateStaff = (id: number, data: { nickname?: string; role?: string; status?: string; password?: string }) =>
|
|
put<StaffUser>(`/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<string, string>
|
|
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<TenantSettings>('/settings')
|
|
export const updateTenantSettings = (
|
|
data: Partial<TenantSettings> & { work_hours?: WorkHours; welcome_messages?: WelcomeSegment[] },
|
|
) => put<TenantSettings>('/settings', data)
|
|
|
|
// Statistics
|
|
export const getKPIs = (params?: StatsQuery) =>
|
|
get<StatisticsKpis>(`/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<Tenant>(`/admin/tenants?${search}`)
|
|
}
|
|
export const getAdminStats = () => get<AdminStats>('/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<Tenant>) => put<Tenant>(`/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<Plan[]>('/admin/plans')
|
|
export const createPlan = (data: Partial<Plan>) => post<Plan>('/admin/plans', data)
|
|
export const updatePlan = (id: number, data: Partial<Plan>) => put<Plan>(`/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<OperationLog>(`/admin/logs?${search}`)
|
|
}
|
|
export const getAnnouncements = () => get<Announcement[]>('/admin/announcements')
|
|
export const createAnnouncement = (data: { title: string; content: string; status?: string }) =>
|
|
post<Announcement>('/admin/announcements', data)
|
|
export const updateAnnouncement = (id: number, data: Partial<Announcement>) =>
|
|
put<Announcement>(`/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<string, 'all' | 'self'>
|
|
member_count: number
|
|
}
|
|
|
|
export interface MePermissions {
|
|
role: string
|
|
permissions: string[]
|
|
}
|
|
|
|
export const getPermissions = () => get<Permission[]>('/roles/permissions')
|
|
export const getRoles = () => get<RoleWithStats[]>('/roles')
|
|
export const getRoleDetail = (id: number) => get<RoleDetail>(`/roles/${id}`)
|
|
export const createRole = (data: { name: string; desc?: string; permissions: string[]; data_scopes?: Record<string, string> }) =>
|
|
post<RoleWithStats>('/roles', data)
|
|
export const updateRole = (id: number, data: { name?: string; desc?: string; permissions: string[]; data_scopes: Record<string, string> }) =>
|
|
put<RoleWithStats>(`/roles/${id}`, data)
|
|
export const updateBuiltinRole = (id: number, data: { desc?: string; permissions: string[]; data_scopes: Record<string, string> }) =>
|
|
put<RoleWithStats>(`/roles/${id}/builtin`, data)
|
|
export const deleteRole = (id: number) => del(`/roles/${id}`)
|
|
export const mePermissions = () => get<MePermissions>('/me/permissions')
|