update web to dashboard

This commit is contained in:
mlogclub
2026-04-15 17:20:57 +08:00
parent 3d98cf1e27
commit c07e588030
206 changed files with 0 additions and 0 deletions
-1693
View File
File diff suppressed because it is too large Load Diff
-268
View File
@@ -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()}`
}
-44
View File
@@ -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()
}
}
-104
View File
@@ -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
}
}
-77
View File
@@ -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 }),
})
}
-77
View File
@@ -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 }),
})
}
-108
View File
@@ -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 }),
})
}
-81
View File
@@ -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}`)
}
-226
View File
@@ -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,
})
}
-139
View File
@@ -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 }),
})
}
-487
View File
@@ -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),
})
}
-6
View File
@@ -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(/\/$/, "")
}
-55
View File
@@ -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)
}
-15
View File
@@ -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,
}))
}
-391
View File
@@ -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",
}
-145
View File
@@ -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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
.replaceAll("\n", "<br>")
}
function escapeHTMLAttr(value: string) {
return value
.replaceAll("&", "&amp;")
.replaceAll('"', "&quot;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
}
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>`
}
-308
View File
@@ -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;
}
-42
View File
@@ -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)
}
})
}
}
-608
View File
@@ -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,
}
-19
View File
@@ -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>
}
-50
View File
@@ -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())}`
}