From b26d10709aa6a96283597a8e6a1cc739cafe2aef Mon Sep 17 00:00:00 2001 From: mlogclub Date: Tue, 25 Aug 2026 10:20:08 +0800 Subject: [PATCH 01/29] feat: implement AI customer service configuration and validation --- internal/pkg/dto/request/support_request.go | 5 + internal/pkg/dto/response/support_response.go | 11 +- internal/pkg/i18nx/locales/en-US.yml | 9 ++ internal/pkg/i18nx/locales/zh-CN.yml | 9 ++ internal/services/system_config_service.go | 48 ++++++- .../system_config_support_validator.go | 56 ++++++++ .../system_config_support_validator_test.go | 74 +++++++++++ .../_components/support-config-panel.tsx | 122 +++++++++++++++++- .../_components/support-ai-chat-widget.tsx | 66 ++++++++++ .../_components/support-page-shell.tsx | 2 + web/lib/api/admin.ts | 10 ++ web/lib/api/support-config.ts | 4 + web/messages/en-US.json | 13 ++ web/messages/zh-CN.json | 13 ++ 14 files changed, 431 insertions(+), 11 deletions(-) create mode 100644 web/app/(support)/support/_components/support-ai-chat-widget.tsx diff --git a/internal/pkg/dto/request/support_request.go b/internal/pkg/dto/request/support_request.go index 7343fa7f..f50903ac 100644 --- a/internal/pkg/dto/request/support_request.go +++ b/internal/pkg/dto/request/support_request.go @@ -126,3 +126,8 @@ type SupportNavigationMenuItemRequest struct { Visible *bool `json:"visible"` Children []SupportNavigationMenuItemRequest `json:"children"` } + +type SupportAICustomerServiceConfigRequest struct { + Enabled bool `json:"enabled"` + ChannelID string `json:"channelId"` +} diff --git a/internal/pkg/dto/response/support_response.go b/internal/pkg/dto/response/support_response.go index cabc4f60..d6b3df7c 100644 --- a/internal/pkg/dto/response/support_response.go +++ b/internal/pkg/dto/response/support_response.go @@ -27,11 +27,18 @@ type SupportNavigationMenuItemResponse struct { } type PublicSupportConfigResponse struct { - NavigationMenu []SupportNavigationMenuItemResponse `json:"navigationMenu"` + NavigationMenu []SupportNavigationMenuItemResponse `json:"navigationMenu"` + AICustomerService SupportAICustomerServiceConfigResponse `json:"aiCustomerService"` } type DashboardSupportConfigResponse struct { - NavigationMenu []SupportNavigationMenuItemResponse `json:"navigationMenu"` + NavigationMenu []SupportNavigationMenuItemResponse `json:"navigationMenu"` + AICustomerService SupportAICustomerServiceConfigResponse `json:"aiCustomerService"` +} + +type SupportAICustomerServiceConfigResponse struct { + Enabled bool `json:"enabled"` + ChannelID string `json:"channelId"` } type DocPageResponse struct { diff --git a/internal/pkg/i18nx/locales/en-US.yml b/internal/pkg/i18nx/locales/en-US.yml index 9fbec600..ac67c301 100644 --- a/internal/pkg/i18nx/locales/en-US.yml +++ b/internal/pkg/i18nx/locales/en-US.yml @@ -503,12 +503,21 @@ error.supportConfig.navigationTitleTooLong: "Navigation titles cannot exceed 64 error.supportConfig.navigationURLRequired: "Enter a navigation URL" error.supportConfig.navigationURLInvalid: "Navigation URLs must be internal paths or http/https URLs" error.supportConfig.navigationVisibleRequired: "Show at least one navigation item" +error.supportConfig.aiCustomerServiceInvalidJSON: "AI support config must be a valid JSON object" +error.supportConfig.aiCustomerServiceChannelRequired: "Select an AI support channel" +error.supportConfig.aiCustomerServiceChannelNotFound: "AI support channel not found" +error.supportConfig.aiCustomerServiceChannelTypeInvalid: "AI support can only use a web channel" +error.supportConfig.aiCustomerServiceChannelDisabled: "AI support channel is not enabled" +error.supportConfig.aiCustomerServiceAgentDisabled: "The AI Agent linked to the AI support channel does not exist or is not enabled" +error.supportConfig.aiCustomerServiceAgentUnpublished: "The AI Agent linked to the AI support channel has not been published" error.supportConfig.validationFailed: "Config validation failed" error.supportConfig.groupUnsupported: "Unsupported config group" error.supportConfig.keyUnsupported: "Unsupported config key: %v" error.supportConfig.emptyPayload: "Submit at least one config item" systemConfig.support.navigationMenu.title: "Support center navigation menu" systemConfig.support.navigationMenu.description: "Navigation menu for the support center public header and mobile views" +systemConfig.support.aiCustomerService.title: "Support center AI support" +systemConfig.support.aiCustomerService.description: "AI support channel used by the public support center" systemConfig.support.navigationMenu.default.home: "Home" systemConfig.support.navigationMenu.default.docs: "Docs" systemConfig.support.navigationMenu.default.community: "Community" diff --git a/internal/pkg/i18nx/locales/zh-CN.yml b/internal/pkg/i18nx/locales/zh-CN.yml index 6b3aa8eb..e168083c 100644 --- a/internal/pkg/i18nx/locales/zh-CN.yml +++ b/internal/pkg/i18nx/locales/zh-CN.yml @@ -503,12 +503,21 @@ error.supportConfig.navigationTitleTooLong: "导航标题不能超过 64 个字 error.supportConfig.navigationURLRequired: "请填写导航链接" error.supportConfig.navigationURLInvalid: "导航链接必须是站内路径或 http/https 地址" error.supportConfig.navigationVisibleRequired: "请至少显示一个导航菜单" +error.supportConfig.aiCustomerServiceInvalidJSON: "AI 客服配置必须是合法的 JSON 对象" +error.supportConfig.aiCustomerServiceChannelRequired: "请选择 AI 客服接入渠道" +error.supportConfig.aiCustomerServiceChannelNotFound: "AI 客服接入渠道不存在" +error.supportConfig.aiCustomerServiceChannelTypeInvalid: "AI 客服只能使用 Web 渠道" +error.supportConfig.aiCustomerServiceChannelDisabled: "AI 客服接入渠道未启用" +error.supportConfig.aiCustomerServiceAgentDisabled: "AI 客服渠道绑定的 AI Agent 不存在或未启用" +error.supportConfig.aiCustomerServiceAgentUnpublished: "AI 客服渠道绑定的 AI Agent 尚未发布" error.supportConfig.validationFailed: "配置校验失败" error.supportConfig.groupUnsupported: "不支持该配置分组" error.supportConfig.keyUnsupported: "不支持的配置项:%v" error.supportConfig.emptyPayload: "请至少提交一个配置项" systemConfig.support.navigationMenu.title: "支持中心导航菜单" systemConfig.support.navigationMenu.description: "支持中心公开页面顶部和移动端导航菜单" +systemConfig.support.aiCustomerService.title: "支持中心 AI 客服" +systemConfig.support.aiCustomerService.description: "支持中心公开页面使用的 AI 客服接入渠道" systemConfig.support.navigationMenu.default.home: "首页" systemConfig.support.navigationMenu.default.docs: "文档" systemConfig.support.navigationMenu.default.community: "社区" diff --git a/internal/services/system_config_service.go b/internal/services/system_config_service.go index 57f24050..1efeda73 100644 --- a/internal/services/system_config_service.go +++ b/internal/services/system_config_service.go @@ -29,8 +29,9 @@ type systemConfigService struct { } const ( - systemConfigGroupSupportCenter = "support" - systemConfigKeySupportNavMenu = "navigationMenu" + systemConfigGroupSupportCenter = "support" + systemConfigKeySupportNavMenu = "navigationMenu" + systemConfigKeySupportAICustomerService = "aiCustomerService" ) type configValidator interface { @@ -89,6 +90,14 @@ var systemConfigDefinitions = map[string]map[string]systemConfigDefinition{ DefaultValue: defaultSupportNavigationMenu(), Validator: supportNavigationMenuValidator{}, }, + systemConfigKeySupportAICustomerService: { + GroupCode: systemConfigGroupSupportCenter, + Key: systemConfigKeySupportAICustomerService, + TitleKey: "systemConfig.support.aiCustomerService.title", + DescriptionKey: "systemConfig.support.aiCustomerService.description", + DefaultValue: defaultSupportAICustomerServiceConfig(), + Validator: supportAICustomerServiceConfigValidator{}, + }, }, } @@ -118,13 +127,15 @@ func (s *systemConfigService) FindPageByCnd(cnd *sqls.Cnd) (list []models.System func (s *systemConfigService) GetPublicSupportConfig() response.PublicSupportConfigResponse { return response.PublicSupportConfigResponse{ - NavigationMenu: s.enabledSupportNavigationMenu(), + NavigationMenu: s.enabledSupportNavigationMenu(), + AICustomerService: s.publicSupportAICustomerServiceConfig(), } } func (s *systemConfigService) GetDashboardSupportConfig() response.DashboardSupportConfigResponse { return response.DashboardSupportConfigResponse{ - NavigationMenu: s.supportNavigationMenu(), + NavigationMenu: s.supportNavigationMenu(), + AICustomerService: s.supportAICustomerServiceConfig(), } } @@ -247,6 +258,35 @@ func (s *systemConfigService) supportNavigationMenu() []response.SupportNavigati return sortSupportNavigationMenu(list) } +func (s *systemConfigService) publicSupportAICustomerServiceConfig() response.SupportAICustomerServiceConfigResponse { + cfg := s.supportAICustomerServiceConfig() + if !cfg.Enabled { + return response.SupportAICustomerServiceConfigResponse{} + } + channel := repositories.ChannelRepository.GetByChannelID(sqls.DB(), cfg.ChannelID) + if channel == nil || channel.Status != enums.StatusOk || channel.ChannelType != enums.ChannelTypeWeb { + return response.SupportAICustomerServiceConfigResponse{} + } + aiAgent := repositories.AIAgentRepository.Get(sqls.DB(), channel.AIAgentID) + if aiAgent == nil || aiAgent.Status != enums.StatusOk || aiAgent.PublishedRevisionID <= 0 { + return response.SupportAICustomerServiceConfigResponse{} + } + return cfg +} + +func (s *systemConfigService) supportAICustomerServiceConfig() response.SupportAICustomerServiceConfigResponse { + item := repositories.SystemConfigRepository.FindByGroupAndKey(sqls.DB(), systemConfigGroupSupportCenter, systemConfigKeySupportAICustomerService) + if item == nil || strings.TrimSpace(item.ConfigValue) == "" { + return defaultSupportAICustomerServiceConfig() + } + var cfg response.SupportAICustomerServiceConfigResponse + if err := json.Unmarshal([]byte(item.ConfigValue), &cfg); err != nil { + return defaultSupportAICustomerServiceConfig() + } + cfg.ChannelID = strings.TrimSpace(cfg.ChannelID) + return cfg +} + func sortSupportNavigationMenu(items []response.SupportNavigationMenuItemResponse) []response.SupportNavigationMenuItemResponse { ret := append([]response.SupportNavigationMenuItemResponse(nil), items...) for i := 0; i < len(ret)-1; i++ { diff --git a/internal/services/system_config_support_validator.go b/internal/services/system_config_support_validator.go index 79ebc65c..6c99ae0c 100644 --- a/internal/services/system_config_support_validator.go +++ b/internal/services/system_config_support_validator.go @@ -8,13 +8,18 @@ import ( "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/dto/response" + "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/i18nx" + "agent-desk/internal/repositories" "github.com/mlogclub/simple/common/strs" + "github.com/mlogclub/simple/sqls" ) type supportNavigationMenuValidator struct{} +type supportAICustomerServiceConfigValidator struct{} + func (supportNavigationMenuValidator) Validate(raw json.RawMessage) (json.RawMessage, []response.ConfigFieldError, error) { var input []request.SupportNavigationMenuItemRequest if err := json.Unmarshal(raw, &input); err != nil { @@ -31,6 +36,53 @@ func (supportNavigationMenuValidator) Validate(raw json.RawMessage) (json.RawMes return normalized, nil, nil } +func (supportAICustomerServiceConfigValidator) Validate(raw json.RawMessage) (json.RawMessage, []response.ConfigFieldError, error) { + var input request.SupportAICustomerServiceConfigRequest + if err := json.Unmarshal(raw, &input); err != nil { + return nil, []response.ConfigFieldError{configFieldError("aiCustomerService", "invalid_json", "error.supportConfig.aiCustomerServiceInvalidJSON")}, nil + } + cfg, fieldErrors := normalizeSupportAICustomerServiceConfig(input) + if len(fieldErrors) > 0 { + return nil, fieldErrors, nil + } + normalized, err := json.Marshal(cfg) + if err != nil { + return nil, nil, err + } + return normalized, nil, nil +} + +func normalizeSupportAICustomerServiceConfig(input request.SupportAICustomerServiceConfigRequest) (response.SupportAICustomerServiceConfigResponse, []response.ConfigFieldError) { + cfg := response.SupportAICustomerServiceConfigResponse{ + Enabled: input.Enabled, + ChannelID: strings.TrimSpace(input.ChannelID), + } + if !cfg.Enabled { + return cfg, nil + } + if cfg.ChannelID == "" { + return cfg, []response.ConfigFieldError{configFieldError("aiCustomerService.channelId", "required", "error.supportConfig.aiCustomerServiceChannelRequired")} + } + channel := repositories.ChannelRepository.GetByChannelID(sqls.DB(), cfg.ChannelID) + if channel == nil || channel.Status == enums.StatusDeleted { + return cfg, []response.ConfigFieldError{configFieldError("aiCustomerService.channelId", "not_found", "error.supportConfig.aiCustomerServiceChannelNotFound")} + } + if channel.ChannelType != enums.ChannelTypeWeb { + return cfg, []response.ConfigFieldError{configFieldError("aiCustomerService.channelId", "type_invalid", "error.supportConfig.aiCustomerServiceChannelTypeInvalid")} + } + if channel.Status != enums.StatusOk { + return cfg, []response.ConfigFieldError{configFieldError("aiCustomerService.channelId", "disabled", "error.supportConfig.aiCustomerServiceChannelDisabled")} + } + aiAgent := repositories.AIAgentRepository.Get(sqls.DB(), channel.AIAgentID) + if aiAgent == nil || aiAgent.Status != enums.StatusOk { + return cfg, []response.ConfigFieldError{configFieldError("aiCustomerService.channelId", "agent_disabled", "error.supportConfig.aiCustomerServiceAgentDisabled")} + } + if aiAgent.PublishedRevisionID <= 0 { + return cfg, []response.ConfigFieldError{configFieldError("aiCustomerService.channelId", "agent_unpublished", "error.supportConfig.aiCustomerServiceAgentUnpublished")} + } + return cfg, nil +} + func normalizeSupportNavigationMenu(input []request.SupportNavigationMenuItemRequest) ([]response.SupportNavigationMenuItemResponse, []response.ConfigFieldError) { if len(input) == 0 { return nil, []response.ConfigFieldError{configFieldError("navigationMenu", "required", "error.supportConfig.navigationRequired")} @@ -168,3 +220,7 @@ func defaultSupportNavigationMenu() []response.SupportNavigationMenuItemResponse {ID: "community", Title: i18nx.Get("systemConfig.support.navigationMenu.default.community"), URL: "/support/community/posts", SortNo: 30, Visible: true}, } } + +func defaultSupportAICustomerServiceConfig() response.SupportAICustomerServiceConfigResponse { + return response.SupportAICustomerServiceConfigResponse{} +} diff --git a/internal/services/system_config_support_validator_test.go b/internal/services/system_config_support_validator_test.go index b56df300..03a85325 100644 --- a/internal/services/system_config_support_validator_test.go +++ b/internal/services/system_config_support_validator_test.go @@ -4,6 +4,9 @@ import ( "encoding/json" "testing" + "agent-desk/internal/models" + "agent-desk/internal/pkg/dto/request" + "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/i18nx" ) @@ -28,3 +31,74 @@ func TestSystemConfigValidationErrorLocalizesFieldErrors(t *testing.T) { t.Fatalf("message key = %q", localized[0].MessageKey) } } + +func TestSupportAICustomerServiceConfigValidatesWebChannel(t *testing.T) { + db := setupChannelServiceTestDB(t) + if err := db.AutoMigrate(&models.SystemConfig{}); err != nil { + t.Fatalf("migrate system config: %v", err) + } + agent := createChannelServiceTestAgent(t, db, 1001) + channel, err := ChannelService.CreateChannel(request.CreateChannelRequest{ + ChannelType: enums.ChannelTypeWeb, + AIAgentID: agent.ID, + Name: "支持中心 AI 客服", + Status: int(enums.StatusOk), + }, channelServiceTestOperator()) + if err != nil { + t.Fatalf("create channel: %v", err) + } + + config, err := SystemConfigService.SaveSupportConfig(map[string]json.RawMessage{ + systemConfigKeySupportAICustomerService: json.RawMessage(`{"enabled":true,"channelId":"` + channel.ChannelID + `"}`), + }, channelServiceTestOperator()) + if err != nil { + t.Fatalf("SaveSupportConfig() error = %v", err) + } + if !config.AICustomerService.Enabled || config.AICustomerService.ChannelID != channel.ChannelID { + t.Fatalf("unexpected dashboard config: %#v", config.AICustomerService) + } + publicConfig := SystemConfigService.GetPublicSupportConfig() + if !publicConfig.AICustomerService.Enabled || publicConfig.AICustomerService.ChannelID != channel.ChannelID { + t.Fatalf("unexpected public config: %#v", publicConfig.AICustomerService) + } +} + +func TestPublicSupportAICustomerServiceHidesDisabledChannel(t *testing.T) { + db := setupChannelServiceTestDB(t) + if err := db.AutoMigrate(&models.SystemConfig{}); err != nil { + t.Fatalf("migrate system config: %v", err) + } + agent := createChannelServiceTestAgent(t, db, 1001) + channel, err := ChannelService.CreateChannel(request.CreateChannelRequest{ + ChannelType: enums.ChannelTypeWeb, + AIAgentID: agent.ID, + Name: "支持中心 AI 客服", + Status: int(enums.StatusOk), + }, channelServiceTestOperator()) + if err != nil { + t.Fatalf("create channel: %v", err) + } + if _, err := SystemConfigService.SaveSupportConfig(map[string]json.RawMessage{ + systemConfigKeySupportAICustomerService: json.RawMessage(`{"enabled":true,"channelId":"` + channel.ChannelID + `"}`), + }, channelServiceTestOperator()); err != nil { + t.Fatalf("SaveSupportConfig() error = %v", err) + } + if err := ChannelService.UpdateStatus(channel.ID, int(enums.StatusDisabled), channelServiceTestOperator()); err != nil { + t.Fatalf("disable channel: %v", err) + } + + publicConfig := SystemConfigService.GetPublicSupportConfig() + if publicConfig.AICustomerService.Enabled || publicConfig.AICustomerService.ChannelID != "" { + t.Fatalf("disabled channel should be hidden from public config: %#v", publicConfig.AICustomerService) + } +} + +func TestSupportAICustomerServiceConfigAllowsDisabledConfigWithStaleChannel(t *testing.T) { + _, fieldErrors, err := supportAICustomerServiceConfigValidator{}.Validate(json.RawMessage(`{"enabled":false,"channelId":"stale"}`)) + if err != nil { + t.Fatalf("Validate() error = %v", err) + } + if len(fieldErrors) != 0 { + t.Fatalf("disabled config should not validate stale channel: %#v", fieldErrors) + } +} diff --git a/web/app/(dashboard)/dashboard/support/_components/support-config-panel.tsx b/web/app/(dashboard)/dashboard/support/_components/support-config-panel.tsx index 50e3eff9..2cb9f26d 100644 --- a/web/app/(dashboard)/dashboard/support/_components/support-config-panel.tsx +++ b/web/app/(dashboard)/dashboard/support/_components/support-config-panel.tsx @@ -19,10 +19,11 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable" import { CSS } from "@dnd-kit/utilities" -import { ExternalLinkIcon, GripVerticalIcon, PlusIcon, RefreshCwIcon, SaveIcon, Trash2Icon } from "lucide-react" +import { BotIcon, ExternalLinkIcon, GripVerticalIcon, PlusIcon, RefreshCwIcon, SaveIcon, Trash2Icon } from "lucide-react" import { toast } from "sonner" import { DashboardPage, DashboardTableShell, DashboardTableStateRow, DashboardToolbar } from "@/components/dashboard-page" +import { OptionCombobox } from "@/components/option-combobox" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" @@ -31,8 +32,10 @@ import { Switch } from "@/components/ui/switch" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { useI18n } from "@/i18n/provider" import { + fetchChannels, fetchSupportConfigAdmin, saveSupportConfigAdmin, + type AdminChannel, type SupportNavigationMenuItem, } from "@/lib/api/admin" import { isApiRequestError } from "@/lib/api/client" @@ -52,6 +55,16 @@ type NavigationMenuRowProps = { onDelete: (id: string) => void } +type AICustomerServiceConfig = { + enabled: boolean + channelId: string +} + +const DEFAULT_AI_CUSTOMER_SERVICE_CONFIG: AICustomerServiceConfig = { + enabled: false, + channelId: "", +} + const newMenuItem = (): SupportNavigationMenuItem => ({ id: `draft-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, title: "", @@ -77,12 +90,32 @@ function serializeRows(rows: SupportNavigationMenuItem[]) { ) } +function serializeConfig(rows: SupportNavigationMenuItem[], aiCustomerService: AICustomerServiceConfig) { + return JSON.stringify({ + navigationMenu: JSON.parse(serializeRows(rows)), + aiCustomerService: { + enabled: aiCustomerService.enabled, + channelId: aiCustomerService.channelId.trim(), + }, + }) +} + +function normalizeAICustomerServiceConfig(config?: Partial | null): AICustomerServiceConfig { + return { + enabled: Boolean(config?.enabled), + channelId: config?.channelId?.trim() ?? "", + } +} + export function SupportConfigPanel() { const t = useI18n() const [items, setItems] = useState([]) + const [aiCustomerService, setAICustomerService] = useState(DEFAULT_AI_CUSTOMER_SERVICE_CONFIG) + const [channels, setChannels] = useState([]) const [savedSnapshot, setSavedSnapshot] = useState("") const [fieldErrors, setFieldErrors] = useState([]) const [loading, setLoading] = useState(true) + const [channelsLoading, setChannelsLoading] = useState(true) const [saving, setSaving] = useState(false) const sensors = useSensors( @@ -91,16 +124,24 @@ export function SupportConfigPanel() { useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) ) - const dirty = useMemo(() => serializeRows(items) !== savedSnapshot, [items, savedSnapshot]) + const dirty = useMemo(() => serializeConfig(items, aiCustomerService) !== savedSnapshot, [items, aiCustomerService, savedSnapshot]) const canDelete = items.length > 1 + const channelOptions = useMemo(() => channels.map((channel) => ({ + value: channel.channelId, + label: channel.name || channel.channelId, + subtitle: channel.aiAgentName ? t("supportConfig.aiChannelAgent", { name: channel.aiAgentName }) : channel.channelId, + })), [channels, t]) + const selectedChannel = channels.find((channel) => channel.channelId === aiCustomerService.channelId) const loadConfig = useCallback(async () => { try { setLoading(true) const config = await fetchSupportConfigAdmin() const nextItems = normalizeRows(config.navigationMenu) + const nextAIConfig = normalizeAICustomerServiceConfig(config.aiCustomerService) setItems(nextItems) - setSavedSnapshot(serializeRows(nextItems)) + setAICustomerService(nextAIConfig) + setSavedSnapshot(serializeConfig(nextItems, nextAIConfig)) setFieldErrors([]) } catch (error) { toast.error(error instanceof Error ? error.message : t("supportConfig.loadFailed")) @@ -109,10 +150,26 @@ export function SupportConfigPanel() { } }, [t]) + const loadChannels = useCallback(async () => { + try { + setChannelsLoading(true) + const page = await fetchChannels({ channelType: "web", status: 0, limit: 100 }) + setChannels(page.results.filter((channel) => channel.channelType === "web" && channel.status === 0)) + } catch (error) { + toast.error(error instanceof Error ? error.message : t("supportConfig.loadChannelsFailed")) + } finally { + setChannelsLoading(false) + } + }, [t]) + useEffect(() => { void loadConfig() }, [loadConfig]) + useEffect(() => { + void loadChannels() + }, [loadChannels]) + useEffect(() => { if (!dirty) { return @@ -162,10 +219,15 @@ export function SupportConfigPanel() { async function handleSave() { try { setSaving(true) - const config = await saveSupportConfigAdmin({ navigationMenu: items }) + const config = await saveSupportConfigAdmin({ + navigationMenu: items, + aiCustomerService, + }) const saved = normalizeRows(config.navigationMenu) + const savedAIConfig = normalizeAICustomerServiceConfig(config.aiCustomerService) setItems(saved) - setSavedSnapshot(serializeRows(saved)) + setAICustomerService(savedAIConfig) + setSavedSnapshot(serializeConfig(saved, savedAIConfig)) setFieldErrors([]) toast.success(t("supportConfig.saved")) } catch (error) { @@ -201,6 +263,56 @@ export function SupportConfigPanel() {
+
+
+
+
+ +
+
+

{t("supportConfig.aiCustomerServiceTitle")}

+

{t("supportConfig.aiCustomerServiceDescription")}

+
+
+
+ + setAICustomerService((current) => ({ ...current, enabled }))} + disabled={loading || saving} + aria-label={t("supportConfig.toggleAIService")} + /> +
+
+ +
+ + setAICustomerService((current) => ({ ...current, channelId }))} + options={channelOptions} + placeholder={channelsLoading ? t("supportConfig.loadingChannels") : t("supportConfig.selectAIChannel")} + searchPlaceholder={t("supportConfig.searchAIChannel")} + emptyText={t("supportConfig.emptyAIChannel")} + disabled={loading || saving || channelsLoading} + triggerClassName="rounded-md" + /> + {selectedChannel ? ( +

+ {t("supportConfig.aiCustomerServiceChannelSummary", { + agent: selectedChannel.aiAgentName || "-", + rollout: selectedChannel.aiAgentRolloutPercent, + })} +

+ ) : ( +

{t("supportConfig.aiCustomerServiceChannelHint")}

+ )} +
+
+

{t("supportConfig.navigationTitle")}

diff --git a/web/app/(support)/support/_components/support-ai-chat-widget.tsx b/web/app/(support)/support/_components/support-ai-chat-widget.tsx new file mode 100644 index 00000000..1ba29982 --- /dev/null +++ b/web/app/(support)/support/_components/support-ai-chat-widget.tsx @@ -0,0 +1,66 @@ +"use client" + +import { useEffect } from "react" + +import { fetchSupportConfig } from "@/lib/api/support-config" +import type { AgentDeskConfig } from "@/lib/sdk/config-types" + +const WIDGET_SCRIPT_SELECTOR = '[data-agent-desk-widget="support-platform-script"]' + +function removeSupportWidget() { + window.AgentDeskWidget?.destroy() + document.querySelector(WIDGET_SCRIPT_SELECTOR)?.remove() + delete window.AgentDeskConfig +} + +function mountSupportWidget(config: AgentDeskConfig) { + window.AgentDeskConfig = config + if (window.AgentDeskWidget) { + window.AgentDeskWidget.mount(config) + return + } + + const script = document.createElement("script") + script.async = true + script.src = "/sdk/agent-desk-sdk.min.js" + script.dataset.agentDeskWidget = "support-platform-script" + document.body.appendChild(script) +} + +export function SupportAIChatWidget() { + useEffect(() => { + let cancelled = false + + async function loadConfig() { + try { + const config = await fetchSupportConfig() + if (cancelled) { + return + } + const aiCustomerService = config.aiCustomerService + if (!aiCustomerService?.enabled || !aiCustomerService.channelId) { + removeSupportWidget() + return + } + mountSupportWidget({ + channelId: aiCustomerService.channelId, + baseUrl: window.location.origin, + widgetBaseUrl: window.location.origin, + }) + } catch { + if (!cancelled) { + removeSupportWidget() + } + } + } + + void loadConfig() + + return () => { + cancelled = true + removeSupportWidget() + } + }, []) + + return null +} diff --git a/web/app/(support)/support/_components/support-page-shell.tsx b/web/app/(support)/support/_components/support-page-shell.tsx index 00060b07..5c124e5d 100644 --- a/web/app/(support)/support/_components/support-page-shell.tsx +++ b/web/app/(support)/support/_components/support-page-shell.tsx @@ -1,5 +1,6 @@ import { type ReactNode } from "react" +import { SupportAIChatWidget } from "@/app/(support)/support/_components/support-ai-chat-widget" import { SupportHeader, type SupportHeaderSection } from "@/app/(support)/support/_components/support-header" import { cn } from "@/lib/utils" @@ -9,6 +10,7 @@ export function SupportPageShell({ children, section = "home" }: { children: Rea
{children} +
) } diff --git a/web/lib/api/admin.ts b/web/lib/api/admin.ts index 8f6da064..491a5a60 100644 --- a/web/lib/api/admin.ts +++ b/web/lib/api/admin.ts @@ -2386,6 +2386,10 @@ export type SupportNavigationMenuItem = { export type DashboardSupportConfig = { navigationMenu: SupportNavigationMenuItem[] + aiCustomerService: { + enabled: boolean + channelId: string + } } export function fetchSupportConfigAdmin() { @@ -2405,6 +2409,12 @@ export function saveSupportConfigAdmin(payload: Partial) visible, children, })), + aiCustomerService: payload.aiCustomerService + ? { + enabled: payload.aiCustomerService.enabled, + channelId: payload.aiCustomerService.channelId, + } + : undefined, }), }) } diff --git a/web/lib/api/support-config.ts b/web/lib/api/support-config.ts index 38ccd179..7fd672e4 100644 --- a/web/lib/api/support-config.ts +++ b/web/lib/api/support-config.ts @@ -12,6 +12,10 @@ export type SupportNavigationMenuItem = { export type PublicSupportConfig = { navigationMenu: SupportNavigationMenuItem[] + aiCustomerService: { + enabled: boolean + channelId: string + } } export function fetchSupportConfig() { diff --git a/web/messages/en-US.json b/web/messages/en-US.json index c83bc000..9f18cb53 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -1504,10 +1504,23 @@ "createTitle": "New Workflow", "refresh": "Refresh", "create": "Create Workflow", + "loadChannelsFailed": "Could not load AI support channels.", "query": "Search", "loading": "Loading workflows...", "empty": "No workflows yet", + "disabled": "Disabled", "actions": "Actions", + "aiCustomerServiceTitle": "AI Support", + "aiCustomerServiceDescription": "Select the web channel used by the public support center. The linked AI Agent and service mode are managed in channel settings.", + "aiCustomerServiceChannel": "Channel", + "selectAIChannel": "Select a web channel", + "searchAIChannel": "Search channels", + "emptyAIChannel": "No available web channels", + "loadingChannels": "Loading channels...", + "aiChannelAgent": "AI Agent: {name}", + "aiCustomerServiceChannelSummary": "AI Agent: {agent}, rollout: {rollout}%", + "aiCustomerServiceChannelHint": "Select an enabled web channel linked to a published AI Agent before enabling AI support.", + "toggleAIService": "Enable or disable AI support", "edit": "Edit", "delete": "Delete", "processing": "Processing...", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 14e67ae3..92c36265 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -1505,9 +1505,22 @@ "refresh": "刷新", "create": "创建工作流", "query": "查询", + "loadChannelsFailed": "AI 客服渠道加载失败", "loading": "正在加载工作流…", "empty": "暂无工作流", + "disabled": "停用", "actions": "操作", + "aiCustomerServiceTitle": "AI 客服", + "aiCustomerServiceDescription": "选择支持中心公开页面使用的 Web 接入渠道。渠道绑定的 AI Agent 和服务模式在渠道管理中维护。", + "aiCustomerServiceChannel": "接入渠道", + "selectAIChannel": "选择 Web 渠道", + "searchAIChannel": "搜索渠道", + "emptyAIChannel": "暂无可用 Web 渠道", + "loadingChannels": "正在加载渠道...", + "aiChannelAgent": "AI Agent:{name}", + "aiCustomerServiceChannelSummary": "AI Agent:{agent},灰度比例:{rollout}%", + "aiCustomerServiceChannelHint": "启用 AI 客服前,请先选择一个已启用并绑定已发布 AI Agent 的 Web 渠道。", + "toggleAIService": "启用或停用 AI 客服", "edit": "编辑", "delete": "删除", "processing": "处理中…", From 0b5bc1d37d64712daea185b9f7ce6a553ffaee9b Mon Sep 17 00:00:00 2001 From: mlogclub Date: Tue, 25 Aug 2026 11:18:17 +0800 Subject: [PATCH 02/29] feat: add AI customer service user token endpoint and related functionality --- internal/bootstrap/routes.go | 1 + .../handlers/api/support_config_handler.go | 24 ++++++ internal/pkg/dto/response/support_response.go | 5 ++ internal/pkg/enums/external_identity.go | 6 +- internal/pkg/openidentity/openidentity.go | 35 ++++++-- .../pkg/openidentity/openidentity_test.go | 86 ------------------- internal/services/customer_service.go | 33 ++++++- internal/services/customer_service_test.go | 48 ++++++++++- internal/services/customer_session_service.go | 39 +++++++++ internal/services/message_service_test.go | 2 +- internal/services/system_config_service.go | 8 ++ .../system_config_support_validator_test.go | 47 ++++++++++ .../_components/support-ai-chat-widget.tsx | 25 +++++- web/lib/api/support-config.ts | 9 ++ web/lib/generated/enums.ts | 4 +- 15 files changed, 268 insertions(+), 104 deletions(-) delete mode 100644 internal/pkg/openidentity/openidentity_test.go diff --git a/internal/bootstrap/routes.go b/internal/bootstrap/routes.go index e35dff8c..45a4a887 100644 --- a/internal/bootstrap/routes.go +++ b/internal/bootstrap/routes.go @@ -53,6 +53,7 @@ func registerApiMessageRoutes(group *gin.RouterGroup) { func registerApiSupportRoutes(group *gin.RouterGroup) { group.GET("/config", api.SupportConfigGetConfig) + group.GET("/ai-customer-service/user-token", api.SupportConfigGetAICustomerServiceUserToken) group.POST("/auth/register", api.SupportAuthPostRegister) group.GET("/me", api.SupportGetMe) group.Any("/doc-page/list", api.DocPageAnyList) diff --git a/internal/handlers/api/support_config_handler.go b/internal/handlers/api/support_config_handler.go index 7d3538da..7b5ac45e 100644 --- a/internal/handlers/api/support_config_handler.go +++ b/internal/handlers/api/support_config_handler.go @@ -10,3 +10,27 @@ import ( func SupportConfigGetConfig(ctx *gin.Context) { httpx.WriteJSON(ctx, services.SystemConfigService.GetPublicSupportConfig()) } + +func SupportConfigGetAICustomerServiceUserToken(ctx *gin.Context) { + principal, err := services.AuthService.Authenticate(ctx) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + channel := services.SystemConfigService.GetPublicSupportAICustomerServiceChannel() + if channel == nil { + httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0209")) + return + } + user := services.UserService.Get(principal.UserID) + if user == nil { + httpx.WriteJSON(ctx, httpx.JsonErrorMsg(ctx, "error.e0256")) + return + } + token, err := services.CustomerSessionService.SignSupportUserToken(channel, user) + if err != nil { + httpx.WriteJSON(ctx, err) + return + } + httpx.WriteJSON(ctx, token) +} diff --git a/internal/pkg/dto/response/support_response.go b/internal/pkg/dto/response/support_response.go index d6b3df7c..c8fa5fe8 100644 --- a/internal/pkg/dto/response/support_response.go +++ b/internal/pkg/dto/response/support_response.go @@ -41,6 +41,11 @@ type SupportAICustomerServiceConfigResponse struct { ChannelID string `json:"channelId"` } +type SupportAICustomerServiceUserTokenResponse struct { + UserToken string `json:"userToken"` + ExpiresAt string `json:"expiresAt"` +} + type DocPageResponse struct { ID int64 `json:"id"` ParentID int64 `json:"parentId"` diff --git a/internal/pkg/enums/external_identity.go b/internal/pkg/enums/external_identity.go index 8135fd56..2ace3f0a 100644 --- a/internal/pkg/enums/external_identity.go +++ b/internal/pkg/enums/external_identity.go @@ -8,7 +8,8 @@ type ExternalSource string const ( ExternalSourceGuest ExternalSource = "guest" // 访客 ExternalSourceWxWorkKF ExternalSource = "wxwork_kf" // 企业微信客服 - ExternalSourceUser ExternalSource = "user" // 用户信息 + ExternalSourceUser ExternalSource = "user" // 站内用户 + ExternalSourceExternal ExternalSource = "external" // 外部接入方用户 ExternalSourceTelegram ExternalSource = "telegram" // Telegram ExternalSourceZaloOA ExternalSource = "zalo_oa" // Zalo OA ) @@ -16,7 +17,8 @@ const ( var externalSourceLabelMap = map[ExternalSource]string{ ExternalSourceGuest: "访客", ExternalSourceWxWorkKF: "企业微信客服", - ExternalSourceUser: "用户", + ExternalSourceUser: "站内用户", + ExternalSourceExternal: "外部用户", ExternalSourceTelegram: "Telegram", ExternalSourceZaloOA: "Zalo OA", } diff --git a/internal/pkg/openidentity/openidentity.go b/internal/pkg/openidentity/openidentity.go index cb6b3653..3444af46 100644 --- a/internal/pkg/openidentity/openidentity.go +++ b/internal/pkg/openidentity/openidentity.go @@ -1,6 +1,7 @@ package openidentity import ( + "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/errorsx" "errors" @@ -22,19 +23,32 @@ type ExternalUser struct { } type UserTokenClaims struct { - UserID string `json:"userId"` - Name string `json:"name"` + TokenType string `json:"typ,omitempty"` + UserID string `json:"userId"` + Name string `json:"name"` jwt.RegisteredClaims } -func GetExternalUser(ctx *gin.Context, secret string) (*ExternalUser, error) { +const SupportUserTokenType = "support_user" + +func GetExternalUser(ctx *gin.Context, externalUserSecret string) (*ExternalUser, error) { if userToken := getUserToken(ctx); strs.IsNotBlank(userToken) { - claims, err := verifyUserToken(userToken, secret) + supportUserSecret := config.Current().CustomerSession.Secret + if strs.IsNotBlank(supportUserSecret) { + if claims, err := verifySupportUserToken(userToken, supportUserSecret); err == nil { + return &ExternalUser{ + ExternalSource: enums.ExternalSourceUser, + ExternalID: claims.UserID, + ExternalName: claims.Name, + }, nil + } + } + claims, err := verifyUserToken(userToken, externalUserSecret) if err != nil { return nil, err } return &ExternalUser{ - ExternalSource: enums.ExternalSourceUser, + ExternalSource: enums.ExternalSourceExternal, ExternalID: claims.UserID, ExternalName: claims.Name, }, nil @@ -84,6 +98,17 @@ func verifyUserToken(userToken, secret string) (*UserTokenClaims, error) { return claims, nil } +func verifySupportUserToken(userToken, secret string) (*UserTokenClaims, error) { + claims, err := verifyUserToken(userToken, secret) + if err != nil { + return nil, err + } + if claims.TokenType != SupportUserTokenType { + return nil, errorsx.UnauthorizedI18n("error.e0265") + } + return claims, nil +} + func getUserToken(ctx *gin.Context) string { auth := strings.TrimSpace(ctx.GetHeader("Authorization")) if len(auth) > 7 && strings.EqualFold(auth[:7], "Bearer ") { diff --git a/internal/pkg/openidentity/openidentity_test.go b/internal/pkg/openidentity/openidentity_test.go deleted file mode 100644 index d6e74172..00000000 --- a/internal/pkg/openidentity/openidentity_test.go +++ /dev/null @@ -1,86 +0,0 @@ -package openidentity - -import ( - "testing" - "time" - - "github.com/golang-jwt/jwt/v5" -) - -func TestVerifyUserTokenOK(t *testing.T) { - token := signTestUserToken(t, jwt.SigningMethodHS256, map[string]any{ - "userId": "u_10001", - "name": "张三", - "exp": time.Now().Add(time.Hour).Unix(), - }, "secret") - - claims, err := verifyUserToken(token, "secret") - if err != nil { - t.Fatalf("expected token to verify: %v", err) - } - if claims.UserID != "u_10001" || claims.Name != "张三" { - t.Fatalf("unexpected claims: %#v", claims) - } -} - -func TestVerifyUserTokenUsesJWTHeaderAlgorithm(t *testing.T) { - token := signTestUserToken(t, jwt.SigningMethodHS384, map[string]any{ - "userId": "u_10001", - "name": "张三", - "exp": time.Now().Add(time.Hour).Unix(), - }, "secret") - - claims, err := verifyUserToken(token, "secret") - if err != nil { - t.Fatalf("expected HS384 token to verify from JWT header: %v", err) - } - if claims.UserID != "u_10001" || claims.Name != "张三" { - t.Fatalf("unexpected claims: %#v", claims) - } -} - -func TestVerifyUserTokenRejectsInvalidSignature(t *testing.T) { - token := signTestUserToken(t, jwt.SigningMethodHS256, map[string]any{ - "userId": "u_10001", - "name": "张三", - "exp": time.Now().Add(time.Hour).Unix(), - }, "secret") - - if _, err := verifyUserToken(token, "other-secret"); err == nil { - t.Fatalf("expected invalid signature to fail") - } -} - -func TestVerifyUserTokenRejectsExpiredToken(t *testing.T) { - token := signTestUserToken(t, jwt.SigningMethodHS256, map[string]any{ - "userId": "u_10001", - "name": "张三", - "exp": time.Now().Add(-time.Minute).Unix(), - }, "secret") - - if _, err := verifyUserToken(token, "secret"); err == nil { - t.Fatalf("expected expired token to fail") - } -} - -func TestVerifyUserTokenRequiresUserIDAndName(t *testing.T) { - tests := []map[string]any{ - {"name": "张三", "exp": time.Now().Add(time.Hour).Unix()}, - {"userId": "u_10001", "exp": time.Now().Add(time.Hour).Unix()}, - } - for _, payload := range tests { - token := signTestUserToken(t, jwt.SigningMethodHS256, payload, "secret") - if _, err := verifyUserToken(token, "secret"); err == nil { - t.Fatalf("expected payload %#v to fail", payload) - } - } -} - -func signTestUserToken(t *testing.T, method jwt.SigningMethod, payload map[string]any, secret string) string { - t.Helper() - token, err := jwt.NewWithClaims(method, jwt.MapClaims(payload)).SignedString([]byte(secret)) - if err != nil { - t.Fatal(err) - } - return token -} diff --git a/internal/services/customer_service.go b/internal/services/customer_service.go index 14d8fa4a..e4b838c5 100644 --- a/internal/services/customer_service.go +++ b/internal/services/customer_service.go @@ -4,6 +4,9 @@ import ( "crypto/md5" "encoding/hex" "log/slog" + "strconv" + "strings" + "time" "agent-desk/internal/models" "agent-desk/internal/pkg/dto" @@ -13,8 +16,6 @@ import ( "agent-desk/internal/pkg/openidentity" "agent-desk/internal/pkg/utils" "agent-desk/internal/repositories" - "strings" - "time" "agent-desk/internal/pkg/httpx/params" @@ -124,11 +125,18 @@ func (s *customerService) EnsureExternalCustomer(ctx *sqls.TxContext, externalUs return 0, errorsx.UnauthorizedI18n("error.e0149") } now := time.Now() + localUserID, localUserEmail := supportUserProfileFromExternalUser(externalUser) if identity := repositories.CustomerIdentityRepository.GetBy(ctx.Tx, externalSource, externalID); identity != nil { updates := map[string]any{ "last_active_at": now, "updated_at": now, } + if localUserID > 0 { + updates["user_id"] = localUserID + } + if localUserEmail != "" { + updates["primary_email"] = localUserEmail + } if strs.IsNotBlank(externalUser.ExternalName) { updates["name"] = externalUser.ExternalName } @@ -151,8 +159,10 @@ func (s *customerService) EnsureExternalCustomer(ctx *sqls.TxContext, externalUs } customer := &models.Customer{ + UserID: localUserID, Name: buildExternalCustomerName(externalUser), LastActiveAt: &now, + PrimaryEmail: localUserEmail, Status: enums.StatusOk, AuditFields: utils.BuildAuditFields(nil), } @@ -171,6 +181,25 @@ func (s *customerService) EnsureExternalCustomer(ctx *sqls.TxContext, externalUs return customer.ID, nil } +func supportUserProfileFromExternalUser(externalUser openidentity.ExternalUser) (int64, string) { + if externalUser.ExternalSource != enums.ExternalSourceUser { + return 0, "" + } + userID, err := strconv.ParseInt(strings.TrimSpace(externalUser.ExternalID), 10, 64) + if err != nil || userID <= 0 { + return 0, "" + } + user := UserService.Get(userID) + if user == nil || user.Status != enums.StatusOk { + return 0, "" + } + email := "" + if user.Email != nil { + email = strings.TrimSpace(*user.Email) + } + return user.ID, email +} + func buildExternalCustomerName(externalUser openidentity.ExternalUser) string { if strs.IsNotBlank(externalUser.ExternalName) { return externalUser.ExternalName diff --git a/internal/services/customer_service_test.go b/internal/services/customer_service_test.go index b23349cb..6cf80b8e 100644 --- a/internal/services/customer_service_test.go +++ b/internal/services/customer_service_test.go @@ -1,6 +1,7 @@ package services_test import ( + "strconv" "testing" "time" @@ -21,7 +22,7 @@ func TestEnsureExternalCustomerUpdatesNameFromExternalIdentity(t *testing.T) { var firstID int64 if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { id, err := services.CustomerService.EnsureExternalCustomer(ctx, openidentity.ExternalUser{ - ExternalSource: enums.ExternalSourceUser, + ExternalSource: enums.ExternalSourceExternal, ExternalID: "user-1", ExternalName: "张三", }) @@ -44,7 +45,7 @@ func TestEnsureExternalCustomerUpdatesNameFromExternalIdentity(t *testing.T) { var secondID int64 if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { id, err := services.CustomerService.EnsureExternalCustomer(ctx, openidentity.ExternalUser{ - ExternalSource: enums.ExternalSourceUser, + ExternalSource: enums.ExternalSourceExternal, ExternalID: "user-1", ExternalName: "李四", }) @@ -74,6 +75,47 @@ func TestEnsureExternalCustomerUpdatesNameFromExternalIdentity(t *testing.T) { } } +func TestEnsureExternalCustomerLinksSupportUserProfile(t *testing.T) { + db := setupCustomerServiceTestDB(t) + email := "support-user@example.com" + user := models.User{ + Username: "support-user", + Nickname: "支持中心用户", + Email: &email, + Status: enums.StatusOk, + } + if err := db.Create(&user).Error; err != nil { + t.Fatalf("create user: %v", err) + } + + var customerID int64 + if err := sqls.WithTransaction(func(ctx *sqls.TxContext) error { + id, err := services.CustomerService.EnsureExternalCustomer(ctx, openidentity.ExternalUser{ + ExternalSource: enums.ExternalSourceUser, + ExternalID: strconv.FormatInt(user.ID, 10), + ExternalName: "支持中心用户", + }) + customerID = id + return err + }); err != nil { + t.Fatalf("EnsureExternalCustomer() error = %v", err) + } + + customer := services.CustomerService.Get(customerID) + if customer == nil { + t.Fatal("customer not found") + } + if customer.UserID != user.ID { + t.Fatalf("customer.UserID = %d, want %d", customer.UserID, user.ID) + } + if customer.Name != "支持中心用户" { + t.Fatalf("customer.Name = %q", customer.Name) + } + if customer.PrimaryEmail != email { + t.Fatalf("customer.PrimaryEmail = %q, want %q", customer.PrimaryEmail, email) + } +} + func setupCustomerServiceTestDB(t *testing.T) *gorm.DB { t.Helper() @@ -92,7 +134,7 @@ func setupCustomerServiceTestDB(t *testing.T) *gorm.DB { _ = sqlDB.Close() } }) - if err := db.AutoMigrate(&models.Customer{}, &models.CustomerIdentity{}, &models.Conversation{}); err != nil { + if err := db.AutoMigrate(&models.User{}, &models.Customer{}, &models.CustomerIdentity{}, &models.Conversation{}); err != nil { t.Fatalf("auto migrate error = %v", err) } sqls.SetDB(db) diff --git a/internal/services/customer_session_service.go b/internal/services/customer_session_service.go index 1bd4dc9a..9ed6657e 100644 --- a/internal/services/customer_session_service.go +++ b/internal/services/customer_session_service.go @@ -2,6 +2,7 @@ package services import ( "errors" + "strconv" "strings" "time" @@ -24,6 +25,7 @@ const ( customerSessionTokenType = "customer_session" customerSessionHeader = "X-Customer-Session-Token" customerSessionExpHeader = "X-Customer-Session-Expires-At" + supportUserTokenTTL = 10 * time.Minute ) var CustomerSessionService = newCustomerSessionService() @@ -86,6 +88,39 @@ func (s *customerSessionService) Exchange(channel *models.Channel, externalUser }, nil } +func (s *customerSessionService) SignSupportUserToken(channel *models.Channel, user *models.User) (*response.SupportAICustomerServiceUserTokenResponse, error) { + if channel == nil || channel.Status != enums.StatusOk || channel.ChannelType != enums.ChannelTypeWeb { + return nil, errorsx.InvalidParamI18n("error.e0209") + } + if user == nil || user.Status != enums.StatusOk { + return nil, errorsx.UnauthorizedI18n("error.e0256") + } + secret := strings.TrimSpace(config.Current().CustomerSession.Secret) + if strings.TrimSpace(secret) == "" { + return nil, errorsx.BusinessErrorI18n(1, "error.customerSession.secretMissing") + } + now := time.Now() + expiresAt := now.Add(supportUserTokenTTL) + name := strings.TrimSpace(user.Nickname) + if name == "" { + name = strings.TrimSpace(user.Username) + } + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "typ": openidentity.SupportUserTokenType, + "userId": strconv.FormatInt(user.ID, 10), + "name": name, + "iat": now.Unix(), + "exp": expiresAt.Unix(), + }).SignedString([]byte(secret)) + if err != nil { + return nil, err + } + return &response.SupportAICustomerServiceUserTokenResponse{ + UserToken: token, + ExpiresAt: expiresAt.Format(time.DateTime), + }, nil +} + func (s *customerSessionService) Sign(channel *models.Channel, customer *models.Customer, externalUser openidentity.ExternalUser) (string, time.Time, error) { cfg := config.Current().CustomerSession secret := strings.TrimSpace(cfg.Secret) @@ -206,6 +241,8 @@ func (s *customerSessionService) externalUserFromClaims(claims *customerSessionC switch parts[0] { case "user": source = enums.ExternalSourceUser + case "external": + source = enums.ExternalSourceExternal case "guest": source = enums.ExternalSourceGuest default: @@ -235,6 +272,8 @@ func (s *customerSessionService) identityKey(externalUser openidentity.ExternalU switch externalUser.ExternalSource { case enums.ExternalSourceUser: return "user:" + strings.TrimSpace(externalUser.ExternalID) + case enums.ExternalSourceExternal: + return "external:" + strings.TrimSpace(externalUser.ExternalID) default: return "guest:" + strings.TrimSpace(externalUser.ExternalID) } diff --git a/internal/services/message_service_test.go b/internal/services/message_service_test.go index 222e636e..897a5e42 100644 --- a/internal/services/message_service_test.go +++ b/internal/services/message_service_test.go @@ -100,7 +100,7 @@ func createWelcomeTestAIAgent(t *testing.T, db *gorm.DB, welcomeMessage string) func welcomeTestExternalUser(id string) openidentity.ExternalUser { return openidentity.ExternalUser{ - ExternalSource: enums.ExternalSourceUser, + ExternalSource: enums.ExternalSourceExternal, ExternalID: id, ExternalName: "访客" + id, } diff --git a/internal/services/system_config_service.go b/internal/services/system_config_service.go index 1efeda73..47a0bfcd 100644 --- a/internal/services/system_config_service.go +++ b/internal/services/system_config_service.go @@ -139,6 +139,14 @@ func (s *systemConfigService) GetDashboardSupportConfig() response.DashboardSupp } } +func (s *systemConfigService) GetPublicSupportAICustomerServiceChannel() *models.Channel { + cfg := s.publicSupportAICustomerServiceConfig() + if !cfg.Enabled || strings.TrimSpace(cfg.ChannelID) == "" { + return nil + } + return repositories.ChannelRepository.GetByChannelID(sqls.DB(), cfg.ChannelID) +} + func (s *systemConfigService) SaveSupportConfig(payload map[string]json.RawMessage, operator *dto.AuthPrincipal) (response.DashboardSupportConfigResponse, error) { if err := s.SaveGroupConfig(systemConfigGroupSupportCenter, payload, operator); err != nil { return response.DashboardSupportConfigResponse{}, err diff --git a/internal/services/system_config_support_validator_test.go b/internal/services/system_config_support_validator_test.go index 03a85325..eb076d87 100644 --- a/internal/services/system_config_support_validator_test.go +++ b/internal/services/system_config_support_validator_test.go @@ -3,11 +3,16 @@ package services import ( "encoding/json" "testing" + "time" "agent-desk/internal/models" + "agent-desk/internal/pkg/config" "agent-desk/internal/pkg/dto/request" "agent-desk/internal/pkg/enums" "agent-desk/internal/pkg/i18nx" + "agent-desk/internal/pkg/openidentity" + + "github.com/golang-jwt/jwt/v5" ) func TestSystemConfigValidationErrorLocalizesFieldErrors(t *testing.T) { @@ -102,3 +107,45 @@ func TestSupportAICustomerServiceConfigAllowsDisabledConfigWithStaleChannel(t *t t.Fatalf("disabled config should not validate stale channel: %#v", fieldErrors) } } + +func TestSignSupportUserTokenUsesInternalUserSource(t *testing.T) { + db := setupChannelServiceTestDB(t) + config.SetCurrent(&config.Config{ + CustomerSession: config.CustomerSessionConfig{Secret: "customer-session-secret"}, + }) + agent := createChannelServiceTestAgent(t, db, 1001) + channel, err := ChannelService.CreateChannel(request.CreateChannelRequest{ + ChannelType: enums.ChannelTypeWeb, + AIAgentID: agent.ID, + Name: "支持中心 AI 客服", + Status: int(enums.StatusOk), + }, channelServiceTestOperator()) + if err != nil { + t.Fatalf("create channel: %v", err) + } + user := &models.User{ID: 88, Username: "support-user", Nickname: "支持中心用户", Status: enums.StatusOk} + + result, err := CustomerSessionService.SignSupportUserToken(channel, user) + if err != nil { + t.Fatalf("SignSupportUserToken() error = %v", err) + } + claims := jwt.MapClaims{} + token, err := jwt.ParseWithClaims(result.UserToken, claims, func(token *jwt.Token) (any, error) { + return []byte(config.Current().CustomerSession.Secret), nil + }, jwt.WithExpirationRequired()) + if err != nil || token == nil || !token.Valid { + t.Fatalf("parse signed token: token=%#v err=%v", token, err) + } + if claims["typ"] != openidentity.SupportUserTokenType { + t.Fatalf("typ claim = %#v", claims["typ"]) + } + if claims["userId"] != "88" { + t.Fatalf("userId claim = %#v", claims["userId"]) + } + if claims["name"] != "支持中心用户" { + t.Fatalf("name claim = %#v", claims["name"]) + } + if expiresAt, err := time.Parse(time.DateTime, result.ExpiresAt); err != nil || time.Until(expiresAt) <= 0 { + t.Fatalf("invalid expiresAt %q: %v", result.ExpiresAt, err) + } +} diff --git a/web/app/(support)/support/_components/support-ai-chat-widget.tsx b/web/app/(support)/support/_components/support-ai-chat-widget.tsx index 1ba29982..8aa17646 100644 --- a/web/app/(support)/support/_components/support-ai-chat-widget.tsx +++ b/web/app/(support)/support/_components/support-ai-chat-widget.tsx @@ -2,7 +2,11 @@ import { useEffect } from "react" -import { fetchSupportConfig } from "@/lib/api/support-config" +import { useSupportAuth } from "@/app/(support)/support/_components/support-auth-provider" +import { + fetchSupportAICustomerServiceUserToken, + fetchSupportConfig, +} from "@/lib/api/support-config" import type { AgentDeskConfig } from "@/lib/sdk/config-types" const WIDGET_SCRIPT_SELECTOR = '[data-agent-desk-widget="support-platform-script"]' @@ -28,7 +32,13 @@ function mountSupportWidget(config: AgentDeskConfig) { } export function SupportAIChatWidget() { + const { ready, session } = useSupportAuth() + useEffect(() => { + if (!ready) { + return + } + let cancelled = false async function loadConfig() { @@ -42,11 +52,18 @@ export function SupportAIChatWidget() { removeSupportWidget() return } - mountSupportWidget({ + const widgetConfig: AgentDeskConfig = { channelId: aiCustomerService.channelId, baseUrl: window.location.origin, widgetBaseUrl: window.location.origin, - }) + } + if (session) { + widgetConfig.getUserToken = async () => { + const token = await fetchSupportAICustomerServiceUserToken() + return token.userToken + } + } + mountSupportWidget(widgetConfig) } catch { if (!cancelled) { removeSupportWidget() @@ -60,7 +77,7 @@ export function SupportAIChatWidget() { cancelled = true removeSupportWidget() } - }, []) + }, [ready, session]) return null } diff --git a/web/lib/api/support-config.ts b/web/lib/api/support-config.ts index 7fd672e4..acc39c6a 100644 --- a/web/lib/api/support-config.ts +++ b/web/lib/api/support-config.ts @@ -18,6 +18,15 @@ export type PublicSupportConfig = { } } +export type SupportAICustomerServiceUserToken = { + userToken: string + expiresAt: string +} + export function fetchSupportConfig() { return request("/api/support/config", { skipAuth: true }) } + +export function fetchSupportAICustomerServiceUserToken() { + return request("/api/support/ai-customer-service/user-token") +} diff --git a/web/lib/generated/enums.ts b/web/lib/generated/enums.ts index 0b404a59..76d6e21b 100644 --- a/web/lib/generated/enums.ts +++ b/web/lib/generated/enums.ts @@ -79,11 +79,13 @@ export enum ExternalSource { User = "user", Telegram = "telegram", ZaloOA = "zalo_oa", + External = "external", } export const ExternalSourceLabels: Record = { [ExternalSource.Guest]: "访客", [ExternalSource.WxWorkKF]: "企业微信客服", - [ExternalSource.User]: "用户", + [ExternalSource.User]: "站内用户", + [ExternalSource.External]: "外部用户", [ExternalSource.Telegram]: "Telegram", [ExternalSource.ZaloOA]: "Zalo OA", } From 80dc488e4807cfb35812247fc79fec258a6e57c1 Mon Sep 17 00:00:00 2001 From: mlogclub Date: Tue, 25 Aug 2026 11:59:08 +0800 Subject: [PATCH 03/29] feat: add AI customer service status localization in English and Chinese --- .../_components/support-config-panel.tsx | 204 +++++++++--------- web/messages/en-US.json | 2 +- web/messages/zh-CN.json | 2 +- 3 files changed, 98 insertions(+), 110 deletions(-) diff --git a/web/app/(dashboard)/dashboard/support/_components/support-config-panel.tsx b/web/app/(dashboard)/dashboard/support/_components/support-config-panel.tsx index 2cb9f26d..681646e6 100644 --- a/web/app/(dashboard)/dashboard/support/_components/support-config-panel.tsx +++ b/web/app/(dashboard)/dashboard/support/_components/support-config-panel.tsx @@ -19,7 +19,7 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable" import { CSS } from "@dnd-kit/utilities" -import { BotIcon, ExternalLinkIcon, GripVerticalIcon, PlusIcon, RefreshCwIcon, SaveIcon, Trash2Icon } from "lucide-react" +import { ExternalLinkIcon, GripVerticalIcon, PlusIcon, RefreshCwIcon, SaveIcon, Trash2Icon } from "lucide-react" import { toast } from "sonner" import { DashboardPage, DashboardTableShell, DashboardTableStateRow, DashboardToolbar } from "@/components/dashboard-page" @@ -30,6 +30,7 @@ import { Label } from "@/components/ui/label" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Switch } from "@/components/ui/switch" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { useI18n } from "@/i18n/provider" import { fetchChannels, @@ -131,7 +132,6 @@ export function SupportConfigPanel() { label: channel.name || channel.channelId, subtitle: channel.aiAgentName ? t("supportConfig.aiChannelAgent", { name: channel.aiAgentName }) : channel.channelId, })), [channels, t]) - const selectedChannel = channels.find((channel) => channel.channelId === aiCustomerService.channelId) const loadConfig = useCallback(async () => { try { @@ -258,120 +258,108 @@ export function SupportConfigPanel() { >

{t("supportConfig.title")}

-

{t("supportConfig.description")}

-
-
-
-
-
- -
-
-

{t("supportConfig.aiCustomerServiceTitle")}

-

{t("supportConfig.aiCustomerServiceDescription")}

+ {fieldErrors.length > 0 ? ( +
+
{t("supportConfig.validationFailed")}
+
    + {fieldErrors.map((error) => ( +
  • {error.path ? `${error.path}: ${error.message}` : error.message}
  • + ))} +
+
+ ) : null} + + + + {t("supportConfig.navigationTitle")} + {t("supportConfig.aiCustomerServiceTitle")} + + + +
+ +
+ + + + + + + {t("supportConfig.sort")} + {t("supportConfig.menuTitle")} + {t("supportConfig.menuURL")} + {t("supportConfig.target")} + {t("supportConfig.visible")} + {t("supportConfig.actions")} + + + + {loading ? ( + + ) : items.length === 0 ? ( + + ) : ( + item.id)} strategy={verticalListSortingStrategy}> + {items.map((item) => ( + + ))} + + )} + +
+
+
+
+ + +
+
+ +
+ + {aiCustomerService.enabled ? t("supportConfig.enabled") : t("supportConfig.disabled")} + + setAICustomerService((current) => ({ ...current, enabled }))} + disabled={loading || saving} + aria-label={t("supportConfig.toggleAIService")} + />
-
- - setAICustomerService((current) => ({ ...current, enabled }))} - disabled={loading || saving} - aria-label={t("supportConfig.toggleAIService")} + +
+ + setAICustomerService((current) => ({ ...current, channelId }))} + options={channelOptions} + placeholder={channelsLoading ? t("supportConfig.loadingChannels") : t("supportConfig.selectAIChannel")} + searchPlaceholder={t("supportConfig.searchAIChannel")} + emptyText={t("supportConfig.emptyAIChannel")} + disabled={loading || saving || channelsLoading} + triggerClassName="rounded-md" />
- -
- - setAICustomerService((current) => ({ ...current, channelId }))} - options={channelOptions} - placeholder={channelsLoading ? t("supportConfig.loadingChannels") : t("supportConfig.selectAIChannel")} - searchPlaceholder={t("supportConfig.searchAIChannel")} - emptyText={t("supportConfig.emptyAIChannel")} - disabled={loading || saving || channelsLoading} - triggerClassName="rounded-md" - /> - {selectedChannel ? ( -

- {t("supportConfig.aiCustomerServiceChannelSummary", { - agent: selectedChannel.aiAgentName || "-", - rollout: selectedChannel.aiAgentRolloutPercent, - })} -

- ) : ( -

{t("supportConfig.aiCustomerServiceChannelHint")}

- )} -
-
- -
-
-

{t("supportConfig.navigationTitle")}

-

{t("supportConfig.navigationDescription")}

-
- -
- - {fieldErrors.length > 0 ? ( -
-
{t("supportConfig.validationFailed")}
-
    - {fieldErrors.map((error) => ( -
  • {error.path ? `${error.path}: ${error.message}` : error.message}
  • - ))} -
-
- ) : null} - - - - - - - {t("supportConfig.sort")} - {t("supportConfig.menuTitle")} - {t("supportConfig.menuURL")} - {t("supportConfig.target")} - {t("supportConfig.visible")} - {t("supportConfig.actions")} - - - - {loading ? ( - - ) : items.length === 0 ? ( - - ) : ( - item.id)} strategy={verticalListSortingStrategy}> - {items.map((item) => ( - - ))} - - )} - -
-
-
-
+ + ) } diff --git a/web/messages/en-US.json b/web/messages/en-US.json index 9f18cb53..1fd77a10 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -1511,6 +1511,7 @@ "disabled": "Disabled", "actions": "Actions", "aiCustomerServiceTitle": "AI Support", + "aiCustomerServiceStatus": "AI Support", "aiCustomerServiceDescription": "Select the web channel used by the public support center. The linked AI Agent and service mode are managed in channel settings.", "aiCustomerServiceChannel": "Channel", "selectAIChannel": "Select a web channel", @@ -1518,7 +1519,6 @@ "emptyAIChannel": "No available web channels", "loadingChannels": "Loading channels...", "aiChannelAgent": "AI Agent: {name}", - "aiCustomerServiceChannelSummary": "AI Agent: {agent}, rollout: {rollout}%", "aiCustomerServiceChannelHint": "Select an enabled web channel linked to a published AI Agent before enabling AI support.", "toggleAIService": "Enable or disable AI support", "edit": "Edit", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 92c36265..80f15b45 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -1511,6 +1511,7 @@ "disabled": "停用", "actions": "操作", "aiCustomerServiceTitle": "AI 客服", + "aiCustomerServiceStatus": "AI 客服", "aiCustomerServiceDescription": "选择支持中心公开页面使用的 Web 接入渠道。渠道绑定的 AI Agent 和服务模式在渠道管理中维护。", "aiCustomerServiceChannel": "接入渠道", "selectAIChannel": "选择 Web 渠道", @@ -1518,7 +1519,6 @@ "emptyAIChannel": "暂无可用 Web 渠道", "loadingChannels": "正在加载渠道...", "aiChannelAgent": "AI Agent:{name}", - "aiCustomerServiceChannelSummary": "AI Agent:{agent},灰度比例:{rollout}%", "aiCustomerServiceChannelHint": "启用 AI 客服前,请先选择一个已启用并绑定已发布 AI Agent 的 Web 渠道。", "toggleAIService": "启用或停用 AI 客服", "edit": "编辑", From 72039b90cf14fd01b57fbd8d169076d99e7333ce Mon Sep 17 00:00:00 2001 From: mlogclub Date: Sat, 29 Aug 2026 11:40:44 +0800 Subject: [PATCH 04/29] feat: enhance localization for community features and documentation center --- web/messages/en-US.json | 364 ++++++++++++++++++++++++++++++++++----- web/messages/zh-CN.json | 365 +++++++++++++++++++++++++++++++++++----- 2 files changed, 644 insertions(+), 85 deletions(-) diff --git a/web/messages/en-US.json b/web/messages/en-US.json index 1fd77a10..894e5958 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -479,7 +479,6 @@ "claimConfirmSuffix": "\"? It will move to your conversations.", "claimCurrent": "Claim the current conversation?", "customerFallbackPrefix": "Customer #", - "cancel": "Cancel", "claiming": "Claiming...", "confirmClaim": "Claim" }, @@ -1127,7 +1126,8 @@ "removedRoles": "Removed Roles", "noneAdded": "None added", "noneRemoved": "None removed", - "confirmAssign": "Assign Roles" + "confirmAssign": "Assign Roles", + "visible": "Visible" }, "aiAgent": { "allStatuses": "All statuses", @@ -2613,13 +2613,33 @@ "create": "Create Role" }, "workflowRun": { + "recordsTitle": "AI Execution Logs", + "loadingLogs": "Loading execution logs...", + "emptyLogs": "No AI execution logs", + "detailTitle": "AI Execution Details", + "description": "Workflow Execution Trace", + "loadingDetail": "Loading execution details...", + "emptyDetail": "Execution record not found.", + "loadLogsFailed": "Failed to load AI execution logs", + "loadDetailFailed": "Failed to load AI execution details", + "labelConversation": "Conversation", + "labelMessage": "Message", + "labelAgent": "Agent", + "labelStatus": "Status", + "labelStartedAt": "Started At", + "labelEndedAt": "Ended At", + "labelInterruptNode": "Interrupt Node", + "labelWorkflow": "Workflow", + "emptyNodes": "No node execution records", + "input": "Input", + "output": "Output", + "viewDetails": "Execution Details", "allStatus": "All statuses", "completed": "Completed", "interrupted": "Interrupted", "failed": "Failed", "allAgents": "All agents", "loadAgentsFailed": "Could not load AI agents.", - "loadDetailFailed": "Could not load workflow run details.", "loadConversationFailed": "Could not load conversation details.", "conversationId": "Conversation ID", "messageId": "Message ID", @@ -2642,16 +2662,11 @@ "loading": "Loading workflow runs", "empty": "No workflow runs yet", "loadFailed": "Could not load workflow runs.", - "detailTitle": "Workflow Run Detail", "detailDescription": "Inspect workflow execution path", "close": "Close", - "loadingDetail": "Loading workflow run detail", "interruptNode": "Interrupt Node", "nodeDetails": "Node Run Details", - "emptyNodes": "No node records", - "notFound": "Workflow run not found", - "input": "Input", - "output": "Output" + "notFound": "Workflow run not found" }, "agentRun": { "conversation": "Conversation", @@ -2730,36 +2745,16 @@ "empty": "No failed outbox messages", "loadFailed": "Failed to load outbox messages" }, - "workflowRun": { - "recordsTitle": "AI Execution Logs", - "loadingLogs": "Loading execution logs...", - "emptyLogs": "No AI execution logs", - "detailTitle": "AI Execution Details", - "description": "Workflow Execution Trace", - "loadingDetail": "Loading execution details...", - "emptyDetail": "Execution record not found.", - "loadLogsFailed": "Failed to load AI execution logs", - "loadDetailFailed": "Failed to load AI execution details", - "labelConversation": "Conversation", - "labelMessage": "Message", - "labelAgent": "Agent", - "labelStatus": "Status", - "labelStartedAt": "Started At", - "labelEndedAt": "Ended At", - "labelInterruptNode": "Interrupt Node", - "labelWorkflow": "Workflow", - "emptyNodes": "No node execution records", - "input": "Input", - "output": "Output", - "viewDetails": "Execution Details" - }, "supportPublic": { "brand": "AgentDesk Support", "nav": { "home": "Home", "help": "Help", "questions": "FAQ", - "login": "Log In" + "login": "Log In", + "community": "Community", + "menu": "Menu", + "siteNavigation": "Site navigation" }, "home": { "badge": "Support Center", @@ -2778,7 +2773,9 @@ "browseDocs": "Browse help docs", "askQuestion": "Ask a question", "recommendedPages": "Recommended Pages", - "hotQuestions": "Popular Questions" + "hotQuestions": "Popular Questions", + "postsTitle": "Community Discussions", + "postsDescription": "Browse posts, then log in to publish and comment." }, "help": { "title": "Help Center", @@ -2851,13 +2848,20 @@ "loginAction": "Log In", "registerAction": "Register and Log In", "switchToRegister": "No account? Register", - "switchToLogin": "Already have an account? Log in" + "switchToLogin": "Already have an account? Log in", + "welcomeBack": "Welcome back", + "account": "Account", + "accountPlaceholder": "Enter email or username", + "noMethodsTitle": "No sign-in methods are enabled", + "noMethodsDescription": "Ask an administrator to enable password, WeCom, or OIDC sign-in." }, "account": { "loading": "Loading sign-in status", "openMenu": "Open account menu", "signOut": "Sign out", - "signingOut": "Signing out..." + "signingOut": "Signing out...", + "profile": "Profile", + "editProfile": "Edit profile" }, "actions": { "search": "Search", @@ -2874,7 +2878,20 @@ "retry": "Retry", "loadMore": "Load more", "viewDiscussion": "View discussion", - "answerQuestion": "Answer" + "answerQuestion": "Answer", + "createPost": "Create Post", + "publishPost": "Publish Post", + "publishComment": "Publish Comment", + "publishReply": "Publish Reply", + "reply": "Reply", + "edit": "Edit", + "delete": "Delete", + "report": "Report", + "copyLink": "Copy link", + "cancel": "Cancel", + "save": "Save", + "viewReplies": "View all {count} replies", + "noMore": "No more" }, "status": { "all": "All", @@ -2896,7 +2913,11 @@ "noQuestionsMatched": "No questions found", "questionsFailed": "Could not load questions. Try again later.", "noAnswers": "No answers yet", - "selectPage": "Select a page on the left" + "selectPage": "Select a page on the left", + "noPosts": "No community posts yet", + "noPostsMatched": "No posts found", + "postsFailed": "Could not load posts. Try again later.", + "noComments": "No comments yet" }, "loading": { "categories": "Loading categories…", @@ -2904,20 +2925,100 @@ "page": "Loading page...", "question": "Loading question...", "questions": "Loading questions...", - "session": "Checking sign-in status..." + "session": "Checking sign-in status...", + "post": "Loading post...", + "posts": "Loading posts...", + "comments": "Loading comments..." }, "toast": { "feedbackSaved": "Thanks for the feedback", "codeCopied": "Code copied", "answerCreated": "Answer published", "questionCreated": "Question published", - "loggedIn": "Logged in" + "loggedIn": "Logged in", + "profileUpdated": "Profile updated", + "postCreated": "Post published", + "commentCreated": "Comment published", + "replyCreated": "Reply published", + "commentUpdated": "Comment updated", + "commentDeleted": "Comment deleted", + "commentReported": "Report received", + "linkCopied": "Link copied" }, "a11y": { "openNavigation": "Open documentation navigation", "closeNavigation": "Close documentation navigation", "collapse": "Collapse child pages", - "expand": "Expand child pages" + "expand": "Expand child pages", + "openMenu": "Open menu", + "closeMenu": "Close menu" + }, + "posts": { + "title": "Community Discussions", + "description": "Signed-in users can publish posts, comment, like, and accept useful replies.", + "categoryNavigation": "Post categories", + "moreCategories": "More categories", + "more": "More", + "categoriesFailed": "Could not load categories. Click to retry.", + "searchPlaceholder": "Search post titles", + "detailTitle": "Post Detail", + "comments": "Comments", + "likes": "Likes", + "views": "Views", + "createdBy": "Posted by {name}", + "updatedAt": "Updated {date}" + }, + "createPost": { + "title": "Create Post", + "description": "Sign in to publish a post. Community members can join the discussion.", + "formTitle": "Create post", + "category": "Category", + "categoryPlaceholder": "Select category", + "categorySearch": "Search categories", + "categoryEmpty": "No categories", + "categoriesFailed": "Could not load categories. Click to retry.", + "categoryRequired": "Select a category", + "postTitle": "Post title", + "postTitlePlaceholder": "Summarize the topic in one sentence", + "titleRequired": "Enter a post title", + "content": "Content", + "contentPlaceholder": "Add background, context, environment, and the points you want people to focus on", + "contentRequired": "Add the post content", + "tags": "Tags", + "tagsPlaceholder": "Separate tags with commas" + }, + "comment": { + "title": "Post a comment", + "placeholder": "Write your comment", + "replyPlaceholder": "Write your reply", + "count": "{count} comments", + "authorBadge": "Author", + "deleted": "This comment has been deleted.", + "deleteConfirm": "This comment will no longer be shown after deletion. Delete it?", + "sort": { + "default": "Default", + "latest": "Newest", + "hot": "Popular" + }, + "accepted": "Accepted" + }, + "profile": { + "title": "Profile", + "username": "Username", + "email": "Email", + "emailUnset": "Not set", + "edit": "Edit profile", + "editTitle": "Edit profile", + "editDescription": "Update the profile shown in the support center.", + "nickname": "Nickname", + "nicknamePlaceholder": "Enter nickname", + "nicknameRequired": "Enter a nickname.", + "avatar": "Avatar", + "avatarPlaceholder": "Upload avatar", + "emailPlaceholder": "Enter email", + "communityTitle": "My Community Content", + "communityDescription": "Review posts you published in the support center.", + "noPosts": "You have not published any community posts yet" } }, "nav": { @@ -2958,7 +3059,11 @@ "notifications": "Notifications", "preferences": "Preferences", "changePassword": "Change Password", - "signOut": "Sign Out" + "signOut": "Sign Out", + "supportDocs": "Documentation Center", + "supportCommunity": "Community", + "supportCommunityCategories": "Community Categories", + "supportConfig": "Support Settings" }, "workspace": { "dashboard": "Admin Dashboard", @@ -2977,5 +3082,180 @@ "green": "Service Green", "gray": "Neutral Gray", "blue": "Clear Blue" + }, + "docWorkbench": { + "title": "Documentation Center", + "createPage": "New page", + "createChildPage": "New child page", + "createDescription": "The page appears in the document navigation and can also contain content.", + "createAndEdit": "Create and edit", + "creating": "Creating...", + "created": "Page created", + "pageTitle": "Page title", + "searchPlaceholder": "Search pages", + "filterAll": "All", + "filterDraft": "Draft", + "filterPublished": "Published", + "filterHidden": "Hidden", + "sortDisabledDuringSearch": "Sorting is unavailable in search results", + "loading": "Loading pages...", + "empty": "No doc pages", + "selectPrompt": "Select a page on the left or create a new page", + "pageSettings": "Page settings", + "parentPage": "Parent page", + "selectParentPage": "Select a parent page", + "rootDirectory": "Root", + "slug": "Slug", + "slugFormatHint": "Use letters, numbers, and hyphens (-) only.", + "slugChangeWarning": "Changing the slug breaks the previous URL. Update any external references.", + "status": "Status", + "selectStatus": "Select a status", + "statusDraft": "Draft", + "statusPublished": "Published", + "statusHidden": "Hidden", + "summary": "Page summary", + "summaryPlaceholder": "Used in document navigation, recommendations, and search results", + "contentPlaceholder": "Write the page content", + "save": "Save", + "saving": "Saving...", + "saved": "Page saved", + "settingsSaved": "Page settings saved", + "savedState": "All changes saved", + "unsaved": "Unsaved changes", + "unsavedLeaveConfirm": "This page has unsaved changes. Leave anyway?", + "unsavedLeaveTitle": "Discard unsaved changes?", + "discardChanges": "Discard changes", + "continueEditing": "Continue editing", + "publishedSaveWarning": "Saving a published page updates the public content immediately", + "saveBeforeStatusChange": "Save your changes before changing the publication status", + "publishedAction": "Publish", + "publishedConfirmTitle": "Publish “{title}”?", + "publishedConfirmDescription": "The page will become publicly available immediately.", + "publishedSuccess": "Page published", + "draftAction": "Withdraw", + "draftConfirmTitle": "Withdraw “{title}”?", + "draftConfirmDescription": "The page will no longer be available from the public help center.", + "draftSuccess": "Page withdrawn to draft", + "withdrawAction": "Withdraw", + "hiddenAction": "Hide", + "hiddenConfirmTitle": "Hide “{title}”?", + "hiddenConfirmDescription": "The page will no longer be publicly available, but its content will remain in the admin.", + "hiddenSuccess": "Page hidden", + "openPublicPage": "Open public page", + "collapse": "Collapse child pages", + "expand": "Expand child pages", + "dragPage": "Drag page “{title}” to reorder", + "dragDisabled": "Sorting is unavailable in the current state", + "sameLevelOnly": "Pages can only be reordered under the same parent", + "sortSaved": "Page order updated", + "pageActions": "Page actions: {title}", + "deletePage": "Delete page", + "deleteConfirm": "Delete “{title}”? Pages with children cannot be deleted.", + "deleteConfirmTitle": "Delete “{title}”?", + "deleted": "Page deleted", + "cancel": "Cancel" + }, + "docs": { + "title": "Documentation Center Scaffold", + "description": "The documentation center will collect product guidance, permission rules, channel integration workflows, and FAQs.", + "step1": "Collect project development standards and admin usage guides.", + "step2": "Add third-party integration flows and configuration checklists.", + "step3": "Connect Markdown or a documentation center later." + }, + "supportCommunityCategory": { + "allStatuses": "All statuses", + "enabled": "Enabled", + "disabled": "Disabled", + "name": "Category name", + "searchName": "Search category names", + "noDescription": "No description", + "status": "Status", + "confirmDeleteTitle": "Delete community category?", + "confirmDeleteDescription": "Delete \"{name}\"? Categories used by posts cannot be deleted.", + "delete": "Delete", + "sortUpdated": "Community category order updated.", + "sortUpdateFailed": "Could not update the order.", + "dragSort": "Drag to reorder {name}", + "create": "Create", + "save": "Save", + "saving": "Saving...", + "cancel": "Cancel", + "loadingDetail": "Loading details...", + "required": "This field is required.", + "invalidNumber": "Enter a valid number.", + "minValue": "Must be at least {min}", + "maxValue": "Must be no more than {max}", + "namePlaceholder": "Enter a category name", + "slugPlaceholder": "Example: account-security", + "slugPatternMessage": "Use letters, numbers, and hyphens (-) only.", + "description": "Description", + "descriptionPlaceholder": "Describe the posts covered by this category", + "remark": "Notes", + "remarkPlaceholder": "Visible to dashboard users only", + "createTitle": "New community category", + "editTitle": "Edit community category", + "refresh": "Refresh", + "new": "New category", + "query": "Search", + "loading": "Loading categories...", + "empty": "No community categories yet", + "actions": "Actions", + "edit": "Edit", + "processing": "Processing...", + "moreActions": "More actions for {name}", + "loadFailed": "Could not load categories.", + "saveFailed": "Could not save the category.", + "deleteFailed": "Could not delete the category.", + "created": "Category created: {name}", + "updated": "Category updated: {name}", + "deleted": "Category deleted: {name}" + }, + "supportCommunityAdmin": { + "postStatusUpdated": "Post status updated." + }, + "supportConfig": { + "title": "Support Center Settings", + "description": "Manage public support center navigation and future global settings.", + "navigationTitle": "Navigation Menu", + "navigationDescription": "Configure the public support center header and mobile site navigation. Drag the handle to reorder items, then save.", + "refresh": "Refresh", + "save": "Save Settings", + "saving": "Saving...", + "saved": "Support center settings saved.", + "loadFailed": "Could not load support center settings.", + "loadChannelsFailed": "Could not load AI support channels.", + "saveFailed": "Could not save support center settings.", + "validationFailed": "Fix the following config issues.", + "loading": "Loading support center settings...", + "addNavigation": "Add Item", + "emptyNavigation": "No navigation items", + "sort": "Sort", + "menuTitle": "Title", + "menuURL": "URL", + "target": "Open In", + "targetSelf": "Current window", + "targetBlank": "New window", + "visible": "Visible", + "enabled": "Enabled", + "disabled": "Disabled", + "actions": "Actions", + "aiCustomerServiceTitle": "AI Support", + "aiCustomerServiceStatus": "AI Support", + "aiCustomerServiceDescription": "Select the web channel used by the public support center. The linked AI Agent and service mode are managed in channel settings.", + "aiCustomerServiceChannel": "Channel", + "selectAIChannel": "Select a web channel", + "searchAIChannel": "Search channels", + "emptyAIChannel": "No available web channels", + "loadingChannels": "Loading channels...", + "aiChannelAgent": "AI Agent: {name}", + "aiCustomerServiceChannelHint": "Select an enabled web channel linked to a published AI Agent before enabling AI support.", + "toggleAIService": "Enable or disable AI support", + "titlePlaceholder": "For example: Documentation Center", + "untitled": "Untitled item", + "dragNavigation": "Reorder: {title}", + "toggleNavigation": "Enable or disable: {title}", + "deleteNavigation": "Delete item: {title}", + "confirmDelete": "Delete navigation item \"{title}\"? It will be removed from support center navigation after you save.", + "openURL": "Open URL" } } diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 80f15b45..3ca55a1d 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -479,7 +479,6 @@ "claimConfirmSuffix": "”吗?认领后会话会进入我的列表。", "claimCurrent": "确认认领当前会话吗?", "customerFallbackPrefix": "客户 #", - "cancel": "取消", "claiming": "认领中...", "confirmClaim": "确认认领" }, @@ -1127,7 +1126,8 @@ "removedRoles": "移除角色", "noneAdded": "无新增", "noneRemoved": "无移除", - "confirmAssign": "确认分配" + "confirmAssign": "确认分配", + "visible": "显示" }, "aiAgent": { "allStatuses": "全部状态", @@ -1963,7 +1963,6 @@ "searchStatus": "搜索状态", "emptyStatus": "未找到状态", "query": "查询", - "status": "状态", "updatedAt": "最近更新", "actions": "操作", "loadingRows": "正在加载 Skill...", @@ -2613,13 +2612,33 @@ "create": "创建角色" }, "workflowRun": { + "recordsTitle": "AI 执行记录", + "loadingLogs": "加载执行记录中", + "emptyLogs": "暂无 AI 执行记录", + "detailTitle": "AI 执行详情", + "description": "Workflow 执行链路", + "loadingDetail": "加载执行详情中", + "emptyDetail": "未找到执行记录", + "loadLogsFailed": "加载 AI 执行记录失败", + "loadDetailFailed": "加载 AI 执行详情失败", + "labelConversation": "会话", + "labelMessage": "消息", + "labelAgent": "Agent", + "labelStatus": "状态", + "labelStartedAt": "开始", + "labelEndedAt": "结束", + "labelInterruptNode": "中断节点", + "labelWorkflow": "Workflow", + "emptyNodes": "暂无节点记录", + "input": "输入", + "output": "输出", + "viewDetails": "执行详情", "allStatus": "全部状态", "completed": "已完成", "interrupted": "已中断", "failed": "失败", "allAgents": "全部 Agent", "loadAgentsFailed": "加载 AI Agent 列表失败", - "loadDetailFailed": "加载流程执行详情失败", "loadConversationFailed": "加载会话详情失败", "conversationId": "会话ID", "messageId": "消息ID", @@ -2642,16 +2661,11 @@ "loading": "加载流程执行记录中", "empty": "暂无流程执行记录", "loadFailed": "加载流程执行记录失败", - "detailTitle": "流程执行详情", "detailDescription": "查看流程执行链路", "close": "关闭", - "loadingDetail": "加载流程执行详情中", "interruptNode": "中断节点", "nodeDetails": "节点运行明细", - "emptyNodes": "暂无节点记录", - "notFound": "未找到流程执行记录", - "input": "输入", - "output": "输出" + "notFound": "未找到流程执行记录" }, "agentRun": { "conversation": "会话", @@ -2730,36 +2744,16 @@ "empty": "暂无失败 outbox", "loadFailed": "加载企业微信 outbox 失败" }, - "workflowRun": { - "recordsTitle": "AI 执行记录", - "loadingLogs": "加载执行记录中", - "emptyLogs": "暂无 AI 执行记录", - "detailTitle": "AI 执行详情", - "description": "Workflow 执行链路", - "loadingDetail": "加载执行详情中", - "emptyDetail": "未找到执行记录", - "loadLogsFailed": "加载 AI 执行记录失败", - "loadDetailFailed": "加载 AI 执行详情失败", - "labelConversation": "会话", - "labelMessage": "消息", - "labelAgent": "Agent", - "labelStatus": "状态", - "labelStartedAt": "开始", - "labelEndedAt": "结束", - "labelInterruptNode": "中断节点", - "labelWorkflow": "Workflow", - "emptyNodes": "暂无节点记录", - "input": "输入", - "output": "输出", - "viewDetails": "执行详情" - }, "supportPublic": { "brand": "AgentDesk 支持中心", "nav": { "home": "首页", "help": "帮助", "questions": "FAQ", - "login": "登录" + "login": "登录", + "community": "社区", + "menu": "菜单", + "siteNavigation": "站点导航" }, "home": { "badge": "支持中心", @@ -2778,7 +2772,9 @@ "browseDocs": "浏览帮助文档", "askQuestion": "提出一个问题", "recommendedPages": "推荐页面", - "hotQuestions": "热门问题" + "hotQuestions": "热门问题", + "postsTitle": "社区讨论", + "postsDescription": "浏览帖子,登录后发帖和评论。" }, "help": { "title": "帮助中心", @@ -2851,13 +2847,20 @@ "loginAction": "登录", "registerAction": "注册并登录", "switchToRegister": "没有账号,去注册", - "switchToLogin": "已有账号,去登录" + "switchToLogin": "已有账号,去登录", + "welcomeBack": "欢迎回来", + "account": "账号", + "accountPlaceholder": "请输入邮箱或用户名", + "noMethodsTitle": "暂未开启登录方式", + "noMethodsDescription": "请联系管理员开启密码、企业微信或 OIDC 登录。" }, "account": { "loading": "正在加载登录状态", "openMenu": "打开账号菜单", "signOut": "退出登录", - "signingOut": "正在退出..." + "signingOut": "正在退出...", + "profile": "个人中心", + "editProfile": "编辑资料" }, "actions": { "search": "搜索", @@ -2874,7 +2877,20 @@ "retry": "重试", "loadMore": "加载更多", "viewDiscussion": "查看讨论", - "answerQuestion": "参与回答" + "answerQuestion": "参与回答", + "createPost": "发布帖子", + "publishPost": "发布帖子", + "publishComment": "发布评论", + "publishReply": "发布回复", + "reply": "回复", + "edit": "编辑", + "delete": "删除", + "report": "举报", + "copyLink": "复制链接", + "cancel": "取消", + "save": "保存", + "viewReplies": "查看全部 {count} 条回复", + "noMore": "没有更多了" }, "status": { "all": "全部", @@ -2896,7 +2912,11 @@ "noQuestionsMatched": "没有找到问题", "questionsFailed": "问题加载失败,请稍后重试", "noAnswers": "暂无回答", - "selectPage": "选择左侧页面查看内容" + "selectPage": "选择左侧页面查看内容", + "noPosts": "暂无社区帖子", + "noPostsMatched": "没有找到帖子", + "postsFailed": "帖子加载失败,请稍后重试", + "noComments": "暂无评论" }, "loading": { "categories": "正在加载分类…", @@ -2904,20 +2924,100 @@ "page": "正在加载页面...", "question": "正在加载问题...", "questions": "正在加载问题...", - "session": "正在检查登录状态..." + "session": "正在检查登录状态...", + "post": "正在加载帖子...", + "posts": "正在加载帖子...", + "comments": "正在加载评论..." }, "toast": { "feedbackSaved": "感谢反馈", "codeCopied": "代码已复制", "answerCreated": "回答已发布", "questionCreated": "问题已发布", - "loggedIn": "已登录" + "loggedIn": "已登录", + "profileUpdated": "资料已更新", + "postCreated": "帖子已发布", + "commentCreated": "评论已发布", + "replyCreated": "回复已发布", + "commentUpdated": "评论已更新", + "commentDeleted": "评论已删除", + "commentReported": "已收到举报", + "linkCopied": "链接已复制" }, "a11y": { "openNavigation": "打开文档导航", "closeNavigation": "关闭文档导航", "collapse": "折叠子页面", - "expand": "展开子页面" + "expand": "展开子页面", + "openMenu": "打开菜单", + "closeMenu": "关闭菜单" + }, + "posts": { + "title": "社区讨论", + "description": "登录用户可以发帖、评论、点赞并采纳有效回复。", + "categoryNavigation": "帖子分类", + "moreCategories": "更多分类", + "more": "更多", + "categoriesFailed": "分类加载失败,点击重试", + "searchPlaceholder": "搜索帖子标题", + "detailTitle": "帖子详情", + "comments": "评论", + "likes": "点赞", + "views": "浏览", + "createdBy": "{name} 发布", + "updatedAt": "{date} 更新" + }, + "createPost": { + "title": "发布帖子", + "description": "登录后发布帖子,社区成员可以参与评论。", + "formTitle": "发布帖子", + "category": "分类", + "categoryPlaceholder": "选择分类", + "categorySearch": "搜索分类", + "categoryEmpty": "暂无分类", + "categoriesFailed": "分类加载失败,点击重试", + "categoryRequired": "请选择帖子分类", + "postTitle": "帖子标题", + "postTitlePlaceholder": "用一句话概括你要讨论的内容", + "titleRequired": "请输入帖子标题", + "content": "正文", + "contentPlaceholder": "补充背景、现象、环境和你希望大家关注的重点", + "contentRequired": "请补充帖子正文", + "tags": "标签", + "tagsPlaceholder": "标签,用英文逗号分隔" + }, + "comment": { + "title": "发表评论", + "placeholder": "写下你的评论", + "replyPlaceholder": "写下你的回复", + "count": "{count} 条评论", + "authorBadge": "楼主", + "deleted": "该评论已删除", + "deleteConfirm": "删除后该评论将不再展示,确定删除吗?", + "sort": { + "default": "默认", + "latest": "最新", + "hot": "热门" + }, + "accepted": "已采纳" + }, + "profile": { + "title": "个人中心", + "username": "用户名", + "email": "邮箱", + "emailUnset": "未设置", + "edit": "编辑资料", + "editTitle": "编辑资料", + "editDescription": "更新你在支持中心展示的个人资料。", + "nickname": "昵称", + "nicknamePlaceholder": "请输入昵称", + "nicknameRequired": "请输入昵称", + "avatar": "头像", + "avatarPlaceholder": "上传头像", + "emailPlaceholder": "请输入邮箱", + "communityTitle": "我的社区内容", + "communityDescription": "查看你在支持中心发布的帖子。", + "noPosts": "你还没有发布社区帖子" } }, "nav": { @@ -2958,7 +3058,11 @@ "notifications": "通知中心", "preferences": "偏好设置", "changePassword": "修改密码", - "signOut": "退出登录" + "signOut": "退出登录", + "supportDocs": "文档中心", + "supportCommunity": "社区内容", + "supportCommunityCategories": "社区分类", + "supportConfig": "支持中心配置" }, "workspace": { "dashboard": "管理后台", @@ -2977,5 +3081,180 @@ "green": "温润服务绿", "gray": "中性精密灰", "blue": "清透科技蓝" + }, + "docWorkbench": { + "title": "文档中心", + "createPage": "新建页面", + "createChildPage": "新建子页面", + "createDescription": "页面会显示在文档目录中,也可以直接承载正文。", + "createAndEdit": "创建并编辑", + "creating": "创建中...", + "created": "页面已创建", + "pageTitle": "页面标题", + "searchPlaceholder": "搜索页面", + "filterAll": "全部", + "filterDraft": "草稿", + "filterPublished": "已发布", + "filterHidden": "已隐藏", + "sortDisabledDuringSearch": "搜索结果中暂不支持排序", + "loading": "正在加载页面...", + "empty": "暂无文档页面", + "selectPrompt": "从左侧选择页面,或新建页面", + "pageSettings": "页面设置", + "parentPage": "父页面", + "selectParentPage": "选择父页面", + "rootDirectory": "根目录", + "slug": "Slug", + "slugFormatHint": "仅支持字母、数字和连字符(-)。", + "slugChangeWarning": "修改后原文档链接将失效,请同步更新外部引用。", + "status": "状态", + "selectStatus": "选择状态", + "statusDraft": "草稿", + "statusPublished": "已发布", + "statusHidden": "已隐藏", + "summary": "页面摘要", + "summaryPlaceholder": "用于文档目录、推荐列表和搜索结果", + "contentPlaceholder": "编写页面正文", + "save": "保存", + "saving": "保存中...", + "saved": "页面已保存", + "settingsSaved": "页面设置已保存", + "savedState": "所有修改已保存", + "unsaved": "有未保存修改", + "unsavedLeaveConfirm": "当前页面有未保存的修改,确定离开吗?", + "unsavedLeaveTitle": "放弃未保存的修改?", + "discardChanges": "放弃修改", + "continueEditing": "继续编辑", + "publishedSaveWarning": "已发布文档保存后会立即更新前台", + "saveBeforeStatusChange": "请先保存当前修改,再变更发布状态", + "publishedAction": "发布", + "publishedConfirmTitle": "发布“{title}”?", + "publishedConfirmDescription": "发布后文档将立即在前台可见。", + "publishedSuccess": "文档已发布", + "draftAction": "撤回", + "draftConfirmTitle": "撤回“{title}”?", + "draftConfirmDescription": "撤回后前台将无法继续访问该文档。", + "draftSuccess": "文档已撤回为草稿", + "withdrawAction": "撤回", + "hiddenAction": "隐藏", + "hiddenConfirmTitle": "隐藏“{title}”?", + "hiddenConfirmDescription": "隐藏后前台将无法访问该文档,内容仍保留在后台。", + "hiddenSuccess": "文档已隐藏", + "openPublicPage": "打开前台页面", + "collapse": "折叠子页面", + "expand": "展开子页面", + "dragPage": "拖动页面“{title}”排序", + "dragDisabled": "当前状态下不能排序", + "sameLevelOnly": "只能调整同一父页面下的顺序", + "sortSaved": "页面顺序已更新", + "pageActions": "页面操作:{title}", + "deletePage": "删除页面", + "deleteConfirm": "确定删除“{title}”吗?存在子页面时不能删除。", + "deleteConfirmTitle": "删除“{title}”?", + "deleted": "页面已删除", + "cancel": "取消" + }, + "docs": { + "title": "文档中心骨架", + "description": "文档中心用于沉淀产品说明、权限约定、渠道接入流程与常见问题。", + "step1": "汇总项目开发规范与后台使用说明。", + "step2": "补充第三方接入流程图和配置校验清单。", + "step3": "后续可接入 markdown 或文档中心能力。" + }, + "supportCommunityCategory": { + "allStatuses": "全部状态", + "enabled": "启用", + "disabled": "停用", + "name": "分类名称", + "searchName": "搜索分类名称", + "noDescription": "暂无说明", + "status": "状态", + "confirmDeleteTitle": "删除社区分类", + "confirmDeleteDescription": "确定删除“{name}”吗?仍被帖子使用的分类不能删除。", + "delete": "删除", + "sortUpdated": "社区分类排序已更新", + "sortUpdateFailed": "更新排序失败", + "dragSort": "拖拽排序 {name}", + "create": "新建", + "save": "保存", + "saving": "保存中...", + "cancel": "取消", + "loadingDetail": "正在加载详情...", + "required": "此项不能为空", + "invalidNumber": "请输入有效数字", + "minValue": "不能小于 {min}", + "maxValue": "不能大于 {max}", + "namePlaceholder": "请输入分类名称", + "slugPlaceholder": "例如 account-security", + "slugPatternMessage": "仅支持字母、数字和连字符(-)", + "description": "分类说明", + "descriptionPlaceholder": "说明该分类包含的帖子范围", + "remark": "备注", + "remarkPlaceholder": "仅后台可见", + "createTitle": "新建社区分类", + "editTitle": "编辑社区分类", + "refresh": "刷新", + "new": "新建分类", + "query": "查询", + "loading": "正在加载分类...", + "empty": "暂无社区分类", + "actions": "操作", + "edit": "编辑", + "processing": "处理中...", + "moreActions": "更多操作:{name}", + "loadFailed": "分类加载失败", + "saveFailed": "分类保存失败", + "deleteFailed": "分类删除失败", + "created": "分类“{name}”已创建", + "updated": "分类“{name}”已更新", + "deleted": "分类“{name}”已删除" + }, + "supportCommunityAdmin": { + "postStatusUpdated": "帖子状态已更新" + }, + "supportConfig": { + "title": "支持中心配置", + "description": "管理支持中心公开页面的导航菜单和后续全局配置。", + "navigationTitle": "导航菜单", + "navigationDescription": "配置公开支持中心顶部导航和移动端站点导航。拖拽左侧手柄调整顺序,保存后生效。", + "refresh": "刷新", + "save": "保存配置", + "saving": "保存中...", + "saved": "支持中心配置已保存", + "loadFailed": "支持中心配置加载失败", + "loadChannelsFailed": "AI 客服渠道加载失败", + "saveFailed": "支持中心配置保存失败", + "validationFailed": "请修正以下配置问题", + "loading": "正在加载支持中心配置...", + "addNavigation": "新增菜单", + "emptyNavigation": "暂无导航菜单", + "sort": "排序", + "menuTitle": "标题", + "menuURL": "链接", + "target": "打开方式", + "targetSelf": "当前窗口", + "targetBlank": "新窗口", + "visible": "是否显示", + "enabled": "启用", + "disabled": "停用", + "actions": "操作", + "aiCustomerServiceTitle": "AI 客服", + "aiCustomerServiceStatus": "AI 客服", + "aiCustomerServiceDescription": "选择支持中心公开页面使用的 Web 接入渠道。渠道绑定的 AI Agent 和服务模式在渠道管理中维护。", + "aiCustomerServiceChannel": "接入渠道", + "selectAIChannel": "选择 Web 渠道", + "searchAIChannel": "搜索渠道", + "emptyAIChannel": "暂无可用 Web 渠道", + "loadingChannels": "正在加载渠道...", + "aiChannelAgent": "AI Agent:{name}", + "aiCustomerServiceChannelHint": "启用 AI 客服前,请先选择一个已启用并绑定已发布 AI Agent 的 Web 渠道。", + "toggleAIService": "启用或停用 AI 客服", + "titlePlaceholder": "例如:文档中心", + "untitled": "未命名菜单", + "dragNavigation": "拖拽排序:{title}", + "toggleNavigation": "启用或停用:{title}", + "deleteNavigation": "删除菜单:{title}", + "confirmDelete": "确定删除导航菜单“{title}”吗?保存配置后将从支持中心导航中移除。", + "openURL": "打开链接" } } From 095016d0aa7cbe28fc7195ac449b27f063fb3187 Mon Sep 17 00:00:00 2001 From: mlogclub Date: Sat, 29 Aug 2026 11:47:36 +0800 Subject: [PATCH 05/29] feat: simplify support center layout and remove unused components --- web/app/(support)/support/page-client.tsx | 66 ++--------------------- web/messages/en-US.json | 8 +-- web/messages/zh-CN.json | 8 +-- 3 files changed, 5 insertions(+), 77 deletions(-) diff --git a/web/app/(support)/support/page-client.tsx b/web/app/(support)/support/page-client.tsx index 46523d66..e1ba7e87 100644 --- a/web/app/(support)/support/page-client.tsx +++ b/web/app/(support)/support/page-client.tsx @@ -1,12 +1,11 @@ "use client" -import { useState, type ReactNode } from "react" +import { useState } from "react" import Link from "next/link" -import { ArrowRightIcon, BookOpenIcon, CircleHelpIcon, HeadphonesIcon } from "lucide-react" import { Badge } from "@/components/ui/badge" import { buttonVariants } from "@/components/ui/button" -import { SupportPageContent, SupportPageShell } from "@/app/(support)/support/_components/support-page-shell" +import { SupportPageShell } from "@/app/(support)/support/_components/support-page-shell" import { SupportSearchInput } from "@/app/(support)/support/_components/support-ui" import { useI18n } from "@/i18n/provider" import { postsHref } from "@/lib/api/support-community" @@ -18,7 +17,7 @@ export function SupportCenterHome() { return ( -
+
{t("supportPublic.home.badge")} @@ -46,65 +45,6 @@ export function SupportCenterHome() {
- -
- } - title={t("supportPublic.home.helpTitle")} - description={t("supportPublic.home.helpDescription")} - accent="sky" - /> - } - title={t("supportPublic.home.postsTitle")} - description={t("supportPublic.home.postsDescription")} - accent="violet" - /> - } - title={t("supportPublic.home.chatTitle")} - description={t("supportPublic.home.chatDescription")} - accent="emerald" - /> -
-
) } - -function SupportEntryCard({ - href, - icon, - title, - description, - accent, -}: { - href: string - icon: ReactNode - title: string - description: string - accent: "sky" | "violet" | "emerald" -}) { - const accentClass = { - sky: "bg-sky-500/10 text-sky-600 dark:text-sky-400", - violet: "bg-violet-500/10 text-violet-600 dark:text-violet-400", - emerald: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400", - }[accent] - - return ( - -
- {icon} - -
-

{title}

-

{description}

- - ) -} diff --git a/web/messages/en-US.json b/web/messages/en-US.json index 894e5958..082e0a21 100644 --- a/web/messages/en-US.json +++ b/web/messages/en-US.json @@ -2761,21 +2761,15 @@ "title": "Docs, community answers, and live support in one place.", "description": "Capture recurring questions like a community and organize guidance like a docs site. Search first, then contact support when needed.", "searchPlaceholder": "Search help pages or questions", - "helpTitle": "Help Center", - "helpDescription": "Official guides, deployment notes, and troubleshooting.", "questionsTitle": "FAQ Community", "questionsDescription": "Browse questions, then log in to ask or answer.", - "chatTitle": "Live Support", - "chatDescription": "Open a support conversation when self-service is not enough.", "quickPanelTitle": "Quick Links", "quickPanelDescription": "Continue by task", "unsolvedQuestions": "View unsolved questions", "browseDocs": "Browse help docs", "askQuestion": "Ask a question", "recommendedPages": "Recommended Pages", - "hotQuestions": "Popular Questions", - "postsTitle": "Community Discussions", - "postsDescription": "Browse posts, then log in to publish and comment." + "hotQuestions": "Popular Questions" }, "help": { "title": "Help Center", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 3ca55a1d..2e754eb2 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -2760,21 +2760,15 @@ "title": "文档、社区问答和在线咨询集中在这里。", "description": "像社区一样沉淀问题,像文档站一样组织知识。先自助检索,必要时再进入在线咨询。", "searchPlaceholder": "搜索帮助页面或问题", - "helpTitle": "帮助中心", - "helpDescription": "官方指南、部署文档和故障排查。", "questionsTitle": "FAQ 社区", "questionsDescription": "浏览问题,登录后提问和回答。", - "chatTitle": "在线咨询", - "chatDescription": "仍然无法解决时进入客服会话。", "quickPanelTitle": "快速入口", "quickPanelDescription": "按当前任务继续", "unsolvedQuestions": "查看未解决问题", "browseDocs": "浏览帮助文档", "askQuestion": "提出一个问题", "recommendedPages": "推荐页面", - "hotQuestions": "热门问题", - "postsTitle": "社区讨论", - "postsDescription": "浏览帖子,登录后发帖和评论。" + "hotQuestions": "热门问题" }, "help": { "title": "帮助中心", From fd686c5f4393f730dec60c9f2adc296db04569bd Mon Sep 17 00:00:00 2001 From: mlogclub Date: Sun, 30 Aug 2026 11:09:15 +0800 Subject: [PATCH 06/29] feat: refactor support components to use SupportPageLayout and enhance article TOC functionality --- .../_components/community-category-nav.tsx | 13 ++- .../support/_components/community-frame.tsx | 39 ++++----- .../_components/support-article-toc.tsx | 20 ++++- .../_components/support-page-layout.tsx | 54 ++++++++++++ .../_components/support-page-shell.tsx | 19 +++- .../posts/detail/_components/post-detail.tsx | 2 +- web/app/(support)/support/page-client.tsx | 6 +- .../profile/_components/profile-page.tsx | 87 +++++++++---------- 8 files changed, 158 insertions(+), 82 deletions(-) create mode 100644 web/app/(support)/support/_components/support-page-layout.tsx diff --git a/web/app/(support)/support/_components/community-category-nav.tsx b/web/app/(support)/support/_components/community-category-nav.tsx index 7c69a281..5e953727 100644 --- a/web/app/(support)/support/_components/community-category-nav.tsx +++ b/web/app/(support)/support/_components/community-category-nav.tsx @@ -2,7 +2,6 @@ import { FileTextIcon, LayoutGridIcon } from "lucide-react" -import { ScrollArea } from "@/components/ui/scroll-area" import { useI18n } from "@/i18n/provider" import type { Category } from "@/lib/api/support-community" import { cn } from "@/lib/utils" @@ -20,13 +19,11 @@ type CommunityCategoryNavProps = { export function CommunityCategoryNav(props: CommunityCategoryNavProps) { const t = useI18n() return ( -
, - }} - /> -
+ -
- {children} -
- {toc ?
{toc}
: null} -
- + )} + toc={toc} + mobileNavigation={{ + title: t("supportPublic.posts.categoryNavigation"), + content:
{categoryNavigation}
, + }} + > +
+ {children} +
+ ) } diff --git a/web/app/(support)/support/_components/support-article-toc.tsx b/web/app/(support)/support/_components/support-article-toc.tsx index 88202ed3..877741f8 100644 --- a/web/app/(support)/support/_components/support-article-toc.tsx +++ b/web/app/(support)/support/_components/support-article-toc.tsx @@ -6,7 +6,17 @@ import { useI18n } from "@/i18n/provider" import { articleHeadingId, markdownHeadingText } from "@/lib/support-article" import { cn } from "@/lib/utils" -export function PublicArticleToc({ articleId, content, contentType = "markdown" }: { articleId: string; content: string; contentType?: string }) { +export function PublicArticleToc({ + articleId, + content, + contentType = "markdown", + stickyOffset = "header", +}: { + articleId: string + content: string + contentType?: string + stickyOffset?: "header" | "content" +}) { const t = useI18n() const tocRef = useRef(null) const headings = useMemo(() => getArticleTocHeadings(content, contentType), [content, contentType]) @@ -82,7 +92,13 @@ export function PublicArticleToc({ articleId, content, contentType = "markdown" }, [activeId]) return ( -
+ + ) } diff --git a/web/app/(support)/support/_components/support-article-toc.tsx b/web/app/(support)/support/_components/support-article-toc.tsx index 877741f8..556e553e 100644 --- a/web/app/(support)/support/_components/support-article-toc.tsx +++ b/web/app/(support)/support/_components/support-article-toc.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent } from "react" +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area" import { useI18n } from "@/i18n/provider" import { articleHeadingId, markdownHeadingText } from "@/lib/support-article" import { cn } from "@/lib/utils" @@ -76,7 +77,7 @@ export function PublicArticleToc({ } useEffect(() => { - const container = tocRef.current + const container = tocRef.current?.querySelector("[data-slot='scroll-area-viewport']") if (!container || !activeId) return const activeLink = Array.from(container.querySelectorAll("[data-toc-id]")) .find((link) => link.dataset.tocId === activeId) @@ -92,32 +93,36 @@ export function PublicArticleToc({ }, [activeId]) return ( -