diff --git a/src/apps/developers/content/docs/en/settings/channels.md b/src/apps/developers/content/docs/en/settings/channels.md index b57038b7c..5d70915c1 100644 --- a/src/apps/developers/content/docs/en/settings/channels.md +++ b/src/apps/developers/content/docs/en/settings/channels.md @@ -61,6 +61,7 @@ Connect to a self-hosted OneBot v11 backend (go-cqhttp, NapCat, Lagrange, etc.). - **WebSocket URL** — reverse or forward WebSocket endpoint of the OneBot service. - **HTTP API URL** + **Token** — used for outbound API calls and authentication. +- **Bot name** — robot name used as a group-chat trigger keyword. - **QR Login** — scan to log in the underlying QQ account. - **Auto Re-login** — automatically reconnect when the session drops. - **Allowed QQ Users** — QQ numbers allowed in private chats. diff --git a/src/apps/developers/content/docs/zh/settings/channels.md b/src/apps/developers/content/docs/zh/settings/channels.md index 332893228..41b5c76dc 100644 --- a/src/apps/developers/content/docs/zh/settings/channels.md +++ b/src/apps/developers/content/docs/zh/settings/channels.md @@ -61,6 +61,7 @@ Channel 设置用于配置 Arkloop 与各个外部消息平台的连接。每个 - **WebSocket URL** —— OneBot 服务的正向或反向 WebSocket 端点。 - **HTTP API URL** + **Token** —— 对外 API 调用地址及鉴权 token。 +- **Bot 名称** —— 群聊中可作为触发关键词使用的机器人名称。 - **QR Login** —— 扫码登录底层 QQ 账号。 - **Auto Re-login** —— 会话掉线时自动重连。 - **Allowed QQ Users** —— 允许私聊的 QQ 号。 diff --git a/src/apps/web/src/__tests__/chatInputPersonaSelector.test.tsx b/src/apps/web/src/__tests__/chatInputPersonaSelector.test.tsx index da0e10cce..461ecde0b 100644 --- a/src/apps/web/src/__tests__/chatInputPersonaSelector.test.tsx +++ b/src/apps/web/src/__tests__/chatInputPersonaSelector.test.tsx @@ -129,7 +129,7 @@ describe('ChatInput persona selector', () => { } }) - it('按动态列表循环切换并可从下拉选择人格', async () => { + it('不再从加号菜单加载动态人格,提交沿用已选人格', async () => { const onSubmit = vi.fn<(event: FormEvent, personaKey: string) => void>((event) => event.preventDefault()) const container = document.createElement('div') document.body.appendChild(container) @@ -149,49 +149,22 @@ describe('ChatInput persona selector', () => { await flushMicrotasks() }) - expect(mockedListSelectablePersonas).toHaveBeenCalledWith('token') - - const selectorButton = findButtonByText(container, 'Normal') - expect(selectorButton).not.toBeNull() - if (!selectorButton) return - - await act(async () => { - selectorButton.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - const searchMenuButton = Array.from(container.querySelectorAll('button')).find( - (button) => button !== selectorButton && button.textContent?.trim() === 'Search', - ) as HTMLButtonElement | null - expect(searchMenuButton).not.toBeNull() - if (!searchMenuButton) return - - await act(async () => { - searchMenuButton.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - expect(findButtonByText(container, 'Search')).not.toBeNull() - - const searchSelectorButton = findButtonByText(container, 'Search') - expect(searchSelectorButton).not.toBeNull() - if (!searchSelectorButton) return - - await act(async () => { - searchSelectorButton.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) + const form = container.querySelector('form') + expect(form).not.toBeNull() + if (!form) return - const menuNormalButton = Array.from(container.querySelectorAll('button')).find( - (button) => button !== searchSelectorButton && button.textContent?.trim() === 'Normal', - ) as HTMLButtonElement | null - expect(menuNormalButton).not.toBeNull() - if (!menuNormalButton) return + const menuButton = form.querySelector('button[type="button"]') + expect(menuButton).not.toBeNull() + if (!menuButton) return await act(async () => { - menuNormalButton.dispatchEvent(new MouseEvent('click', { bubbles: true })) + menuButton.dispatchEvent(new MouseEvent('click', { bubbles: true })) + await flushMicrotasks() }) - const form = container.querySelector('form') - expect(form).not.toBeNull() - if (!form) return + expect(mockedListSelectablePersonas).not.toHaveBeenCalled() + expect(findButtonByText(container, 'Normal')).toBeFalsy() + expect(findButtonByText(container, 'Search')).toBeFalsy() await act(async () => { form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) diff --git a/src/apps/web/src/__tests__/desktopChannelsSettings.test.tsx b/src/apps/web/src/__tests__/desktopChannelsSettings.test.tsx index b650dc7c0..8b30754b0 100644 --- a/src/apps/web/src/__tests__/desktopChannelsSettings.test.tsx +++ b/src/apps/web/src/__tests__/desktopChannelsSettings.test.tsx @@ -118,6 +118,9 @@ async function loadChannelsSubject() { updateChannel: vi.fn(), verifyChannel: vi.fn(), createChannelBindCode: vi.fn(), + listChannelBindings: vi.fn().mockResolvedValue([]), + updateChannelBinding: vi.fn(), + deleteChannelBinding: vi.fn(), unbindChannelIdentity: vi.fn(), isApiError: vi.fn(() => false), } @@ -510,6 +513,172 @@ describe('DesktopChannelsSettings', () => { expect(document.body.textContent).toContain('app-123') }) + it('可以保存 QQ OneBot 的 Bot 名称配置', async () => { + const { api, DesktopChannelsSettings, LocaleProvider } = await loadChannelsSubject() + const qqChannel = { + id: 'qq-1', + account_id: 'acc-1', + channel_type: 'qq', + persona_id: 'persona-1', + webhook_url: null, + is_active: true, + config_json: { + onebot_ws_url: 'ws://127.0.0.1:6098', + onebot_http_url: 'http://127.0.0.1:3000', + onebot_token: 'secret', + bot_name: 'Old Bot', + allowed_user_ids: ['10001'], + allowed_group_ids: ['20001'], + }, + has_credentials: true, + created_at: '2026-03-26T00:00:00Z', + updated_at: '2026-03-26T00:00:00Z', + } + vi.mocked(api.listChannels).mockResolvedValue([qqChannel]) + vi.mocked(api.listMyChannelIdentities).mockResolvedValue([]) + vi.mocked(api.listChannelPersonas).mockResolvedValue([ + { + id: 'persona-1', + persona_key: 'normal', + version: '1', + display_name: 'Normal', + source: 'project', + } as never, + ]) + vi.mocked(api.listLlmProviders).mockResolvedValue([]) + vi.mocked(api.listChannelBindings).mockResolvedValue([]) + vi.mocked(api.updateChannel).mockResolvedValue({ + ...qqChannel, + config_json: { + ...qqChannel.config_json, + bot_name: 'New Bot', + }, + }) + + await act(async () => { + root!.render( + + + , + ) + }) + await flushEffects() + + const qqOneBotTab = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('OneBot')) + expect(qqOneBotTab).toBeTruthy() + + await act(async () => { + qqOneBotTab!.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + await flushEffects() + + const botNameInput = Array.from(document.body.querySelectorAll('input')).find((input) => input.value === 'Old Bot') as HTMLInputElement + expect(botNameInput).toBeTruthy() + + await act(async () => { + setInputValue(botNameInput, 'New Bot') + }) + await flushEffects() + + const saveButton = Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent?.trim() === '保存') + await act(async () => { + saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + await flushEffects() + + expect(api.updateChannel).toHaveBeenCalledWith('token', 'qq-1', { + persona_id: 'persona-1', + is_active: true, + config_json: { + onebot_ws_url: 'ws://127.0.0.1:6098', + onebot_http_url: 'http://127.0.0.1:3000', + onebot_token: 'secret', + bot_name: 'New Bot', + allowed_user_ids: ['10001'], + allowed_group_ids: ['20001'], + }, + }) + }) + + it('清空 QQ OneBot 的 Bot 名称时会移除配置字段', async () => { + const { api, DesktopChannelsSettings, LocaleProvider } = await loadChannelsSubject() + const qqChannel = { + id: 'qq-1', + account_id: 'acc-1', + channel_type: 'qq', + persona_id: 'persona-1', + webhook_url: null, + is_active: true, + config_json: { + onebot_ws_url: 'ws://127.0.0.1:6098', + bot_name: 'Old Bot', + }, + has_credentials: true, + created_at: '2026-03-26T00:00:00Z', + updated_at: '2026-03-26T00:00:00Z', + } + vi.mocked(api.listChannels).mockResolvedValue([qqChannel]) + vi.mocked(api.listMyChannelIdentities).mockResolvedValue([]) + vi.mocked(api.listChannelPersonas).mockResolvedValue([ + { + id: 'persona-1', + persona_key: 'normal', + version: '1', + display_name: 'Normal', + source: 'project', + } as never, + ]) + vi.mocked(api.listLlmProviders).mockResolvedValue([]) + vi.mocked(api.listChannelBindings).mockResolvedValue([]) + vi.mocked(api.updateChannel).mockResolvedValue({ + ...qqChannel, + config_json: { + onebot_ws_url: 'ws://127.0.0.1:6098', + }, + }) + + await act(async () => { + root!.render( + + + , + ) + }) + await flushEffects() + + const qqOneBotTab = Array.from(container.querySelectorAll('button')).find((button) => button.textContent?.includes('OneBot')) + expect(qqOneBotTab).toBeTruthy() + + await act(async () => { + qqOneBotTab!.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + await flushEffects() + + const botNameInput = Array.from(document.body.querySelectorAll('input')).find((input) => input.value === 'Old Bot') as HTMLInputElement + expect(botNameInput).toBeTruthy() + + await act(async () => { + setInputValue(botNameInput, ' ') + }) + await flushEffects() + + const saveButton = Array.from(document.body.querySelectorAll('button')).find((button) => button.textContent?.trim() === '保存') + await act(async () => { + saveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + await flushEffects() + + expect(api.updateChannel).toHaveBeenCalledWith('token', 'qq-1', { + persona_id: 'persona-1', + is_active: true, + config_json: { + onebot_ws_url: 'ws://127.0.0.1:6098', + allowed_user_ids: [], + allowed_group_ids: [], + }, + }) + }) + it('可以创建飞书官方渠道并提交官方接入配置', async () => { const { api, DesktopChannelsSettings, LocaleProvider } = await loadChannelsSubject() vi.mocked(api.listChannels).mockResolvedValue([]) diff --git a/src/apps/web/src/__tests__/documentPanelPreview.test.tsx b/src/apps/web/src/__tests__/documentPanelPreview.test.tsx index d4972ead8..5f0927109 100644 --- a/src/apps/web/src/__tests__/documentPanelPreview.test.tsx +++ b/src/apps/web/src/__tests__/documentPanelPreview.test.tsx @@ -1,16 +1,11 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act } from 'react' -import { createRoot } from 'react-dom/client' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ResourcePreviewPanel } from '../components/resource-preview/ResourcePreviewPanel' import { LocaleProvider } from '../contexts/LocaleContext' import type { ArtifactRef } from '../storage' -type URLWithObjectURL = typeof URL & { - createObjectURL?: (object: Blob) => string - revokeObjectURL?: (url: string) => void -} - type GlobalWithActEnvironment = typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } @@ -22,42 +17,51 @@ function flushMicrotasks(): Promise { return Promise.resolve() .then(() => Promise.resolve()) .then(() => Promise.resolve()) - .then(() => Promise.resolve()) } -async function flushPreviewWork(): Promise { - for (let i = 0; i < 8; i++) { - await flushMicrotasks() - await new Promise((resolve) => setTimeout(resolve, 0)) +async function waitForAssertion(assertion: () => void): Promise { + let lastError: unknown + for (let i = 0; i < 20; i++) { + try { + assertion() + return + } catch (err) { + lastError = err + } + await act(async () => { + await flushMicrotasks() + await new Promise((resolve) => setTimeout(resolve, 0)) + }) } + throw lastError } describe('ResourcePreviewPanel artifact preview', () => { - const urlWithObjectURL = URL as URLWithObjectURL const actEnvironmentGlobal = globalThis as GlobalWithActEnvironment - const originalCreateObjectURL = urlWithObjectURL.createObjectURL - const originalRevokeObjectURL = urlWithObjectURL.revokeObjectURL const originalFetch = globalThis.fetch const originalActEnvironment = actEnvironmentGlobal.IS_REACT_ACT_ENVIRONMENT + let container: HTMLDivElement + let root: Root beforeEach(() => { actEnvironmentGlobal.IS_REACT_ACT_ENVIRONMENT = true - globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => { - cb(performance.now()) + globalThis.requestAnimationFrame = (callback: FrameRequestCallback) => { + callback(performance.now()) return 0 } globalThis.cancelAnimationFrame = () => {} - urlWithObjectURL.createObjectURL = vi.fn(() => 'blob:artifact-preview') - urlWithObjectURL.revokeObjectURL = vi.fn() + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => { const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url if (url.endsWith('/doc.md')) { - return new Response('[预览](artifact:preview.html)', { + return new Response('[Preview](artifact:preview.html)', { headers: { 'Content-Type': 'text/markdown' }, }) } if (url.endsWith('/preview.html')) { - return new Response('ok', { + return new Response('preview', { headers: { 'Content-Type': 'text/html' }, }) } @@ -66,16 +70,10 @@ describe('ResourcePreviewPanel artifact preview', () => { }) afterEach(() => { - if (originalCreateObjectURL) { - urlWithObjectURL.createObjectURL = originalCreateObjectURL - } else { - Reflect.deleteProperty(urlWithObjectURL, 'createObjectURL') - } - if (originalRevokeObjectURL) { - urlWithObjectURL.revokeObjectURL = originalRevokeObjectURL - } else { - Reflect.deleteProperty(urlWithObjectURL, 'revokeObjectURL') - } + act(() => { + root.unmount() + }) + container.remove() globalThis.fetch = originalFetch if (originalActEnvironment === undefined) { delete actEnvironmentGlobal.IS_REACT_ACT_ENVIRONMENT @@ -87,13 +85,7 @@ describe('ResourcePreviewPanel artifact preview', () => { vi.restoreAllMocks() }) - it('Markdown 文档中的 html artifact 应继续内联渲染', async () => { - const markdownArtifact: ArtifactRef = { - key: 'doc.md', - filename: 'doc.md', - size: 10, - mime_type: 'text/markdown', - } + it('loads markdown artifacts before rendering linked html artifacts inline', async () => { const htmlArtifact: ArtifactRef = { key: 'preview.html', filename: 'preview.html', @@ -101,20 +93,16 @@ describe('ResourcePreviewPanel artifact preview', () => { mime_type: 'text/html', } - const container = document.createElement('div') - document.body.appendChild(container) - const root = createRoot(container) - await act(async () => { root.render( { ) }) - await act(async () => { - await flushPreviewWork() + await waitForAssertion(() => { + expect(container.querySelector('iframe[title="preview.html"]')).not.toBeNull() }) expect(globalThis.fetch).toHaveBeenCalledTimes(2) - expect(container.querySelector('iframe')).not.toBeNull() - - act(() => { - root.unmount() - }) - container.remove() + const [markdownUrl, markdownInit] = vi.mocked(globalThis.fetch).mock.calls[0]! + expect(String(markdownUrl)).toContain('/v1/artifacts/doc.md') + expect((markdownInit as RequestInit | undefined)?.headers).toEqual({ Authorization: 'Bearer token' }) + const [htmlUrl, htmlInit] = vi.mocked(globalThis.fetch).mock.calls[1]! + expect(String(htmlUrl)).toContain('/v1/artifacts/preview.html') + expect((htmlInit as RequestInit | undefined)?.headers).toEqual({ Authorization: 'Bearer token' }) }) }) diff --git a/src/apps/web/src/components/ChatInput.tsx b/src/apps/web/src/components/ChatInput.tsx index d024c0d34..7dac3bb6a 100644 --- a/src/apps/web/src/components/ChatInput.tsx +++ b/src/apps/web/src/components/ChatInput.tsx @@ -776,16 +776,12 @@ export const ChatInput = forwardRef(function ChatInput({ return () => cancelAnimationFrame(id) }, [persistSelectedPersona, searchMode, selectedPersonaKey]) - // sync persona when appMode changes - useEffect(() => { - const id = requestAnimationFrame(() => { - if (appMode === 'work' && selectedPersonaKey !== WORK_PERSONA_KEY) { - persistSelectedPersona(WORK_PERSONA_KEY) - } else if (appMode !== 'work' && selectedPersonaKey === WORK_PERSONA_KEY) { - persistSelectedPersona(DEFAULT_PERSONA_KEY) - } - }) - return () => cancelAnimationFrame(id) + useLayoutEffect(() => { + if (appMode === 'work' && selectedPersonaKey !== WORK_PERSONA_KEY) { + persistSelectedPersona(WORK_PERSONA_KEY) + } else if (appMode !== 'work' && selectedPersonaKey === WORK_PERSONA_KEY) { + persistSelectedPersona(DEFAULT_PERSONA_KEY) + } }, [persistSelectedPersona, appMode, selectedPersonaKey]) const typewriterTarget = placeholder diff --git a/src/apps/web/src/components/settings/DesktopQQSettingsPanel.tsx b/src/apps/web/src/components/settings/DesktopQQSettingsPanel.tsx index 21d13ed89..bc7e34326 100644 --- a/src/apps/web/src/components/settings/DesktopQQSettingsPanel.tsx +++ b/src/apps/web/src/components/settings/DesktopQQSettingsPanel.tsx @@ -67,6 +67,7 @@ export function DesktopQQSettingsPanel({ const [onebotWSUrl, setOnebotWSUrl] = useState((channel?.config_json?.onebot_ws_url as string | undefined) ?? '') const [onebotHTTPUrl, setOnebotHTTPUrl] = useState((channel?.config_json?.onebot_http_url as string | undefined) ?? '') const [onebotToken, setOnebotToken] = useState((channel?.config_json?.onebot_token as string | undefined) ?? '') + const [botName, setBotName] = useState((channel?.config_json?.bot_name as string | undefined) ?? '') const [autoLoginUin, setAutoLoginUin] = useState((channel?.config_json?.auto_login_uin as string | undefined) ?? '') const refreshBindings = useCallback(async () => { if (!channel?.id) { @@ -90,6 +91,7 @@ export function DesktopQQSettingsPanel({ setOnebotWSUrl((channel?.config_json?.onebot_ws_url as string | undefined) ?? '') setOnebotHTTPUrl((channel?.config_json?.onebot_http_url as string | undefined) ?? '') setOnebotToken((channel?.config_json?.onebot_token as string | undefined) ?? '') + setBotName((channel?.config_json?.bot_name as string | undefined) ?? '') setAutoLoginUin((channel?.config_json?.auto_login_uin as string | undefined) ?? '') }, [channel, personas]) @@ -124,6 +126,7 @@ export function DesktopQQSettingsPanel({ const persistedOnebotWSUrl = (channel?.config_json?.onebot_ws_url as string | undefined) ?? '' const persistedOnebotHTTPUrl = (channel?.config_json?.onebot_http_url as string | undefined) ?? '' const persistedOnebotToken = (channel?.config_json?.onebot_token as string | undefined) ?? '' + const persistedBotName = (channel?.config_json?.bot_name as string | undefined) ?? '' const persistedAutoLoginUin = (channel?.config_json?.auto_login_uin as string | undefined) ?? '' const dirty = useMemo(() => { if ((channel?.is_active ?? false) !== enabled) return true @@ -133,9 +136,11 @@ export function DesktopQQSettingsPanel({ if (onebotWSUrl !== persistedOnebotWSUrl) return true if (onebotHTTPUrl !== persistedOnebotHTTPUrl) return true if (onebotToken !== persistedOnebotToken) return true + if (botName !== persistedBotName) return true if (autoLoginUin !== persistedAutoLoginUin) return true return false }, [ + botName, channel, effectiveAllowedUserIDs, effectiveAllowedGroupIDs, @@ -150,6 +155,7 @@ export function DesktopQQSettingsPanel({ persistedOnebotWSUrl, persistedOnebotHTTPUrl, persistedOnebotToken, + persistedBotName, autoLoginUin, persistedAutoLoginUin, ]) @@ -203,6 +209,8 @@ export function DesktopQQSettingsPanel({ else delete configJSON.onebot_http_url if (onebotToken.trim()) configJSON.onebot_token = onebotToken.trim() else delete configJSON.onebot_token + if (botName.trim()) configJSON.bot_name = botName.trim() + else delete configJSON.bot_name if (autoLoginUin.trim()) configJSON.auto_login_uin = autoLoginUin.trim() else delete configJSON.auto_login_uin @@ -397,34 +405,46 @@ export function DesktopQQSettingsPanel({ onChange={(v) => { setOnebotToken(v); setSaved(false) }} /> - - { - setAllowedUserIDs((current) => current.filter((item) => item !== value)) - setSaved(false) - }} + + { setBotName(e.target.value); setSaved(false) }} + placeholder={ct.qqBotNamePlaceholder} + disabled={saving} + className={inputCls} /> + +
+ { + setAllowedUserIDs((current) => current.filter((item) => item !== value)) + setSaved(false) + }} + /> - - { - setAllowedGroupIDs((current) => current.filter((item) => item !== value)) - setSaved(false) - }} - /> + { + setAllowedGroupIDs((current) => current.filter((item) => item !== value)) + setSaved(false) + }} + /> +
diff --git a/src/apps/web/src/locales/en.ts b/src/apps/web/src/locales/en.ts index e45f056b9..4aeb1de1d 100644 --- a/src/apps/web/src/locales/en.ts +++ b/src/apps/web/src/locales/en.ts @@ -826,6 +826,8 @@ export const en: LocaleStrings = { qqOneBotHTTPUrlPlaceholder: 'http://127.0.0.1:3000', qqOneBotToken: 'Token', qqOneBotTokenPlaceholder: 'Access token', + qqBotName: 'Bot name', + qqBotNamePlaceholder: 'Enter bot name', qqOneBotAutoFilled: 'Auto-filled from NapCat', qqExternalOneBotHint: 'Please deploy NapCat or another OneBot11 service externally, then fill in the connection info below', bindingsTitle: 'Linked accounts', diff --git a/src/apps/web/src/locales/index.ts b/src/apps/web/src/locales/index.ts index 8c17b7aec..c320e9fd2 100644 --- a/src/apps/web/src/locales/index.ts +++ b/src/apps/web/src/locales/index.ts @@ -826,6 +826,8 @@ export interface LocaleStrings { qqOneBotHTTPUrlPlaceholder: string qqOneBotToken: string qqOneBotTokenPlaceholder: string + qqBotName: string + qqBotNamePlaceholder: string qqOneBotAutoFilled: string qqExternalOneBotHint: string weixin: string diff --git a/src/apps/web/src/locales/zh.ts b/src/apps/web/src/locales/zh.ts index f13362df9..694476ddb 100644 --- a/src/apps/web/src/locales/zh.ts +++ b/src/apps/web/src/locales/zh.ts @@ -820,6 +820,8 @@ export const zh: LocaleStrings = { qqOneBotHTTPUrlPlaceholder: 'http://127.0.0.1:3000', qqOneBotToken: 'Token', qqOneBotTokenPlaceholder: '鉴权 Token', + qqBotName: 'Bot 名称', + qqBotNamePlaceholder: '输入 Bot 名称', qqOneBotAutoFilled: '已从 NapCat 自动填充', qqExternalOneBotHint: '当前平台不支持内置 QQ 管理,请自行部署 NapCat 或其他 OneBot11 服务后填写连接信息', bindingsTitle: '已关联账号',