Files
kefu_cloud/web/src/services/api.ts
T
yml2213 e675f427a7 feat(auth): JWT 滑动续期 + 401 自动登出,修复长挂机莫名掉线
- 后端: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 通过
2026-09-05 15:57:42 +08:00

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')