From 219fdfbbe3160fafb737c8bc39a7fe7c4a9808c0 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 9 Jul 2026 02:43:12 +0800 Subject: [PATCH 1/3] refactor(settings): unify per-page loading skeletons onto SettingsSkeletonStack permission-center, health-center, and about each hand-wrote a near-identical maka-skeleton-stack with aria-busy/aria-label. Extract SettingsSkeletonStack (label + optional lines, snapshot preset default) and route the three pages through it. SettingsSkeleton (settings-surface shell) is untouched. The about-page loading contract asserted the literal aria-label source text; synced it to the new label= prop form (same intent: skeleton carries the accessible label while loading). --- .../settings-app-info-contract.test.ts | 2 +- .../renderer/settings/about-settings-page.tsx | 14 +++++--- .../renderer/settings/health-center-page.tsx | 8 ++--- .../settings/permission-center-page.tsx | 8 ++--- .../renderer/settings/settings-skeleton.tsx | 32 +++++++++++++++++++ 5 files changed, 46 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/main/__tests__/settings-app-info-contract.test.ts b/apps/desktop/src/main/__tests__/settings-app-info-contract.test.ts index cc954cab6e..0260623510 100644 --- a/apps/desktop/src/main/__tests__/settings-app-info-contract.test.ts +++ b/apps/desktop/src/main/__tests__/settings-app-info-contract.test.ts @@ -20,7 +20,7 @@ describe('Settings app-info loading contract', () => { ); assert.match( aboutBlock, - /if \(!info && !infoError\) \{[\s\S]*aria-label="正在加载关于页"/, + /if \(!info && !infoError\) \{[\s\S]*label="正在加载关于页"/, 'About page skeleton should only render while no error has occurred', ); assert.match( diff --git a/apps/desktop/src/renderer/settings/about-settings-page.tsx b/apps/desktop/src/renderer/settings/about-settings-page.tsx index bbe3366a7d..148e8fc723 100644 --- a/apps/desktop/src/renderer/settings/about-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/about-settings-page.tsx @@ -3,6 +3,7 @@ import { Sparkles } from '@maka/ui/icons'; import { Button, useToast } from '@maka/ui'; import { SettingsRows, SettingRow } from './settings-rows'; import { settingsActionErrorMessage } from './settings-error-copy'; +import { SettingsSkeletonStack } from './settings-skeleton'; type AppInfo = Awaited>; @@ -47,11 +48,14 @@ export function AboutSettingsPage() { if (!info && !infoError) { return ( -
-
-
-
-
+ ); } diff --git a/apps/desktop/src/renderer/settings/health-center-page.tsx b/apps/desktop/src/renderer/settings/health-center-page.tsx index 0c993e7302..fd6c470ef9 100644 --- a/apps/desktop/src/renderer/settings/health-center-page.tsx +++ b/apps/desktop/src/renderer/settings/health-center-page.tsx @@ -10,6 +10,7 @@ import { HEALTH_SIGNAL_LAYERS } from '@maka/core'; import { Button, Badge, RelativeTime } from '@maka/ui'; import { settingsActionErrorMessage } from './settings-error-copy'; import { statusBadgeVariant } from './settings-status-badge'; +import { SettingsSkeletonStack } from './settings-skeleton'; /** * PR-UI-9 — Health Center read-only page. Consumes `window.maka.health.getSnapshot()` @@ -91,12 +92,7 @@ export function HealthCenterPage() { if (loading) { return ( -
-
-
-
-
-
+ ); } diff --git a/apps/desktop/src/renderer/settings/permission-center-page.tsx b/apps/desktop/src/renderer/settings/permission-center-page.tsx index e03490dbce..ce106e10b7 100644 --- a/apps/desktop/src/renderer/settings/permission-center-page.tsx +++ b/apps/desktop/src/renderer/settings/permission-center-page.tsx @@ -21,6 +21,7 @@ import { OS_PERMISSION_IDS } from '@maka/core'; import { Button, Badge, RelativeTime, useToast } from '@maka/ui'; import { settingsActionErrorMessage } from './settings-error-copy'; import { statusBadgeVariant } from './settings-status-badge'; +import { SettingsSkeletonStack } from './settings-skeleton'; /** * PR-UI-8 — Permission Center read-only page. Consumes `window.maka.permissions.getSnapshot()` @@ -173,12 +174,7 @@ export function PermissionCenterPage() { if (loading) { return ( -
-
-
-
-
-
+ ); } diff --git a/apps/desktop/src/renderer/settings/settings-skeleton.tsx b/apps/desktop/src/renderer/settings/settings-skeleton.tsx index 4ea02d1a69..aa194c5cc1 100644 --- a/apps/desktop/src/renderer/settings/settings-skeleton.tsx +++ b/apps/desktop/src/renderer/settings/settings-skeleton.tsx @@ -1,3 +1,35 @@ +type SkeletonLine = { width: string; size?: 'lg' | 'sm' }; + +// 权限/健康快照页共用的骨架行预设:首行大号标题条,其余模拟段落行宽。 +const SNAPSHOT_SKELETON_LINES: ReadonlyArray = [ + { width: '38%', size: 'lg' }, + { width: '72%' }, + { width: '60%' }, + { width: '80%' }, +]; + +// 带可访问性 label 的骨架行堆,供各设置页加载态共用,避免每页手写重复的 skeleton 标记。 +export function SettingsSkeletonStack({ + label, + lines = SNAPSHOT_SKELETON_LINES, +}: { + label: string; + lines?: ReadonlyArray; +}) { + return ( +
+ {lines.map((line, index) => ( +
+ ))} +
+ ); +} + export function SettingsSkeleton() { return (
From 1b5fb35aae16e592171eb2c7694e54ceca051da0 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 9 Jul 2026 02:46:30 +0800 Subject: [PATCH 2/3] refactor(settings): extract memory page label/format helpers to own module The 11 React-free label/format/filter helpers (memoryOriginLabel, memoryStatusLabel, memoryStatusTone, filterLocalMemoryEntries, the backup summarizers, etc.) lived at the tail of memory-settings-page.tsx. Move them to memory-settings-labels.ts and import them back. memory-settings-labels.ts is registered in SETTINGS_SOURCE_REPO_PATHS right after memory-settings-page.tsx so the contract source mirror still contains the same code and the MemoryEntryList .. filterLocalMemoryEntries block boundary still resolves (now across the two files). --- .../settings-contract-source-helpers.ts | 1 + .../settings/memory-settings-labels.ts | 118 ++++++++++++++++ .../settings/memory-settings-page.tsx | 130 ++---------------- 3 files changed, 132 insertions(+), 117 deletions(-) create mode 100644 apps/desktop/src/renderer/settings/memory-settings-labels.ts diff --git a/apps/desktop/src/main/__tests__/settings-contract-source-helpers.ts b/apps/desktop/src/main/__tests__/settings-contract-source-helpers.ts index 168abcf7a8..43e8617023 100644 --- a/apps/desktop/src/main/__tests__/settings-contract-source-helpers.ts +++ b/apps/desktop/src/main/__tests__/settings-contract-source-helpers.ts @@ -18,6 +18,7 @@ const sourcePaths = [ 'appearance-settings-page.tsx', 'web-search-settings-page.tsx', 'memory-settings-page.tsx', + 'memory-settings-labels.ts', 'settings-error-copy.ts', 'general-settings-page.tsx', 'open-gateway-settings-page.tsx', diff --git a/apps/desktop/src/renderer/settings/memory-settings-labels.ts b/apps/desktop/src/renderer/settings/memory-settings-labels.ts new file mode 100644 index 0000000000..832eb7d41e --- /dev/null +++ b/apps/desktop/src/renderer/settings/memory-settings-labels.ts @@ -0,0 +1,118 @@ +import type { LocalMemoryState } from '@maka/core'; + +export function filterLocalMemoryEntries( + entries: LocalMemoryState['activeEntries'], + query: string, +): LocalMemoryState['activeEntries'] { + if (!query) return entries; + const needle = query.toLocaleLowerCase('zh-CN'); + return entries.filter((entry) => { + const haystack = [ + entry.id, + entry.title, + entry.content, + entry.origin, + memoryOriginLabel(entry.origin), + entry.createdAt === undefined ? '' : String(entry.createdAt), + entry.updatedAt === undefined ? '' : String(entry.updatedAt), + ...entry.tags, + ].join('\n').toLocaleLowerCase('zh-CN'); + return haystack.includes(needle); + }); +} + +export function memoryOriginLabel(origin: NonNullable['origin']): string { + switch (origin) { + case 'manual': return '手动记录'; + case 'imported': return '导入记录'; + case 'extracted': return '确认提取'; + case 'unknown': return '手写条目'; + } +} + +export function memoryEntryStatusLabel(status: LocalMemoryState['entries'][number]['status']): string { + switch (status) { + case 'draft': return '草稿'; + case 'review_required': return '待确认'; + case 'active': return '生效'; + case 'archived': return '已归档'; + case 'rejected': return '已拒绝'; + case 'unknown': return '未识别'; + } +} + +export function formatLocalMemorySaveSummary(state: LocalMemoryState): string { + const archived = state.archivedEntryCount > 0 ? ` / ${state.archivedEntryCount} 条已归档` : ''; + return `当前 ${state.activeEntryCount} 条生效${archived};已保留上一版备份。`; +} + +/** Display-only path shortening: the full absolute MEMORY.md path used + * to render as a full-width mono line that shoved the sibling status + * words into a cramped stack (and leaked the raw absolute path into + * the renderer, against the UI quality plan). Show the meaningful + * trailing segments; the full path stays available via title= and the + * copy-path action. */ +export function displayMemoryPath(path: string): string { + const parts = path.split('/').filter(Boolean); + if (parts.length <= 3) return path; + return `…/${parts.slice(-3).join('/')}`; +} + +export function localMemoryBackupKindLabel(kind: NonNullable['kind']): string { + switch (kind) { + case 'reset': return '重置前备份'; + case 'restore': return '恢复前备份'; + case 'save': return '保存前备份'; + } +} + +export function localMemoryBackupSummary(backup: NonNullable): string { + if (backup.safeMode) return '备份过大,无法预览条目'; + const archived = backup.archivedEntryCount > 0 ? ` / ${backup.archivedEntryCount} 条已归档` : ''; + return `${backup.activeEntryCount} 条生效${archived}`; +} + +export function memoryStatusLabel(status: LocalMemoryState['status']): string { + switch (status) { + case 'ok': return '本地文件已就绪'; + case 'disabled': return '已关闭'; + case 'safe_mode': return '安全模式'; + case 'incognito_blocked': return '隐身禁用'; + case 'error': return '读取失败'; + } +} + +export function localMemoryPromptPreviewBlockedReason(state: LocalMemoryState): string { + if (!state.enabled) return '本地记忆已关闭。'; + if (state.status === 'incognito_blocked') return '隐身模式下不会注入本地记忆。'; + if (state.status === 'safe_mode') return 'MEMORY.md 过大,当前不会注入。'; + if (!state.agentReadEnabled) return '模型上下文读取未开启。'; + return ''; +} + +export function workspaceInstructionStatusLabel(status: string, chars: number, truncated: boolean): string { + switch (status) { + case 'available': + return `${chars.toLocaleString('zh-CN')} 字符${truncated ? ',已截断' : ''}`; + case 'missing': + return '未找到'; + case 'blocked': + return '路径被拦截'; + case 'empty': + return '空文件'; + case 'unreadable': + return '无法读取'; + default: + return '未知状态'; + } +} + +export function memoryStatusTone(status: LocalMemoryState['status']): 'success' | 'info' | 'warning' | 'destructive' { + switch (status) { + case 'ok': return 'success'; + case 'disabled': return 'info'; + case 'safe_mode': + case 'incognito_blocked': return 'warning'; + case 'error': return 'destructive'; + } +} diff --git a/apps/desktop/src/renderer/settings/memory-settings-page.tsx b/apps/desktop/src/renderer/settings/memory-settings-page.tsx index 4735a999b0..7edb6fead5 100644 --- a/apps/desktop/src/renderer/settings/memory-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/memory-settings-page.tsx @@ -12,6 +12,19 @@ import { Button, Chip, Input, RelativeTime, SettingsSwitch as Switch, Textarea, import { openPathFailureCopy, openPathActionLabel } from '../open-path'; import { settingsActionErrorMessage } from './settings-error-copy'; import { SettingsRows } from './settings-rows'; +import { + displayMemoryPath, + filterLocalMemoryEntries, + formatLocalMemorySaveSummary, + localMemoryBackupKindLabel, + localMemoryBackupSummary, + localMemoryPromptPreviewBlockedReason, + memoryEntryStatusLabel, + memoryOriginLabel, + memoryStatusLabel, + memoryStatusTone, + workspaceInstructionStatusLabel, +} from './memory-settings-labels'; export function MemorySettingsPage(props: { settings: AppSettings; @@ -1094,120 +1107,3 @@ function MemoryEntryList(props: { ); } - -function filterLocalMemoryEntries( - entries: LocalMemoryState['activeEntries'], - query: string, -): LocalMemoryState['activeEntries'] { - if (!query) return entries; - const needle = query.toLocaleLowerCase('zh-CN'); - return entries.filter((entry) => { - const haystack = [ - entry.id, - entry.title, - entry.content, - entry.origin, - memoryOriginLabel(entry.origin), - entry.createdAt === undefined ? '' : String(entry.createdAt), - entry.updatedAt === undefined ? '' : String(entry.updatedAt), - ...entry.tags, - ].join('\n').toLocaleLowerCase('zh-CN'); - return haystack.includes(needle); - }); -} - -function memoryOriginLabel(origin: NonNullable['origin']): string { - switch (origin) { - case 'manual': return '手动记录'; - case 'imported': return '导入记录'; - case 'extracted': return '确认提取'; - case 'unknown': return '手写条目'; - } -} - -function memoryEntryStatusLabel(status: LocalMemoryState['entries'][number]['status']): string { - switch (status) { - case 'draft': return '草稿'; - case 'review_required': return '待确认'; - case 'active': return '生效'; - case 'archived': return '已归档'; - case 'rejected': return '已拒绝'; - case 'unknown': return '未识别'; - } -} - -function formatLocalMemorySaveSummary(state: LocalMemoryState): string { - const archived = state.archivedEntryCount > 0 ? ` / ${state.archivedEntryCount} 条已归档` : ''; - return `当前 ${state.activeEntryCount} 条生效${archived};已保留上一版备份。`; -} - -/** Display-only path shortening: the full absolute MEMORY.md path used - * to render as a full-width mono line that shoved the sibling status - * words into a cramped stack (and leaked the raw absolute path into - * the renderer, against the UI quality plan). Show the meaningful - * trailing segments; the full path stays available via title= and the - * copy-path action. */ -function displayMemoryPath(path: string): string { - const parts = path.split('/').filter(Boolean); - if (parts.length <= 3) return path; - return `…/${parts.slice(-3).join('/')}`; -} - -function localMemoryBackupKindLabel(kind: NonNullable['kind']): string { - switch (kind) { - case 'reset': return '重置前备份'; - case 'restore': return '恢复前备份'; - case 'save': return '保存前备份'; - } -} - -function localMemoryBackupSummary(backup: NonNullable): string { - if (backup.safeMode) return '备份过大,无法预览条目'; - const archived = backup.archivedEntryCount > 0 ? ` / ${backup.archivedEntryCount} 条已归档` : ''; - return `${backup.activeEntryCount} 条生效${archived}`; -} - -function memoryStatusLabel(status: LocalMemoryState['status']): string { - switch (status) { - case 'ok': return '本地文件已就绪'; - case 'disabled': return '已关闭'; - case 'safe_mode': return '安全模式'; - case 'incognito_blocked': return '隐身禁用'; - case 'error': return '读取失败'; - } -} - -function localMemoryPromptPreviewBlockedReason(state: LocalMemoryState): string { - if (!state.enabled) return '本地记忆已关闭。'; - if (state.status === 'incognito_blocked') return '隐身模式下不会注入本地记忆。'; - if (state.status === 'safe_mode') return 'MEMORY.md 过大,当前不会注入。'; - if (!state.agentReadEnabled) return '模型上下文读取未开启。'; - return ''; -} - -function workspaceInstructionStatusLabel(status: string, chars: number, truncated: boolean): string { - switch (status) { - case 'available': - return `${chars.toLocaleString('zh-CN')} 字符${truncated ? ',已截断' : ''}`; - case 'missing': - return '未找到'; - case 'blocked': - return '路径被拦截'; - case 'empty': - return '空文件'; - case 'unreadable': - return '无法读取'; - default: - return '未知状态'; - } -} - -function memoryStatusTone(status: LocalMemoryState['status']): 'success' | 'info' | 'warning' | 'destructive' { - switch (status) { - case 'ok': return 'success'; - case 'disabled': return 'info'; - case 'safe_mode': - case 'incognito_blocked': return 'warning'; - case 'error': return 'destructive'; - } -} From 44381716d4ca9a7dadb096f762274f8ed485318b Mon Sep 17 00:00:00 2001 From: AstroHan Date: Thu, 9 Jul 2026 02:50:36 +0800 Subject: [PATCH 3/3] refactor(settings): extract WeChat login modals and fields to own module BotWeChatFields, WeChatScanLoginModal, and WechatQrLoginModal (the self-contained WeChat scan/QR login flow, ~370 lines) lived inside bot-chat-settings-page.tsx. Move them to bot-wechat-login.tsx and import them back, dropping the now-unused X / DialogRoot / DialogContent / WechatBridgeQrCodeResult imports from the host page. bot-wechat-login.tsx is registered in SETTINGS_SOURCE_REPO_PATHS right after bot-chat-settings-page.tsx so the contract source mirror still contains the WeChat modal code. --- .../settings-contract-source-helpers.ts | 1 + .../settings/bot-chat-settings-page.tsx | 392 +---------------- .../renderer/settings/bot-wechat-login.tsx | 393 ++++++++++++++++++ 3 files changed, 396 insertions(+), 390 deletions(-) create mode 100644 apps/desktop/src/renderer/settings/bot-wechat-login.tsx diff --git a/apps/desktop/src/main/__tests__/settings-contract-source-helpers.ts b/apps/desktop/src/main/__tests__/settings-contract-source-helpers.ts index 43e8617023..1ef61ccca8 100644 --- a/apps/desktop/src/main/__tests__/settings-contract-source-helpers.ts +++ b/apps/desktop/src/main/__tests__/settings-contract-source-helpers.ts @@ -23,6 +23,7 @@ const sourcePaths = [ 'general-settings-page.tsx', 'open-gateway-settings-page.tsx', 'bot-chat-settings-page.tsx', + 'bot-wechat-login.tsx', 'usage-settings-page.tsx', 'settings-metric-card.tsx', 'settings-status-badge.ts', diff --git a/apps/desktop/src/renderer/settings/bot-chat-settings-page.tsx b/apps/desktop/src/renderer/settings/bot-chat-settings-page.tsx index c15015dc48..4dc0b69dce 100644 --- a/apps/desktop/src/renderer/settings/bot-chat-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/bot-chat-settings-page.tsx @@ -1,7 +1,6 @@ import { useEffect, useId, useMemo, useRef, useState, type ComponentType, type ReactNode } from 'react'; import { Bot, - X, type LucideProps, } from '@maka/ui/icons'; import type { @@ -12,14 +11,12 @@ import type { LlmConnection, UpdateAppSettingsResult, } from '@maka/core'; -import type { BotStatus, WechatBridgeQrCodeResult } from '@maka/runtime'; +import type { BotStatus } from '@maka/runtime'; import { BOT_PROVIDERS, MAX_ALLOWED_USER_IDS, parseAllowedUserIdsFromText } from '@maka/core/settings'; import { BOT_BRAND, BotBrandLogo as BotBrandMark, Button, - DialogContent, - DialogRoot, Input, RelativeTime, SettingsSelect, @@ -29,6 +26,7 @@ import { } from '@maka/ui'; import { PasswordInput } from './password-input'; import { settingsActionErrorMessage } from './settings-error-copy'; +import { BotWeChatFields, WeChatScanLoginModal, WechatQrLoginModal } from './bot-wechat-login'; /** * Per-platform brand presentation. @@ -176,392 +174,6 @@ function BotStatusPill(props: { tone: 'neutral' | 'info' | 'success' | 'warning' ); } -/** - * PR-BOT-WECHAT-SCAN-LOGIN-0 (WAWQAQ msg `1d9c412e` / `e0ae9de2`): - * WeChat detail follows the reference design — primary surface is a - * single Bot Token field for the local bridge, with 公众号 (App ID / - * App Secret) and the bridge URL tucked into a collapsed "高级设置" - * section so backend wiring stays intact for users that depend on - * 公众号 messaging. - * - * The Bot Token field maps to `channel.token` (used by wechat-bridge - * for Bearer auth). Advanced fields keep `appId / appSecret / - * webhookUrl` so the existing runtime contract continues to work. - */ -function BotWeChatFields(props: { - channel: BotChannelSettings; - updateChannel(patch: Partial): Promise; -}) { - const { channel, updateChannel } = props; - const hasAdvanced = Boolean(channel.appId || channel.appSecret || channel.webhookUrl); - const [advancedOpen, setAdvancedOpen] = useState(hasAdvanced); - return ( - <> - -
- - {advancedOpen && ( -
- - - -
- 本机 bridge 默认为 http://127.0.0.1:18400。公众号 App ID / App Secret 仅用于公众号消息发送,个人微信扫码登录走本机 bridge。 -
-
- )} -
- - ); -} - -function WeChatScanLoginModal(props: { - onClose(): void; - onConfirmed(credentials: { botToken: string; baseUrl: string; botId: string; userId: string }): Promise; -}) { - const [qr, setQr] = useState<{ qrcodeUrl: string; qrToken: string } | null>(null); - const [status, setStatus] = useState<'fetching' | 'waiting' | 'expired' | 'confirmed' | 'error'>('fetching'); - const [errorMessage, setErrorMessage] = useState(null); - const fetchingQrRef = useRef(false); - const scanLoginPollingRef = useRef(false); - const scanLoginConfirmingRef = useRef(false); - const scanLoginMountedRef = useRef(false); - const scanLoginFetchTicketRef = useRef(0); - - async function fetchQr() { - if (fetchingQrRef.current) return; - fetchingQrRef.current = true; - const ticket = ++scanLoginFetchTicketRef.current; - const isCurrentRequest = () => scanLoginMountedRef.current && scanLoginFetchTicketRef.current === ticket; - setStatus('fetching'); - setErrorMessage(null); - try { - const result = await window.maka.settings.bots.wechat.fetchQrcode(); - if (!isCurrentRequest()) return; - if (!result.ok) { - setStatus('error'); - setErrorMessage(settingsActionErrorMessage(result.error.message)); - return; - } - setQr(result.data); - setStatus('waiting'); - } catch (error) { - if (isCurrentRequest()) { - setStatus('error'); - setErrorMessage(settingsActionErrorMessage(error)); - } - } finally { - if (!scanLoginMountedRef.current || scanLoginFetchTicketRef.current === ticket) { - fetchingQrRef.current = false; - } - } - } - - useEffect(() => { - scanLoginMountedRef.current = true; - void fetchQr(); - return () => { - scanLoginMountedRef.current = false; - scanLoginFetchTicketRef.current += 1; - fetchingQrRef.current = false; - scanLoginPollingRef.current = false; - scanLoginConfirmingRef.current = false; - }; - }, []); - - useEffect(() => { - if (status !== 'waiting' || !qr?.qrToken) return; - let cancelled = false; - const interval = window.setInterval(async () => { - if (cancelled || scanLoginPollingRef.current || scanLoginConfirmingRef.current) return; - scanLoginPollingRef.current = true; - try { - const result = await window.maka.settings.bots.wechat.pollQrcodeStatus(qr.qrToken); - if (cancelled || !scanLoginMountedRef.current) return; - if (!result.ok) { - setStatus('error'); - setErrorMessage(settingsActionErrorMessage(result.error.message)); - return; - } - if (result.data.status === 'confirmed') { - scanLoginConfirmingRef.current = true; - setStatus('confirmed'); - await props.onConfirmed(result.data.credentials); - scanLoginConfirmingRef.current = false; - } else if (result.data.status === 'expired') { - setStatus('expired'); - } - } catch (error) { - if (cancelled || !scanLoginMountedRef.current) return; - scanLoginConfirmingRef.current = false; - setStatus('error'); - setErrorMessage(settingsActionErrorMessage(error)); - } finally { - if (!scanLoginConfirmingRef.current) { - scanLoginPollingRef.current = false; - } - } - }, 2500); - return () => { - cancelled = true; - window.clearInterval(interval); - scanLoginPollingRef.current = false; - }; - }, [status, qr?.qrToken]); - - const statusCopy = (() => { - switch (status) { - case 'fetching': return '正在获取二维码…'; - case 'waiting': return '请使用 iOS / Android 微信 8.0.70+ 扫描二维码'; - case 'expired': return '二维码已过期,请刷新'; - case 'confirmed': return '已扫码登录'; - case 'error': return errorMessage ?? '扫码登录失败'; - } - })(); - - return ( - { - if (!open) props.onClose(); - }} - > - -
-

微信扫码登录

- -
-
- {qr?.qrcodeUrl && (status === 'waiting' || status === 'confirmed') ? ( - 微信扫码登录二维码 - ) : ( - - )} -

{statusCopy}

-

- 扫码确认后会保存个人微信机器人凭据;Maka 不保存二维码轮询的中间状态。 -

-
-
- {(status === 'expired' || status === 'error') && ( - - )} - -
-
-
- ); -} - -function WechatQrLoginModal(props: { - onClose(): void; - onRefreshStatuses(): void | Promise; -}) { - const [result, setResult] = useState(null); - const [loading, setLoading] = useState(true); - const [reloadNonce, setReloadNonce] = useState(0); - const notifiedLoggedInRef = useRef(false); - const loadingQrRef = useRef(false); - - function reloadQrCode() { - if (loadingQrRef.current) return; - loadingQrRef.current = true; - setLoading(true); - setReloadNonce((current) => current + 1); - } - - useEffect(() => { - let active = true; - loadingQrRef.current = true; - setLoading(true); - void window.maka.settings.bots.wechatQrCode() - .then((next) => { - if (!active) return; - setResult(next); - if (next.ok && next.loggedIn && !notifiedLoggedInRef.current) { - notifiedLoggedInRef.current = true; - void props.onRefreshStatuses(); - } - }) - .catch((error) => { - if (!active) return; - setResult({ - ok: false, - error: settingsActionErrorMessage(error), - hint: '读取本机 wechat-bridge 二维码失败,请确认 bridge 已启动。', - }); - }) - .finally(() => { - if (active) { - setLoading(false); - loadingQrRef.current = false; - } - }); - return () => { - active = false; - }; - }, [reloadNonce]); - - // PR-FE-BUG-HUNT-2 (kenji bug-hunt 2026-06-24 MEDIUM): the previous - // dep `[result]` re-armed the 3-second polling interval every time - // the QR refresh produced a new `result` object reference — even - // when the meaningful state (`ok` / `loggedIn` / `expired`) was - // unchanged. The interval clock drifted on every refresh, - // sometimes pushing the next poll 2.9s past the intended cadence. - // Depend on the gating booleans directly so the interval stays - // armed continuously while the user is actively scanning. - const shouldPollQr = !!result?.ok && !result.loggedIn && !result.expired; - useEffect(() => { - if (!shouldPollQr) return undefined; - const interval = window.setInterval(() => { - reloadQrCode(); - }, 3_000); - return () => window.clearInterval(interval); - }, [shouldPollQr]); - - const qrDataUrl = result?.ok ? result.qrcode : null; - const expired = result?.ok ? result.expired : false; - const loggedIn = result?.ok ? result.loggedIn : false; - const error = result && !result.ok ? result : null; - - return ( - { - if (!open) props.onClose(); - }} - > - -
-
-

微信扫码登录

-

使用手机微信扫描二维码,并在手机上确认登录本机 wechat-bridge。

-
- -
- -
- {loading ? ( -
- 正在生成二维码… -
- ) : loggedIn ? ( -
- 微信已登录,返回后可以测试连接或重启监听。 -
- ) : expired ? ( -
- 二维码已过期 - -
- ) : qrDataUrl ? ( - <> -
- 微信扫码登录二维码 -
-

等待扫码确认… 窗口会每 3 秒刷新登录状态。

- - ) : error ? ( -
- {error.error} - {error.hint} - -
- ) : ( -
- bridge 正在生成二维码 - -
- )} -
-
-
- ); -} - export function BotChatSettingsPage(props: { settings: AppSettings; onUpdate(patch: Parameters[0]): Promise; diff --git a/apps/desktop/src/renderer/settings/bot-wechat-login.tsx b/apps/desktop/src/renderer/settings/bot-wechat-login.tsx new file mode 100644 index 0000000000..296d84afed --- /dev/null +++ b/apps/desktop/src/renderer/settings/bot-wechat-login.tsx @@ -0,0 +1,393 @@ +import { useEffect, useRef, useState } from 'react'; +import { X } from '@maka/ui/icons'; +import type { BotChannelSettings } from '@maka/core'; +import type { WechatBridgeQrCodeResult } from '@maka/runtime'; +import { Button, DialogContent, DialogRoot, Input } from '@maka/ui'; +import { PasswordInput } from './password-input'; +import { settingsActionErrorMessage } from './settings-error-copy'; + +/** + * PR-BOT-WECHAT-SCAN-LOGIN-0 (WAWQAQ msg `1d9c412e` / `e0ae9de2`): + * WeChat detail follows the reference design — primary surface is a + * single Bot Token field for the local bridge, with 公众号 (App ID / + * App Secret) and the bridge URL tucked into a collapsed "高级设置" + * section so backend wiring stays intact for users that depend on + * 公众号 messaging. + * + * The Bot Token field maps to `channel.token` (used by wechat-bridge + * for Bearer auth). Advanced fields keep `appId / appSecret / + * webhookUrl` so the existing runtime contract continues to work. + */ +export function BotWeChatFields(props: { + channel: BotChannelSettings; + updateChannel(patch: Partial): Promise; +}) { + const { channel, updateChannel } = props; + const hasAdvanced = Boolean(channel.appId || channel.appSecret || channel.webhookUrl); + const [advancedOpen, setAdvancedOpen] = useState(hasAdvanced); + return ( + <> + +
+ + {advancedOpen && ( +
+ + + +
+ 本机 bridge 默认为 http://127.0.0.1:18400。公众号 App ID / App Secret 仅用于公众号消息发送,个人微信扫码登录走本机 bridge。 +
+
+ )} +
+ + ); +} + +export function WeChatScanLoginModal(props: { + onClose(): void; + onConfirmed(credentials: { botToken: string; baseUrl: string; botId: string; userId: string }): Promise; +}) { + const [qr, setQr] = useState<{ qrcodeUrl: string; qrToken: string } | null>(null); + const [status, setStatus] = useState<'fetching' | 'waiting' | 'expired' | 'confirmed' | 'error'>('fetching'); + const [errorMessage, setErrorMessage] = useState(null); + const fetchingQrRef = useRef(false); + const scanLoginPollingRef = useRef(false); + const scanLoginConfirmingRef = useRef(false); + const scanLoginMountedRef = useRef(false); + const scanLoginFetchTicketRef = useRef(0); + + async function fetchQr() { + if (fetchingQrRef.current) return; + fetchingQrRef.current = true; + const ticket = ++scanLoginFetchTicketRef.current; + const isCurrentRequest = () => scanLoginMountedRef.current && scanLoginFetchTicketRef.current === ticket; + setStatus('fetching'); + setErrorMessage(null); + try { + const result = await window.maka.settings.bots.wechat.fetchQrcode(); + if (!isCurrentRequest()) return; + if (!result.ok) { + setStatus('error'); + setErrorMessage(settingsActionErrorMessage(result.error.message)); + return; + } + setQr(result.data); + setStatus('waiting'); + } catch (error) { + if (isCurrentRequest()) { + setStatus('error'); + setErrorMessage(settingsActionErrorMessage(error)); + } + } finally { + if (!scanLoginMountedRef.current || scanLoginFetchTicketRef.current === ticket) { + fetchingQrRef.current = false; + } + } + } + + useEffect(() => { + scanLoginMountedRef.current = true; + void fetchQr(); + return () => { + scanLoginMountedRef.current = false; + scanLoginFetchTicketRef.current += 1; + fetchingQrRef.current = false; + scanLoginPollingRef.current = false; + scanLoginConfirmingRef.current = false; + }; + }, []); + + useEffect(() => { + if (status !== 'waiting' || !qr?.qrToken) return; + let cancelled = false; + const interval = window.setInterval(async () => { + if (cancelled || scanLoginPollingRef.current || scanLoginConfirmingRef.current) return; + scanLoginPollingRef.current = true; + try { + const result = await window.maka.settings.bots.wechat.pollQrcodeStatus(qr.qrToken); + if (cancelled || !scanLoginMountedRef.current) return; + if (!result.ok) { + setStatus('error'); + setErrorMessage(settingsActionErrorMessage(result.error.message)); + return; + } + if (result.data.status === 'confirmed') { + scanLoginConfirmingRef.current = true; + setStatus('confirmed'); + await props.onConfirmed(result.data.credentials); + scanLoginConfirmingRef.current = false; + } else if (result.data.status === 'expired') { + setStatus('expired'); + } + } catch (error) { + if (cancelled || !scanLoginMountedRef.current) return; + scanLoginConfirmingRef.current = false; + setStatus('error'); + setErrorMessage(settingsActionErrorMessage(error)); + } finally { + if (!scanLoginConfirmingRef.current) { + scanLoginPollingRef.current = false; + } + } + }, 2500); + return () => { + cancelled = true; + window.clearInterval(interval); + scanLoginPollingRef.current = false; + }; + }, [status, qr?.qrToken]); + + const statusCopy = (() => { + switch (status) { + case 'fetching': return '正在获取二维码…'; + case 'waiting': return '请使用 iOS / Android 微信 8.0.70+ 扫描二维码'; + case 'expired': return '二维码已过期,请刷新'; + case 'confirmed': return '已扫码登录'; + case 'error': return errorMessage ?? '扫码登录失败'; + } + })(); + + return ( + { + if (!open) props.onClose(); + }} + > + +
+

微信扫码登录

+ +
+
+ {qr?.qrcodeUrl && (status === 'waiting' || status === 'confirmed') ? ( + 微信扫码登录二维码 + ) : ( + + )} +

{statusCopy}

+

+ 扫码确认后会保存个人微信机器人凭据;Maka 不保存二维码轮询的中间状态。 +

+
+
+ {(status === 'expired' || status === 'error') && ( + + )} + +
+
+
+ ); +} + +export function WechatQrLoginModal(props: { + onClose(): void; + onRefreshStatuses(): void | Promise; +}) { + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(true); + const [reloadNonce, setReloadNonce] = useState(0); + const notifiedLoggedInRef = useRef(false); + const loadingQrRef = useRef(false); + + function reloadQrCode() { + if (loadingQrRef.current) return; + loadingQrRef.current = true; + setLoading(true); + setReloadNonce((current) => current + 1); + } + + useEffect(() => { + let active = true; + loadingQrRef.current = true; + setLoading(true); + void window.maka.settings.bots.wechatQrCode() + .then((next) => { + if (!active) return; + setResult(next); + if (next.ok && next.loggedIn && !notifiedLoggedInRef.current) { + notifiedLoggedInRef.current = true; + void props.onRefreshStatuses(); + } + }) + .catch((error) => { + if (!active) return; + setResult({ + ok: false, + error: settingsActionErrorMessage(error), + hint: '读取本机 wechat-bridge 二维码失败,请确认 bridge 已启动。', + }); + }) + .finally(() => { + if (active) { + setLoading(false); + loadingQrRef.current = false; + } + }); + return () => { + active = false; + }; + }, [reloadNonce]); + + // PR-FE-BUG-HUNT-2 (kenji bug-hunt 2026-06-24 MEDIUM): the previous + // dep `[result]` re-armed the 3-second polling interval every time + // the QR refresh produced a new `result` object reference — even + // when the meaningful state (`ok` / `loggedIn` / `expired`) was + // unchanged. The interval clock drifted on every refresh, + // sometimes pushing the next poll 2.9s past the intended cadence. + // Depend on the gating booleans directly so the interval stays + // armed continuously while the user is actively scanning. + const shouldPollQr = !!result?.ok && !result.loggedIn && !result.expired; + useEffect(() => { + if (!shouldPollQr) return undefined; + const interval = window.setInterval(() => { + reloadQrCode(); + }, 3_000); + return () => window.clearInterval(interval); + }, [shouldPollQr]); + + const qrDataUrl = result?.ok ? result.qrcode : null; + const expired = result?.ok ? result.expired : false; + const loggedIn = result?.ok ? result.loggedIn : false; + const error = result && !result.ok ? result : null; + + return ( + { + if (!open) props.onClose(); + }} + > + +
+
+

微信扫码登录

+

使用手机微信扫描二维码,并在手机上确认登录本机 wechat-bridge。

+
+ +
+ +
+ {loading ? ( +
+ 正在生成二维码… +
+ ) : loggedIn ? ( +
+ 微信已登录,返回后可以测试连接或重启监听。 +
+ ) : expired ? ( +
+ 二维码已过期 + +
+ ) : qrDataUrl ? ( + <> +
+ 微信扫码登录二维码 +
+

等待扫码确认… 窗口会每 3 秒刷新登录状态。

+ + ) : error ? ( +
+ {error.error} + {error.hint} + +
+ ) : ( +
+ bridge 正在生成二维码 + +
+ )} +
+
+
+ ); +}