refactor: support i18n

This commit is contained in:
mlogclub
2026-05-25 12:06:15 +08:00
parent 309ac1fe9e
commit 988f55c80d
179 changed files with 10968 additions and 3763 deletions
+2 -1
View File
@@ -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()
+6 -1
View File
@@ -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}`, {
+4 -4
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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) {
+56
View File
@@ -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")
})
+48
View File
@@ -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
View File
@@ -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;
}
}
}
+53
View File
@@ -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"
)
})
+103
View File
@@ -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")
}
+38
View File
@@ -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")
})
+109
View File
@@ -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)
}
+38
View File
@@ -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")
})
+18
View File
@@ -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
}
+4 -4
View File
@@ -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
}
+1 -1
View File
@@ -54,7 +54,7 @@ async function loadSdk(config) {
json: async () => ({
success: true,
data: {
title: "在线客服",
title: "\u5728\u7ebf\u5ba2\u670d",
themeColor: "#2563eb",
},
}),
+21 -3
View File
@@ -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"
+5 -6
View File
@@ -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
View File
@@ -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"),
})
}
},