update web to dashboard
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,268 +0,0 @@
|
||||
import { request } from "@/lib/api/client"
|
||||
import { readSession } from "@/lib/auth"
|
||||
import { createWebSocketBaseUrl } from "@/lib/api/websocket"
|
||||
|
||||
export type Paging = {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type PageResult<T> = {
|
||||
results: T[]
|
||||
page: Paging
|
||||
}
|
||||
|
||||
export type CursorResult<T> = {
|
||||
results: T[]
|
||||
cursor: string
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
export type AgentConversationTag = {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export type AgentConversationParticipant = {
|
||||
id: number
|
||||
participantType: string
|
||||
participantId: number
|
||||
externalParticipantId?: string
|
||||
joinedAt?: string
|
||||
leftAt?: string
|
||||
status: number
|
||||
}
|
||||
|
||||
export type AgentConversation = {
|
||||
id: number
|
||||
aiAgentId?: number
|
||||
customerId?: number
|
||||
externalSource: string
|
||||
externalId: string
|
||||
subject: string
|
||||
status: number
|
||||
serviceMode: number
|
||||
priority: number
|
||||
currentAssigneeId: number
|
||||
currentAssigneeName?: string
|
||||
lastMessageId: number
|
||||
lastMessageAt?: string
|
||||
lastActiveAt?: string
|
||||
lastMessageSummary?: string
|
||||
customerUnreadCount: number
|
||||
agentUnreadCount: number
|
||||
customerLastReadMessageId: number
|
||||
customerLastReadSeqNo: number
|
||||
customerLastReadAt?: string
|
||||
agentLastReadMessageId: number
|
||||
agentLastReadSeqNo: number
|
||||
agentLastReadAt?: string
|
||||
customerOnline: boolean
|
||||
closedAt?: string
|
||||
tags?: AgentConversationTag[]
|
||||
participants?: AgentConversationParticipant[]
|
||||
}
|
||||
|
||||
export type AgentConversationDetail = AgentConversation & {
|
||||
participants?: AgentConversationParticipant[]
|
||||
}
|
||||
|
||||
export type AgentMessage = {
|
||||
id: number
|
||||
conversationId: number
|
||||
clientMsgId?: string
|
||||
senderType: string
|
||||
senderId: number
|
||||
senderName?: string
|
||||
senderAvatar?: string
|
||||
messageType: string
|
||||
content: string
|
||||
payload?: string
|
||||
seqNo: number
|
||||
sendStatus: number
|
||||
sentAt?: string
|
||||
deliveredAt?: string
|
||||
readAt?: string
|
||||
customerRead: boolean
|
||||
customerReadAt?: string
|
||||
agentRead: boolean
|
||||
agentReadAt?: string
|
||||
recalledAt?: string
|
||||
quotedMessageId?: number
|
||||
}
|
||||
|
||||
export type AgentAsset = {
|
||||
id: number
|
||||
assetId: string
|
||||
provider: string
|
||||
filename: string
|
||||
fileSize: number
|
||||
mimeType: string
|
||||
status: number
|
||||
url: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
createUserId: number
|
||||
createUserName: string
|
||||
updateUserId: number
|
||||
updateUserName: string
|
||||
}
|
||||
|
||||
function toQueryString(query?: Record<string, string | number | undefined>) {
|
||||
if (!query) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const params = new URLSearchParams()
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value === undefined || value === "") {
|
||||
return
|
||||
}
|
||||
params.set(key, String(value))
|
||||
})
|
||||
const output = params.toString()
|
||||
return output ? `?${output}` : ""
|
||||
}
|
||||
|
||||
export function fetchAgentConversations(
|
||||
query?: Record<string, string | number | undefined>
|
||||
) {
|
||||
return request<PageResult<AgentConversation>>(
|
||||
`/api/dashboard/conversation/conversations${toQueryString(query)}`
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAgentConversationDetail(id: number) {
|
||||
return request<AgentConversationDetail>(`/api/dashboard/conversation/${id}`)
|
||||
}
|
||||
|
||||
export function fetchAgentMessages(
|
||||
query?: Record<string, string | number | undefined>
|
||||
) {
|
||||
return request<CursorResult<AgentMessage>>(
|
||||
`/api/dashboard/conversation/message_list${toQueryString(query)}`
|
||||
)
|
||||
}
|
||||
|
||||
export function sendAgentMessage(payload: {
|
||||
conversationId: number
|
||||
messageType: string
|
||||
content: string
|
||||
payload?: string
|
||||
clientMsgId?: string
|
||||
}) {
|
||||
return request<AgentMessage>("/api/dashboard/conversation/send_message", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function recallAgentMessage(messageId: number) {
|
||||
return request<AgentMessage>("/api/dashboard/conversation/recall_message", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ messageId }),
|
||||
})
|
||||
}
|
||||
|
||||
export function markAgentMessageRead(conversationId: number, messageId = 0) {
|
||||
return request<void>("/api/dashboard/conversation/read", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ conversationId, messageId }),
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadAgentConversationImage(conversationId: number, file: File) {
|
||||
const formData = new FormData()
|
||||
formData.set("conversationId", String(conversationId))
|
||||
formData.set("file", file)
|
||||
return request<AgentAsset>("/api/dashboard/conversation/upload_image", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadAgentConversationAttachment(conversationId: number, file: File) {
|
||||
const formData = new FormData()
|
||||
formData.set("conversationId", String(conversationId))
|
||||
formData.set("file", file)
|
||||
return request<AgentAsset>("/api/dashboard/conversation/upload_attachment", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
}
|
||||
|
||||
export function closeAgentConversation(
|
||||
conversationId: number,
|
||||
closeReason: string
|
||||
) {
|
||||
return request<void>("/api/dashboard/conversation/close", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ conversationId, closeReason }),
|
||||
})
|
||||
}
|
||||
|
||||
export function assignAgentConversation(
|
||||
conversationId: number,
|
||||
assigneeId: number,
|
||||
reason: string
|
||||
) {
|
||||
return request<void>("/api/dashboard/conversation/assign", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ conversationId, assigneeId, reason }),
|
||||
})
|
||||
}
|
||||
|
||||
export function transferAgentConversation(
|
||||
conversationId: number,
|
||||
toUserId: number,
|
||||
reason: string
|
||||
) {
|
||||
return request<void>("/api/dashboard/conversation/transfer", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ conversationId, toUserId, reason }),
|
||||
})
|
||||
}
|
||||
|
||||
export function linkConversationToCustomer(payload: {
|
||||
conversationId: number
|
||||
customerId: number
|
||||
}) {
|
||||
return request<void>("/api/dashboard/conversation/link_customer", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function addConversationTag(payload: {
|
||||
conversationId: number
|
||||
tagId: number
|
||||
}) {
|
||||
return request<void>("/api/dashboard/conversation/add_tag", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function removeConversationTag(payload: {
|
||||
conversationId: number
|
||||
tagId: number
|
||||
}) {
|
||||
return request<void>("/api/dashboard/conversation/remove_tag", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function createAgentWebSocketUrl() {
|
||||
const session = readSession()
|
||||
if (!session?.accessToken) {
|
||||
throw new Error("未登录或登录已过期")
|
||||
}
|
||||
|
||||
const baseUrl = createWebSocketBaseUrl()
|
||||
const params = new URLSearchParams({
|
||||
accessToken: session.accessToken,
|
||||
})
|
||||
return `${baseUrl}/api/dashboard/ws?${params.toString()}`
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { clearSession, writeSession, type AuthSession } from "@/lib/auth"
|
||||
import { request } from "@/lib/api/client"
|
||||
|
||||
export type LoginRequest = {
|
||||
username: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export async function loginWithPassword(payload: LoginRequest) {
|
||||
const data = await request<AuthSession>("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
skipAuth: true,
|
||||
})
|
||||
writeSession(data)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function exchangeWxWorkTicket(ticket: string) {
|
||||
const data = await request<AuthSession>("/api/auth/wxwork_exchange", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ticket }),
|
||||
skipAuth: true,
|
||||
})
|
||||
writeSession(data)
|
||||
return data
|
||||
}
|
||||
|
||||
export async function fetchProfile() {
|
||||
return request<AuthSession>("/api/auth/profile")
|
||||
}
|
||||
|
||||
export async function logout(refreshToken?: string) {
|
||||
try {
|
||||
await request("/api/auth/logout", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
refreshToken,
|
||||
}),
|
||||
})
|
||||
} finally {
|
||||
clearSession()
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { clearSession, readSession, writeSession, type AuthSession } from "@/lib/auth"
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() || ""
|
||||
|
||||
type JsonResult<T> = {
|
||||
errorCode: number
|
||||
message: string
|
||||
data: T
|
||||
success: boolean
|
||||
}
|
||||
|
||||
type RequestOptions = RequestInit & {
|
||||
skipAuth?: boolean
|
||||
retryOnAuthError?: boolean
|
||||
}
|
||||
|
||||
async function parseResult<T>(response: Response) {
|
||||
const payload = (await response.json()) as JsonResult<T>
|
||||
if (!response.ok || !payload.success) {
|
||||
const error = new Error(payload.message || "请求失败")
|
||||
;(error as Error & { errorCode?: number }).errorCode = payload.errorCode
|
||||
throw error
|
||||
}
|
||||
return payload.data
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
const session = readSession()
|
||||
if (!session?.refreshToken) {
|
||||
clearSession()
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await request<AuthSession>(
|
||||
"/api/auth/refresh_token",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ refreshToken: session.refreshToken }),
|
||||
skipAuth: true,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
false
|
||||
)
|
||||
const merged = {
|
||||
...data,
|
||||
refreshToken: data.refreshToken || session.refreshToken,
|
||||
}
|
||||
writeSession(merged)
|
||||
return merged
|
||||
}
|
||||
|
||||
export async function request<T>(
|
||||
path: string,
|
||||
options: RequestOptions = {},
|
||||
retryOnAuthError = true
|
||||
): Promise<T> {
|
||||
const { headers, skipAuth, ...rest } = options
|
||||
delete (rest as RequestOptions).retryOnAuthError
|
||||
const session = readSession()
|
||||
const authHeaders = new Headers(headers)
|
||||
|
||||
if (!skipAuth && session?.accessToken) {
|
||||
authHeaders.set("Authorization", `Bearer ${session.accessToken}`)
|
||||
}
|
||||
if (
|
||||
!authHeaders.has("Content-Type") &&
|
||||
rest.body &&
|
||||
!(typeof FormData !== "undefined" && rest.body instanceof FormData)
|
||||
) {
|
||||
authHeaders.set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...rest,
|
||||
headers: authHeaders,
|
||||
cache: "no-store",
|
||||
})
|
||||
|
||||
try {
|
||||
return await parseResult<T>(response)
|
||||
} catch (error) {
|
||||
const errorCode = (error as Error & { errorCode?: number }).errorCode
|
||||
if (
|
||||
!skipAuth &&
|
||||
retryOnAuthError &&
|
||||
(errorCode === 3000 || errorCode === 3002) &&
|
||||
session?.refreshToken
|
||||
) {
|
||||
const refreshed = await refreshAccessToken()
|
||||
if (!refreshed) {
|
||||
throw error
|
||||
}
|
||||
return request<T>(path, options, false)
|
||||
}
|
||||
|
||||
if (errorCode === 3000 || errorCode === 3002) {
|
||||
clearSession()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { request } from "@/lib/api/client"
|
||||
import type { PageResult } from "@/lib/api/admin"
|
||||
|
||||
export type AdminCompany = {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
customerCount: number
|
||||
status: number
|
||||
remark: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type CreateAdminCompanyPayload = {
|
||||
name: string
|
||||
code: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
export type UpdateAdminCompanyPayload = CreateAdminCompanyPayload & {
|
||||
id: number
|
||||
}
|
||||
|
||||
function toQueryString(query?: Record<string, string | number | undefined>) {
|
||||
if (!query) {
|
||||
return ""
|
||||
}
|
||||
const params = new URLSearchParams()
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value === undefined || value === "") {
|
||||
return
|
||||
}
|
||||
params.set(key, String(value))
|
||||
})
|
||||
const output = params.toString()
|
||||
return output ? `?${output}` : ""
|
||||
}
|
||||
|
||||
export function fetchCompanies(query?: Record<string, string | number | undefined>) {
|
||||
return request<PageResult<AdminCompany>>(
|
||||
`/api/dashboard/company/list${toQueryString(query)}`
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchCompany(id: number) {
|
||||
return request<AdminCompany>(`/api/dashboard/company/${id}`)
|
||||
}
|
||||
|
||||
export function createCompany(payload: CreateAdminCompanyPayload) {
|
||||
return request<AdminCompany>("/api/dashboard/company/create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCompany(payload: UpdateAdminCompanyPayload) {
|
||||
return request<void>("/api/dashboard/company/update", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCompanyStatus(id: number, status: number) {
|
||||
return request<void>("/api/dashboard/company/update_status", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id, status }),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteCompany(id: number) {
|
||||
return request<void>("/api/dashboard/company/delete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { request } from "@/lib/api/client"
|
||||
import type { ContactType } from "@/lib/generated/enums"
|
||||
|
||||
export type AdminCustomerContact = {
|
||||
id: number
|
||||
customerId: number
|
||||
contactType: ContactType | string
|
||||
contactValue: string
|
||||
isPrimary: boolean
|
||||
isVerified: boolean
|
||||
verifiedAt?: string
|
||||
source: string
|
||||
status: number
|
||||
remark: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type CreateCustomerContactPayload = {
|
||||
customerId: number
|
||||
contactType: ContactType | string
|
||||
contactValue: string
|
||||
isPrimary: boolean
|
||||
isVerified: boolean
|
||||
source: string
|
||||
status: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
export type UpdateCustomerContactPayload = Omit<
|
||||
CreateCustomerContactPayload,
|
||||
"customerId"
|
||||
> & {
|
||||
id: number
|
||||
}
|
||||
|
||||
function toQueryString(query?: Record<string, string | number | undefined>) {
|
||||
if (!query) {
|
||||
return ""
|
||||
}
|
||||
const params = new URLSearchParams()
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value === undefined || value === "") {
|
||||
return
|
||||
}
|
||||
params.set(key, String(value))
|
||||
})
|
||||
const output = params.toString()
|
||||
return output ? `?${output}` : ""
|
||||
}
|
||||
|
||||
export function fetchCustomerContacts(customerId: number) {
|
||||
return request<AdminCustomerContact[]>(
|
||||
`/api/dashboard/customer-contact/list${toQueryString({ customerId })}`
|
||||
)
|
||||
}
|
||||
|
||||
export function createCustomerContact(payload: CreateCustomerContactPayload) {
|
||||
return request<AdminCustomerContact>("/api/dashboard/customer-contact/create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCustomerContact(payload: UpdateCustomerContactPayload) {
|
||||
return request<void>("/api/dashboard/customer-contact/update", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteCustomerContact(id: number) {
|
||||
return request<void>("/api/dashboard/customer-contact/delete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { request } from "@/lib/api/client"
|
||||
import type { PageResult } from "@/lib/api/admin"
|
||||
import type { ContactType } from "@/lib/generated/enums"
|
||||
import { AdminCompany } from "./company"
|
||||
|
||||
export type AdminCustomer = {
|
||||
id: number
|
||||
name: string
|
||||
gender: number
|
||||
companyId: number
|
||||
company?: AdminCompany
|
||||
lastActiveAt?: string
|
||||
primaryMobile: string
|
||||
primaryEmail: string
|
||||
status: number
|
||||
remark: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type CreateAdminCustomerPayload = {
|
||||
name: string
|
||||
gender: number
|
||||
companyId: number
|
||||
primaryMobile: string
|
||||
primaryEmail: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
export type UpdateAdminCustomerPayload = CreateAdminCustomerPayload & {
|
||||
id: number
|
||||
}
|
||||
|
||||
/** 与 POST /customer/save/profile 请求体一致 */
|
||||
export type SaveCustomerProfileContactLine = {
|
||||
id?: number
|
||||
contactType: ContactType | string
|
||||
contactValue: string
|
||||
remark: string
|
||||
isPrimary: boolean
|
||||
}
|
||||
|
||||
export type SaveCustomerProfilePayload = {
|
||||
id?: number
|
||||
name: string
|
||||
gender: number
|
||||
companyId: number
|
||||
remark: string
|
||||
contacts: SaveCustomerProfileContactLine[]
|
||||
}
|
||||
|
||||
/** 与 POST /customer/list JSON Body 一致 */
|
||||
export type CustomerListRequest = {
|
||||
page: number
|
||||
limit: number
|
||||
status?: number
|
||||
gender?: number
|
||||
companyId?: number
|
||||
/** 模糊匹配:客户名、主手机、主邮箱、联系方式、公司名称 */
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export function fetchCustomers(body: CustomerListRequest) {
|
||||
return request<PageResult<AdminCustomer>>("/api/dashboard/customer/list", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchCustomer(id: number) {
|
||||
return request<AdminCustomer | null>(`/api/dashboard/customer/${id}`)
|
||||
}
|
||||
|
||||
export function createCustomer(payload: CreateAdminCustomerPayload) {
|
||||
return request<AdminCustomer>("/api/dashboard/customer/create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
/** 单请求 + 单事务保存客户主信息与联系方式全量 */
|
||||
export function saveCustomerProfile(payload: SaveCustomerProfilePayload) {
|
||||
return request<AdminCustomer>("/api/dashboard/customer/save_profile", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCustomer(payload: UpdateAdminCustomerPayload) {
|
||||
return request<void>("/api/dashboard/customer/update", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateCustomerStatus(id: number, status: number) {
|
||||
return request<void>("/api/dashboard/customer/update_status", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id, status }),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteCustomer(id: number) {
|
||||
return request<void>("/api/dashboard/customer/delete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { request } from "@/lib/api/client"
|
||||
|
||||
export type DashboardRange = "today" | "7d" | "30d"
|
||||
|
||||
export type DashboardStatusDistributionItem = {
|
||||
status: number
|
||||
label: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export type DashboardTrendItem = {
|
||||
date: string
|
||||
newCount: number
|
||||
closedCount: number
|
||||
}
|
||||
|
||||
export type DashboardTeamLoad = {
|
||||
teamId: number
|
||||
teamName: string
|
||||
totalAgents: number
|
||||
onlineAgents: number
|
||||
busyAgents: number
|
||||
offlineAgents: number
|
||||
waitingConversations: number
|
||||
processingConversations: number
|
||||
maxConcurrentCapacity: number
|
||||
loadRate: number
|
||||
hasScheduleNow: boolean
|
||||
}
|
||||
|
||||
export type DashboardAlert = {
|
||||
id: string
|
||||
level: "info" | "warning" | "error"
|
||||
title: string
|
||||
description: string
|
||||
count: number
|
||||
link: string
|
||||
}
|
||||
|
||||
export type DashboardQuickLink = {
|
||||
title: string
|
||||
description: string
|
||||
link: string
|
||||
}
|
||||
|
||||
export type DashboardOverview = {
|
||||
range: DashboardRange
|
||||
generatedAt: string
|
||||
summary: {
|
||||
todayNewConversations: number
|
||||
processingConversations: number
|
||||
pendingDispatchConversations: number
|
||||
onlineAgents: number
|
||||
aiServiceRate: number
|
||||
}
|
||||
conversationStats: {
|
||||
statusDistribution: DashboardStatusDistributionItem[]
|
||||
trend: DashboardTrendItem[]
|
||||
}
|
||||
agentStats: {
|
||||
onlineAgents: number
|
||||
busyAgents: number
|
||||
offlineAgents: number
|
||||
teamLoads: DashboardTeamLoad[]
|
||||
}
|
||||
aiStats: {
|
||||
enabledAiAgents: number
|
||||
enabledChannels: number
|
||||
todayKnowledgeRetrieves: number
|
||||
todayKnowledgeRetrieveFailCount: number
|
||||
todayKnowledgeRetrieveFailRate: number
|
||||
todaySkillRunFailCount: number
|
||||
todayAiHandoffCount: number
|
||||
}
|
||||
alerts: DashboardAlert[]
|
||||
quickLinks: DashboardQuickLink[]
|
||||
}
|
||||
|
||||
export function fetchDashboardOverview(range: DashboardRange) {
|
||||
return request<DashboardOverview>(`/api/dashboard/dashboard/overview?range=${range}`)
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
import { request } from "@/lib/api/client"
|
||||
import { generateUUID } from "@/lib/utils"
|
||||
import { createWebSocketBaseUrl } from "@/lib/api/websocket"
|
||||
|
||||
export type Paging = {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type PageResult<T> = {
|
||||
results: T[]
|
||||
page: Paging
|
||||
}
|
||||
|
||||
export type ImConversationTag = {
|
||||
id: number
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export type ImConversationParticipant = {
|
||||
id: number
|
||||
participantType: string
|
||||
participantId: number
|
||||
externalParticipantId?: string
|
||||
joinedAt?: string
|
||||
leftAt?: string
|
||||
status: number
|
||||
}
|
||||
|
||||
export type ImConversation = {
|
||||
id: number
|
||||
externalSource: string
|
||||
externalId: string
|
||||
subject: string
|
||||
status: number
|
||||
serviceMode: number
|
||||
priority: number
|
||||
currentAssigneeId: number
|
||||
currentAssigneeName?: string
|
||||
lastMessageId: number
|
||||
lastMessageAt?: string
|
||||
lastActiveAt?: string
|
||||
lastMessageSummary?: string
|
||||
customerUnreadCount: number
|
||||
agentUnreadCount: number
|
||||
customerLastReadMessageId: number
|
||||
customerLastReadSeqNo: number
|
||||
customerLastReadAt?: string
|
||||
agentLastReadMessageId: number
|
||||
agentLastReadSeqNo: number
|
||||
agentLastReadAt?: string
|
||||
closedAt?: string
|
||||
tags?: ImConversationTag[]
|
||||
participants?: ImConversationParticipant[]
|
||||
}
|
||||
|
||||
export type ImConversationDetail = ImConversation
|
||||
|
||||
export type ImMessage = {
|
||||
id: number
|
||||
conversationId: number
|
||||
clientMsgId?: string
|
||||
senderType: string
|
||||
senderId: number
|
||||
senderName?: string
|
||||
senderAvatar?: string
|
||||
messageType: string
|
||||
content: string
|
||||
payload?: string
|
||||
seqNo: number
|
||||
sendStatus: number
|
||||
sentAt?: string
|
||||
deliveredAt?: string
|
||||
readAt?: string
|
||||
customerRead: boolean
|
||||
customerReadAt?: string
|
||||
agentRead: boolean
|
||||
agentReadAt?: string
|
||||
recalledAt?: string
|
||||
quotedMessageId?: number
|
||||
}
|
||||
|
||||
export type ImAsset = {
|
||||
id: number
|
||||
assetId: string
|
||||
provider: string
|
||||
filename: string
|
||||
fileSize: number
|
||||
mimeType: string
|
||||
status: number
|
||||
url: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
createUserId: number
|
||||
createUserName: string
|
||||
updateUserId: number
|
||||
updateUserName: string
|
||||
}
|
||||
|
||||
const VISITOR_STORAGE_KEY = "cs_agent_im_visitor_id"
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() || ""
|
||||
const OPEN_IM_CHANNEL_ID =
|
||||
process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() || ""
|
||||
const OPEN_IM_EXTERNAL_SOURCE =
|
||||
process.env.NEXT_PUBLIC_OPEN_IM_EXTERNAL_SOURCE?.trim() || "web_chat"
|
||||
|
||||
function buildVisitorId() {
|
||||
return `visitor_${generateUUID()}`
|
||||
}
|
||||
|
||||
export function getImVisitorId() {
|
||||
if (typeof window === "undefined") {
|
||||
return "visitor_ssr"
|
||||
}
|
||||
const existing = window.localStorage.getItem(VISITOR_STORAGE_KEY)?.trim()
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const visitorId = buildVisitorId()
|
||||
window.localStorage.setItem(VISITOR_STORAGE_KEY, visitorId)
|
||||
return visitorId
|
||||
}
|
||||
|
||||
export function createImWebSocketUrl() {
|
||||
const baseUrl = createWebSocketBaseUrl()
|
||||
const params = new URLSearchParams({
|
||||
externalId: getImVisitorId(),
|
||||
externalSource: OPEN_IM_EXTERNAL_SOURCE,
|
||||
channelId: OPEN_IM_CHANNEL_ID,
|
||||
})
|
||||
return `${baseUrl}/api/open/im/ws?${params.toString()}`
|
||||
}
|
||||
|
||||
function createImHeaders() {
|
||||
return {
|
||||
"X-External-Source": OPEN_IM_EXTERNAL_SOURCE,
|
||||
"X-External-Id": getImVisitorId(),
|
||||
"X-Channel-Id": OPEN_IM_CHANNEL_ID,
|
||||
}
|
||||
}
|
||||
|
||||
function toQueryString(query?: Record<string, string | number | undefined>) {
|
||||
if (!query) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const params = new URLSearchParams()
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value === undefined || value === "") {
|
||||
return
|
||||
}
|
||||
params.set(key, String(value))
|
||||
})
|
||||
const output = params.toString()
|
||||
return output ? `?${output}` : ""
|
||||
}
|
||||
|
||||
export function fetchImConversationDetail(id: number) {
|
||||
return request<ImConversationDetail>(`/api/open/im/conversation/${id}`, {
|
||||
headers: createImHeaders(),
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchImMessages(
|
||||
query?: Record<string, string | number | undefined>
|
||||
) {
|
||||
return request<PageResult<ImMessage>>(
|
||||
`/api/open/im/message/list${toQueryString(query)}`,
|
||||
{ headers: createImHeaders() }
|
||||
)
|
||||
}
|
||||
|
||||
/** 外部身份仅通过 createImHeaders()(X-External-*)传递,无 JSON body */
|
||||
export function createOrMatchImConversation() {
|
||||
return request<ImConversation>("/api/open/im/conversation/create_or_match", {
|
||||
method: "POST",
|
||||
headers: createImHeaders(),
|
||||
})
|
||||
}
|
||||
|
||||
export function sendImMessage(payload: {
|
||||
conversationId: number
|
||||
messageType: string
|
||||
content: string
|
||||
payload?: string
|
||||
clientMsgId?: string
|
||||
}) {
|
||||
return request<ImMessage>("/api/open/im/message/send", {
|
||||
method: "POST",
|
||||
headers: createImHeaders(),
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function markImMessageRead(conversationId: number, messageId = 0) {
|
||||
return request<void>("/api/open/im/message/read", {
|
||||
method: "POST",
|
||||
headers: createImHeaders(),
|
||||
body: JSON.stringify({ conversationId, messageId }),
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadImImage(conversationId: number, file: File) {
|
||||
const formData = new FormData()
|
||||
formData.set("conversationId", String(conversationId))
|
||||
formData.set("file", file)
|
||||
return request<ImAsset>("/api/open/im/message/upload_image", {
|
||||
method: "POST",
|
||||
headers: createImHeaders(),
|
||||
body: formData,
|
||||
})
|
||||
}
|
||||
|
||||
export function uploadImAttachment(conversationId: number, file: File) {
|
||||
const formData = new FormData()
|
||||
formData.set("conversationId", String(conversationId))
|
||||
formData.set("file", file)
|
||||
return request<ImAsset>("/api/open/im/message/upload_attachment", {
|
||||
method: "POST",
|
||||
headers: createImHeaders(),
|
||||
body: formData,
|
||||
})
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { request } from "@/lib/api/client"
|
||||
|
||||
export type Paging = {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type PageResult<T> = {
|
||||
results: T[]
|
||||
page: Paging
|
||||
}
|
||||
|
||||
export type TicketResolutionCode = {
|
||||
id: number
|
||||
name: string
|
||||
code: string
|
||||
sortNo: number
|
||||
status: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
export type TicketPriorityConfig = {
|
||||
id: number
|
||||
name: string
|
||||
sortNo: number
|
||||
firstResponseMinutes: number
|
||||
resolutionMinutes: number
|
||||
status: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
export type CreateTicketResolutionCodePayload = {
|
||||
name: string
|
||||
code: string
|
||||
sortNo: number
|
||||
status: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
export type UpdateTicketResolutionCodePayload = CreateTicketResolutionCodePayload & {
|
||||
id: number
|
||||
}
|
||||
|
||||
export type CreateTicketPriorityConfigPayload = {
|
||||
name: string
|
||||
firstResponseMinutes: number
|
||||
resolutionMinutes: number
|
||||
status: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
export type UpdateTicketPriorityConfigPayload = CreateTicketPriorityConfigPayload & {
|
||||
id: number
|
||||
}
|
||||
|
||||
function toQueryString(query?: Record<string, string | number | undefined>) {
|
||||
if (!query) {
|
||||
return ""
|
||||
}
|
||||
const params = new URLSearchParams()
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value === undefined || value === "") {
|
||||
return
|
||||
}
|
||||
params.set(key, String(value))
|
||||
})
|
||||
const output = params.toString()
|
||||
return output ? `?${output}` : ""
|
||||
}
|
||||
|
||||
export function fetchTicketResolutionCodes(query?: Record<string, string | number | undefined>) {
|
||||
return request<PageResult<TicketResolutionCode>>(
|
||||
`/api/dashboard/ticket-resolution-code/list${toQueryString(query)}`
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchTicketResolutionCodesAll() {
|
||||
return request<TicketResolutionCode[]>("/api/dashboard/ticket-resolution-code/list_all")
|
||||
}
|
||||
|
||||
export function createTicketResolutionCode(payload: CreateTicketResolutionCodePayload) {
|
||||
return request<TicketResolutionCode>("/api/dashboard/ticket-resolution-code/create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateTicketResolutionCode(payload: UpdateTicketResolutionCodePayload) {
|
||||
return request<void>("/api/dashboard/ticket-resolution-code/update", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteTicketResolutionCode(id: number) {
|
||||
return request<void>("/api/dashboard/ticket-resolution-code/delete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchTicketPriorityConfigs(query?: Record<string, string | number | undefined>) {
|
||||
return request<TicketPriorityConfig[]>(
|
||||
`/api/dashboard/ticket-priority-config/list${toQueryString(query)}`
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchTicketPriorityConfigsAll() {
|
||||
return request<TicketPriorityConfig[]>("/api/dashboard/ticket-priority-config/list_all")
|
||||
}
|
||||
|
||||
export function createTicketPriorityConfig(payload: CreateTicketPriorityConfigPayload) {
|
||||
return request<TicketPriorityConfig>("/api/dashboard/ticket-priority-config/create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateTicketPriorityConfig(payload: UpdateTicketPriorityConfigPayload) {
|
||||
return request<void>("/api/dashboard/ticket-priority-config/update", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateTicketPriorityConfigSort(ids: number[]) {
|
||||
return request<void>("/api/dashboard/ticket-priority-config/update_sort", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(ids),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteTicketPriorityConfig(id: number) {
|
||||
return request<void>("/api/dashboard/ticket-priority-config/delete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
}
|
||||
@@ -1,487 +0,0 @@
|
||||
import { request } from "@/lib/api/client"
|
||||
import type { Tag } from "@/lib/api/admin"
|
||||
|
||||
export type Paging = {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type PageResult<T> = {
|
||||
results: T[]
|
||||
page: Paging
|
||||
}
|
||||
|
||||
export type TicketCustomer = {
|
||||
id: number
|
||||
name: string
|
||||
companyId?: number
|
||||
company?: {
|
||||
id: number
|
||||
name: string
|
||||
code?: string
|
||||
remark?: string
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
primaryMobile?: string
|
||||
primaryEmail?: string
|
||||
}
|
||||
|
||||
export type TicketSLA = {
|
||||
slaType: string
|
||||
targetMinutes: number
|
||||
status: string
|
||||
startedAt?: string
|
||||
pausedAt?: string
|
||||
stoppedAt?: string
|
||||
breachedAt?: string
|
||||
elapsedMin: number
|
||||
}
|
||||
|
||||
export type TicketComment = {
|
||||
id: number
|
||||
ticketId: number
|
||||
commentType: string
|
||||
authorType: string
|
||||
authorId: number
|
||||
authorName?: string
|
||||
contentType: string
|
||||
content: string
|
||||
payload?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export type TicketEvent = {
|
||||
id: number
|
||||
ticketId: number
|
||||
eventType: string
|
||||
operatorType: string
|
||||
operatorId: number
|
||||
operatorName?: string
|
||||
oldValue?: string
|
||||
newValue?: string
|
||||
content?: string
|
||||
payload?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
export type TicketItem = {
|
||||
id: number
|
||||
ticketNo: string
|
||||
title: string
|
||||
description: string
|
||||
source: string
|
||||
channel: string
|
||||
customerId: number
|
||||
conversationId: number
|
||||
tags?: Tag[]
|
||||
type: string
|
||||
priority: number
|
||||
priorityName?: string
|
||||
severity: number
|
||||
status: string
|
||||
currentTeamId: number
|
||||
currentTeamName?: string
|
||||
currentAssigneeId: number
|
||||
currentAssigneeName?: string
|
||||
watchedByMe: boolean
|
||||
pendingReason?: string
|
||||
closeReason?: string
|
||||
resolutionCode?: string
|
||||
resolutionCodeName?: string
|
||||
resolutionSummary?: string
|
||||
firstResponseAt?: string
|
||||
resolvedAt?: string
|
||||
closedAt?: string
|
||||
dueAt?: string
|
||||
nextReplyDeadlineAt?: string
|
||||
resolveDeadlineAt?: string
|
||||
reopenedCount: number
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
customer?: TicketCustomer
|
||||
sla?: TicketSLA[]
|
||||
}
|
||||
|
||||
export type TicketDetail = {
|
||||
ticket: TicketItem
|
||||
watchers?: Array<{
|
||||
id: number
|
||||
userId: number
|
||||
userName?: string
|
||||
}>
|
||||
collaborators?: TicketCollaborator[]
|
||||
comments?: TicketComment[]
|
||||
events?: TicketEvent[]
|
||||
relatedTickets?: TicketRelation[]
|
||||
}
|
||||
|
||||
export type TicketCollaborator = {
|
||||
id: number
|
||||
userId: number
|
||||
userName?: string
|
||||
teamName?: string
|
||||
}
|
||||
|
||||
export type TicketRelation = {
|
||||
id: number
|
||||
ticketId: number
|
||||
relatedTicketId: number
|
||||
relationType: string
|
||||
relatedTicketNo?: string
|
||||
relatedTicketTitle?: string
|
||||
relatedTicketStatus?: string
|
||||
currentTeamName?: string
|
||||
currentAssigneeName?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export type TicketSummary = {
|
||||
all: number
|
||||
mine: number
|
||||
watching: number
|
||||
collaboration: number
|
||||
participating: number
|
||||
mentioned: number
|
||||
unassigned: number
|
||||
pendingCustomer: number
|
||||
pendingInternal: number
|
||||
overdue: number
|
||||
}
|
||||
|
||||
export type TicketRiskReason = {
|
||||
code: string
|
||||
title: string
|
||||
description: string
|
||||
count: number
|
||||
}
|
||||
|
||||
export type TicketRiskOverview = {
|
||||
overdue: number
|
||||
highRisk: number
|
||||
unassigned: number
|
||||
pendingInternal: number
|
||||
pendingCustomer: number
|
||||
riskWindowMins: number
|
||||
reasons?: TicketRiskReason[]
|
||||
}
|
||||
|
||||
export type TicketSavedView = {
|
||||
id: number
|
||||
name: string
|
||||
filters?: Record<string, unknown>
|
||||
sortNo: number
|
||||
}
|
||||
|
||||
export type TicketListQuery = {
|
||||
page?: number
|
||||
limit?: number
|
||||
keyword?: string
|
||||
status?: string
|
||||
priority?: number
|
||||
severity?: number
|
||||
tagId?: number
|
||||
currentTeamId?: number
|
||||
currentAssigneeId?: number
|
||||
customerId?: number
|
||||
conversationId?: number
|
||||
source?: string
|
||||
watching?: number
|
||||
collaboration?: number
|
||||
collaborating?: number
|
||||
mentioned?: number
|
||||
mine?: number
|
||||
unassigned?: number
|
||||
overdue?: number
|
||||
}
|
||||
|
||||
export type TicketRiskListQuery = {
|
||||
riskType: "overdue" | "high_risk" | "unassigned" | "pending_internal" | "pending_customer"
|
||||
currentTeamId?: number
|
||||
riskWindowMins?: number
|
||||
page?: number
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export type CreateTicketPayload = {
|
||||
title: string
|
||||
description: string
|
||||
source?: string
|
||||
channel?: string
|
||||
customerId?: number
|
||||
conversationId?: number
|
||||
tagIds?: number[]
|
||||
type?: string
|
||||
priority: number
|
||||
severity: number
|
||||
currentTeamId?: number
|
||||
currentAssigneeId?: number
|
||||
dueAt?: string
|
||||
customFields?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type CreateTicketFromConversationPayload = {
|
||||
conversationId: number
|
||||
title: string
|
||||
description: string
|
||||
tagIds?: number[]
|
||||
priority: number
|
||||
severity: number
|
||||
currentTeamId?: number
|
||||
currentAssigneeId?: number
|
||||
syncToConversation: boolean
|
||||
customFields?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type UpdateTicketPayload = {
|
||||
ticketId: number
|
||||
title: string
|
||||
description: string
|
||||
tagIds?: number[]
|
||||
type?: string
|
||||
priority: number
|
||||
severity: number
|
||||
currentTeamId?: number
|
||||
currentAssigneeId?: number
|
||||
dueAt?: string
|
||||
customFields?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function toQueryString(query?: Record<string, string | number | undefined>) {
|
||||
if (!query) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const params = new URLSearchParams()
|
||||
Object.entries(query).forEach(([key, value]) => {
|
||||
if (value === undefined || value === "") {
|
||||
return
|
||||
}
|
||||
params.set(key, String(value))
|
||||
})
|
||||
const output = params.toString()
|
||||
return output ? `?${output}` : ""
|
||||
}
|
||||
|
||||
export function fetchTickets(query?: TicketListQuery) {
|
||||
return request<PageResult<TicketItem>>(`/api/dashboard/ticket/list${toQueryString(query)}`)
|
||||
}
|
||||
|
||||
export function fetchTicketDetail(id: number) {
|
||||
return request<TicketDetail>(`/api/dashboard/ticket/${id}`)
|
||||
}
|
||||
|
||||
export function fetchTicketSummary() {
|
||||
return request<TicketSummary>("/api/dashboard/ticket/summary")
|
||||
}
|
||||
|
||||
export function fetchTicketViews() {
|
||||
return request<TicketSavedView[]>("/api/dashboard/ticket/view_list")
|
||||
}
|
||||
|
||||
export function saveTicketView(payload: {
|
||||
id?: number
|
||||
name: string
|
||||
filters?: Record<string, unknown>
|
||||
}) {
|
||||
return request<TicketSavedView>("/api/dashboard/ticket/save_view", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteTicketView(id: number) {
|
||||
return request<void>("/api/dashboard/ticket/delete_view", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id }),
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchTicketRiskOverview(query?: {
|
||||
currentTeamId?: number
|
||||
riskWindowMins?: number
|
||||
}) {
|
||||
return request<TicketRiskOverview>(`/api/dashboard/ticket/risk_overview${toQueryString(query)}`)
|
||||
}
|
||||
|
||||
export function fetchTicketRiskList(query: TicketRiskListQuery) {
|
||||
return request<PageResult<TicketItem>>(`/api/dashboard/ticket/risk_list${toQueryString(query)}`)
|
||||
}
|
||||
|
||||
export function createTicket(payload: CreateTicketPayload) {
|
||||
return request<TicketItem>("/api/dashboard/ticket/create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function createTicketFromConversation(payload: CreateTicketFromConversationPayload) {
|
||||
return request<TicketItem>("/api/dashboard/ticket/create_from_conversation", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function updateTicket(payload: UpdateTicketPayload) {
|
||||
return request<void>("/api/dashboard/ticket/update", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function linkTicketToCustomer(payload: {
|
||||
ticketId: number
|
||||
customerId: number
|
||||
}) {
|
||||
return request<void>("/api/dashboard/ticket/link_customer", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function assignTicket(payload: {
|
||||
ticketId: number
|
||||
toUserId: number
|
||||
toTeamId?: number
|
||||
reason?: string
|
||||
}) {
|
||||
return request<void>("/api/dashboard/ticket/assign", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function batchAssignTickets(payload: {
|
||||
ticketIds: number[]
|
||||
toUserId: number
|
||||
toTeamId?: number
|
||||
reason?: string
|
||||
}) {
|
||||
return request<void>("/api/dashboard/ticket/batch_assign", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function changeTicketStatus(payload: {
|
||||
ticketId: number
|
||||
status: string
|
||||
pendingReason?: string
|
||||
closeReason?: string
|
||||
resolutionCode?: string
|
||||
resolutionSummary?: string
|
||||
reason?: string
|
||||
}) {
|
||||
return request<void>("/api/dashboard/ticket/change_status", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function batchChangeTicketStatus(payload: {
|
||||
ticketIds: number[]
|
||||
status: string
|
||||
pendingReason?: string
|
||||
closeReason?: string
|
||||
resolutionCode?: string
|
||||
resolutionSummary?: string
|
||||
reason?: string
|
||||
}) {
|
||||
return request<void>("/api/dashboard/ticket/batch_change_status", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function replyTicket(payload: {
|
||||
ticketId: number
|
||||
contentType?: string
|
||||
content: string
|
||||
payload?: string
|
||||
}) {
|
||||
return request<TicketComment>("/api/dashboard/ticket/reply", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function addTicketInternalNote(payload: {
|
||||
ticketId: number
|
||||
contentType?: string
|
||||
content: string
|
||||
payload?: string
|
||||
}) {
|
||||
return request<TicketComment>("/api/dashboard/ticket/internal_note", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function closeTicket(payload: { ticketId: number; closeReason: string }) {
|
||||
return request<void>("/api/dashboard/ticket/close", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function reopenTicket(payload: { ticketId: number; reason: string }) {
|
||||
return request<void>("/api/dashboard/ticket/reopen", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function watchTicket(ticketId: number) {
|
||||
return request<void>("/api/dashboard/ticket/watch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ticketId }),
|
||||
})
|
||||
}
|
||||
|
||||
export function unwatchTicket(ticketId: number) {
|
||||
return request<void>("/api/dashboard/ticket/unwatch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ticketId }),
|
||||
})
|
||||
}
|
||||
|
||||
export function batchWatchTickets(payload: { ticketIds: number[]; watched: boolean }) {
|
||||
return request<void>("/api/dashboard/ticket/batch_watch", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function addTicketRelation(payload: {
|
||||
ticketId: number
|
||||
relatedTicketId?: number
|
||||
relatedTicketNo?: string
|
||||
relationType: string
|
||||
}) {
|
||||
return request<void>("/api/dashboard/ticket/add_relation", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteTicketRelation(payload: { ticketId: number; relationId: number }) {
|
||||
return request<void>("/api/dashboard/ticket/delete_relation", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function addTicketCollaborator(payload: { ticketId: number; userId: number }) {
|
||||
return request<void>("/api/dashboard/ticket/add_collaborator", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteTicketCollaborator(payload: { ticketId: number; collaboratorId: number }) {
|
||||
return request<void>("/api/dashboard/ticket/delete_collaborator", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() || "http://127.0.0.1:8083"
|
||||
|
||||
export function createWebSocketBaseUrl() {
|
||||
return API_BASE_URL.replace(/^http/, "ws").replace(/\/$/, "")
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
export type AuthUser = {
|
||||
id: number
|
||||
username: string
|
||||
nickname: string
|
||||
avatar: string
|
||||
status: number
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
export type AuthSession = {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiresAt?: string
|
||||
user: AuthUser
|
||||
permissions: string[]
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
const SESSION_STORAGE_KEY = "cs-agent.admin.session"
|
||||
|
||||
function hasWindow() {
|
||||
return typeof window !== "undefined"
|
||||
}
|
||||
|
||||
export function readSession(): AuthSession | null {
|
||||
if (!hasWindow()) {
|
||||
return null
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(SESSION_STORAGE_KEY)
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as AuthSession
|
||||
} catch {
|
||||
window.localStorage.removeItem(SESSION_STORAGE_KEY)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSession(session: AuthSession) {
|
||||
if (!hasWindow()) {
|
||||
return
|
||||
}
|
||||
window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session))
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
if (!hasWindow()) {
|
||||
return
|
||||
}
|
||||
window.localStorage.removeItem(SESSION_STORAGE_KEY)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
export function getEnumLabel<T extends string | number>(
|
||||
enumLabels: Record<T, string>,
|
||||
value: T
|
||||
): string {
|
||||
return enumLabels[value] || String(value)
|
||||
}
|
||||
|
||||
export function getEnumOptions<T extends string | number>(
|
||||
enumLabels: Record<T, string>
|
||||
): Array<{ value: T; label: string }> {
|
||||
return Object.entries(enumLabels).map(([value, label]) => ({
|
||||
value: value as T,
|
||||
label: label as string,
|
||||
}))
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
// Code generated by cmd/enums/generator.go. DO NOT EDIT.
|
||||
|
||||
export enum AIAgentHandoffMode {
|
||||
WaitPool = 1,
|
||||
DefaultTeamPool = 2,
|
||||
AIHoldAndNotify = 3,
|
||||
}
|
||||
export const AIAgentHandoffModeLabels: Record<AIAgentHandoffMode, string> = {
|
||||
[AIAgentHandoffMode.WaitPool]: "进入待接入池",
|
||||
[AIAgentHandoffMode.DefaultTeamPool]: "进入默认客服组待接入池",
|
||||
[AIAgentHandoffMode.AIHoldAndNotify]: "AI托底并提醒人工",
|
||||
}
|
||||
|
||||
export enum AIModelType {
|
||||
LLM = "llm",
|
||||
Embedding = "embedding",
|
||||
Rerank = "rerank",
|
||||
}
|
||||
export const AIModelTypeLabels: Record<AIModelType, string> = {
|
||||
[AIModelType.LLM]: "大语言模型",
|
||||
[AIModelType.Embedding]: "向量模型",
|
||||
[AIModelType.Rerank]: "重排序模型",
|
||||
}
|
||||
|
||||
export enum AIProvider {
|
||||
OpenAI = "openai",
|
||||
}
|
||||
export const AIProviderLabels: Record<AIProvider, string> = {
|
||||
[AIProvider.OpenAI]: "OpenAI",
|
||||
}
|
||||
|
||||
export enum AssetProvider {
|
||||
Local = "local",
|
||||
OSS = "oss",
|
||||
}
|
||||
export const AssetProviderLabels: Record<AssetProvider, string> = {
|
||||
[AssetProvider.Local]: "本地存储",
|
||||
[AssetProvider.OSS]: "对象存储",
|
||||
}
|
||||
|
||||
export enum AssetStatus {
|
||||
Pending = 1,
|
||||
Success = 2,
|
||||
Failed = 3,
|
||||
Deleted = 4,
|
||||
}
|
||||
export const AssetStatusLabels: Record<AssetStatus, string> = {
|
||||
[AssetStatus.Pending]: "处理中",
|
||||
[AssetStatus.Success]: "成功",
|
||||
[AssetStatus.Failed]: "失败",
|
||||
[AssetStatus.Deleted]: "已删除",
|
||||
}
|
||||
|
||||
export enum ContactType {
|
||||
Mobile = "mobile",
|
||||
Email = "email",
|
||||
Other = "other",
|
||||
}
|
||||
export const ContactTypeLabels: Record<ContactType, string> = {
|
||||
[ContactType.Mobile]: "手机号",
|
||||
[ContactType.Email]: "邮箱",
|
||||
[ContactType.Other]: "其他",
|
||||
}
|
||||
|
||||
export enum ExternalSource {
|
||||
WebChat = "web_chat",
|
||||
WxWorkKF = "wxwork_kf",
|
||||
}
|
||||
export const ExternalSourceLabels: Record<ExternalSource, string> = {
|
||||
[ExternalSource.WebChat]: "网页客服",
|
||||
[ExternalSource.WxWorkKF]: "企业微信客服",
|
||||
}
|
||||
|
||||
export enum Gender {
|
||||
Unknown = 0,
|
||||
Male = 1,
|
||||
Female = 2,
|
||||
}
|
||||
export const GenderLabels: Record<Gender, string> = {
|
||||
[Gender.Unknown]: "未知",
|
||||
[Gender.Male]: "男",
|
||||
[Gender.Female]: "女",
|
||||
}
|
||||
|
||||
export enum IMAssignmentStatus {
|
||||
Active = 0,
|
||||
Inactive = 1,
|
||||
}
|
||||
export const IMAssignmentStatusLabels: Record<IMAssignmentStatus, string> = {
|
||||
[IMAssignmentStatus.Active]: "进行中",
|
||||
[IMAssignmentStatus.Inactive]: "已结束",
|
||||
}
|
||||
|
||||
export enum IMAssignmentType {
|
||||
Assign = "assign",
|
||||
Transfer = "transfer",
|
||||
}
|
||||
export const IMAssignmentTypeLabels: Record<IMAssignmentType, string> = {
|
||||
[IMAssignmentType.Assign]: "分配",
|
||||
[IMAssignmentType.Transfer]: "转接",
|
||||
}
|
||||
|
||||
export enum IMConversationServiceMode {
|
||||
AIOnly = 1,
|
||||
HumanOnly = 2,
|
||||
AIFirst = 3,
|
||||
}
|
||||
export const IMConversationServiceModeLabels: Record<IMConversationServiceMode, string> = {
|
||||
[IMConversationServiceMode.AIOnly]: "仅AI",
|
||||
[IMConversationServiceMode.HumanOnly]: "仅人工",
|
||||
[IMConversationServiceMode.AIFirst]: "AI优先",
|
||||
}
|
||||
|
||||
export enum IMConversationStatus {
|
||||
AIServing = 1,
|
||||
Pending = 2,
|
||||
Active = 3,
|
||||
Closed = 4,
|
||||
}
|
||||
export const IMConversationStatusLabels: Record<IMConversationStatus, string> = {
|
||||
[IMConversationStatus.AIServing]: "AI接待中",
|
||||
[IMConversationStatus.Pending]: "待接入",
|
||||
[IMConversationStatus.Active]: "处理中",
|
||||
[IMConversationStatus.Closed]: "已关闭",
|
||||
}
|
||||
|
||||
export enum IMEventType {
|
||||
Create = "create",
|
||||
Assign = "assign",
|
||||
Transfer = "transfer",
|
||||
Close = "close",
|
||||
MessageSend = "message_send",
|
||||
MessageRecall = "message_recall",
|
||||
}
|
||||
export const IMEventTypeLabels: Record<IMEventType, string> = {
|
||||
[IMEventType.Create]: "创建会话",
|
||||
[IMEventType.Assign]: "分配会话",
|
||||
[IMEventType.Transfer]: "转接会话",
|
||||
[IMEventType.Close]: "关闭会话",
|
||||
[IMEventType.MessageSend]: "发送消息",
|
||||
[IMEventType.MessageRecall]: "撤回消息",
|
||||
}
|
||||
|
||||
export enum IMMessageStatus {
|
||||
Sending = 1,
|
||||
Sent = 2,
|
||||
Delivered = 3,
|
||||
Read = 4,
|
||||
Failed = 5,
|
||||
Recalled = 6,
|
||||
}
|
||||
export const IMMessageStatusLabels: Record<IMMessageStatus, string> = {
|
||||
[IMMessageStatus.Sending]: "发送中",
|
||||
[IMMessageStatus.Sent]: "已发送",
|
||||
[IMMessageStatus.Delivered]: "已送达",
|
||||
[IMMessageStatus.Read]: "已读",
|
||||
[IMMessageStatus.Failed]: "发送失败",
|
||||
[IMMessageStatus.Recalled]: "已撤回",
|
||||
}
|
||||
|
||||
export enum IMMessageType {
|
||||
Text = "text",
|
||||
Image = "image",
|
||||
Attachment = "attachment",
|
||||
HTML = "html",
|
||||
}
|
||||
export const IMMessageTypeLabels: Record<IMMessageType, string> = {
|
||||
[IMMessageType.Text]: "文本",
|
||||
[IMMessageType.Image]: "图片",
|
||||
[IMMessageType.Attachment]: "附件",
|
||||
[IMMessageType.HTML]: "富文本",
|
||||
}
|
||||
|
||||
export enum IMParticipantType {
|
||||
Customer = "customer",
|
||||
Agent = "agent",
|
||||
AI = "ai",
|
||||
System = "system",
|
||||
}
|
||||
export const IMParticipantTypeLabels: Record<IMParticipantType, string> = {
|
||||
[IMParticipantType.Customer]: "客户",
|
||||
[IMParticipantType.Agent]: "客服",
|
||||
[IMParticipantType.AI]: "AI",
|
||||
[IMParticipantType.System]: "系统",
|
||||
}
|
||||
|
||||
export enum IMSenderType {
|
||||
Agent = "agent",
|
||||
Customer = "customer",
|
||||
AI = "ai",
|
||||
System = "system",
|
||||
}
|
||||
export const IMSenderTypeLabels: Record<IMSenderType, string> = {
|
||||
[IMSenderType.Agent]: "客服",
|
||||
[IMSenderType.Customer]: "客户",
|
||||
[IMSenderType.AI]: "AI",
|
||||
[IMSenderType.System]: "系统",
|
||||
}
|
||||
|
||||
export enum KnowledgeAnswerMode {
|
||||
Strict = 1,
|
||||
Assist = 2,
|
||||
}
|
||||
export const KnowledgeAnswerModeLabels: Record<KnowledgeAnswerMode, string> = {
|
||||
[KnowledgeAnswerMode.Strict]: "严格模式",
|
||||
[KnowledgeAnswerMode.Assist]: "辅助模式",
|
||||
}
|
||||
|
||||
export enum KnowledgeAnswerStatus {
|
||||
Normal = 1,
|
||||
NoAnswer = 2,
|
||||
Fallback = 3,
|
||||
Blocked = 4,
|
||||
}
|
||||
export const KnowledgeAnswerStatusLabels: Record<KnowledgeAnswerStatus, string> = {
|
||||
[KnowledgeAnswerStatus.Normal]: "正常",
|
||||
[KnowledgeAnswerStatus.NoAnswer]: "无答案",
|
||||
[KnowledgeAnswerStatus.Fallback]: "兜底回复",
|
||||
[KnowledgeAnswerStatus.Blocked]: "已屏蔽",
|
||||
}
|
||||
|
||||
export enum KnowledgeBaseType {
|
||||
Document = "document",
|
||||
FAQ = "faq",
|
||||
}
|
||||
export const KnowledgeBaseTypeLabels: Record<KnowledgeBaseType, string> = {
|
||||
[KnowledgeBaseType.Document]: "文档知识库",
|
||||
[KnowledgeBaseType.FAQ]: "FAQ知识库",
|
||||
}
|
||||
|
||||
export enum KnowledgeChunkProvider {
|
||||
Fixed = "fixed",
|
||||
Structured = "structured",
|
||||
FAQ = "faq",
|
||||
Semantic = "semantic",
|
||||
}
|
||||
export const KnowledgeChunkProviderLabels: Record<KnowledgeChunkProvider, string> = {
|
||||
[KnowledgeChunkProvider.Fixed]: "固定长度",
|
||||
[KnowledgeChunkProvider.Structured]: "结构化分块",
|
||||
[KnowledgeChunkProvider.FAQ]: "问答式分块",
|
||||
[KnowledgeChunkProvider.Semantic]: "语义分块",
|
||||
}
|
||||
|
||||
export enum KnowledgeChunkType {
|
||||
Text = "text",
|
||||
FAQ = "faq",
|
||||
Table = "table",
|
||||
Code = "code",
|
||||
}
|
||||
export const KnowledgeChunkTypeLabels: Record<KnowledgeChunkType, string> = {
|
||||
[KnowledgeChunkType.Text]: "文本",
|
||||
[KnowledgeChunkType.FAQ]: "问答",
|
||||
[KnowledgeChunkType.Table]: "表格",
|
||||
[KnowledgeChunkType.Code]: "代码",
|
||||
}
|
||||
|
||||
export enum KnowledgeDocumentContentType {
|
||||
HTML = "html",
|
||||
Markdown = "markdown",
|
||||
}
|
||||
export const KnowledgeDocumentContentTypeLabels: Record<KnowledgeDocumentContentType, string> = {
|
||||
[KnowledgeDocumentContentType.HTML]: "HTML",
|
||||
[KnowledgeDocumentContentType.Markdown]: "Markdown",
|
||||
}
|
||||
|
||||
export enum KnowledgeDocumentIndexStatus {
|
||||
Pending = "pending",
|
||||
Indexed = "indexed",
|
||||
Failed = "failed",
|
||||
}
|
||||
export const KnowledgeDocumentIndexStatusLabels: Record<KnowledgeDocumentIndexStatus, string> = {
|
||||
[KnowledgeDocumentIndexStatus.Pending]: "待索引",
|
||||
[KnowledgeDocumentIndexStatus.Indexed]: "已索引",
|
||||
[KnowledgeDocumentIndexStatus.Failed]: "索引失败",
|
||||
}
|
||||
|
||||
export enum KnowledgeFallbackMode {
|
||||
NoAnswer = 1,
|
||||
SuggestRetry = 2,
|
||||
TransferHuman = 3,
|
||||
}
|
||||
export const KnowledgeFallbackModeLabels: Record<KnowledgeFallbackMode, string> = {
|
||||
[KnowledgeFallbackMode.NoAnswer]: "直接声明无答案",
|
||||
[KnowledgeFallbackMode.SuggestRetry]: "建议重试",
|
||||
[KnowledgeFallbackMode.TransferHuman]: "转人工",
|
||||
}
|
||||
|
||||
export enum KnowledgeFeedbackType {
|
||||
Like = 1,
|
||||
Dislike = 2,
|
||||
NotHelpful = 3,
|
||||
WrongCitation = 4,
|
||||
Other = 5,
|
||||
}
|
||||
export const KnowledgeFeedbackTypeLabels: Record<KnowledgeFeedbackType, string> = {
|
||||
[KnowledgeFeedbackType.Like]: "点赞",
|
||||
[KnowledgeFeedbackType.Dislike]: "点踩",
|
||||
[KnowledgeFeedbackType.NotHelpful]: "无帮助",
|
||||
[KnowledgeFeedbackType.WrongCitation]: "引用错误",
|
||||
[KnowledgeFeedbackType.Other]: "其他",
|
||||
}
|
||||
|
||||
export enum KnowledgeRetrieveChannel {
|
||||
IM = "im",
|
||||
AgentAssist = "agent_assist",
|
||||
API = "api",
|
||||
Debug = "debug",
|
||||
}
|
||||
export const KnowledgeRetrieveChannelLabels: Record<KnowledgeRetrieveChannel, string> = {
|
||||
[KnowledgeRetrieveChannel.IM]: "客服会话",
|
||||
[KnowledgeRetrieveChannel.AgentAssist]: "客服助手",
|
||||
[KnowledgeRetrieveChannel.API]: "API接口",
|
||||
[KnowledgeRetrieveChannel.Debug]: "调试",
|
||||
}
|
||||
|
||||
export enum KnowledgeRetrieveScene {
|
||||
FirstResponse = "first_response",
|
||||
Assist = "assist",
|
||||
QA = "qa",
|
||||
}
|
||||
export const KnowledgeRetrieveSceneLabels: Record<KnowledgeRetrieveScene, string> = {
|
||||
[KnowledgeRetrieveScene.FirstResponse]: "首次回复",
|
||||
[KnowledgeRetrieveScene.Assist]: "辅助回复",
|
||||
[KnowledgeRetrieveScene.QA]: "问答",
|
||||
}
|
||||
|
||||
export enum ServiceStatus {
|
||||
Idle = 0,
|
||||
Busy = 1,
|
||||
}
|
||||
export const ServiceStatusLabels: Record<ServiceStatus, string> = {
|
||||
[ServiceStatus.Idle]: "空闲",
|
||||
[ServiceStatus.Busy]: "忙碌",
|
||||
}
|
||||
|
||||
export enum Status {
|
||||
Ok = 0,
|
||||
Disabled = 1,
|
||||
Deleted = 2,
|
||||
}
|
||||
export const StatusLabels: Record<Status, string> = {
|
||||
[Status.Ok]: "启用",
|
||||
[Status.Disabled]: "禁用",
|
||||
[Status.Deleted]: "已删除",
|
||||
}
|
||||
|
||||
export enum ThirdProvider {
|
||||
WxWork = "wxwork",
|
||||
Dingtalk = "dingtalk",
|
||||
}
|
||||
export const ThirdProviderLabels: Record<ThirdProvider, string> = {
|
||||
[ThirdProvider.WxWork]: "企业微信",
|
||||
[ThirdProvider.Dingtalk]: "钉钉",
|
||||
}
|
||||
|
||||
export enum TicketSeverity {
|
||||
Minor = 1,
|
||||
Major = 2,
|
||||
Critical = 3,
|
||||
}
|
||||
export const TicketSeverityLabels: Record<TicketSeverity, string> = {
|
||||
[TicketSeverity.Minor]: "轻微",
|
||||
[TicketSeverity.Major]: "严重",
|
||||
[TicketSeverity.Critical]: "致命",
|
||||
}
|
||||
|
||||
export enum TicketStatus {
|
||||
New = "new",
|
||||
Open = "open",
|
||||
PendingCustomer = "pending_customer",
|
||||
PendingInternal = "pending_internal",
|
||||
Resolved = "resolved",
|
||||
Closed = "closed",
|
||||
Cancelled = "cancelled",
|
||||
}
|
||||
export const TicketStatusLabels: Record<TicketStatus, string> = {
|
||||
[TicketStatus.New]: "新建",
|
||||
[TicketStatus.Open]: "处理中",
|
||||
[TicketStatus.PendingCustomer]: "待客户反馈",
|
||||
[TicketStatus.PendingInternal]: "待内部处理",
|
||||
[TicketStatus.Resolved]: "已解决",
|
||||
[TicketStatus.Closed]: "已关闭",
|
||||
[TicketStatus.Cancelled]: "已取消",
|
||||
}
|
||||
|
||||
export enum VectorDBType {
|
||||
Qdrant = "qdrant",
|
||||
}
|
||||
export const VectorDBTypeLabels: Record<VectorDBType, string> = {
|
||||
[VectorDBType.Qdrant]: "Qdrant",
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import MarkdownIt from "markdown-it"
|
||||
|
||||
export type MessageAssetPayload = {
|
||||
assetId: string
|
||||
filename?: string
|
||||
fileSize?: number
|
||||
mimeType?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
const messageMarkdown = new MarkdownIt({
|
||||
html: false,
|
||||
linkify: true,
|
||||
breaks: true,
|
||||
})
|
||||
|
||||
export function parseMessageAssetPayload(payload?: string): MessageAssetPayload | null {
|
||||
if (!payload?.trim()) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(payload) as MessageAssetPayload
|
||||
if (!parsed?.assetId?.trim()) {
|
||||
return null
|
||||
}
|
||||
return parsed
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function renderIMMessageHTML(message: {
|
||||
messageType: string
|
||||
content: string
|
||||
payload?: string
|
||||
}) {
|
||||
if (message.messageType === "html") {
|
||||
return message.content
|
||||
}
|
||||
|
||||
const asset = parseMessageAssetPayload(message.payload)
|
||||
if (message.messageType === "image") {
|
||||
if (asset?.url) {
|
||||
return `<p><img src="${escapeHTMLAttr(asset.url)}" alt="${escapeHTMLAttr(
|
||||
asset.filename || "image"
|
||||
)}"></p>`
|
||||
}
|
||||
return "<p>[图片]</p>"
|
||||
}
|
||||
|
||||
if (message.messageType === "attachment") {
|
||||
if (asset?.url) {
|
||||
const title = escapeHTML(asset.filename || message.content || "附件")
|
||||
const meta = formatFileSize(asset.fileSize ?? 0)
|
||||
const metaHTML = meta ? `<div class="im-attachment-meta">${escapeHTML(meta)}</div>` : ""
|
||||
return `<div class="im-attachment"><a href="${escapeHTMLAttr(
|
||||
asset.url
|
||||
)}" target="_blank" rel="noreferrer" download="${escapeHTMLAttr(
|
||||
asset.filename || ""
|
||||
)}" class="im-attachment-link"><span class="im-attachment-icon" aria-hidden="true">${getAttachmentIconSVG()}</span><span class="im-attachment-content"><span class="im-attachment-title">${title}</span>${metaHTML}</span></a></div>`
|
||||
}
|
||||
return `<p>${escapeHTML(message.content || "[附件]")}</p>`
|
||||
}
|
||||
|
||||
return renderTextMessageHTML(message.content || "")
|
||||
}
|
||||
|
||||
export function summarizeIMMessage(message: {
|
||||
messageType: string
|
||||
content: string
|
||||
payload?: string
|
||||
}) {
|
||||
if (message.messageType === "image") {
|
||||
return "[图片]"
|
||||
}
|
||||
if (message.messageType === "attachment") {
|
||||
const asset = parseMessageAssetPayload(message.payload)
|
||||
return asset?.filename?.trim() ? `[附件] ${asset.filename.trim()}` : "[附件]"
|
||||
}
|
||||
if (message.messageType === "html") {
|
||||
const text = extractTextFromHTML(message.content)
|
||||
if (text.trim()) {
|
||||
return text.substring(0, 100)
|
||||
}
|
||||
if (message.content.includes("<img")) {
|
||||
return "[图片]"
|
||||
}
|
||||
return "[消息]"
|
||||
}
|
||||
return message.content?.substring(0, 100) || "[消息]"
|
||||
}
|
||||
|
||||
export function formatFileSize(size: number) {
|
||||
if (!Number.isFinite(size) || size <= 0) {
|
||||
return ""
|
||||
}
|
||||
const units = ["B", "KB", "MB", "GB"]
|
||||
let value = size
|
||||
let index = 0
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024
|
||||
index += 1
|
||||
}
|
||||
const digits = value >= 10 || index === 0 ? 0 : 1
|
||||
return `${value.toFixed(digits)} ${units[index]}`
|
||||
}
|
||||
|
||||
function extractTextFromHTML(html: string): string {
|
||||
if (typeof document === "undefined") {
|
||||
return ""
|
||||
}
|
||||
const div = document.createElement("div")
|
||||
div.innerHTML = html
|
||||
return div.textContent || div.innerText || ""
|
||||
}
|
||||
|
||||
function renderTextMessageHTML(content: string) {
|
||||
const value = content.trim()
|
||||
if (!value) {
|
||||
return "<p></p>"
|
||||
}
|
||||
return messageMarkdown.render(value)
|
||||
}
|
||||
|
||||
function escapeHTML(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("\n", "<br>")
|
||||
}
|
||||
|
||||
function escapeHTMLAttr(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
}
|
||||
|
||||
function getAttachmentIconSVG() {
|
||||
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><path d="M14 2v6h6"></path><path d="M9 15h6"></path><path d="M9 11h2"></path></svg>`
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
import {
|
||||
ActivitySquareIcon,
|
||||
BotMessageSquareIcon,
|
||||
BrainCircuitIcon,
|
||||
Building2Icon,
|
||||
CalendarClockIcon,
|
||||
ChartColumnIncreasingIcon,
|
||||
FileTextIcon,
|
||||
GlobeIcon,
|
||||
KeyRoundIcon,
|
||||
LayoutDashboardIcon,
|
||||
MessageSquareCodeIcon,
|
||||
MessageSquareMoreIcon,
|
||||
Settings2Icon,
|
||||
ShieldCheckIcon,
|
||||
TagsIcon,
|
||||
UserCogIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** 与后端 internal/pkg/constants/auth.go RoleCodeSuperAdmin 一致 */
|
||||
export const DASHBOARD_ROLE_SUPER_ADMIN = "super_admin";
|
||||
|
||||
export type DashboardNavMenuItem = {
|
||||
title: string;
|
||||
url: string;
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
export type DashboardNavItemConfig = DashboardNavMenuItem & {
|
||||
/**
|
||||
* 与后端 Permission.Code 一致;缺省表示任意已登录管理员可见
|
||||
* (对应控制台接口尚未 RequirePermission 的模块,如接入站点、AI Agent)
|
||||
*/
|
||||
requiredPermission?: string;
|
||||
};
|
||||
|
||||
export type DashboardNavSectionConfig = {
|
||||
title: string;
|
||||
items: DashboardNavItemConfig[];
|
||||
};
|
||||
|
||||
function navItemVisible(
|
||||
item: DashboardNavItemConfig,
|
||||
superAdmin: boolean,
|
||||
permissionSet: Set<string>,
|
||||
): boolean {
|
||||
if (superAdmin) {
|
||||
return true;
|
||||
}
|
||||
if (!item.requiredPermission) {
|
||||
return true;
|
||||
}
|
||||
return permissionSet.has(item.requiredPermission);
|
||||
}
|
||||
|
||||
export function filterDashboardNavForSession(
|
||||
permissions: readonly string[] | undefined,
|
||||
roles: readonly string[] | undefined,
|
||||
): { title: string; items: DashboardNavMenuItem[] }[] {
|
||||
const superAdmin = roles?.includes(DASHBOARD_ROLE_SUPER_ADMIN) ?? false;
|
||||
const permissionSet = new Set(permissions ?? []);
|
||||
return dashboardNavSections
|
||||
.map((section) => ({
|
||||
title: section.title,
|
||||
items: section.items
|
||||
.filter((item) => navItemVisible(item, superAdmin, permissionSet))
|
||||
.map(({ title, url, icon }) => ({ title, url, icon })),
|
||||
}))
|
||||
.filter((section) => section.items.length > 0);
|
||||
}
|
||||
|
||||
export function filterDashboardSecondaryNavForSession(
|
||||
permissions: readonly string[] | undefined,
|
||||
roles: readonly string[] | undefined,
|
||||
): DashboardNavMenuItem[] {
|
||||
const superAdmin = roles?.includes(DASHBOARD_ROLE_SUPER_ADMIN) ?? false;
|
||||
const permissionSet = new Set(permissions ?? []);
|
||||
return dashboardSecondaryNav
|
||||
.filter((item) => navItemVisible(item, superAdmin, permissionSet))
|
||||
.map(({ title, url, icon }) => ({ title, url, icon }));
|
||||
}
|
||||
|
||||
export const dashboardNavSections: DashboardNavSectionConfig[] = [
|
||||
// {
|
||||
// title: "总览",
|
||||
// items: [
|
||||
// {
|
||||
// title: "总览",
|
||||
// url: "/",
|
||||
// icon: <LayoutDashboardIcon />,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
{
|
||||
title: "接待中心",
|
||||
items: [
|
||||
{
|
||||
title: "总览",
|
||||
url: "/",
|
||||
icon: <LayoutDashboardIcon />,
|
||||
},
|
||||
{
|
||||
title: "会话",
|
||||
url: "/conversations",
|
||||
icon: <BotMessageSquareIcon />,
|
||||
requiredPermission: "conversation.view",
|
||||
},
|
||||
{
|
||||
title: "工单",
|
||||
url: "/tickets",
|
||||
icon: <FileTextIcon />,
|
||||
requiredPermission: "ticket.view",
|
||||
},
|
||||
{
|
||||
title: "会话监控",
|
||||
url: "/conversation-monitor",
|
||||
icon: <BotMessageSquareIcon />,
|
||||
requiredPermission: "conversation.view",
|
||||
},
|
||||
{
|
||||
title: "SLA风险",
|
||||
url: "/ticket-risk",
|
||||
icon: <ChartColumnIncreasingIcon />,
|
||||
requiredPermission: "ticket.view",
|
||||
},
|
||||
{
|
||||
title: "客户管理",
|
||||
url: "/customers",
|
||||
icon: <UsersIcon />,
|
||||
requiredPermission: "customer.view",
|
||||
},
|
||||
{
|
||||
title: "公司管理",
|
||||
url: "/companies",
|
||||
icon: <Building2Icon />,
|
||||
requiredPermission: "company.view",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "客服配置",
|
||||
items: [
|
||||
{
|
||||
title: "分类标签",
|
||||
url: "/tags",
|
||||
icon: <TagsIcon />,
|
||||
requiredPermission: "tag.view",
|
||||
},
|
||||
{
|
||||
title: "快捷回复",
|
||||
url: "/quick-replies",
|
||||
icon: <MessageSquareMoreIcon />,
|
||||
requiredPermission: "quickReply.view",
|
||||
},
|
||||
{
|
||||
title: "工单优先级",
|
||||
url: "/ticket-priorities",
|
||||
icon: <Settings2Icon />,
|
||||
requiredPermission: "ticketPriorityConfig.view",
|
||||
},
|
||||
{
|
||||
title: "工单解决码",
|
||||
url: "/ticket-resolution-codes",
|
||||
icon: <KeyRoundIcon />,
|
||||
requiredPermission: "ticketResolutionCode.view",
|
||||
},
|
||||
{
|
||||
title: "客服档案",
|
||||
url: "/agents",
|
||||
icon: <UserCogIcon />,
|
||||
requiredPermission: "agent.view",
|
||||
},
|
||||
{
|
||||
title: "客服组排班",
|
||||
url: "/agent-team-schedules",
|
||||
icon: <CalendarClockIcon />,
|
||||
requiredPermission: "agentTeamSchedule.view",
|
||||
},
|
||||
{
|
||||
title: "接入渠道",
|
||||
url: "/channels",
|
||||
icon: <GlobeIcon />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "知识与AI",
|
||||
items: [
|
||||
{
|
||||
title: "知识库",
|
||||
url: "/knowledge",
|
||||
icon: <FileTextIcon />,
|
||||
requiredPermission: "knowledgeBase.view",
|
||||
},
|
||||
{
|
||||
title: "AI配置",
|
||||
url: "/ai-configs",
|
||||
icon: <BrainCircuitIcon />,
|
||||
requiredPermission: "aiConfig.view",
|
||||
},
|
||||
{
|
||||
title: "AI Agent",
|
||||
url: "/ai-agents",
|
||||
icon: <MessageSquareMoreIcon />,
|
||||
},
|
||||
{
|
||||
title: "Skills",
|
||||
url: "/skill-definition",
|
||||
icon: <MessageSquareCodeIcon />,
|
||||
requiredPermission: "skillDefinition.view",
|
||||
},
|
||||
{
|
||||
title: "MCP调试",
|
||||
url: "/mcp",
|
||||
icon: <MessageSquareCodeIcon />,
|
||||
requiredPermission: "mcp.view",
|
||||
},
|
||||
{
|
||||
title: "Agent日志",
|
||||
url: "/agent-run-logs",
|
||||
icon: <ActivitySquareIcon />,
|
||||
requiredPermission: "conversation.view",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "系统管理",
|
||||
items: [
|
||||
{
|
||||
title: "用户管理",
|
||||
url: "/users",
|
||||
icon: <UsersIcon />,
|
||||
requiredPermission: "user.view",
|
||||
},
|
||||
{
|
||||
title: "角色管理",
|
||||
url: "/roles",
|
||||
icon: <ShieldCheckIcon />,
|
||||
requiredPermission: "role.view",
|
||||
},
|
||||
{
|
||||
title: "权限管理",
|
||||
url: "/permissions",
|
||||
icon: <KeyRoundIcon />,
|
||||
requiredPermission: "permission.view",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const dashboardSecondaryNav: DashboardNavItemConfig[] = [
|
||||
// {
|
||||
// title: "系统设置",
|
||||
// url: "/settings",
|
||||
// icon: <Settings2Icon />,
|
||||
// },
|
||||
// {
|
||||
// title: "帮助中心",
|
||||
// url: "/help",
|
||||
// icon: <LifeBuoyIcon />,
|
||||
// },
|
||||
];
|
||||
|
||||
export const dashboardQuickActions = [
|
||||
{
|
||||
title: "查看会话",
|
||||
icon: <BotMessageSquareIcon />,
|
||||
},
|
||||
{
|
||||
title: "邀请成员",
|
||||
icon: <UserCogIcon />,
|
||||
},
|
||||
{
|
||||
title: "接入机器人",
|
||||
icon: <MessageSquareCodeIcon />,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function getPageTitle(pathname: string): string {
|
||||
let matchedTitle = "后台总览";
|
||||
let longestMatch = 0;
|
||||
|
||||
for (const section of dashboardNavSections) {
|
||||
for (const item of section.items) {
|
||||
if (pathname === item.url || pathname.startsWith(item.url + "/")) {
|
||||
const matchLength = item.url.length;
|
||||
if (matchLength > longestMatch) {
|
||||
longestMatch = matchLength;
|
||||
matchedTitle = item.title;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of dashboardSecondaryNav) {
|
||||
if (pathname === item.url || pathname.startsWith(item.url + "/")) {
|
||||
const matchLength = item.url.length;
|
||||
if (matchLength > longestMatch) {
|
||||
longestMatch = matchLength;
|
||||
matchedTitle = item.title;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matchedTitle;
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { summarizeIMMessage } from "@/lib/im-message"
|
||||
|
||||
type NotificationMessage = {
|
||||
messageType: string
|
||||
content: string
|
||||
payload?: string
|
||||
}
|
||||
|
||||
export function getNotificationBody(message: NotificationMessage): string {
|
||||
return summarizeIMMessage(message)
|
||||
}
|
||||
|
||||
export function showNotification(title: string, body: string, onClick?: () => void) {
|
||||
if (typeof Notification === "undefined") {
|
||||
return
|
||||
}
|
||||
|
||||
if (Notification.permission === "granted") {
|
||||
const notification = new Notification(title, {
|
||||
body,
|
||||
icon: "/favicon.ico",
|
||||
badge: "/favicon.ico",
|
||||
})
|
||||
|
||||
if (onClick) {
|
||||
notification.onclick = () => {
|
||||
onClick()
|
||||
notification.close()
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
notification.close()
|
||||
}, 5000)
|
||||
} else if (Notification.permission === "default") {
|
||||
Notification.requestPermission().then((permission) => {
|
||||
if (permission === "granted") {
|
||||
showNotification(title, body, onClick)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,608 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { create } from "zustand"
|
||||
|
||||
import {
|
||||
fetchAgentConversations,
|
||||
fetchAgentMessages,
|
||||
markAgentMessageRead,
|
||||
recallAgentMessage,
|
||||
sendAgentMessage,
|
||||
uploadAgentConversationAttachment,
|
||||
uploadAgentConversationImage,
|
||||
type AgentAsset,
|
||||
type AgentConversation,
|
||||
type AgentMessage,
|
||||
} from "@/lib/api/agent"
|
||||
import type { RealtimeConnectionStatusValue } from "@/components/realtime-connection-status"
|
||||
import { summarizeIMMessage } from "@/lib/im-message"
|
||||
import { generateUUID } from "@/lib/utils"
|
||||
|
||||
export const agentConversationFilterOptions = [
|
||||
// { value: "mine", label: "我的" },
|
||||
{ value: "active", label: "处理中" },
|
||||
{ value: "pending", label: "待接入" },
|
||||
{ value: "ai_serving", label: "AI接待中" },
|
||||
{ value: "closed", label: "已关闭" },
|
||||
] as const
|
||||
|
||||
export type AgentConversationFilterKey =
|
||||
(typeof agentConversationFilterOptions)[number]["value"]
|
||||
|
||||
function buildConversationQuery(filter: AgentConversationFilterKey, keyword: string) {
|
||||
const query: Record<string, string | number | undefined> = {
|
||||
filter,
|
||||
keyword: keyword.trim() || undefined,
|
||||
limit: 100,
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
type LoadMessagesOptions = {
|
||||
forceLoading?: boolean
|
||||
reset?: boolean
|
||||
}
|
||||
|
||||
function ensureArray<T>(value: T[] | null | undefined): T[] {
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
function mergeMessagesByIdAsc(
|
||||
a: AgentMessage[],
|
||||
b: AgentMessage[]
|
||||
): AgentMessage[] {
|
||||
const byId = new Map<number, AgentMessage>()
|
||||
for (const m of a) {
|
||||
byId.set(m.id, m)
|
||||
}
|
||||
for (const m of b) {
|
||||
byId.set(m.id, m)
|
||||
}
|
||||
return Array.from(byId.values()).sort((x, y) => x.id - y.id)
|
||||
}
|
||||
|
||||
function parseCursorId(cursor: string): number {
|
||||
const n = Number.parseInt(cursor, 10)
|
||||
return Number.isFinite(n) && n > 0 ? n : 0
|
||||
}
|
||||
|
||||
/** 下一页「更旧」请求应传入的游标:当前已加载列表中的最小 message id(后端用 id < cursor) */
|
||||
function cursorFromLoadedMessages(messages: AgentMessage[]): string {
|
||||
if (messages.length === 0) {
|
||||
return ""
|
||||
}
|
||||
return String(Math.min(...messages.map((m) => m.id)))
|
||||
}
|
||||
|
||||
function minMessageId(messages: AgentMessage[]): number | null {
|
||||
if (messages.length === 0) {
|
||||
return null
|
||||
}
|
||||
return Math.min(...messages.map((m) => m.id))
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉「最新一页」做增量合并后,是否仍显示「还有更旧」。
|
||||
* 若本地已确认没有更旧,且合并后最早一条 id 没有变小,则不能用接口对「最新一页」的 hasMore 再次打开(满页会误报)。
|
||||
*/
|
||||
function hasMoreAfterLatestSyncMerge(args: {
|
||||
previousMessages: AgentMessage[]
|
||||
previousHasMore: boolean
|
||||
merged: AgentMessage[]
|
||||
apiHasMore: boolean
|
||||
}): boolean {
|
||||
const prevMin = minMessageId(args.previousMessages)
|
||||
const mergedMin = minMessageId(args.merged)
|
||||
|
||||
if (mergedMin === null) {
|
||||
return Boolean(args.apiHasMore)
|
||||
}
|
||||
|
||||
if (
|
||||
!args.previousHasMore &&
|
||||
prevMin !== null &&
|
||||
mergedMin >= prevMin
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return args.previousHasMore || Boolean(args.apiHasMore)
|
||||
}
|
||||
|
||||
type AgentConversationsStore = {
|
||||
searchKeyword: string
|
||||
conversationFilter: AgentConversationFilterKey
|
||||
conversations: AgentConversation[]
|
||||
conversationsLoading: boolean
|
||||
conversationsLoaded: boolean
|
||||
selectedConversationId: number | null
|
||||
messages: AgentMessage[]
|
||||
messagesLoading: boolean
|
||||
messagesLoadingMore: boolean
|
||||
messagesCursor: string
|
||||
messagesHasMore: boolean
|
||||
messagesLoadedConversationId: number | null
|
||||
sending: boolean
|
||||
uploadingAsset: boolean
|
||||
recallingMessageId: number
|
||||
readingMessageId: number
|
||||
realtimeStatus: RealtimeConnectionStatusValue
|
||||
setSearchKeyword: (keyword: string) => void
|
||||
setConversationFilter: (filter: AgentConversationFilterKey) => void
|
||||
setRealtimeStatus: (status: RealtimeConnectionStatusValue) => void
|
||||
setConversationTags: (
|
||||
conversationId: number,
|
||||
tags: AgentConversation["tags"]
|
||||
) => void
|
||||
loadConversations: () => Promise<void>
|
||||
selectConversation: (conversationId: number) => Promise<void>
|
||||
loadMessages: (conversationId: number, options?: LoadMessagesOptions) => Promise<void>
|
||||
loadOlderMessages: () => Promise<void>
|
||||
syncLatestMessages: (conversationId: number) => Promise<void>
|
||||
markSelectedConversationRead: () => Promise<void>
|
||||
sendMessage: (html: string) => Promise<AgentMessage | null>
|
||||
uploadImage: (file: File) => Promise<AgentAsset | null>
|
||||
sendAttachment: (file: File) => Promise<AgentMessage | null>
|
||||
recallMessage: (messageId: number) => Promise<AgentMessage | null>
|
||||
}
|
||||
|
||||
let conversationsRequestSeq = 0
|
||||
let messagesRequestSeq = 0
|
||||
|
||||
export const useAgentConversationsStore = create<AgentConversationsStore>((set, get) => ({
|
||||
searchKeyword: "",
|
||||
conversationFilter: "active",
|
||||
conversations: [],
|
||||
conversationsLoading: false,
|
||||
conversationsLoaded: false,
|
||||
selectedConversationId: null,
|
||||
messages: [],
|
||||
messagesLoading: false,
|
||||
messagesLoadingMore: false,
|
||||
messagesCursor: "",
|
||||
messagesHasMore: false,
|
||||
messagesLoadedConversationId: null,
|
||||
sending: false,
|
||||
uploadingAsset: false,
|
||||
recallingMessageId: 0,
|
||||
readingMessageId: 0,
|
||||
realtimeStatus: "connecting",
|
||||
|
||||
setSearchKeyword: (keyword) => {
|
||||
set({ searchKeyword: keyword })
|
||||
},
|
||||
|
||||
setConversationFilter: (filter) => {
|
||||
set({ conversationFilter: filter })
|
||||
},
|
||||
|
||||
setRealtimeStatus: (status) => {
|
||||
set({ realtimeStatus: status })
|
||||
},
|
||||
|
||||
setConversationTags: (conversationId, tags) => {
|
||||
set((state) => ({
|
||||
conversations: state.conversations.map((item) =>
|
||||
item.id === conversationId
|
||||
? {
|
||||
...item,
|
||||
tags: tags && tags.length > 0 ? tags : [],
|
||||
}
|
||||
: item
|
||||
),
|
||||
}))
|
||||
},
|
||||
|
||||
loadConversations: async () => {
|
||||
const requestSeq = ++conversationsRequestSeq
|
||||
const store = get()
|
||||
|
||||
if (!store.conversationsLoaded) {
|
||||
set({ conversationsLoading: true })
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchAgentConversations(
|
||||
buildConversationQuery(store.conversationFilter, store.searchKeyword)
|
||||
)
|
||||
const conversations = ensureArray(data.results)
|
||||
|
||||
if (requestSeq !== conversationsRequestSeq) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentSelectedId = get().selectedConversationId
|
||||
const hasCurrentSelection =
|
||||
currentSelectedId !== null && conversations.some((item) => item.id === currentSelectedId)
|
||||
const nextSelectedId = hasCurrentSelection ? currentSelectedId : (conversations[0]?.id ?? null)
|
||||
const selectionChanged = nextSelectedId !== currentSelectedId
|
||||
|
||||
set({
|
||||
conversations,
|
||||
conversationsLoaded: true,
|
||||
conversationsLoading: false,
|
||||
selectedConversationId: nextSelectedId,
|
||||
})
|
||||
|
||||
if (nextSelectedId === null) {
|
||||
set({
|
||||
messages: [],
|
||||
messagesLoading: false,
|
||||
messagesLoadingMore: false,
|
||||
messagesCursor: "",
|
||||
messagesHasMore: false,
|
||||
messagesLoadedConversationId: null,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (selectionChanged || get().messagesLoadedConversationId === null) {
|
||||
await get().loadMessages(nextSelectedId, {
|
||||
forceLoading: true,
|
||||
reset: true,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestSeq === conversationsRequestSeq) {
|
||||
set({ conversationsLoading: false })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
selectConversation: async (conversationId) => {
|
||||
if (get().selectedConversationId === conversationId) {
|
||||
return
|
||||
}
|
||||
|
||||
set({
|
||||
selectedConversationId: conversationId,
|
||||
messages: [],
|
||||
messagesLoading: true,
|
||||
messagesLoadingMore: false,
|
||||
messagesCursor: "",
|
||||
messagesHasMore: false,
|
||||
messagesLoadedConversationId: null,
|
||||
})
|
||||
|
||||
await get().loadMessages(conversationId, {
|
||||
forceLoading: true,
|
||||
reset: true,
|
||||
})
|
||||
},
|
||||
|
||||
loadMessages: async (conversationId, options = {}) => {
|
||||
const requestSeq = ++messagesRequestSeq
|
||||
const store = get()
|
||||
const shouldShowLoading =
|
||||
options.forceLoading || store.messagesLoadedConversationId !== conversationId
|
||||
|
||||
if (shouldShowLoading) {
|
||||
set({
|
||||
messagesLoading: true,
|
||||
...(options.reset
|
||||
? {
|
||||
messages: [],
|
||||
messagesCursor: "",
|
||||
messagesHasMore: false,
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fetchAgentMessages({
|
||||
conversationId,
|
||||
limit: 50,
|
||||
})
|
||||
|
||||
if (requestSeq !== messagesRequestSeq) {
|
||||
return
|
||||
}
|
||||
|
||||
if (get().selectedConversationId !== conversationId) {
|
||||
return
|
||||
}
|
||||
|
||||
const list = ensureArray(data.results)
|
||||
set({
|
||||
messages: list,
|
||||
messagesLoading: false,
|
||||
messagesLoadedConversationId: conversationId,
|
||||
messagesCursor:
|
||||
cursorFromLoadedMessages(list) || (data.cursor ?? ""),
|
||||
messagesHasMore: Boolean(data.hasMore),
|
||||
})
|
||||
} catch (error) {
|
||||
if (requestSeq === messagesRequestSeq) {
|
||||
set({ messagesLoading: false })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
loadOlderMessages: async () => {
|
||||
const conversationId = get().selectedConversationId
|
||||
if (!conversationId || get().messagesLoadingMore || !get().messagesHasMore) {
|
||||
return
|
||||
}
|
||||
const cursorId = parseCursorId(get().messagesCursor)
|
||||
if (cursorId <= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
set({ messagesLoadingMore: true })
|
||||
try {
|
||||
const data = await fetchAgentMessages({
|
||||
conversationId,
|
||||
cursor: cursorId,
|
||||
limit: 50,
|
||||
})
|
||||
if (get().selectedConversationId !== conversationId) {
|
||||
return
|
||||
}
|
||||
const incoming = ensureArray(data.results)
|
||||
set((state) => {
|
||||
const merged = mergeMessagesByIdAsc(incoming, state.messages)
|
||||
return {
|
||||
messages: merged,
|
||||
messagesCursor:
|
||||
cursorFromLoadedMessages(merged) ||
|
||||
(data.cursor ?? state.messagesCursor),
|
||||
messagesHasMore: Boolean(data.hasMore),
|
||||
messagesLoadingMore: false,
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
set({ messagesLoadingMore: false })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
syncLatestMessages: async (conversationId) => {
|
||||
if (conversationId <= 0) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await fetchAgentMessages({
|
||||
conversationId,
|
||||
limit: 50,
|
||||
})
|
||||
if (get().selectedConversationId !== conversationId) {
|
||||
return
|
||||
}
|
||||
const batch = ensureArray(data.results)
|
||||
if (batch.length === 0) {
|
||||
return
|
||||
}
|
||||
const firstId = batch[0]!.id
|
||||
set((state) => {
|
||||
const preserved = state.messages.filter((m) => m.id < firstId)
|
||||
const merged = mergeMessagesByIdAsc(preserved, batch)
|
||||
return {
|
||||
messages: merged,
|
||||
messagesCursor:
|
||||
cursorFromLoadedMessages(merged) ||
|
||||
(data.cursor ?? state.messagesCursor),
|
||||
messagesHasMore: hasMoreAfterLatestSyncMerge({
|
||||
previousMessages: state.messages,
|
||||
previousHasMore: state.messagesHasMore,
|
||||
merged,
|
||||
apiHasMore: Boolean(data.hasMore),
|
||||
}),
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// 实时同步失败不抛给 WS 回调
|
||||
}
|
||||
},
|
||||
|
||||
markSelectedConversationRead: async () => {
|
||||
const store = get()
|
||||
const conversationId = store.selectedConversationId
|
||||
const conversation = store.conversations.find((item) => item.id === conversationId)
|
||||
const lastMessage = store.messages.at(-1)
|
||||
if (!conversationId || !conversation || !lastMessage) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
conversation.agentUnreadCount <= 0 &&
|
||||
(conversation.agentLastReadMessageId ?? 0) >= lastMessage.id
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (store.readingMessageId === lastMessage.id) {
|
||||
return
|
||||
}
|
||||
|
||||
set({ readingMessageId: lastMessage.id })
|
||||
try {
|
||||
await markAgentMessageRead(conversationId, lastMessage.id)
|
||||
set((current) => {
|
||||
if (current.selectedConversationId !== conversationId) {
|
||||
return { readingMessageId: 0 }
|
||||
}
|
||||
return {
|
||||
readingMessageId: 0,
|
||||
messages: current.messages.map((item) =>
|
||||
item.seqNo <= lastMessage.seqNo
|
||||
? {
|
||||
...item,
|
||||
agentRead: true,
|
||||
}
|
||||
: item
|
||||
),
|
||||
conversations: current.conversations.map((item) =>
|
||||
item.id === conversationId
|
||||
? {
|
||||
...item,
|
||||
agentUnreadCount: 0,
|
||||
agentLastReadMessageId: lastMessage.id,
|
||||
agentLastReadSeqNo: lastMessage.seqNo,
|
||||
}
|
||||
: item
|
||||
),
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
set({ readingMessageId: 0 })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
sendMessage: async (html) => {
|
||||
const trimmedContent = html.trim()
|
||||
const { selectedConversationId, sending } = get()
|
||||
if (!selectedConversationId || !trimmedContent || sending) {
|
||||
return null
|
||||
}
|
||||
|
||||
set({ sending: true })
|
||||
try {
|
||||
const message = await sendAgentMessage({
|
||||
conversationId: selectedConversationId,
|
||||
messageType: "html",
|
||||
content: trimmedContent,
|
||||
clientMsgId: `agent_${generateUUID()}`,
|
||||
})
|
||||
|
||||
if (get().selectedConversationId === selectedConversationId) {
|
||||
set((current) => ({
|
||||
messages: current.messages.some((m) => m.id === message.id)
|
||||
? current.messages.map((m) => (m.id === message.id ? message : m))
|
||||
: [...current.messages, message],
|
||||
conversations: current.conversations.map((item) =>
|
||||
item.id === selectedConversationId
|
||||
? {
|
||||
...item,
|
||||
lastMessageAt: message.sentAt,
|
||||
lastActiveAt: message.sentAt,
|
||||
lastMessageSummary: summarizeIMMessage({
|
||||
messageType: "html",
|
||||
content: trimmedContent,
|
||||
}),
|
||||
agentUnreadCount: 0,
|
||||
customerUnreadCount: (item.customerUnreadCount ?? 0) + 1,
|
||||
agentLastReadMessageId: message.id,
|
||||
agentLastReadSeqNo: message.seqNo,
|
||||
}
|
||||
: item
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
return message
|
||||
} finally {
|
||||
set({ sending: false })
|
||||
}
|
||||
},
|
||||
|
||||
uploadImage: async (file) => {
|
||||
const { selectedConversationId, sending, uploadingAsset } = get()
|
||||
if (!selectedConversationId || sending || uploadingAsset) {
|
||||
return null
|
||||
}
|
||||
|
||||
set({ uploadingAsset: true })
|
||||
try {
|
||||
return await uploadAgentConversationImage(selectedConversationId, file)
|
||||
} finally {
|
||||
set({ uploadingAsset: false })
|
||||
}
|
||||
},
|
||||
|
||||
sendAttachment: async (file) => {
|
||||
const { selectedConversationId, sending, uploadingAsset } = get()
|
||||
if (!selectedConversationId || sending || uploadingAsset) {
|
||||
return null
|
||||
}
|
||||
|
||||
set({ uploadingAsset: true })
|
||||
try {
|
||||
const asset = await uploadAgentConversationAttachment(selectedConversationId, file)
|
||||
const message = await sendAgentMessage({
|
||||
conversationId: selectedConversationId,
|
||||
messageType: "attachment",
|
||||
content: asset.filename,
|
||||
payload: JSON.stringify({ assetId: asset.assetId }),
|
||||
clientMsgId: `agent_attachment_${generateUUID()}`,
|
||||
})
|
||||
|
||||
if (get().selectedConversationId === selectedConversationId) {
|
||||
set((current) => ({
|
||||
messages: current.messages.some((m) => m.id === message.id)
|
||||
? current.messages.map((m) => (m.id === message.id ? message : m))
|
||||
: [...current.messages, message],
|
||||
conversations: current.conversations.map((item) =>
|
||||
item.id === selectedConversationId
|
||||
? {
|
||||
...item,
|
||||
lastMessageAt: message.sentAt,
|
||||
lastActiveAt: message.sentAt,
|
||||
lastMessageSummary: summarizeIMMessage(message),
|
||||
agentUnreadCount: 0,
|
||||
customerUnreadCount: (item.customerUnreadCount ?? 0) + 1,
|
||||
agentLastReadMessageId: message.id,
|
||||
agentLastReadSeqNo: message.seqNo,
|
||||
}
|
||||
: item
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
return message
|
||||
} finally {
|
||||
set({ uploadingAsset: false })
|
||||
}
|
||||
},
|
||||
|
||||
recallMessage: async (messageId) => {
|
||||
const { selectedConversationId, recallingMessageId } = get()
|
||||
if (!selectedConversationId || messageId <= 0 || recallingMessageId === messageId) {
|
||||
return null
|
||||
}
|
||||
|
||||
set({ recallingMessageId: messageId })
|
||||
try {
|
||||
const message = await recallAgentMessage(messageId)
|
||||
if (get().selectedConversationId === selectedConversationId) {
|
||||
set((current) => {
|
||||
const nextMessages = current.messages.map((item) =>
|
||||
item.id === message.id ? message : item
|
||||
)
|
||||
const lastActiveMessage = [...nextMessages]
|
||||
.reverse()
|
||||
.find((item) => !item.recalledAt && item.sendStatus !== 6)
|
||||
return {
|
||||
recallingMessageId: 0,
|
||||
messages: nextMessages,
|
||||
conversations: current.conversations.map((item) =>
|
||||
item.id === selectedConversationId
|
||||
? {
|
||||
...item,
|
||||
lastMessageId: lastActiveMessage?.id ?? 0,
|
||||
lastMessageAt: lastActiveMessage?.sentAt ?? "",
|
||||
lastMessageSummary: lastActiveMessage
|
||||
? summarizeIMMessage(lastActiveMessage)
|
||||
: "",
|
||||
}
|
||||
: item
|
||||
),
|
||||
}
|
||||
})
|
||||
} else {
|
||||
set({ recallingMessageId: 0 })
|
||||
}
|
||||
return message
|
||||
} catch (error) {
|
||||
set({ recallingMessageId: 0 })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
export const agentConversationSelectors = {
|
||||
selectedConversation: (state: AgentConversationsStore) =>
|
||||
state.conversations.find((item) => item.id === state.selectedConversationId) ?? null,
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { fetchTicketPriorityConfigsAll } from "@/lib/api/ticket-config"
|
||||
|
||||
export type TicketPriorityOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export async function getTicketPriorityOptions() {
|
||||
const list = await fetchTicketPriorityConfigsAll()
|
||||
return (Array.isArray(list) ? list : []).map((item) => ({
|
||||
value: String(item.id),
|
||||
label: item.name,
|
||||
})) satisfies TicketPriorityOption[]
|
||||
}
|
||||
|
||||
export async function getTicketPriorityMap() {
|
||||
const options = await getTicketPriorityOptions()
|
||||
return Object.fromEntries(options.map((item) => [Number(item.value), item.label])) as Record<number, string>
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function generateUUID() {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||||
return globalThis.crypto.randomUUID()
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(16)
|
||||
if (typeof globalThis.crypto?.getRandomValues === "function") {
|
||||
globalThis.crypto.getRandomValues(bytes)
|
||||
} else {
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
bytes[i] = Math.floor(Math.random() * 256)
|
||||
}
|
||||
}
|
||||
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80
|
||||
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"))
|
||||
return [
|
||||
hex.slice(0, 4).join(""),
|
||||
hex.slice(4, 6).join(""),
|
||||
hex.slice(6, 8).join(""),
|
||||
hex.slice(8, 10).join(""),
|
||||
hex.slice(10, 16).join(""),
|
||||
].join("-")
|
||||
}
|
||||
|
||||
function pad(value: number) {
|
||||
return value.toString().padStart(2, "0")
|
||||
}
|
||||
|
||||
export function formatDateTime(value?: string | number | Date | null) {
|
||||
if (!value) {
|
||||
return "-"
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return "-"
|
||||
}
|
||||
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
}
|
||||
Reference in New Issue
Block a user