refactor: support i18n
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { readSession } from "@/lib/auth"
|
||||
import { request } from "@/lib/api/client"
|
||||
import { createWebSocketBaseUrl } from "@/lib/api/websocket"
|
||||
import { translateCurrentMessage } from "@/i18n/messages"
|
||||
|
||||
export type Paging = {
|
||||
page: number
|
||||
@@ -576,7 +577,7 @@ function toQueryString(query?: Record<string, string | number | undefined>) {
|
||||
export function createAdminWebSocketUrl() {
|
||||
const session = readSession()
|
||||
if (!session?.accessToken) {
|
||||
throw new Error("未登录或登录已过期")
|
||||
throw new Error(translateCurrentMessage("api.authExpired"))
|
||||
}
|
||||
|
||||
const baseUrl = createWebSocketBaseUrl()
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { expireSession, readSession } from "@/lib/auth"
|
||||
import { readStoredLocale } from "@/i18n/config"
|
||||
import { translateCurrentMessage } from "@/i18n/messages"
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() || ""
|
||||
@@ -22,7 +24,7 @@ async function parseResult<T>(response: Response) {
|
||||
if (payload.errorCode === 3000 || payload.errorCode === 3002) {
|
||||
expireSession()
|
||||
}
|
||||
const error = new Error(payload.message || "请求失败")
|
||||
const error = new Error(payload.message || translateCurrentMessage("api.requestFailed"))
|
||||
;(error as Error & { errorCode?: number }).errorCode = payload.errorCode
|
||||
throw error
|
||||
}
|
||||
@@ -49,6 +51,9 @@ export async function request<T>(
|
||||
) {
|
||||
authHeaders.set("Content-Type", "application/json")
|
||||
}
|
||||
const locale = readStoredLocale()
|
||||
authHeaders.set("Accept-Language", locale)
|
||||
authHeaders.set("X-Locale", locale)
|
||||
|
||||
const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL
|
||||
const response = await fetch(`${requestBaseUrl}${path}`, {
|
||||
|
||||
@@ -31,7 +31,7 @@ export type UpdateAdminCustomerPayload = CreateAdminCustomerPayload & {
|
||||
id: number
|
||||
}
|
||||
|
||||
/** 与 POST /customer/save/profile 请求体一致 */
|
||||
/** Matches POST /customer/save/profile request body. */
|
||||
export type SaveCustomerProfileContactLine = {
|
||||
id?: number
|
||||
contactType: ContactType | string
|
||||
@@ -49,14 +49,14 @@ export type SaveCustomerProfilePayload = {
|
||||
contacts: SaveCustomerProfileContactLine[]
|
||||
}
|
||||
|
||||
/** 与 POST /customer/list JSON Body 一致 */
|
||||
/** Matches POST /customer/list JSON body. */
|
||||
export type CustomerListRequest = {
|
||||
page: number
|
||||
limit: number
|
||||
status?: number
|
||||
gender?: number
|
||||
companyId?: number
|
||||
/** 模糊匹配:客户名、主手机、主邮箱、联系方式、公司名称 */
|
||||
/** Fuzzy match against customer name, primary phone, primary email, contacts, and company name. */
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export function createCustomer(payload: CreateAdminCustomerPayload) {
|
||||
})
|
||||
}
|
||||
|
||||
/** 单请求 + 单事务保存客户主信息与联系方式全量 */
|
||||
/** Saves the customer profile and the full contact list in one request and transaction. */
|
||||
export function saveCustomerProfile(payload: SaveCustomerProfilePayload) {
|
||||
return request<AdminCustomer>("/api/dashboard/customer/save_profile", {
|
||||
method: "POST",
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
import { request } from "@/lib/api/client"
|
||||
import { translateCurrentMessage } from "@/i18n/messages"
|
||||
import { readKefuChatRuntimeConfig } from "@/lib/sdk/runtime-config"
|
||||
import { generateUUID } from "@/lib/utils"
|
||||
|
||||
@@ -280,7 +281,7 @@ function createExchangeHeaders() {
|
||||
function createImHeaders() {
|
||||
const sessionToken = getCustomerSessionToken()
|
||||
if (!sessionToken) {
|
||||
throw new Error("客服会话未初始化")
|
||||
throw new Error(translateCurrentMessage("api.customerSessionNotReady"))
|
||||
}
|
||||
return {
|
||||
...createChannelHeaders(),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { readSession } from "@/lib/auth"
|
||||
import { request } from "@/lib/api/client"
|
||||
import { createWebSocketBaseUrl } from "@/lib/api/websocket"
|
||||
import type { PageResult } from "@/lib/api/admin"
|
||||
import { translateCurrentMessage } from "@/i18n/messages"
|
||||
|
||||
export type NotificationReadStatus = "all" | "unread" | "read"
|
||||
|
||||
@@ -71,7 +72,7 @@ export function markAllNotificationsRead() {
|
||||
export function createNotificationWebSocketUrl() {
|
||||
const session = readSession()
|
||||
if (!session?.accessToken) {
|
||||
throw new Error("未登录或登录已过期")
|
||||
throw new Error(translateCurrentMessage("api.authExpired"))
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
|
||||
+16
-8
@@ -1,5 +1,7 @@
|
||||
import MarkdownIt from "markdown-it"
|
||||
|
||||
import { translateCurrentMessage } from "@/i18n/messages"
|
||||
|
||||
export type MessageAssetPayload = {
|
||||
assetId: string
|
||||
filename?: string
|
||||
@@ -14,6 +16,10 @@ const messageMarkdown = new MarkdownIt({
|
||||
breaks: true,
|
||||
})
|
||||
|
||||
function t(key: string) {
|
||||
return translateCurrentMessage(key)
|
||||
}
|
||||
|
||||
export function parseMessageAssetPayload(payload?: string): MessageAssetPayload | null {
|
||||
if (!payload?.trim()) {
|
||||
return null
|
||||
@@ -45,12 +51,12 @@ export function renderIMMessageHTML(message: {
|
||||
asset.filename || "image"
|
||||
)}"></p>`
|
||||
}
|
||||
return "<p>[图片]</p>"
|
||||
return `<p>${escapeHTML(t("kefu.imageSummary"))}</p>`
|
||||
}
|
||||
|
||||
if (message.messageType === "attachment") {
|
||||
if (asset?.url) {
|
||||
const title = escapeHTML(asset.filename || message.content || "附件")
|
||||
const title = escapeHTML(asset.filename || message.content || t("kefu.attachmentSummary"))
|
||||
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(
|
||||
@@ -59,7 +65,7 @@ export function renderIMMessageHTML(message: {
|
||||
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 `<p>${escapeHTML(message.content || t("kefu.attachmentSummary"))}</p>`
|
||||
}
|
||||
|
||||
return renderTextMessageHTML(message.content || "")
|
||||
@@ -71,11 +77,13 @@ export function summarizeIMMessage(message: {
|
||||
payload?: string
|
||||
}) {
|
||||
if (message.messageType === "image") {
|
||||
return "[图片]"
|
||||
return t("kefu.imageSummary")
|
||||
}
|
||||
if (message.messageType === "attachment") {
|
||||
const asset = parseMessageAssetPayload(message.payload)
|
||||
return asset?.filename?.trim() ? `[附件] ${asset.filename.trim()}` : "[附件]"
|
||||
return asset?.filename?.trim()
|
||||
? `${t("kefu.attachmentSummary")} ${asset.filename.trim()}`
|
||||
: t("kefu.attachmentSummary")
|
||||
}
|
||||
if (message.messageType === "html") {
|
||||
const text = extractTextFromHTML(message.content)
|
||||
@@ -83,11 +91,11 @@ export function summarizeIMMessage(message: {
|
||||
return text.substring(0, 100)
|
||||
}
|
||||
if (message.content.includes("<img")) {
|
||||
return "[图片]"
|
||||
return t("kefu.imageSummary")
|
||||
}
|
||||
return "[消息]"
|
||||
return t("kefu.messageSummary")
|
||||
}
|
||||
return message.content?.substring(0, 100) || "[消息]"
|
||||
return message.content?.substring(0, 100) || t("kefu.messageSummary")
|
||||
}
|
||||
|
||||
export function formatFileSize(size: number) {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import ts from "typescript"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import vm from "node:vm"
|
||||
|
||||
async function loadModule() {
|
||||
const source = await readFile(new URL("./knowledge-i18n.ts", import.meta.url), "utf8")
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2017,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
},
|
||||
fileName: "knowledge-i18n.ts",
|
||||
})
|
||||
const sandbox = {
|
||||
exports: {},
|
||||
module: { exports: {} },
|
||||
}
|
||||
sandbox.exports = sandbox.module.exports
|
||||
vm.runInNewContext(compiled.outputText, sandbox)
|
||||
return sandbox.module.exports
|
||||
}
|
||||
|
||||
const t = (key) =>
|
||||
({
|
||||
"knowledge.channelIM": "Conversations",
|
||||
"knowledge.channelAgentAssist": "Agent Assist",
|
||||
"knowledge.channelAPI": "API",
|
||||
"knowledge.channelDebug": "Debug",
|
||||
"knowledge.sceneFirstResponse": "First response",
|
||||
"knowledge.sceneAssist": "Assisted reply",
|
||||
"knowledge.sceneQA": "Q&A",
|
||||
"knowledge.answerNormal": "Answered",
|
||||
"knowledge.answerNoAnswer": "No answer",
|
||||
"knowledge.answerFallback": "Fallback",
|
||||
"knowledge.answerBlocked": "Blocked",
|
||||
"knowledge.chunkFixed": "Fixed length",
|
||||
"knowledge.chunkStructured": "Structured chunks",
|
||||
"knowledge.chunkFAQ": "Q&A chunks",
|
||||
"knowledge.chunkSemantic": "Semantic chunks",
|
||||
})[key] ?? key
|
||||
|
||||
test("localizes knowledge retrieve enum labels from stable values", async () => {
|
||||
const {
|
||||
getKnowledgeAnswerStatusLabel,
|
||||
getKnowledgeChunkProviderLabel,
|
||||
getKnowledgeRetrieveChannelLabel,
|
||||
getKnowledgeRetrieveSceneLabel,
|
||||
} = await loadModule()
|
||||
|
||||
assert.equal(getKnowledgeRetrieveChannelLabel("im", "\u5ba2\u670d\u4f1a\u8bdd", t), "Conversations")
|
||||
assert.equal(getKnowledgeRetrieveSceneLabel("first_response", "\u9996\u6b21\u56de\u590d", t), "First response")
|
||||
assert.equal(getKnowledgeAnswerStatusLabel(2, "\u65e0\u7b54\u6848", t), "No answer")
|
||||
assert.equal(getKnowledgeChunkProviderLabel("semantic", t), "Semantic chunks")
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
type TFunction = (key: string, values?: Record<string, string | number>) => string
|
||||
|
||||
const CHANNEL_LABEL_KEYS: Record<string, string> = {
|
||||
im: "knowledge.channelIM",
|
||||
agent_assist: "knowledge.channelAgentAssist",
|
||||
api: "knowledge.channelAPI",
|
||||
debug: "knowledge.channelDebug",
|
||||
}
|
||||
|
||||
const SCENE_LABEL_KEYS: Record<string, string> = {
|
||||
first_response: "knowledge.sceneFirstResponse",
|
||||
assist: "knowledge.sceneAssist",
|
||||
qa: "knowledge.sceneQA",
|
||||
}
|
||||
|
||||
const ANSWER_STATUS_LABEL_KEYS: Record<number, string> = {
|
||||
1: "knowledge.answerNormal",
|
||||
2: "knowledge.answerNoAnswer",
|
||||
3: "knowledge.answerFallback",
|
||||
4: "knowledge.answerBlocked",
|
||||
}
|
||||
|
||||
const PROVIDER_LABEL_KEYS: Record<string, string> = {
|
||||
fixed: "knowledge.chunkFixed",
|
||||
structured: "knowledge.chunkStructured",
|
||||
faq: "knowledge.chunkFAQ",
|
||||
semantic: "knowledge.chunkSemantic",
|
||||
}
|
||||
|
||||
export function getKnowledgeRetrieveChannelLabel(value: string, fallback: string, t: TFunction) {
|
||||
return translateByKey(CHANNEL_LABEL_KEYS[value], fallback, t)
|
||||
}
|
||||
|
||||
export function getKnowledgeRetrieveSceneLabel(value: string, fallback: string, t: TFunction) {
|
||||
return translateByKey(SCENE_LABEL_KEYS[value], fallback, t)
|
||||
}
|
||||
|
||||
export function getKnowledgeAnswerStatusLabel(value: number, fallback: string, t: TFunction) {
|
||||
return translateByKey(ANSWER_STATUS_LABEL_KEYS[value], fallback, t)
|
||||
}
|
||||
|
||||
export function getKnowledgeChunkProviderLabel(value: string, t: TFunction) {
|
||||
return translateByKey(PROVIDER_LABEL_KEYS[value], value, t)
|
||||
}
|
||||
|
||||
function translateByKey(key: string | undefined, fallback: string, t: TFunction) {
|
||||
return key ? t(key) : fallback
|
||||
}
|
||||
+48
-43
@@ -17,25 +17,26 @@ import {
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** 与后端 internal/pkg/constants/auth.go RoleCodeSuperAdmin 一致 */
|
||||
/** Keep in sync with backend internal/pkg/constants/auth.go RoleCodeSuperAdmin. */
|
||||
export const DASHBOARD_ROLE_SUPER_ADMIN = "super_admin";
|
||||
|
||||
export type DashboardNavMenuItem = {
|
||||
title: string;
|
||||
titleKey: string;
|
||||
url: string;
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
export type DashboardNavItemConfig = DashboardNavMenuItem & {
|
||||
export type DashboardNavItemConfig = Omit<DashboardNavMenuItem, "title"> & {
|
||||
/**
|
||||
* 与后端 Permission.Code 一致;缺省表示任意已登录管理员可见
|
||||
* (对应控制台接口尚未 RequirePermission 的模块)
|
||||
* Keep in sync with backend Permission.Code. Missing value means any signed-in
|
||||
* admin can see the module.
|
||||
*/
|
||||
requiredPermission?: string;
|
||||
};
|
||||
|
||||
export type DashboardNavSectionConfig = {
|
||||
title: string;
|
||||
titleKey: string;
|
||||
items: DashboardNavItemConfig[];
|
||||
};
|
||||
|
||||
@@ -56,15 +57,15 @@ function navItemVisible(
|
||||
export function filterDashboardNavForSession(
|
||||
permissions: readonly string[] | undefined,
|
||||
roles: readonly string[] | undefined,
|
||||
): { title: string; items: DashboardNavMenuItem[] }[] {
|
||||
): { titleKey: string; items: DashboardNavMenuItem[] }[] {
|
||||
const superAdmin = roles?.includes(DASHBOARD_ROLE_SUPER_ADMIN) ?? false;
|
||||
const permissionSet = new Set(permissions ?? []);
|
||||
return dashboardNavSections
|
||||
.map((section) => ({
|
||||
title: section.title,
|
||||
titleKey: section.titleKey,
|
||||
items: section.items
|
||||
.filter((item) => navItemVisible(item, superAdmin, permissionSet))
|
||||
.map(({ title, url, icon }) => ({ title, url, icon })),
|
||||
.map(({ titleKey, url, icon }) => ({ title: titleKey, titleKey, url, icon })),
|
||||
}))
|
||||
.filter((section) => section.items.length > 0);
|
||||
}
|
||||
@@ -77,54 +78,54 @@ export function filterDashboardSecondaryNavForSession(
|
||||
const permissionSet = new Set(permissions ?? []);
|
||||
return dashboardSecondaryNav
|
||||
.filter((item) => navItemVisible(item, superAdmin, permissionSet))
|
||||
.map(({ title, url, icon }) => ({ title, url, icon }));
|
||||
.map(({ titleKey, url, icon }) => ({ title: titleKey, titleKey, url, icon }));
|
||||
}
|
||||
|
||||
export const dashboardNavSections: DashboardNavSectionConfig[] = [
|
||||
// {
|
||||
// title: "总览",
|
||||
// title: "Overview",
|
||||
// items: [
|
||||
// {
|
||||
// title: "总览",
|
||||
// title: "Overview",
|
||||
// url: "/",
|
||||
// icon: <LayoutDashboardIcon />,
|
||||
// },
|
||||
// ],
|
||||
// },
|
||||
{
|
||||
title: "接待中心",
|
||||
titleKey: "nav.receptionCenter",
|
||||
items: [
|
||||
{
|
||||
title: "总览",
|
||||
titleKey: "nav.overview",
|
||||
url: "/dashboard",
|
||||
icon: <LayoutDashboardIcon />,
|
||||
},
|
||||
{
|
||||
title: "会话",
|
||||
titleKey: "nav.conversations",
|
||||
url: "/dashboard/conversations",
|
||||
icon: <BotMessageSquareIcon />,
|
||||
requiredPermission: "conversation.view",
|
||||
},
|
||||
{
|
||||
title: "工单",
|
||||
titleKey: "nav.tickets",
|
||||
url: "/dashboard/tickets",
|
||||
icon: <FileTextIcon />,
|
||||
requiredPermission: "ticket.view",
|
||||
},
|
||||
{
|
||||
title: "会话监控",
|
||||
titleKey: "nav.conversationMonitor",
|
||||
url: "/dashboard/conversation-monitor",
|
||||
icon: <BotMessageSquareIcon />,
|
||||
requiredPermission: "conversation.view",
|
||||
},
|
||||
{
|
||||
title: "客户管理",
|
||||
titleKey: "nav.customers",
|
||||
url: "/dashboard/customers",
|
||||
icon: <UsersIcon />,
|
||||
requiredPermission: "customer.view",
|
||||
},
|
||||
{
|
||||
title: "公司管理",
|
||||
titleKey: "nav.companies",
|
||||
url: "/dashboard/companies",
|
||||
icon: <Building2Icon />,
|
||||
requiredPermission: "company.view",
|
||||
@@ -132,34 +133,34 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "客服配置",
|
||||
titleKey: "nav.agentConfig",
|
||||
items: [
|
||||
{
|
||||
title: "分类标签",
|
||||
titleKey: "nav.tags",
|
||||
url: "/dashboard/tags",
|
||||
icon: <TagsIcon />,
|
||||
requiredPermission: "tag.view",
|
||||
},
|
||||
{
|
||||
title: "快捷回复",
|
||||
titleKey: "nav.quickReplies",
|
||||
url: "/dashboard/quick-replies",
|
||||
icon: <MessageSquareMoreIcon />,
|
||||
requiredPermission: "quickReply.view",
|
||||
},
|
||||
{
|
||||
title: "客服档案",
|
||||
titleKey: "nav.agents",
|
||||
url: "/dashboard/agents",
|
||||
icon: <UserCogIcon />,
|
||||
requiredPermission: "agent.view",
|
||||
},
|
||||
{
|
||||
title: "客服组排班",
|
||||
titleKey: "nav.agentTeamSchedules",
|
||||
url: "/dashboard/agent-team-schedules",
|
||||
icon: <CalendarClockIcon />,
|
||||
requiredPermission: "agentTeamSchedule.view",
|
||||
},
|
||||
{
|
||||
title: "接入渠道",
|
||||
titleKey: "nav.channels",
|
||||
url: "/dashboard/channels",
|
||||
icon: <GlobeIcon />,
|
||||
requiredPermission: "channel.view",
|
||||
@@ -167,40 +168,40 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "AI能力",
|
||||
titleKey: "nav.aiCapabilities",
|
||||
items: [
|
||||
{
|
||||
title: "知识库",
|
||||
titleKey: "nav.knowledge",
|
||||
url: "/dashboard/knowledge",
|
||||
icon: <FileTextIcon />,
|
||||
requiredPermission: "knowledgeBase.view",
|
||||
},
|
||||
{
|
||||
title: "模型配置",
|
||||
titleKey: "nav.aiConfigs",
|
||||
url: "/dashboard/ai-configs",
|
||||
icon: <BrainCircuitIcon />,
|
||||
requiredPermission: "aiConfig.view",
|
||||
},
|
||||
{
|
||||
title: "智能客服",
|
||||
titleKey: "nav.aiAgents",
|
||||
url: "/dashboard/ai-agents",
|
||||
icon: <MessageSquareMoreIcon />,
|
||||
requiredPermission: "aiAgent.view",
|
||||
},
|
||||
{
|
||||
title: "能力编排",
|
||||
titleKey: "nav.skillDefinition",
|
||||
url: "/dashboard/skill-definition",
|
||||
icon: <MessageSquareCodeIcon />,
|
||||
requiredPermission: "skillDefinition.view",
|
||||
},
|
||||
{
|
||||
title: "工具调试",
|
||||
titleKey: "nav.mcp",
|
||||
url: "/dashboard/mcp",
|
||||
icon: <MessageSquareCodeIcon />,
|
||||
requiredPermission: "mcp.view",
|
||||
},
|
||||
{
|
||||
title: "运行日志",
|
||||
titleKey: "nav.agentRunLogs",
|
||||
url: "/dashboard/agent-run-logs",
|
||||
icon: <ActivitySquareIcon />,
|
||||
requiredPermission: "conversation.view",
|
||||
@@ -208,22 +209,22 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "系统管理",
|
||||
titleKey: "nav.system",
|
||||
items: [
|
||||
{
|
||||
title: "用户管理",
|
||||
titleKey: "nav.users",
|
||||
url: "/dashboard/users",
|
||||
icon: <UsersIcon />,
|
||||
requiredPermission: "user.view",
|
||||
},
|
||||
{
|
||||
title: "角色管理",
|
||||
titleKey: "nav.roles",
|
||||
url: "/dashboard/roles",
|
||||
icon: <ShieldCheckIcon />,
|
||||
requiredPermission: "role.view",
|
||||
},
|
||||
{
|
||||
title: "权限管理",
|
||||
titleKey: "nav.permissions",
|
||||
url: "/dashboard/permissions",
|
||||
icon: <KeyRoundIcon />,
|
||||
requiredPermission: "permission.view",
|
||||
@@ -234,12 +235,12 @@ export const dashboardNavSections: DashboardNavSectionConfig[] = [
|
||||
|
||||
export const dashboardSecondaryNav: DashboardNavItemConfig[] = [
|
||||
// {
|
||||
// title: "系统设置",
|
||||
// title: "System Settings",
|
||||
// url: "/settings",
|
||||
// icon: <Settings2Icon />,
|
||||
// },
|
||||
// {
|
||||
// title: "帮助中心",
|
||||
// title: "Help Center",
|
||||
// url: "/help",
|
||||
// icon: <LifeBuoyIcon />,
|
||||
// },
|
||||
@@ -247,21 +248,25 @@ export const dashboardSecondaryNav: DashboardNavItemConfig[] = [
|
||||
|
||||
export const dashboardQuickActions = [
|
||||
{
|
||||
title: "查看会话",
|
||||
title: "View Conversations",
|
||||
icon: <BotMessageSquareIcon />,
|
||||
},
|
||||
{
|
||||
title: "邀请成员",
|
||||
title: "Invite Members",
|
||||
icon: <UserCogIcon />,
|
||||
},
|
||||
{
|
||||
title: "接入机器人",
|
||||
title: "Connect Bot",
|
||||
icon: <MessageSquareCodeIcon />,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function getPageTitle(pathname: string): string {
|
||||
let matchedTitle = "后台总览";
|
||||
return getPageTitleKey(pathname);
|
||||
}
|
||||
|
||||
export function getPageTitleKey(pathname: string): string {
|
||||
let matchedTitle = "nav.dashboardHome";
|
||||
let longestMatch = 0;
|
||||
|
||||
for (const section of dashboardNavSections) {
|
||||
@@ -270,7 +275,7 @@ export function getPageTitle(pathname: string): string {
|
||||
const matchLength = item.url.length;
|
||||
if (matchLength > longestMatch) {
|
||||
longestMatch = matchLength;
|
||||
matchedTitle = item.title;
|
||||
matchedTitle = item.titleKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,7 +286,7 @@ export function getPageTitle(pathname: string): string {
|
||||
const matchLength = item.url.length;
|
||||
if (matchLength > longestMatch) {
|
||||
longestMatch = matchLength;
|
||||
matchedTitle = item.title;
|
||||
matchedTitle = item.titleKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import ts from "typescript"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import vm from "node:vm"
|
||||
|
||||
async function loadModule() {
|
||||
const source = await readFile(new URL("./notification-i18n.ts", import.meta.url), "utf8")
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2017,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
},
|
||||
fileName: "notification-i18n.ts",
|
||||
})
|
||||
const sandbox = {
|
||||
exports: {},
|
||||
module: { exports: {} },
|
||||
require: (id) => {
|
||||
if (id === "@/i18n/config") {
|
||||
return { DEFAULT_LOCALE: "zh-CN" }
|
||||
}
|
||||
throw new Error(`unexpected import ${id}`)
|
||||
},
|
||||
}
|
||||
sandbox.exports = sandbox.module.exports
|
||||
vm.runInNewContext(compiled.outputText, sandbox)
|
||||
return sandbox.module.exports
|
||||
}
|
||||
|
||||
test("localizes realtime ticket assignment notification to English", async () => {
|
||||
const { localizeNotificationItem } = await loadModule()
|
||||
|
||||
const result = localizeNotificationItem(
|
||||
{
|
||||
id: 1,
|
||||
recipientUserId: 2,
|
||||
title: "\u5de5\u5355\u6307\u6d3e\u63d0\u9192",
|
||||
content: "\u5de5\u5355 TK-100 \u5df2\u6307\u6d3e\u7ed9\u4f60\nCannot sign in\n\u6307\u6d3e\u539f\u56e0: urgent",
|
||||
notificationType: "ticket_assigned",
|
||||
bizType: "ticket",
|
||||
bizId: 100,
|
||||
actionUrl: "/dashboard/tickets?ticketId=100",
|
||||
},
|
||||
"en-US"
|
||||
)
|
||||
|
||||
assert.equal(result.title, "Ticket assigned")
|
||||
assert.equal(
|
||||
result.content,
|
||||
"Ticket TK-100 has been assigned to you.\nCannot sign in\nAssignment reason: urgent"
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
type LocalizableNotification = {
|
||||
title: string
|
||||
content: string
|
||||
notificationType: string
|
||||
}
|
||||
|
||||
const TICKET_ASSIGNED_TITLE = "\u5de5\u5355\u6307\u6d3e\u63d0\u9192"
|
||||
const CONVERSATION_TRANSFERRED_TITLE = "\u4f1a\u8bdd\u8f6c\u63a5\u63d0\u9192"
|
||||
const CONVERSATION_AUTO_ASSIGNED_TITLE = "\u4f1a\u8bdd\u81ea\u52a8\u5206\u914d\u63d0\u9192"
|
||||
const CONVERSATION_ASSIGNED_TITLE = "\u4f1a\u8bdd\u5206\u914d\u63d0\u9192"
|
||||
|
||||
const TICKET_ASSIGNED_PATTERN = /^\u5de5\u5355 (.+) \u5df2\u6307\u6d3e\u7ed9\u4f60$/
|
||||
const CONVERSATION_ASSIGNED_PATTERN = /^\u4f1a\u8bdd #([0-9]+) \u5df2\u5206\u914d\u7ed9\u4f60$/
|
||||
const ASSIGNMENT_REASON_PREFIX = "\u6307\u6d3e\u539f\u56e0: "
|
||||
const CONVERSATION_ASSIGNMENT_REASON_PREFIX = "\u5206\u914d\u539f\u56e0: "
|
||||
const TRANSFER_REASON_PREFIX = "\u8f6c\u63a5\u539f\u56e0: "
|
||||
|
||||
export function localizeNotificationItem<T extends LocalizableNotification>(
|
||||
notification: T,
|
||||
locale: string
|
||||
): T {
|
||||
if (locale !== "en-US") {
|
||||
return notification
|
||||
}
|
||||
if (notification.notificationType === "ticket_assigned") {
|
||||
return {
|
||||
...notification,
|
||||
title: localizeNotificationTitle(notification.title),
|
||||
content: localizeTicketAssignedContent(notification.content),
|
||||
}
|
||||
}
|
||||
if (notification.notificationType === "conversation_assigned") {
|
||||
return {
|
||||
...notification,
|
||||
title: localizeNotificationTitle(notification.title),
|
||||
content: localizeConversationAssignedContent(notification.content),
|
||||
}
|
||||
}
|
||||
return notification
|
||||
}
|
||||
|
||||
function localizeTicketAssignedContent(content: string) {
|
||||
const lines = splitNotificationLines(content)
|
||||
if (lines.length === 0) {
|
||||
return content
|
||||
}
|
||||
const match = lines[0].match(TICKET_ASSIGNED_PATTERN)
|
||||
if (match?.[1]) {
|
||||
lines[0] = `Ticket ${match[1]} has been assigned to you.`
|
||||
}
|
||||
return lines
|
||||
.map((line) =>
|
||||
line.startsWith(ASSIGNMENT_REASON_PREFIX)
|
||||
? `Assignment reason: ${line.slice(ASSIGNMENT_REASON_PREFIX.length)}`
|
||||
: line
|
||||
)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
function localizeConversationAssignedContent(content: string) {
|
||||
const lines = splitNotificationLines(content)
|
||||
if (lines.length === 0) {
|
||||
return content
|
||||
}
|
||||
const match = lines[0].match(CONVERSATION_ASSIGNED_PATTERN)
|
||||
if (match?.[1]) {
|
||||
lines[0] = `Conversation #${match[1]} has been assigned to you.`
|
||||
}
|
||||
return lines
|
||||
.map((line) => {
|
||||
if (line.startsWith(CONVERSATION_ASSIGNMENT_REASON_PREFIX)) {
|
||||
return `Assignment reason: ${line.slice(CONVERSATION_ASSIGNMENT_REASON_PREFIX.length)}`
|
||||
}
|
||||
if (line.startsWith(TRANSFER_REASON_PREFIX)) {
|
||||
return `Transfer reason: ${line.slice(TRANSFER_REASON_PREFIX.length)}`
|
||||
}
|
||||
return line
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
function localizeNotificationTitle(title: string) {
|
||||
switch (title.trim()) {
|
||||
case TICKET_ASSIGNED_TITLE:
|
||||
return "Ticket assigned"
|
||||
case CONVERSATION_TRANSFERRED_TITLE:
|
||||
return "Conversation transferred"
|
||||
case CONVERSATION_AUTO_ASSIGNED_TITLE:
|
||||
return "Conversation auto-assigned"
|
||||
case CONVERSATION_ASSIGNED_TITLE:
|
||||
return "Conversation assigned"
|
||||
default:
|
||||
return title
|
||||
}
|
||||
}
|
||||
|
||||
function splitNotificationLines(content: string) {
|
||||
const normalized = content.trim()
|
||||
if (!normalized) {
|
||||
return []
|
||||
}
|
||||
return normalized.split("\n")
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import ts from "typescript"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import vm from "node:vm"
|
||||
|
||||
async function loadModule() {
|
||||
const source = await readFile(new URL("./permission-i18n.ts", import.meta.url), "utf8")
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2017,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
},
|
||||
fileName: "permission-i18n.ts",
|
||||
})
|
||||
const sandbox = {
|
||||
exports: {},
|
||||
module: { exports: {} },
|
||||
}
|
||||
sandbox.exports = sandbox.module.exports
|
||||
vm.runInNewContext(compiled.outputText, sandbox)
|
||||
return sandbox.module.exports
|
||||
}
|
||||
|
||||
test("localizes seeded permission display names to English", async () => {
|
||||
const { getPermissionDisplayName, getPermissionGroupName } = await loadModule()
|
||||
|
||||
assert.equal(getPermissionDisplayName("user.assignRole", "\u5206\u914d\u7528\u6237\u89d2\u8272", "en-US"), "Assign user roles")
|
||||
assert.equal(getPermissionDisplayName("agentTeamSchedule.batchGenerate", "\u6279\u91cf\u751f\u6210\u5ba2\u670d\u7ec4\u6392\u73ed", "en-US"), "Batch generate agent team schedules")
|
||||
assert.equal(getPermissionGroupName("agentTeamSchedule", "en-US"), "Agent team schedules")
|
||||
})
|
||||
|
||||
test("keeps original permission names for Chinese locale", async () => {
|
||||
const { getPermissionDisplayName, getPermissionGroupName } = await loadModule()
|
||||
|
||||
assert.equal(getPermissionDisplayName("user.view", "\u67e5\u770b\u7528\u6237", "zh-CN"), "\u67e5\u770b\u7528\u6237")
|
||||
assert.equal(getPermissionGroupName("agentTeam", "zh-CN"), "agentTeam")
|
||||
})
|
||||
@@ -0,0 +1,109 @@
|
||||
const PERMISSION_ACTION_LABELS: Record<string, string> = {
|
||||
view: "View",
|
||||
create: "Create",
|
||||
update: "Update",
|
||||
delete: "Delete",
|
||||
assignRole: "Assign roles to",
|
||||
assignPermission: "Assign permissions to",
|
||||
sync: "Sync",
|
||||
revoke: "Revoke",
|
||||
assign: "Assign",
|
||||
transfer: "Transfer",
|
||||
close: "Close",
|
||||
send: "Send",
|
||||
tag: "Manage tags for",
|
||||
handover: "Handle handoffs for",
|
||||
recycle: "Recycle",
|
||||
linkCustomer: "Link customers to",
|
||||
changeStatus: "Change status for",
|
||||
progress: "Update progress for",
|
||||
resetUserTokenSecret: "Reset user token secret for",
|
||||
updateStatus: "Update status for",
|
||||
config: "Configure service rules for",
|
||||
batchGenerate: "Batch generate",
|
||||
call: "Call",
|
||||
}
|
||||
|
||||
const PERMISSION_RESOURCE_LABELS: Record<string, { singular: string; plural: string }> = {
|
||||
user: { singular: "user", plural: "users" },
|
||||
role: { singular: "role", plural: "roles" },
|
||||
permission: { singular: "permission", plural: "permissions" },
|
||||
session: { singular: "session", plural: "sessions" },
|
||||
conversation: { singular: "conversation", plural: "conversations" },
|
||||
ticket: { singular: "ticket", plural: "tickets" },
|
||||
notification: { singular: "notification", plural: "notifications" },
|
||||
quickReply: { singular: "quick reply", plural: "quick replies" },
|
||||
tag: { singular: "tag", plural: "tags" },
|
||||
company: { singular: "company", plural: "companies" },
|
||||
channel: { singular: "channel", plural: "channels" },
|
||||
customer: { singular: "customer", plural: "customers" },
|
||||
agent: { singular: "agent", plural: "agents" },
|
||||
agentTeam: { singular: "agent team", plural: "agent teams" },
|
||||
agentTeamSchedule: { singular: "agent team schedule", plural: "agent team schedules" },
|
||||
asset: { singular: "file asset", plural: "file assets" },
|
||||
aiAgent: { singular: "AI Agent", plural: "AI Agents" },
|
||||
aiConfig: { singular: "AI configuration", plural: "AI configurations" },
|
||||
knowledgeBase: { singular: "knowledge base", plural: "knowledge bases" },
|
||||
knowledgeDocument: { singular: "knowledge document", plural: "knowledge documents" },
|
||||
knowledgeFAQ: { singular: "knowledge FAQ", plural: "knowledge FAQs" },
|
||||
skillDefinition: { singular: "Skill definition", plural: "Skill definitions" },
|
||||
mcp: { singular: "MCP tool", plural: "MCP tools" },
|
||||
}
|
||||
|
||||
const PERMISSION_NAME_OVERRIDES: Record<string, string> = {
|
||||
"user.assignRole": "Assign user roles",
|
||||
"role.assignPermission": "Assign role permissions",
|
||||
"session.revoke": "Revoke sessions",
|
||||
"conversation.linkCustomer": "Link conversation customer",
|
||||
"ticket.changeStatus": "Change ticket status",
|
||||
"ticket.progress": "Update ticket progress",
|
||||
"channel.resetUserTokenSecret": "Reset channel user token secret",
|
||||
"agent.config": "Configure agent service rules",
|
||||
"agentTeamSchedule.batchGenerate": "Batch generate agent team schedules",
|
||||
"mcp.view": "View MCP debug information",
|
||||
"mcp.call": "Call MCP tools",
|
||||
}
|
||||
|
||||
export function getPermissionDisplayName(
|
||||
code: string | undefined,
|
||||
fallbackName: string,
|
||||
locale: string
|
||||
) {
|
||||
if (locale !== "en-US") {
|
||||
return fallbackName
|
||||
}
|
||||
const normalizedCode = code?.trim() ?? ""
|
||||
if (!normalizedCode) {
|
||||
return fallbackName
|
||||
}
|
||||
const override = PERMISSION_NAME_OVERRIDES[normalizedCode]
|
||||
if (override) {
|
||||
return override
|
||||
}
|
||||
const [resourceKey, actionKey] = normalizedCode.split(".")
|
||||
const resource = PERMISSION_RESOURCE_LABELS[resourceKey]
|
||||
const action = PERMISSION_ACTION_LABELS[actionKey]
|
||||
if (!resource || !action) {
|
||||
return fallbackName
|
||||
}
|
||||
return `${action} ${resource.plural}`
|
||||
}
|
||||
|
||||
export function getPermissionGroupName(groupName: string | undefined, locale: string) {
|
||||
const normalizedGroupName = groupName?.trim() ?? ""
|
||||
if (locale !== "en-US") {
|
||||
return normalizedGroupName
|
||||
}
|
||||
const resource = PERMISSION_RESOURCE_LABELS[normalizedGroupName]
|
||||
if (!resource) {
|
||||
return normalizedGroupName
|
||||
}
|
||||
return sentenceCase(resource.plural)
|
||||
}
|
||||
|
||||
function sentenceCase(value: string) {
|
||||
if (value === "AI Agents" || value === "MCP tools") {
|
||||
return value
|
||||
}
|
||||
return value.charAt(0).toUpperCase() + value.slice(1)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import ts from "typescript"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import vm from "node:vm"
|
||||
|
||||
async function loadModule() {
|
||||
const source = await readFile(new URL("./role-i18n.ts", import.meta.url), "utf8")
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2017,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
},
|
||||
fileName: "role-i18n.ts",
|
||||
})
|
||||
const sandbox = {
|
||||
exports: {},
|
||||
module: { exports: {} },
|
||||
}
|
||||
sandbox.exports = sandbox.module.exports
|
||||
vm.runInNewContext(compiled.outputText, sandbox)
|
||||
return sandbox.module.exports
|
||||
}
|
||||
|
||||
test("localizes seeded role names to English by role code", async () => {
|
||||
const { getRoleDisplayName } = await loadModule()
|
||||
|
||||
assert.equal(getRoleDisplayName("super_admin", "\u8d85\u7ea7\u7ba1\u7406\u5458", "en-US"), "Super admin")
|
||||
assert.equal(getRoleDisplayName("cs_team_leader", "\u5ba2\u670d\u7ec4\u957f", "en-US"), "Support team lead")
|
||||
assert.equal(getRoleDisplayName("cs_user", "\u5ba2\u670d", "en-US"), "Support agent")
|
||||
})
|
||||
|
||||
test("keeps custom or Chinese role names unchanged", async () => {
|
||||
const { getRoleDisplayName } = await loadModule()
|
||||
|
||||
assert.equal(getRoleDisplayName("custom", "Ops reviewer", "en-US"), "Ops reviewer")
|
||||
assert.equal(getRoleDisplayName("super_admin", "\u8d85\u7ea7\u7ba1\u7406\u5458", "zh-CN"), "\u8d85\u7ea7\u7ba1\u7406\u5458")
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
const SEEDED_ROLE_LABELS: Record<string, string> = {
|
||||
super_admin: "Super admin",
|
||||
admin: "Admin",
|
||||
cs_team_leader: "Support team lead",
|
||||
cs_user: "Support agent",
|
||||
}
|
||||
|
||||
export function getRoleDisplayName(
|
||||
code: string | undefined,
|
||||
fallbackName: string,
|
||||
locale: string
|
||||
) {
|
||||
if (locale !== "en-US") {
|
||||
return fallbackName
|
||||
}
|
||||
const label = SEEDED_ROLE_LABELS[code?.trim() ?? ""]
|
||||
return label || fallbackName
|
||||
}
|
||||
@@ -3,11 +3,11 @@ export type CSAgentConfig = {
|
||||
baseUrl?: string
|
||||
apiBaseUrl?: string
|
||||
widgetBaseUrl?: string
|
||||
/** 外部访客稳定标识;未传时使用浏览器本地访客 ID */
|
||||
/** Stable external visitor ID. Uses the browser-local visitor ID when omitted. */
|
||||
externalId?: string
|
||||
/** 访客展示名,仅用于首次换取客服会话 token */
|
||||
/** Visitor display name, only used when first exchanging for a chat token. */
|
||||
externalName?: string
|
||||
/** 打开客服前按需获取业务系统签发的前台用户 JWT */
|
||||
/** Gets the user JWT issued by the host system before opening support. */
|
||||
getUserToken?: () => string | Promise<string>
|
||||
title?: string
|
||||
subtitle?: string
|
||||
@@ -17,7 +17,7 @@ export type CSAgentConfig = {
|
||||
}
|
||||
|
||||
export type KefuChatRuntimeConfig = Omit<CSAgentConfig, "getUserToken"> & {
|
||||
/** 仅用于 /kefu/chat 运行时换取客服会话 token,不属于 CSAgentConfig 接入参数 */
|
||||
/** Used only by /kefu/chat to exchange for a chat token; not part of CSAgentConfig. */
|
||||
userToken?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ async function loadSdk(config) {
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
title: "在线客服",
|
||||
title: "\u5728\u7ebf\u5ba2\u670d",
|
||||
themeColor: "#2563eb",
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -38,6 +38,24 @@ type WidgetConfigResponse = {
|
||||
>>
|
||||
}
|
||||
|
||||
function getWidgetLocale() {
|
||||
try {
|
||||
const stored = window.localStorage?.getItem("cs_ai_agent_locale")
|
||||
const language = stored || document.documentElement.lang || window.navigator?.language || ""
|
||||
return language.toLowerCase().startsWith("en") ? "en-US" : "zh-CN"
|
||||
} catch {
|
||||
return "zh-CN"
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultWidgetTitle() {
|
||||
return getWidgetLocale() === "en-US" ? "Support" : "\u5728\u7ebf\u5ba2\u670d"
|
||||
}
|
||||
|
||||
function getLauncherText() {
|
||||
return getWidgetLocale() === "en-US" ? "Support" : "\u5ba2\u670d"
|
||||
}
|
||||
|
||||
type FrameMessage =
|
||||
| { type: "cs-agent:init"; payload: KefuChatRuntimeConfig }
|
||||
| { type: "cs-agent:open" }
|
||||
@@ -351,7 +369,7 @@ type FrameMessage =
|
||||
|
||||
state.frame = document.createElement("iframe")
|
||||
state.frame.dataset.csAgentWidget = "frame"
|
||||
state.frame.title = state.config.title || "在线客服"
|
||||
state.frame.title = state.config.title || getDefaultWidgetTitle()
|
||||
state.frame.src = state.frameUrl.toString()
|
||||
applyFrameLayout()
|
||||
state.frame.style.display = "block"
|
||||
@@ -417,7 +435,7 @@ type FrameMessage =
|
||||
const text = document.createElement("span")
|
||||
button.type = "button"
|
||||
button.dataset.csAgentWidget = "launcher"
|
||||
button.setAttribute("aria-label", config.title || "在线客服")
|
||||
button.setAttribute("aria-label", config.title || getDefaultWidgetTitle())
|
||||
icon.setAttribute("viewBox", "0 0 24 24")
|
||||
icon.setAttribute("fill", "none")
|
||||
icon.setAttribute("stroke", "currentColor")
|
||||
@@ -433,7 +451,7 @@ type FrameMessage =
|
||||
path.setAttribute("d", pathData)
|
||||
icon.appendChild(path)
|
||||
})
|
||||
text.textContent = "客服"
|
||||
text.textContent = getLauncherText()
|
||||
text.style.display = "block"
|
||||
button.style.position = "fixed"
|
||||
button.style.bottom = "24px"
|
||||
|
||||
@@ -31,11 +31,10 @@ 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: "已关闭" },
|
||||
{ value: "active", labelKey: "conversation.filterActive" },
|
||||
{ value: "pending", labelKey: "conversation.filterPending" },
|
||||
{ value: "ai_serving", labelKey: "conversation.filterAiServing" },
|
||||
{ value: "closed", labelKey: "conversation.filterClosed" },
|
||||
] as const
|
||||
|
||||
export type AgentConversationFilterKey =
|
||||
@@ -346,7 +345,7 @@ export const useAgentConversationsStore = create<AgentConversationsStore>((set,
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
// 实时同步失败不抛给 WS 回调
|
||||
// Keep realtime callback errors contained in the store.
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
+17
-12
@@ -41,6 +41,7 @@ import {
|
||||
readKefuChatRuntimeConfig,
|
||||
setKefuChatRuntimeConfig,
|
||||
} from "@/lib/sdk/runtime-config"
|
||||
import { translateCurrentMessage } from "@/i18n/messages"
|
||||
|
||||
type ChatStatus = "connecting" | "connected" | "disconnected"
|
||||
|
||||
@@ -144,6 +145,10 @@ export type KefuChatStore = {
|
||||
|
||||
let bootstrapToken = 0
|
||||
|
||||
function t(key: string) {
|
||||
return translateCurrentMessage(key)
|
||||
}
|
||||
|
||||
export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
||||
const realtime = createRealtimeConnectionManager({
|
||||
createSocket: createImRealtimeConnection,
|
||||
@@ -199,7 +204,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
||||
document.visibilityState !== "visible"
|
||||
) {
|
||||
const state = get()
|
||||
showNotification("新消息", getNotificationBody(message), () => {
|
||||
showNotification(t("kefu.newMessage"), getNotificationBody(message), () => {
|
||||
state.setIsOpen(true)
|
||||
state.setIsVisible(true)
|
||||
})
|
||||
@@ -231,7 +236,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
||||
}
|
||||
|
||||
return {
|
||||
title: "在线客服",
|
||||
title: t("kefu.title"),
|
||||
subtitle: "",
|
||||
themeColor: "#2563eb",
|
||||
conversation: null,
|
||||
@@ -288,7 +293,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
||||
}
|
||||
|
||||
set({
|
||||
title: widgetConfig.title || "在线客服",
|
||||
title: widgetConfig.title || t("kefu.title"),
|
||||
subtitle: widgetConfig.subtitle || "",
|
||||
themeColor: widgetConfig.themeColor || "#2563eb",
|
||||
})
|
||||
@@ -319,7 +324,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
||||
}
|
||||
set({
|
||||
status: "disconnected",
|
||||
error: error instanceof Error ? error.message : "初始化失败",
|
||||
error: error instanceof Error ? error.message : t("kefu.initFailed"),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -350,7 +355,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
||||
})
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "加载消息失败",
|
||||
error: error instanceof Error ? error.message : t("kefu.loadMessagesFailed"),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
@@ -386,7 +391,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
||||
})
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "同步消息失败",
|
||||
error: error instanceof Error ? error.message : t("kefu.syncMessagesFailed"),
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -429,7 +434,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
||||
} catch (error) {
|
||||
set({
|
||||
messagesLoadingMore: false,
|
||||
error: error instanceof Error ? error.message : "加载历史消息失败",
|
||||
error: error instanceof Error ? error.message : t("kefu.loadHistoryFailed"),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
@@ -514,7 +519,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
||||
} catch (error) {
|
||||
set({
|
||||
sending: false,
|
||||
error: error instanceof Error ? error.message : "发送消息失败",
|
||||
error: error instanceof Error ? error.message : t("kefu.sendMessageFailed"),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
@@ -535,7 +540,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
||||
return await uploadImImage(conversationId, file)
|
||||
} catch (error) {
|
||||
set({
|
||||
error: error instanceof Error ? error.message : "上传图片失败",
|
||||
error: error instanceof Error ? error.message : t("kefu.uploadImageFailed"),
|
||||
})
|
||||
return null
|
||||
} finally {
|
||||
@@ -580,7 +585,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
||||
} catch (error) {
|
||||
set({
|
||||
uploadingAsset: false,
|
||||
error: error instanceof Error ? error.message : "发送附件失败",
|
||||
error: error instanceof Error ? error.message : t("kefu.sendAttachmentFailed"),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
@@ -609,7 +614,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
||||
} catch (error) {
|
||||
set({
|
||||
closingConversation: false,
|
||||
error: error instanceof Error ? error.message : "关闭会话失败",
|
||||
error: error instanceof Error ? error.message : t("kefu.closeConversationFailed"),
|
||||
})
|
||||
throw error
|
||||
}
|
||||
@@ -629,7 +634,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
||||
} catch (error) {
|
||||
set({
|
||||
status: "disconnected",
|
||||
error: error instanceof Error ? error.message : "刷新失败",
|
||||
error: error instanceof Error ? error.message : t("kefu.refreshFailed"),
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user