refactor(ui): 统一迁移原生按钮、输入框及交互控件

This commit is contained in:
Wxw-Gu
2026-08-20 10:18:59 +08:00
parent e9cc996ebb
commit 65830d70fe
87 changed files with 3685 additions and 2768 deletions
@@ -1,6 +1,7 @@
import React from 'react'
import type { DatabaseKeyEnvironment, WechatAccountCandidate } from '../../../shared/database-key'
import { WINDOWS_VC_RUNTIME_DOWNLOAD_URL } from '../../../shared/windows-runtime'
import { Button, IconButton, Input, Tabs, TabsList, TabsTrigger } from './ui'
const GUIDE_URL =
'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md'
@@ -207,366 +208,360 @@ export function DatabaseConnectionPage({
5
</a>
</div>
<div className="database-login-tabs" role="tablist" aria-label="连接方式">
<button
type="button"
role="tab"
aria-selected={mode === 'automatic'}
className={mode === 'automatic' ? 'active' : ''}
onClick={() => onModeChange('automatic')}
<Tabs
value={mode}
onValueChange={(value) => onModeChange(value as DatabaseConnectionMode)}
>
<TabsList
className="database-login-tabs grid h-auto w-full grid-cols-2"
aria-label="连接方式"
>
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'manual'}
className={`database-login-manual-tab ${mode === 'manual' ? 'active' : ''}`}
onClick={() => onModeChange('manual')}
>
</button>
</div>
<TabsTrigger value="automatic"></TabsTrigger>
<TabsTrigger value="manual"></TabsTrigger>
</TabsList>
{mode === 'automatic' ? (
<div className="database-login-auto" role="tabpanel">
<div className="database-login-guide-progress" aria-label={`连接进度 ${guideStep}/6`}>
{Array.from({ length: 6 }, (_, index) => (
<span key={index} className={index + 1 <= guideStep ? 'active' : ''} />
))}
</div>
<div className={`database-login-state-card ${statusKind}`}>
<div className="database-login-state-heading">
<span className="database-login-state-icon">
<LineIcon name="info" />
</span>
<div>
<strong>
{statusKind === 'error'
? '当前步骤未完成'
: [
'检查本机环境',
'让微信停在登录页面',
'确认开始准备',
`正在完成 ${isMac ? 'macOS' : 'Windows'} 授权`,
'获取成功, 现在可以重新登录微信了',
'验证数据库连接'
][guideStep - 1]}
</strong>
<p>
{statusKind === 'error'
? status
: status ||
[
'确认下方检测结果;没有找到目录时可以手动选择。',
'请退出当前微信账号,让微信停留在登录页面,然后点击“我已准备好”。',
'开始后请按页面提示完成系统授权。',
'正在准备连接组件,请不要关闭微信或 TraceMemo。',
'请回到微信完成登录,登录成功后再回来验证。',
'正在验证密钥和本地数据库,请稍候。'
][guideStep - 1]}
</p>
</div>
{mode === 'automatic' ? (
<div className="database-login-auto" role="tabpanel">
<div
className="database-login-guide-progress"
aria-label={`连接进度 ${guideStep}/6`}
>
{Array.from({ length: 6 }, (_, index) => (
<span key={index} className={index + 1 <= guideStep ? 'active' : ''} />
))}
</div>
{guideStep === 1 && (
<>
<dl className="database-login-diagnostics">
<div>
<dt></dt>
<dd>{environment?.osVersion || (isMac ? 'macOS' : 'Windows')}</dd>
</div>
<div>
<dt></dt>
<dd>{environment?.wechatVersion || '未检测到'}</dd>
</div>
<div>
<dt></dt>
<dd>{environment?.dataStructureVersion || '未检测到'}</dd>
</div>
<div>
<dt>
<StoragePathHelp />
</dt>
<dd>
<span className="database-login-path-input-wrap">
<input
type="text"
value={dbRoot}
onChange={(event) => onDbRootChange(event.target.value)}
placeholder={defaultPath}
title={dbRoot || defaultPath}
aria-label="微信数据存储路径"
spellCheck={false}
onFocus={(event) => event.currentTarget.select()}
/>
<span className="database-login-path-value" role="status">
{dbRoot || defaultPath}
<div className={`database-login-state-card ${statusKind}`}>
<div className="database-login-state-heading">
<span className="database-login-state-icon">
<LineIcon name="info" />
</span>
<div>
<strong>
{statusKind === 'error'
? '当前步骤未完成'
: [
'检查本机环境',
'让微信停在登录页面',
'确认开始准备',
`正在完成 ${isMac ? 'macOS' : 'Windows'} 授权`,
'获取成功, 现在可以重新登录微信了',
'验证数据库连接'
][guideStep - 1]}
</strong>
<p>
{statusKind === 'error'
? status
: status ||
[
'确认下方检测结果;没有找到目录时可以手动选择。',
'请退出当前微信账号,让微信停留在登录页面,然后点击“我已准备好”。',
'开始后请按页面提示完成系统授权。',
'正在准备连接组件,请不要关闭微信或 TraceMemo。',
'请回到微信完成登录,登录成功后再回来验证。',
'正在验证密钥和本地数据库,请稍候。'
][guideStep - 1]}
</p>
</div>
</div>
{guideStep === 1 && (
<>
<dl className="database-login-diagnostics">
<div>
<dt></dt>
<dd>{environment?.osVersion || (isMac ? 'macOS' : 'Windows')}</dd>
</div>
<div>
<dt></dt>
<dd>{environment?.wechatVersion || '未检测到'}</dd>
</div>
<div>
<dt></dt>
<dd>{environment?.dataStructureVersion || '未检测到'}</dd>
</div>
<div>
<dt>
<StoragePathHelp />
</dt>
<dd>
<span className="database-login-path-input-wrap">
<Input
className="h-9 border-0 bg-transparent px-0 text-right shadow-none"
type="text"
value={dbRoot}
onChange={(event) => onDbRootChange(event.target.value)}
placeholder={defaultPath}
title={dbRoot || defaultPath}
aria-label="微信数据存储路径"
spellCheck={false}
onFocus={(event) => event.currentTarget.select()}
/>
<span className="database-login-path-value" role="status">
{dbRoot || defaultPath}
</span>
</span>
</span>
<button
type="button"
className="database-login-path-select"
<Button
variant="outline"
size="sm"
className="database-login-path-select"
onClick={onSelectDbRoot}
disabled={isFetching || isConnecting}
>
</Button>
</dd>
</div>
<div>
<dt></dt>
<dd>{environment?.wechatRunning ? '运行中' : '未检测到'}</dd>
</div>
</dl>
{accounts.length > 0 && (
<section className="database-account-list" aria-label="选择微信账号">
<h3></h3>
{accounts.map((account) => (
<button
type="button"
key={account.id}
className={`database-account-card ${selectedAccountId === account.id ? 'selected' : ''}`}
aria-pressed={selectedAccountId === account.id}
onClick={() => onSelectAccount(account)}
>
<span className="database-account-avatar">
{account.avatar ? (
<img src={account.avatar} alt="" />
) : (
(account.nickname || account.directoryName || '?').charAt(0)
)}
</span>
<span className="database-account-identity">
<strong>
{account.nickname ||
(account.wxid
? `微信账号 ${account.wxid}`
: `账号目录 ${account.directoryName || '待识别'}`)}
</strong>
<small>
{account.nickname
? account.wxid || '微信号未读取'
: account.wxid
? '昵称和头像需连接此账号后读取'
: '连接后读取微信号、昵称和头像'}
</small>
<code title={account.accountRoot}>{account.accountRoot}</code>
</span>
<span className="database-account-status">
{account.hasSavedDbKey ? '已有可用密钥' : '尚无可用密钥'}
<small>
{account.loginStatus === 'current'
? '当前已连接账号'
: account.loginStatus === 'other'
? '非当前账号'
: '登录状态未确认'}
</small>
</span>
</button>
))}
<Button
variant="outline"
className="database-login-secondary"
onClick={onSelectDbRoot}
disabled={isFetching || isConnecting}
>
</button>
</dd>
</div>
<div>
<dt></dt>
<dd>{environment?.wechatRunning ? '运行中' : '未检测到'}</dd>
</div>
</dl>
{accounts.length > 0 && (
<section className="database-account-list" aria-label="选择微信账号">
<h3></h3>
{accounts.map((account) => (
<button
type="button"
key={account.id}
className={`database-account-card ${selectedAccountId === account.id ? 'selected' : ''}`}
aria-pressed={selectedAccountId === account.id}
onClick={() => onSelectAccount(account)}
>
<span className="database-account-avatar">
{account.avatar ? (
<img src={account.avatar} alt="" />
) : (
(account.nickname || account.directoryName || '?').charAt(0)
)}
</span>
<span className="database-account-identity">
<strong>
{account.nickname ||
(account.wxid
? `微信账号 ${account.wxid}`
: `账号目录 ${account.directoryName || '待识别'}`)}
</strong>
<small>
{account.nickname
? account.wxid || '微信号未读取'
: account.wxid
? '昵称和头像需连接此账号后读取'
: '连接后读取微信号、昵称和头像'}
</small>
<code title={account.accountRoot}>{account.accountRoot}</code>
</span>
<span className="database-account-status">
{account.hasSavedDbKey ? '已有可用密钥' : '尚无可用密钥'}
<small>
{account.loginStatus === 'current'
? '当前已连接账号'
: account.loginStatus === 'other'
? '非当前账号'
: '登录状态未确认'}
</small>
</span>
</button>
))}
<button
type="button"
className="database-login-secondary"
onClick={onSelectDbRoot}
disabled={isFetching || isConnecting}
>
</button>
</section>
)}
</Button>
</section>
)}
</>
)}
</div>
{guideStep === 1 && (
<>
<Button
className="database-login-primary"
onClick={onGuideNext}
disabled={!selectedAccountId}
>
</Button>
<Button
variant="outline"
className="database-login-secondary"
onClick={onRefreshEnvironment}
>
</Button>
<Button
variant="link"
className="database-login-text-action"
onClick={onCopyDiagnostics}
>
</Button>
</>
)}
</div>
{guideStep === 1 && (
<>
<button
type="button"
{guideStep === 2 && (
<Button className="database-login-primary" onClick={onGuideNext}>
</Button>
)}
{guideStep === 3 && (
<Button className="database-login-primary" onClick={onAutoGetKey}>
</Button>
)}
{guideStep === 4 && (
<Button className="database-login-primary" disabled>
</Button>
)}
{guideStep === 5 && (
<Button
className="database-login-primary"
onClick={onGuideNext}
disabled={!selectedAccountId}
onClick={onValidateConnection}
disabled={!dbKey || isConnecting}
>
</button>
<button
type="button"
className="database-login-secondary"
onClick={onRefreshEnvironment}
>
</button>
<button
type="button"
{isConnecting ? '正在验证…' : '验证连接'}
</Button>
)}
{guideStep === 6 && (
<Button className="database-login-primary" disabled>
</Button>
)}
{guideStep > 1 && !isFetching && !isConnecting && (
<div className="database-login-guide-actions">
<Button variant="link" size="sm" onClick={onGuideBack}>
</Button>
<Button variant="link" size="sm" onClick={onGuideCancel}>
</Button>
</div>
)}
{(isFetching || isConnecting) && (
<Button
variant="link"
className="database-login-text-action"
onClick={onCopyDiagnostics}
onClick={onGuideCancel}
>
</button>
</>
)}
{guideStep === 2 && (
<button type="button" className="database-login-primary" onClick={onGuideNext}>
</button>
)}
{guideStep === 3 && (
<button type="button" className="database-login-primary" onClick={onAutoGetKey}>
</button>
)}
{guideStep === 4 && (
<button type="button" className="database-login-primary" disabled>
</button>
)}
{guideStep === 5 && (
<button
type="button"
className="database-login-primary"
onClick={onValidateConnection}
disabled={!dbKey || isConnecting}
>
{isConnecting ? '正在验证…' : '验证连接'}
</button>
)}
{guideStep === 6 && (
<button type="button" className="database-login-primary" disabled>
</button>
)}
{guideStep > 1 && !isFetching && !isConnecting && (
<div className="database-login-guide-actions">
<button type="button" onClick={onGuideBack}>
</button>
<button type="button" onClick={onGuideCancel}>
</button>
</Button>
)}
<p className="database-login-platform-note">
{isMac ? (
<>
macOS SIP{' '}
<a href={macKeyFaqUrl} target="_blank" rel="noreferrer">
</a>
</>
) : (
<>
Windows Microsoft Visual C++ 2015-2022 x64 {' '}
<a href={WINDOWS_VC_RUNTIME_DOWNLOAD_URL} target="_blank" rel="noreferrer">
</a>
</>
)}
</p>
{showMacKeyFaq && isMac && (
<a href={macKeyFaqUrl} target="_blank" rel="noreferrer">
</a>
)}
</div>
) : (
<div className="database-login-manual" role="tabpanel">
<p className="database-login-manual-note">
使
</p>
<div className="database-login-field">
<label htmlFor="database-login-key"></label>
<div className="database-login-key-input">
<Input
id="database-login-key"
type={showDbKey ? 'text' : 'password'}
value={dbKey}
onChange={(event) => onDbKeyChange(event.target.value)}
placeholder="输入或粘贴 64 位数据库密钥"
autoComplete="off"
spellCheck={false}
/>
<IconButton
variant="ghost"
onClick={onToggleDbKey}
label={showDbKey ? '隐藏密钥' : '显示密钥'}
>
<EyeIcon visible={showDbKey} />
</IconButton>
</div>
<small></small>
</div>
)}
{(isFetching || isConnecting) && (
<button
type="button"
className="database-login-text-action"
onClick={onGuideCancel}
>
</button>
)}
<p className="database-login-platform-note">
{isMac ? (
<>
macOS SIP{' '}
<a href={macKeyFaqUrl} target="_blank" rel="noreferrer">
</a>
</>
) : (
<>
Windows Microsoft Visual C++ 2015-2022 x64 {' '}
{platform === 'win32' && (
<div className="database-login-field">
<label htmlFor="database-login-root">
<StoragePathHelp />
</label>
<div className="database-login-root-control">
<Input
id="database-login-root"
aria-label="微信数据目录"
value={dbRoot}
onChange={(event) => onDbRootChange(event.target.value)}
placeholder={defaultPath}
title={dbRoot || defaultPath}
spellCheck={false}
onFocus={(event) => event.currentTarget.select()}
/>
<Button variant="outline" onClick={onSelectDbRoot} disabled={isConnecting}>
</Button>
</div>
</div>
)}
{status && <div className={`database-login-message ${statusKind}`}>{status}</div>}
{platform === 'win32' && (
<p className="database-login-platform-note">
Microsoft Visual C++ 2015-2022 x64 {' '}
<a href={WINDOWS_VC_RUNTIME_DOWNLOAD_URL} target="_blank" rel="noreferrer">
</a>
</>
</p>
)}
</p>
{showMacKeyFaq && isMac && (
<a href={macKeyFaqUrl} target="_blank" rel="noreferrer">
</a>
)}
</div>
) : (
<div className="database-login-manual" role="tabpanel">
<p className="database-login-manual-note">
使
</p>
<div className="database-login-field">
<label htmlFor="database-login-key"></label>
<div className="database-login-key-input">
<input
id="database-login-key"
type={showDbKey ? 'text' : 'password'}
value={dbKey}
onChange={(event) => onDbKeyChange(event.target.value)}
placeholder="输入或粘贴 64 位数据库密钥"
autoComplete="off"
spellCheck={false}
/>
<button
type="button"
onClick={onToggleDbKey}
title={showDbKey ? '隐藏密钥' : '显示密钥'}
>
<EyeIcon visible={showDbKey} />
</button>
</div>
<small></small>
</div>
{platform === 'win32' && (
<div className="database-login-field">
<label htmlFor="database-login-root">
<StoragePathHelp />
</label>
<div className="database-login-root-control">
<input
id="database-login-root"
aria-label="微信数据目录"
value={dbRoot}
onChange={(event) => onDbRootChange(event.target.value)}
placeholder={defaultPath}
title={dbRoot || defaultPath}
spellCheck={false}
onFocus={(event) => event.currentTarget.select()}
/>
<button type="button" onClick={onSelectDbRoot} disabled={isConnecting}>
</button>
</div>
</div>
)}
{status && <div className={`database-login-message ${statusKind}`}>{status}</div>}
{platform === 'win32' && (
<p className="database-login-platform-note">
Microsoft Visual C++ 2015-2022 x64 {' '}
<a href={WINDOWS_VC_RUNTIME_DOWNLOAD_URL} target="_blank" rel="noreferrer">
</a>
</p>
)}
<button
type="button"
className="database-login-primary"
onClick={onManualConnect}
disabled={!keyIsValid || isConnecting}
>
{isConnecting ? '正在连接…' : '连接数据库'}
</button>
{isConnecting && (
<button
type="button"
className="database-login-text-action"
onClick={onGuideCancel}
<Button
className="database-login-primary"
onClick={onManualConnect}
disabled={!keyIsValid || isConnecting}
>
</button>
)}
<button
type="button"
className="database-login-secondary"
onClick={onPasteKey}
disabled={isConnecting}
>
</button>
</div>
)}
{isConnecting ? '正在连接…' : '连接数据库'}
</Button>
{isConnecting && (
<Button
variant="link"
className="database-login-text-action"
onClick={onGuideCancel}
>
</Button>
)}
<Button
variant="outline"
className="database-login-secondary"
onClick={onPasteKey}
disabled={isConnecting}
>
</Button>
</div>
)}
</Tabs>
<div className="database-login-footer-actions">
<button type="button" onClick={onClearKey}>
<Button variant="destructive" size="sm" onClick={onClearKey}>
</button>
</Button>
<span>TraceMemo</span>
</div>
</div>
+55 -27
View File
@@ -1,6 +1,16 @@
import React, { useState } from 'react'
import { Contact } from '../../../../shared/types'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../ui'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconButton,
Tooltip,
TooltipContent,
TooltipTrigger
} from '../ui'
import { ConversationContentSearch } from './ConversationContentSearch'
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon, SendIcon } from './icons'
import { supportsPersonalWechatSend } from '../../utils/runtime-environment'
@@ -43,7 +53,7 @@ export function ChatHeader({
}
return (
<div className="chat-archive-header">
<div className={`chat-archive-header${searchOpen ? ' is-searching' : ''}`}>
<div className="chat-title-block">
<div className="chat-title-avatar">
{contact.avatar ? (
@@ -69,54 +79,72 @@ export function ChatHeader({
onClose={handleCloseSearch}
/>
) : (
<button
type="button"
className="chat-icon-button"
<IconButton
label="搜索当前聊天"
variant="ghost"
className="h-8 w-8"
onClick={() => setSearchOpen(true)}
title="搜索当前聊天"
>
<SearchIcon />
</button>
</IconButton>
)}
<button type="button" className="chat-icon-button" onClick={onRefresh} title="刷新聊天记录">
<IconButton label="刷新聊天记录" variant="ghost" className="h-8 w-8" onClick={onRefresh}>
<RefreshIcon />
</button>
</IconButton>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className="chat-icon-button" title="更多">
<IconButton label="更多" tooltip="" variant="ghost" className="h-8 w-8">
<MoreIcon />
</button>
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => onRefreshData?.()}></DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<span
className="chat-tool-button-wrapper"
title={supportsPersonalWechatSend ? '发送消息' : '仅支持 macOS'}
aria-label={supportsPersonalWechatSend ? '发送消息' : '仅支持 macOS'}
tabIndex={supportsPersonalWechatSend ? -1 : 0}
>
<button
type="button"
className="chat-tool-button"
{supportsPersonalWechatSend ? (
<Button
variant="outline"
size="sm"
className="chat-header-text-action"
aria-label="发送消息"
onClick={onTestSend}
disabled={!supportsPersonalWechatSend}
>
<SendIcon />
<span></span>
</button>
</span>
<button
type="button"
className="chat-ai-button"
</Button>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span tabIndex={0} aria-label="仅支持 macOS">
<Button
variant="outline"
size="sm"
className="chat-header-text-action"
aria-label="发送消息"
onClick={onTestSend}
disabled
>
<SendIcon />
<span></span>
</Button>
</span>
</TooltipTrigger>
<TooltipContent> macOS</TooltipContent>
</Tooltip>
)}
<Button
variant="default"
size="sm"
className="chat-header-text-action"
aria-label={isAiLoading ? '生成中' : '生成 AI 日报'}
onClick={onOpenAiSettings}
disabled={isAiLoading}
aria-busy={isAiLoading}
title={isGroupChat ? '生成 AI 日报' : 'AI 日报当前仅支持群聊'}
>
<AiIcon />
<span>{isAiLoading ? '生成中' : '生成 AI 日报'}</span>
</button>
</Button>
</div>
</div>
)
@@ -1,4 +1,5 @@
import React from 'react'
import { Button, Switch } from '../ui'
import { RefreshIcon } from './icons'
interface ChatStatusBarProps {
@@ -27,11 +28,7 @@ export function ChatStatusBar({
<div> {count} </div>
<div className="chat-status-actions">
<label className="chat-avatar-toggle">
<input
type="checkbox"
checked={showAvatar}
onChange={(event) => onShowAvatarChange(event.target.checked)}
/>
<Switch checked={showAvatar} onCheckedChange={onShowAvatarChange} aria-label="显示头像" />
<span></span>
</label>
<span
@@ -40,20 +37,22 @@ export function ChatStatusBar({
>
</span>
<button
type="button"
<Button
variant="ghost"
size="sm"
className="chat-avatar-reload"
onClick={onReloadAvatars}
disabled={isReloadingAvatars}
title="尝试重新加载当前聊天的头像和名称"
aria-busy={isReloadingAvatars}
>
<RefreshIcon className={isReloadingAvatars ? 'is-spinning' : ''} />
<span>{isReloadingAvatars ? '加载中' : '重新加载头像'}</span>
</button>
</Button>
<span className="chat-status-separator" aria-hidden />
<button type="button" onClick={onJumpToLatest} disabled={jumpDisabled}>
<Button variant="ghost" size="sm" onClick={onJumpToLatest} disabled={jumpDisabled}>
{jumpDisabled ? '已是最新消息' : '跳转到最新消息'}
</button>
</Button>
</div>
</div>
)
@@ -1,4 +1,5 @@
import React from 'react'
import { IconButton, Input } from '../ui'
import { CloseIcon, SearchIcon } from './icons'
interface ConversationContentSearchProps {
@@ -16,22 +17,30 @@ export function ConversationContentSearch({
}: ConversationContentSearchProps): React.ReactElement {
return (
<div className="chat-content-search">
<span className="chat-content-search-icon">
<span className="chat-content-search-icon pointer-events-none absolute left-3 top-1/2 z-10 -translate-y-1/2">
<SearchIcon />
</span>
<input
<Input
type="text"
value={value}
aria-label="搜索当前聊天内容"
placeholder="搜索当前聊天内容"
onChange={(event) => onChange(event.target.value)}
className="h-9 pr-10 pl-9 text-xs min-[901px]:pr-[7.5rem]"
autoFocus
/>
<span className="chat-content-search-count">
<span className="chat-content-search-count absolute right-10 top-1/2 -translate-y-1/2">
{value ? `${resultCount} 条结果` : '输入关键词'}
</span>
<button type="button" className="chat-icon-button" onClick={onClose} title="关闭搜索">
<IconButton
label="关闭搜索"
variant="ghost"
tooltip="关闭搜索"
className="absolute right-1 top-1 h-7 w-7"
onClick={onClose}
>
<CloseIcon />
</button>
</IconButton>
</div>
)
}
@@ -1,5 +1,11 @@
import React from 'react'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../ui'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '../ui'
import { ArrowDownIcon, ExportIcon } from './icons'
export type ExportRange = number | 'all'
@@ -22,16 +28,16 @@ export function ExportMenu({ disabled, onExport }: ExportMenuProps): React.React
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="chat-tool-button"
<Button
variant="outline"
size="sm"
disabled={disabled}
title={disabled ? '没有可导出的消息' : '导出聊天记录'}
>
<ExportIcon />
<span></span>
<ArrowDownIcon />
</button>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{EXPORT_OPTIONS.map((option, index) => (
@@ -2,7 +2,17 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Contact } from '../../../../shared/types'
import type { PersonalWechatSenderStatus } from '../../../../shared/personal-wechat'
import type { TextToSpeechSettings, TextToSpeechVoice } from '../../../../shared/text-to-speech'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
SegmentedControl,
SegmentedControlItem,
Textarea
} from '../ui'
type SendMode = 'image' | 'voice'
type VoiceSource = 'generated' | 'file'
@@ -440,22 +450,36 @@ export function PersonalWechatSendDialog({
<div className="personal-wechat-send-status-actions">
{(status?.state === 'unsupported_version' || status?.state === 'runtime_missing') &&
onOpenTextToSpeechSettings ? (
<button
type="button"
<Button
variant="link"
size="sm"
className="h-auto p-0"
onClick={() =>
handleOpenTextToSpeechSettings(status.state === 'unsupported_version')
}
disabled={isBusy}
>
{status.state === 'runtime_missing' ? '前往下载组件' : '查看支持版本'}
</button>
</Button>
) : null}
<button type="button" onClick={() => void refreshStatus()} disabled={isBusy}>
<Button
variant="link"
size="sm"
className="h-auto p-0"
onClick={() => void refreshStatus()}
disabled={isBusy}
>
</button>
<button type="button" onClick={() => void handleRebind()} disabled={isBusy}>
</Button>
<Button
variant="link"
size="sm"
className="h-auto p-0"
onClick={() => void handleRebind()}
disabled={isBusy}
>
{isRebinding ? '绑定中…' : '尝试重新绑定'}
</button>
</Button>
</div>
</div>
@@ -468,35 +492,32 @@ export function PersonalWechatSendDialog({
))}
</dl>
<div className="personal-wechat-send-mode" role="radiogroup" aria-label="测试消息类型">
<button
type="button"
role="radio"
aria-checked={mode === 'image'}
className={mode === 'image' ? 'active' : ''}
onClick={() => changeMode('image')}
disabled={isBusy}
>
<SegmentedControl
className="personal-wechat-send-mode grid w-full grid-cols-2"
aria-label="测试消息类型"
value={mode}
onValueChange={(value) => changeMode(value as SendMode)}
disabled={isBusy}
>
<SegmentedControlItem value="image" className="w-full">
</button>
<button
type="button"
role="radio"
aria-checked={mode === 'voice'}
className={mode === 'voice' ? 'active' : ''}
onClick={() => changeMode('voice')}
disabled={isBusy}
>
</SegmentedControlItem>
<SegmentedControlItem value="voice" className="w-full">
</button>
</div>
</SegmentedControlItem>
</SegmentedControl>
{mode === 'image' ? (
<div className="personal-wechat-send-image-picker">
<span></span>
<button type="button" onClick={() => void handleSelectImage()} disabled={isBusy}>
<Button
variant="outline"
size="sm"
onClick={() => void handleSelectImage()}
disabled={isBusy}
>
{image ? '重新选择图片' : '选择图片'}
</button>
</Button>
{image ? (
<div>
<strong>{image.name}</strong>
@@ -508,28 +529,20 @@ export function PersonalWechatSendDialog({
</div>
) : (
<div className="personal-wechat-voice-composer">
<div className="personal-wechat-voice-source" role="radiogroup" aria-label="语音来源">
<button
type="button"
role="radio"
aria-checked={voiceSource === 'generated'}
className={voiceSource === 'generated' ? 'active' : ''}
onClick={() => changeVoiceSource('generated')}
disabled={isBusy}
>
<SegmentedControl
className="personal-wechat-voice-source grid w-full grid-cols-2"
aria-label="语音来源"
value={voiceSource}
onValueChange={(value) => changeVoiceSource(value as VoiceSource)}
disabled={isBusy}
>
<SegmentedControlItem value="generated" className="w-full">
</button>
<button
type="button"
role="radio"
aria-checked={voiceSource === 'file'}
className={voiceSource === 'file' ? 'active' : ''}
onClick={() => changeVoiceSource('file')}
disabled={isBusy}
>
</SegmentedControlItem>
<SegmentedControlItem value="file" className="w-full">
</button>
</div>
</SegmentedControlItem>
</SegmentedControl>
{voiceSource === 'generated' ? (
<div className="personal-wechat-generated-voice">
@@ -539,14 +552,18 @@ export function PersonalWechatSendDialog({
<strong>{selectedTtsVoice?.name || '尚未选择音色'}</strong>
</div>
{onOpenTextToSpeechSettings ? (
<button type="button" onClick={() => handleOpenTextToSpeechSettings()}>
<Button
variant="outline"
size="sm"
onClick={() => handleOpenTextToSpeechSettings()}
>
</button>
</Button>
) : null}
</div>
<label className="personal-wechat-send-editor">
<span></span>
<textarea
<Textarea
aria-label="要生成的文字"
value={voiceText}
maxLength={1000}
@@ -605,21 +622,21 @@ export function PersonalWechatSendDialog({
</div>
</div>
<div className="personal-wechat-generated-result-actions">
<button
type="button"
<Button
variant="outline"
size="sm"
onClick={() => void handleToggleGeneratedPreview()}
disabled={isSending}
>
{isPreviewPlaying ? '暂停' : '播放'}
</button>
<button
type="button"
className="primary"
</Button>
<Button
size="sm"
onClick={() => void handleSendGeneratedVoice()}
disabled={!canSendGenerated}
>
{isSending ? '正在发送…' : `发送到${isGroupChat ? '群聊' : '联系人'}`}
</button>
</Button>
</div>
</div>
) : null}
@@ -627,9 +644,14 @@ export function PersonalWechatSendDialog({
) : (
<div className="personal-wechat-send-image-picker">
<span></span>
<button type="button" onClick={() => void handleSelectVoice()} disabled={isBusy}>
<Button
variant="outline"
size="sm"
onClick={() => void handleSelectVoice()}
disabled={isBusy}
>
{voice ? '重新选择语音' : '选择语音'}
</button>
</Button>
{voice ? (
<div>
<strong>{voice.name}</strong>
@@ -659,24 +681,19 @@ export function PersonalWechatSendDialog({
)}
<footer>
<button type="button" className="secondary" onClick={handleClose} disabled={isBusy}>
<Button variant="outline" onClick={handleClose} disabled={isBusy}>
</button>
</Button>
{mode === 'voice' && voiceSource === 'generated' ? (
<button
type="button"
className="primary"
disabled={!canGenerate}
onClick={() => void handleGenerateVoice()}
>
<Button disabled={!canGenerate} onClick={() => void handleGenerateVoice()}>
{isGenerating ? '正在生成语音…' : generatedVoice ? '重新生成语音' : '生成语音'}
</button>
</Button>
) : (
<button type="button" className="primary" disabled={!canSubmit} onClick={handleSend}>
<Button disabled={!canSubmit} onClick={handleSend}>
{isSending
? '正在发送…'
: `测试发送${mode === 'voice' ? '语音' : '图片'}${isGroupChat ? '群聊' : '联系人'}`}
</button>
</Button>
)}
</footer>
</DialogContent>
@@ -1,4 +1,5 @@
import React from 'react'
import { Input } from '../ui'
interface ConversationSearchProps {
value: string
@@ -23,8 +24,10 @@ export function ConversationSearch({
<span className="conversation-search-icon">
<SearchIcon />
</span>
<input
type="text"
<Input
type="search"
aria-label="搜索会话"
className="h-[34px] pl-9"
value={value}
placeholder="搜索群聊、联系人或 wxid"
onChange={(event) => onChange(event.target.value)}
@@ -1,4 +1,5 @@
import React from 'react'
import { IconButton } from '../ui'
import { ConversationSearch } from './ConversationSearch'
interface ConversationSidebarHeaderProps {
@@ -22,10 +23,10 @@ export function ConversationSidebarHeader({
<h2></h2>
<div className="conversation-sidebar-meta">
<span>{totalCount} </span>
<button
type="button"
<IconButton
variant="ghost"
className={`conversation-refresh-button ${refreshing ? 'is-refreshing' : ''}`}
aria-label={refreshing ? '正在刷新会话列表' : '刷新会话列表'}
label={refreshing ? '正在刷新会话列表' : '刷新会话列表'}
title={refreshing ? '正在刷新…' : '刷新会话列表'}
disabled={refreshing}
onClick={onRefresh}
@@ -33,7 +34,7 @@ export function ConversationSidebarHeader({
<svg viewBox="0 0 20 20" aria-hidden="true">
<path d="M16.1 6.2A7 7 0 1 0 17 12h-2a5 5 0 1 1-.7-3.4L12 11h6V5l-1.9 1.2Z" />
</svg>
</button>
</IconButton>
</div>
</div>
<ConversationSearch value={searchValue} onChange={onSearchChange} />
@@ -17,6 +17,7 @@ import { ReportMemberNameSelector } from './ReportMemberNameSelector'
import { ReportGroupMemberSelector } from './ReportGroupMemberSelector'
import { ReportSectionSelector } from './ReportSectionSelector'
import { ReportTemplateSelector, SelectableReportTemplateId } from './ReportTemplateSelector'
import { Button, Input } from '../ui'
interface AiReportWorkspaceProps {
sourceContact: Contact | null
@@ -198,7 +199,8 @@ export function AiReportWorkspace({
<p></p>
</div>
<label>
<input
<Input
className="w-24"
type="number"
min={30}
max={1800}
@@ -227,18 +229,16 @@ export function AiReportWorkspace({
<img src={generatedImage} alt="生成的群聊日报" />
</div>
<div className="report-result-actions">
<button type="button" onClick={onViewResult}>
</button>
<button type="button" onClick={handleCopy}>
<Button onClick={onViewResult}></Button>
<Button variant="outline" onClick={handleCopy}>
</button>
<button type="button" onClick={handleReveal} disabled={!reportPaths}>
</Button>
<Button variant="outline" onClick={handleReveal} disabled={!reportPaths}>
</button>
<button type="button" onClick={onCloseResult}>
</Button>
<Button variant="ghost" onClick={onCloseResult}>
</button>
</Button>
</div>
{actionStatus && <p className="report-action-status">{actionStatus}</p>}
</section>
@@ -253,13 +253,13 @@ export function AiReportWorkspace({
<div className="report-footer-actions">
{disabledReason && !isGenerating && <span>{disabledReason}</span>}
{hasReportResult && !isGenerating && (
<button type="button" className="secondary" onClick={onViewResult}>
<Button className="min-w-[92px]" variant="outline" onClick={onViewResult}>
</button>
</Button>
)}
<button type="button" disabled={!canGenerate} onClick={onGenerate}>
<Button className="min-w-[132px]" disabled={!canGenerate} onClick={onGenerate}>
{isGenerating ? '正在生成日报' : '开始生成日报'}
</button>
</Button>
</div>
</footer>
</main>
@@ -1,8 +1,6 @@
import React from 'react'
import {
SUMMARY_TYPE_OPTIONS,
SummaryMessageType
} from '../../utils/group-report'
import { SUMMARY_TYPE_OPTIONS, SummaryMessageType } from '../../utils/group-report'
import { Checkbox } from '../ui'
interface MessageTypeSelectorProps {
value: SummaryMessageType[]
@@ -34,12 +32,18 @@ export function MessageTypeSelector({
</div>
<div className="report-type-grid">
{SUMMARY_TYPE_OPTIONS.map((option) => (
<label key={option.value} className="report-check-row">
<input
type="checkbox"
<label
key={option.value}
className="report-check-row"
htmlFor={`report-message-type-${option.value}`}
>
<Checkbox
id={`report-message-type-${option.value}`}
aria-label={option.label}
className="mt-0.5"
checked={value.includes(option.value)}
disabled={disabled || (value.length === 1 && value.includes(option.value))}
onChange={() => toggle(option.value)}
onCheckedChange={() => toggle(option.value)}
/>
<span>
<b>{option.label}</b>
@@ -1,6 +1,7 @@
import React from 'react'
import type { ReportModelChoice } from '../../../../shared/ai-provider'
import { AiModelConfig } from '../../hooks/useGroupReportGeneration'
import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui'
interface ModelSummaryProps {
config: AiModelConfig
@@ -46,44 +47,48 @@ export function ModelSummary({
<div className="report-model-selects">
<label>
<span></span>
<select
aria-label="文字总结模型"
<Select
value={modelKey(config)}
disabled={disabled || !textModels.length}
onChange={(event) => changeModel(event.target.value, textModels, onTextModelChange)}
onValueChange={(key) => changeModel(key, textModels, onTextModelChange)}
>
{!textModels.length && <option value=""></option>}
{textModels.map((model) => (
<option key={modelKey(model)} value={modelKey(model)}>
{optionLabel(model)}
</option>
))}
</select>
<SelectTrigger aria-label="文字总结模型">
<SelectValue placeholder="没有已配置的文字模型" />
</SelectTrigger>
<SelectContent>
{textModels.map((model) => (
<SelectItem key={modelKey(model)} value={modelKey(model)}>
{optionLabel(model)}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
<label>
<span></span>
<select
aria-label="图片理解模型"
value={modelKey(visionConfig)}
<Select
value={modelKey(visionConfig) || undefined}
disabled={disabled || !visionModels.length}
onChange={(event) =>
changeModel(event.target.value, visionModels, onVisionModelChange)
}
onValueChange={(key) => changeModel(key, visionModels, onVisionModelChange)}
>
{!visionModels.length && <option value=""></option>}
{visionModels.map((model) => (
<option key={modelKey(model)} value={modelKey(model)}>
{optionLabel(model)}
</option>
))}
</select>
<SelectTrigger aria-label="图片理解模型">
<SelectValue placeholder="没有已验证的图片理解模型" />
</SelectTrigger>
<SelectContent>
{visionModels.map((model) => (
<SelectItem key={modelKey(model)} value={modelKey(model)}>
{optionLabel(model)}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
</div>
<small> 10 </small>
</div>
<button type="button" onClick={onOpenSettings} disabled={disabled}>
<Button variant="outline" size="sm" onClick={onOpenSettings} disabled={disabled}>
</button>
</Button>
</div>
</section>
)
@@ -1,4 +1,5 @@
import React from 'react'
import { SegmentedControl, SegmentedControlItem } from '../ui'
export function ReportDensitySelector(): React.ReactElement {
return (
@@ -7,17 +8,31 @@ export function ReportDensitySelector(): React.ReactElement {
<h3></h3>
<span>使</span>
</div>
<div className="report-density-options">
<button type="button" disabled>
<span></span>
</button>
<button type="button" className="active" disabled>
<span></span>
</button>
<button type="button" disabled>
<span></span>
</button>
</div>
<SegmentedControl
className="report-density-options w-full"
aria-label="内容密度"
value="standard"
disabled
>
<SegmentedControlItem
className="h-auto min-h-10 flex-col gap-0 whitespace-normal px-2 py-1.5 text-center"
value="concise"
>
<small></small>
</SegmentedControlItem>
<SegmentedControlItem
className="h-auto min-h-10 flex-col gap-0 whitespace-normal px-2 py-1.5 text-center"
value="standard"
>
<small></small>
</SegmentedControlItem>
<SegmentedControlItem
className="h-auto min-h-10 flex-col gap-0 whitespace-normal px-2 py-1.5 text-center"
value="detailed"
>
<small></small>
</SegmentedControlItem>
</SegmentedControl>
</section>
)
}
@@ -1,4 +1,5 @@
import React from 'react'
import { Button } from '../ui'
interface ReportEmptyStateProps {
icon?: 'spark'
@@ -27,9 +28,7 @@ export function ReportEmptyState({
)}
<h2>{title}</h2>
<p>{message}</p>
<button type="button" onClick={onAction}>
{actionLabel}
</button>
<Button onClick={onAction}>{actionLabel}</Button>
</div>
)
}
@@ -1,5 +1,6 @@
import React from 'react'
import type { GeneratedReportRecord } from './types'
import { Button } from '../ui'
interface ReportExportStatusProps {
report: GeneratedReportRecord | null
@@ -48,9 +49,9 @@ export function ReportExportStatus({
)}
</div>
{(report.pngPath || report.htmlPath) && (
<button type="button" onClick={() => void handleReveal()}>
<Button variant="outline" size="sm" onClick={() => void handleReveal()}>
</button>
</Button>
)}
{status && <p>{status}</p>}
</section>
@@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react'
import type { Contact } from '../../../../shared/types'
import { Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui'
interface ReportGroupMember {
wxid: string
@@ -89,7 +90,7 @@ export function ReportGroupMemberSelector({
使
</p>
<div className="report-member-tools">
<input
<Input
type="search"
value={filter}
disabled={disabled || loading}
@@ -97,22 +98,23 @@ export function ReportGroupMemberSelector({
placeholder="搜索成员或 wxid"
aria-label="搜索群成员"
/>
<select
value={selectedMember?.wxid || ''}
<Select
key={`${sourceContactId}:${loading ? 'loading' : 'ready'}`}
value={selectedMember?.wxid || undefined}
disabled={disabled || loading || !filteredMembers.length}
onChange={(event) => setSelectedWxid(event.target.value)}
aria-label="选择群成员"
onValueChange={setSelectedWxid}
>
{filteredMembers.length ? (
filteredMembers.map((member) => (
<option key={member.wxid} value={member.wxid}>
<SelectTrigger aria-label="选择群成员">
<SelectValue placeholder="暂无成员" />
</SelectTrigger>
<SelectContent>
{filteredMembers.map((member) => (
<SelectItem key={member.wxid} value={member.wxid}>
{displayMemberName(member)}
</option>
))
) : (
<option value=""></option>
)}
</select>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{loading && <p className="report-member-status">...</p>}
{error && <p className="report-member-status error">{error}</p>}
@@ -8,7 +8,8 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button
Button,
Input
} from '../ui'
import type { GeneratedReportRecord } from './types'
@@ -155,15 +156,16 @@ export function ReportHistorySidebar({
<circle cx="10.5" cy="10.5" r="5.5" />
<path d="m15 15 4 4" />
</svg>
<input
<Input
className="pl-9"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索群聊或日报"
/>
</label>
<button type="button" className="report-history-create" onClick={onCreateReport}>
<Button className="report-history-create" onClick={onCreateReport}>
+
</button>
</Button>
<div className="report-history-list" aria-label="历史报告">
<div className="report-history-list-title"></div>
{reportGroups.length ? (
@@ -1,5 +1,6 @@
import React, { useState } from 'react'
import type { GeneratedReportRecord } from './types'
import { Button } from '../ui'
interface ReportInfoPanelProps {
report: GeneratedReportRecord | null
@@ -165,9 +166,9 @@ export function ReportInfoPanel({ report, onReveal }: ReportInfoPanelProps): Rea
</div>
</div>
{(report.pngPath || report.htmlPath) && (
<button type="button" onClick={() => void revealReport()}>
<Button variant="outline" size="sm" onClick={() => void revealReport()}>
</button>
</Button>
)}
{revealStatus && <p>{revealStatus}</p>}
</section>
@@ -199,9 +200,9 @@ export function ReportInfoPanel({ report, onReveal }: ReportInfoPanelProps): Rea
{path ? (
<>
<code>{path}</code>
<button type="button" onClick={() => void copyPath()}>
<Button variant="outline" size="sm" onClick={() => void copyPath()}>
</button>
</Button>
{copyStatus && <p>{copyStatus}</p>}
</>
) : (
@@ -1,5 +1,6 @@
import React from 'react'
import { ReportMemberNamePreference } from '../../hooks/useGroupReportGeneration'
import { RadioGroup, RadioGroupItem } from '../ui'
const OPTIONS: {
value: ReportMemberNamePreference
@@ -36,18 +37,22 @@ export function ReportMemberNameSelector({
<section className="report-section">
<h3></h3>
<p className="report-section-desc">使</p>
<div className="report-template-list">
<RadioGroup
className="report-template-list"
value={value}
disabled={disabled}
onValueChange={(nextValue) => onChange(nextValue as ReportMemberNamePreference)}
>
{OPTIONS.map((option) => (
<label
className={`report-template-item ${value === option.value ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
key={option.value}
htmlFor={`report-member-name-${option.value}`}
>
<input
type="radio"
name="report-member-name"
checked={value === option.value}
disabled={disabled}
onChange={() => onChange(option.value)}
<RadioGroupItem
id={`report-member-name-${option.value}`}
value={option.value}
aria-label={option.label}
/>
<div className="report-template-body">
<div className="report-template-title">{option.label}</div>
@@ -55,7 +60,7 @@ export function ReportMemberNameSelector({
</div>
</label>
))}
</div>
</RadioGroup>
</section>
)
}
@@ -1,9 +1,7 @@
import React from 'react'
import {
SUMMARY_DATE_OPTIONS,
SummaryDateRange
} from '../../utils/group-report'
import { SUMMARY_DATE_OPTIONS, SummaryDateRange } from '../../utils/group-report'
import { RangeMessageState } from '../../hooks/useGroupReportGeneration'
import { SegmentedControl, SegmentedControlItem } from '../ui'
interface ReportRangeSelectorProps {
value: SummaryDateRange
@@ -33,22 +31,31 @@ export function ReportRangeSelector({
<h3></h3>
<span className={rangeState.status === 'error' ? 'danger' : ''}>{countText}</span>
</div>
<div className="report-range-options">
<SegmentedControl
className="report-range-options w-full"
aria-label="总结范围"
value={value}
disabled={disabled}
onValueChange={(nextValue) => onChange(nextValue as SummaryDateRange)}
>
{SUMMARY_DATE_OPTIONS.map((option) => (
<button
<SegmentedControlItem
key={option.value}
type="button"
className={value === option.value ? 'active' : ''}
disabled={disabled}
onClick={() => onChange(option.value)}
className="h-auto min-h-10 whitespace-normal px-2 py-1.5 text-center"
value={option.value}
>
{option.label}
</button>
</SegmentedControlItem>
))}
<button type="button" disabled title="当前业务尚未支持自定义开始和结束时间">
<span></span>
</button>
</div>
<SegmentedControlItem
className="h-auto min-h-10 flex-col gap-0 whitespace-normal px-2 py-1.5 text-center"
value="custom"
disabled
title="当前业务尚未支持自定义开始和结束时间"
>
<small></small>
</SegmentedControlItem>
</SegmentedControl>
</section>
)
}
@@ -1,6 +1,7 @@
import React, { useState } from 'react'
import type { GeneratedReportRecord } from './types'
import { ReportExportStatus } from './ReportExportStatus'
import { Button } from '../ui'
interface ReportSettingsPanelProps {
report: GeneratedReportRecord | null
@@ -63,9 +64,9 @@ export function ReportSettingsPanel({
{path ? (
<>
<code>{path}</code>
<button type="button" onClick={() => void copyPath()}>
<Button variant="outline" size="sm" onClick={() => void copyPath()}>
</button>
</Button>
{copyStatus && <p>{copyStatus}</p>}
</>
) : (
@@ -1,6 +1,7 @@
import React, { useMemo, useState } from 'react'
import { Contact } from '../../../../shared/types'
import { AccountSummary } from '../account/AccountSummary'
import { Button, Input } from '../ui'
interface SelfInfo {
wxid: string
@@ -56,7 +57,8 @@ export function ReportSourceSidebar({
<circle cx="10.5" cy="10.5" r="5.5" />
<path d="m15 15 4 4" />
</svg>
<input
<Input
className="pl-9"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索群聊"
@@ -69,9 +71,9 @@ export function ReportSourceSidebar({
const nickname = contact.m_nsNickName.trim()
const displayName = nickname || contact.m_nsUsrName || '未命名群聊'
return (
<button
<Button
key={contact.md5}
type="button"
variant="ghost"
className={`report-source-item ${selected ? 'active' : ''}`}
onClick={() => onSelectContact(contact)}
title={contact.m_nsUsrName}
@@ -86,7 +88,7 @@ export function ReportSourceSidebar({
<span className="report-source-text">
<span>{displayName}</span>
</span>
</button>
</Button>
)
})
) : (
@@ -10,6 +10,7 @@ import type {
ReportImageInsightSummary,
ReportPreparationProgress
} from '../../utils/group-report-facts'
import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui'
interface ReportTaskStatusPanelProps {
phase: ReportGenerationPhase
@@ -223,12 +224,10 @@ export function ReportTaskStatusPanel({
</p>
<div>
<button type="button" onClick={onContinueAfterImageFailures}>
</button>
<button type="button" onClick={onCancelAfterImageFailures}>
<Button onClick={onContinueAfterImageFailures}></Button>
<Button variant="outline" onClick={onCancelAfterImageFailures}>
</button>
</Button>
</div>
</div>
)}
@@ -258,35 +257,35 @@ export function ReportTaskStatusPanel({
{canRetryModelStep ? (
<div className="report-model-retry">
<label htmlFor="report-retry-model"></label>
<select
id="report-retry-model"
value={selectedModelKey}
onChange={(event) => setSelectedModelKey(event.target.value)}
<Select
key={choices.length ? 'ready' : 'loading'}
value={selectedModelKey || undefined}
disabled={!choices.length}
onValueChange={setSelectedModelKey}
>
{choices.map((choice) => (
<option key={choice.key} value={choice.key}>
{choice.label}
</option>
))}
</select>
<SelectTrigger id="report-retry-model" aria-label="切换模型">
<SelectValue placeholder="正在读取模型列表" />
</SelectTrigger>
<SelectContent>
{choices.map((choice) => (
<SelectItem key={choice.key} value={choice.key}>
{choice.label}
</SelectItem>
))}
</SelectContent>
</Select>
<small></small>
{modelLoadError ? <small className="error">{modelLoadError}</small> : null}
<button
type="button"
disabled={!selectedModel}
onClick={() => onRetry(selectedModel)}
>
<Button disabled={!selectedModel} onClick={() => onRetry(selectedModel)}>
使
</button>
</Button>
</div>
) : (
<button type="button" onClick={() => onRetry()}>
</button>
<Button onClick={() => onRetry()}></Button>
)}
<button type="button" onClick={() => void window.api.revealAppLog()}>
<Button variant="ghost" size="sm" onClick={() => void window.api.revealAppLog()}>
</button>
</Button>
{logPath && <small className="report-task-log-path">{logPath}</small>}
</div>
)}
@@ -1,10 +1,22 @@
import React, { useState } from 'react'
import React, { useRef, useState } from 'react'
import {
DEFAULT_REPORT_TEMPLATE,
REPORT_TEMPLATES,
type ReportTemplateDefinition,
type SelectableReportTemplateId
} from '../../../../shared/report-templates'
import {
Button,
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
RadioGroup,
RadioGroupItem
} from '../ui'
export type { SelectableReportTemplateId } from '../../../../shared/report-templates'
@@ -58,6 +70,7 @@ export const ReportTemplateSelector: React.FC<ReportTemplateSelectorProps> = ({
disabled
}) => {
const [previewing, setPreviewing] = useState<ReportTemplateDefinition | null>(null)
const previewTriggerRef = useRef<HTMLButtonElement | null>(null)
const mobileTemplates = REPORT_TEMPLATES.filter((template) => template.platform === 'mobile')
const desktopTemplates = REPORT_TEMPLATES.filter((template) => template.platform === 'desktop')
@@ -75,14 +88,11 @@ export const ReportTemplateSelector: React.FC<ReportTemplateSelectorProps> = ({
key={template.id}
className={`report-template-item ${active ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
>
<label>
<input
type="radio"
name="report-template"
<label htmlFor={`report-template-${template.id}`}>
<RadioGroupItem
id={`report-template-${template.id}`}
value={template.id}
checked={active}
disabled={disabled}
onChange={() => onChange(template.id)}
aria-label={template.name}
/>
<TemplateDiagram template={template} />
<div className="report-template-body">
@@ -91,13 +101,16 @@ export const ReportTemplateSelector: React.FC<ReportTemplateSelectorProps> = ({
<div className="report-template-tagline">{template.tagline}</div>
</div>
</label>
<button
type="button"
className="report-template-preview-btn"
onClick={() => setPreviewing(template)}
<Button
variant="outline"
size="sm"
onClick={(event) => {
previewTriggerRef.current = event.currentTarget
setPreviewing(template)
}}
>
</button>
</Button>
</div>
)
})}
@@ -111,52 +124,66 @@ export const ReportTemplateSelector: React.FC<ReportTemplateSelectorProps> = ({
<p className="report-section-desc">
</p>
<div className="report-template-catalog">
<RadioGroup
className="report-template-catalog"
value={value}
disabled={disabled}
onValueChange={(nextValue) => onChange(nextValue as SelectableReportTemplateId)}
>
{renderGroup('默认模板', [DEFAULT_REPORT_TEMPLATE])}
{renderGroup('手机端 · 375414 px', mobileTemplates)}
{renderGroup('电脑端 · 12801920 px', desktopTemplates)}
</div>
{previewing && (
<div className="report-template-preview-mask" onClick={() => setPreviewing(null)}>
<div
className="report-template-preview-card"
onClick={(event) => event.stopPropagation()}
</RadioGroup>
<Dialog
open={Boolean(previewing)}
onOpenChange={(open) => {
if (!open) setPreviewing(null)
}}
>
{previewing && (
<DialogContent
className="report-template-preview-card max-h-[90vh] max-w-[520px] overflow-y-auto bg-surface-muted p-[18px]"
onCloseAutoFocus={(event) => {
event.preventDefault()
previewTriggerRef.current?.focus()
}}
>
<div className="report-template-preview-heading">
<div>
<span>{previewing.label}</span>
<h4>{previewing.name}</h4>
<DialogHeader className="pr-8">
<div className="report-template-preview-heading">
<div>
<span>{previewing.label}</span>
<DialogTitle>{previewing.name}</DialogTitle>
</div>
<em>
{previewing.platform === 'desktop'
? '桌面宽屏'
: previewing.platform === 'default'
? '经典长图'
: '手机长图'}
</em>
</div>
<em>
{previewing.platform === 'desktop'
? '桌面宽屏'
: previewing.platform === 'default'
? '经典长图'
: '手机长图'}
</em>
</div>
<p className="muted">{previewing.tagline}</p>
<DialogDescription>{previewing.tagline}</DialogDescription>
</DialogHeader>
<TemplateDiagram template={previewing} />
<p className="report-template-preview-note">
<DialogDescription className="report-template-preview-note">
Q&amp;A
</p>
<div className="report-template-preview-actions">
<button type="button" className="secondary" onClick={() => setPreviewing(null)}>
</button>
<button
type="button"
</DialogDescription>
<DialogFooter className="report-template-preview-actions">
<DialogClose asChild>
<Button variant="outline"></Button>
</DialogClose>
<Button
onClick={() => {
onChange(previewing.id)
setPreviewing(null)
}}
>
</button>
</div>
</div>
</div>
)}
</Button>
</DialogFooter>
</DialogContent>
)}
</Dialog>
</section>
)
}
@@ -1,4 +1,5 @@
import React from 'react'
import { Button } from '../ui'
interface ReportZoomBarProps {
zoom: number
@@ -17,19 +18,19 @@ export function ReportZoomBar({
}: ReportZoomBarProps): React.ReactElement {
return (
<div className="report-zoom-bar">
<button type="button" onClick={() => onZoomChange(clampZoom(zoom / 1.25))}>
<Button variant="ghost" size="sm" onClick={() => onZoomChange(clampZoom(zoom / 1.25))}>
</button>
</Button>
<span title="100% 为完整显示在当前预览框内">{Math.round(zoom * 100)}%</span>
<button type="button" onClick={() => onZoomChange(clampZoom(zoom * 1.25))}>
<Button variant="ghost" size="sm" onClick={() => onZoomChange(clampZoom(zoom * 1.25))}>
</button>
<button type="button" onClick={onFitPage}>
</Button>
<Button variant="ghost" size="sm" onClick={onFitPage}>
</button>
<button type="button" onClick={onActualSize}>
</Button>
<Button variant="ghost" size="sm" onClick={onActualSize}>
</button>
</Button>
</div>
)
}
@@ -30,6 +30,7 @@ import { ensureAiSearchDataConsent } from './services/aiSearchProviderConsent'
import { ExternalProviderConsentDialog } from './ExternalProviderConsentDialog'
import { AISearchComposer } from './AISearchComposer'
import { AISearchEvidencePanel } from './AISearchEvidencePanel'
import { Button } from '../ui'
export function AISearchWorkspace({
contacts,
@@ -670,14 +671,28 @@ export function AISearchWorkspace({
{renderSearchDetails()}
</div>
<div className="ai-search-result-actions">
<button type="button" onClick={startNewQuestion} title="清空当前结果并提出新问题">
<Button
variant="outline"
size="sm"
className="px-2"
onClick={startNewQuestion}
title="清空当前结果并提出新问题"
>
</button>
<button type="button" onClick={() => void copyAnswer()} title="复制 AI 摘要">
</Button>
<Button
variant="outline"
size="sm"
className="px-2"
onClick={() => void copyAnswer()}
title="复制 AI 摘要"
>
</button>
<button
type="button"
</Button>
<Button
variant="outline"
size="sm"
className="px-2"
onClick={() => {
skipNextCache()
void runAnalysis()
@@ -685,7 +700,7 @@ export function AISearchWorkspace({
title="跳过缓存并重新读取聊天记录"
>
</button>
</Button>
</div>
</div>
<section className="ai-search-summary-block">
@@ -719,9 +734,9 @@ export function AISearchWorkspace({
<span className="ai-search-kicker"></span>
<h2>{analysisError || '当前范围没有足够证据'}</h2>
<p></p>
<button
type="button"
className="primary"
<Button
size="sm"
className="mt-5"
onClick={() => {
const expandToAll = range === '30d' || range === 'all'
setRange(expandToAll ? 'all' : '30d')
@@ -748,7 +763,7 @@ export function AISearchWorkspace({
}}
>
{range === '30d' || range === 'all' ? '搜索全部历史' : '扩大到近 30 天'}
</button>
</Button>
</div>
)
@@ -779,25 +794,27 @@ export function AISearchWorkspace({
<span className={aiModelConfig.configured ? 'ready' : 'warning'} />
<span>{modelLabel}</span>
{!aiModelConfig.configured && (
<button type="button" onClick={onOpenAISettings}>
<Button variant="link" size="sm" onClick={onOpenAISettings}>
</button>
</Button>
)}
</div>
{debugEnabled && (
<button
type="button"
className={`ai-search-debug-button ${debugPanelOpen ? 'active' : ''}`}
<Button
variant="outline"
size="sm"
onClick={() => setDebugPanelOpen((open) => !open)}
title="查看本次检索诊断信息"
aria-expanded={debugPanelOpen}
aria-controls="ai-search-debug-panel"
>
</button>
</Button>
)}
</div>
</header>
{debugEnabled && debugPanelOpen && (
<section className="ai-search-debug-panel">
<section id="ai-search-debug-panel" className="ai-search-debug-panel">
<div className="ai-search-debug-header">
<div>
<strong></strong>
@@ -806,12 +823,12 @@ export function AISearchWorkspace({
</span>
</div>
<div className="ai-search-debug-actions">
<button type="button" onClick={() => setDebugEntries([])}>
<Button variant="outline" size="sm" onClick={() => setDebugEntries([])}>
</button>
<button type="button" onClick={() => void window.api.revealAppLog()}>
</Button>
<Button variant="outline" size="sm" onClick={() => void window.api.revealAppLog()}>
</button>
</Button>
</div>
</div>
{appLogPath && <small className="ai-search-debug-path">{appLogPath}</small>}
@@ -965,9 +982,9 @@ export function AISearchWorkspace({
{knowledgeStatus.lastError || '同步异常,旧搜索仍可使用。'}
</p>
)}
<button
type="button"
className="ai-search-knowledge-action"
<Button
size="sm"
className="w-full"
disabled={
syncStarting ||
knowledgeStatus?.state === 'building' ||
@@ -982,7 +999,7 @@ export function AISearchWorkspace({
: knowledgeStatus?.state === 'ready'
? '同步最新记录'
: '建立本地知识库'}
</button>
</Button>
<details className="ai-search-knowledge-more">
<summary></summary>
<p>
+1
View File
@@ -10,6 +10,7 @@ export * from './popover'
export * from './progress'
export * from './radio-group'
export * from './scroll-area'
export * from './segmented-control'
export * from './select'
export * from './separator'
export * from './skeleton'
@@ -0,0 +1,32 @@
import * as React from 'react'
import * as RadioGroupPrimitive from '@radix-ui/react-radio-group'
import { cn } from '../../lib/cn'
const SegmentedControl = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
>(({ className, ...props }, ref) => (
<RadioGroupPrimitive.Root
ref={ref}
className={cn('inline-flex flex-wrap gap-1 rounded-md bg-muted p-1', className)}
{...props}
/>
))
SegmentedControl.displayName = 'SegmentedControl'
const SegmentedControlItem = React.forwardRef<
React.ElementRef<typeof RadioGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
>(({ className, ...props }, ref) => (
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
'inline-flex h-8 min-w-0 items-center justify-center rounded-sm border-0 bg-transparent px-3 text-xs font-medium text-muted-foreground transition-colors duration-fast ease-tm-standard hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-surface data-[state=checked]:text-primary data-[state=checked]:shadow-surface',
className
)}
{...props}
/>
))
SegmentedControlItem.displayName = 'SegmentedControlItem'
export { SegmentedControl, SegmentedControlItem }
+3 -1
View File
@@ -71,7 +71,9 @@ const SelectItem = React.forwardRef<
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"></span>
<SelectPrimitive.ItemIndicator className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
</SelectPrimitive.ItemIndicator>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
@@ -5,6 +5,14 @@ import type {
AgentHubStatus,
WechatConnectorStatus
} from '../../../../shared/agent-hub'
import {
Button,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '../../components/ui'
const STATUS_LABELS: Record<WechatConnectorStatus, string> = {
checking: '正在检查',
@@ -127,14 +135,13 @@ export function AgentHubWorkspace(): React.ReactElement {
{status.connector === 'scanned' ? '请在手机上确认登录' : '使用微信扫描二维码'}
</h3>
<p></p>
<button
type="button"
className="agent-hub-button secondary"
<Button
variant="outline"
disabled={busy}
onClick={() => void runAction(() => window.api.cancelAgentHubLogin())}
>
</button>
</Button>
</div>
</div>
) : status.connector === 'online' ? (
@@ -160,32 +167,28 @@ export function AgentHubWorkspace(): React.ReactElement {
<div className="agent-hub-actions">
{status.connector === 'online' ? (
<>
<button
type="button"
className="agent-hub-button secondary"
<Button
variant="outline"
disabled={busy}
onClick={() => void runAction(() => window.api.startAgentHubLogin())}
>
</button>
<button
type="button"
className="agent-hub-button danger"
</Button>
<Button
variant="destructive"
disabled={busy}
onClick={() => void runAction(() => window.api.disconnectAgentHub())}
>
</button>
</Button>
</>
) : !isLoginFlow ? (
<button
type="button"
className="agent-hub-button primary"
<Button
disabled={busy || status.hub !== 'online'}
onClick={() => void runAction(() => window.api.startAgentHubLogin())}
>
{busy ? '正在获取二维码…' : '扫码登录微信机器人'}
</button>
</Button>
) : null}
</div>
</section>
@@ -236,26 +239,31 @@ export function AgentHubWorkspace(): React.ReactElement {
<h2></h2>
</div>
<div className="agent-hub-log-actions">
<select
aria-label="筛选日志来源"
<Select
value={logSource}
onChange={(event) => setLogSource(event.target.value as 'all' | AgentHubLogSource)}
onValueChange={(value) => setLogSource(value as 'all' | AgentHubLogSource)}
>
<option value="all"></option>
<option value="system"></option>
<option value="agent-hub">Agent Hub</option>
<option value="wechat-connector"></option>
</select>
<button
type="button"
<SelectTrigger aria-label="筛选日志来源" className="h-8 min-w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="system"></SelectItem>
<SelectItem value="agent-hub">Agent Hub</SelectItem>
<SelectItem value="wechat-connector"></SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
onClick={() => void copyLogs()}
disabled={visibleLogs.length === 0}
>
</button>
<button type="button" onClick={() => void clearLogs()}>
</Button>
<Button variant="outline" size="sm" onClick={() => void clearLogs()}>
</button>
</Button>
</div>
</div>
<div className="agent-hub-log-body" ref={logBodyRef}>
@@ -1,5 +1,6 @@
import type { ApiEndpoint, ApiServiceState, ApiSettings } from '../model/types'
import { type ReactElement } from 'react'
import { Button, Input, Textarea } from '../../../components/ui'
import { buildApiUrl } from '../utils/buildApiUrl'
interface Props {
@@ -62,7 +63,8 @@ export function ApiRequestTester({
{parameter.label}
{parameter.required && <b></b>}
</span>
<input
<Input
className="h-8 text-xs"
value={params[parameter.key] || ''}
onChange={(event) => update(parameter.key, event.target.value)}
placeholder={parameter.placeholder}
@@ -74,7 +76,8 @@ export function ApiRequestTester({
{endpoint.body && (
<label className="api-json-input">
<span>JSON </span>
<textarea
<Textarea
className="min-h-[180px] resize-y font-mono text-xs"
value={body}
onChange={(event) => onBody(event.target.value)}
spellCheck={false}
@@ -83,23 +86,23 @@ export function ApiRequestTester({
)}
{endpoint.id === 'agent-send' && (
<div className="api-upload-test-row">
<button type="button" onClick={() => void selectTestImage()}>
<Button size="sm" variant="outline" onClick={() => void selectTestImage()}>
</button>
</Button>
<span></span>
</div>
)}
<div className="api-tester-actions">
<button type="button" onClick={onClear}>
<Button size="sm" variant="ghost" onClick={onClear}>
</button>
<button type="button" onClick={() => void onCopyCurl()}>
</Button>
<Button size="sm" variant="outline" onClick={() => void onCopyCurl()}>
curl
</button>
<button
type="button"
className="api-primary-button"
</Button>
<Button
size="sm"
disabled={!service?.running || state === 'loading'}
aria-busy={state === 'loading'}
onClick={onSend}
>
{state === 'loading'
@@ -107,7 +110,7 @@ export function ApiRequestTester({
: service?.running
? '发送请求'
: '请先启动本地 API 服务'}
</button>
</Button>
</div>
{error && <p className="api-inline-error">{error}</p>}
</section>
@@ -5,6 +5,7 @@ import type {
RequestHistoryItem
} from '../model/types'
import { type ReactElement } from 'react'
import { Button } from '../../../components/ui'
import { isLoopbackHost } from '../utils/buildApiUrl'
import { formatJson, formatResponseSize, inferResponseCount } from '../utils/formatResponse'
@@ -78,27 +79,23 @@ export function ApiRuntimePanel({
{service?.error && <p className="api-inline-error">{service.error}</p>}
<div className="api-runtime-actions">
{service?.running ? (
<button type="button" onClick={() => onControl('stop')}>
<Button size="sm" variant="outline" onClick={() => onControl('stop')}>
</button>
</Button>
) : (
<button
type="button"
className="api-primary-button"
onClick={() => onControl('start')}
>
<Button size="sm" onClick={() => onControl('start')}>
</button>
</Button>
)}
<button type="button" onClick={() => onControl('restart')}>
<Button size="sm" variant="outline" onClick={() => onControl('restart')}>
</button>
<button type="button" onClick={copyAddress}>
</Button>
<Button size="sm" variant="outline" onClick={copyAddress}>
</button>
<button type="button" onClick={onOpenSettings}>
</Button>
<Button size="sm" variant="outline" onClick={onOpenSettings}>
API
</button>
</Button>
</div>
</section>
{!localOnly && (
@@ -118,27 +115,30 @@ export function ApiRuntimePanel({
</code>
{tokenStatus?.error && <p className="api-inline-error">{tokenStatus.error}</p>}
<div className="api-runtime-actions">
<button
type="button"
<Button
size="sm"
variant="outline"
disabled={!tokenStatus?.hasToken}
onClick={() => void (revealedToken ? onHideToken() : onRevealToken())}
>
{revealedToken ? '隐藏 Token' : '显示 Token'}
</button>
<button
type="button"
</Button>
<Button
size="sm"
variant="outline"
disabled={!tokenStatus?.hasToken}
onClick={() => void onCopyToken()}
>
Token
</button>
<button
type="button"
</Button>
<Button
size="sm"
variant="outline"
disabled={!tokenStatus?.available}
onClick={() => void onRotateToken()}
>
Token
</button>
</Button>
</div>
</section>
<section>
@@ -160,12 +160,13 @@ export function ApiRuntimePanel({
? ` · ${inferResponseCount(response.data)}`
: ''}
</p>
<button
type="button"
<Button
size="sm"
variant="outline"
onClick={() => void onCopy(formatJson(response.text), 'JSON 响应已复制')}
>
JSON
</button>
</Button>
</>
) : (
<p className="api-empty-text"></p>
@@ -1,5 +1,6 @@
import { API_ENDPOINTS } from '../model/apiEndpoints'
import { type ReactElement } from 'react'
import { Button } from '../../../components/ui'
export function EndpointCatalog({
activeEndpointId,
@@ -38,12 +39,22 @@ export function EndpointCatalog({
<small>{endpoint.description}</small>
</span>
<span>
<button type="button" onClick={() => void onCopy(endpoint.path)}>
<Button
className="h-7 px-2 text-[11px]"
size="sm"
variant="ghost"
onClick={() => void onCopy(endpoint.path)}
>
</button>
<button type="button" onClick={() => onSelect(endpoint.id)}>
</Button>
<Button
className="h-7 px-2 text-[11px]"
size="sm"
variant="outline"
onClick={() => onSelect(endpoint.id)}
>
</button>
</Button>
</span>
</div>
))}
@@ -1,4 +1,11 @@
import { type ReactElement } from 'react'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '../../../components/ui'
import type { ApiServiceState, SkillStatus } from '../model/types'
import type { AgentInstallTarget } from '../model/skillDistribution'
import { SkillDetails } from './SkillDetails'
@@ -46,26 +53,29 @@ export function ReaderSkillOverview({
<p> HTTP API TraceMemo </p>
</div>
<div className="api-header-actions">
<button type="button" onClick={onPreview} disabled={!skill?.available}>
<Button size="sm" variant="outline" onClick={onPreview} disabled={!skill?.available}>
Skill
</button>
<button
type="button"
className="api-primary-button"
</Button>
<Button
size="sm"
disabled={!service?.running || !skill?.available}
onClick={onCopyInstruction}
>
</button>
<details className="api-skill-more">
<summary></summary>
<button type="button" onClick={onOpenFolder} disabled={!skill?.available}>
</button>
<button type="button" onClick={onOpenGithub}>
GitHub
</button>
</details>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline">
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={onOpenFolder} disabled={!skill?.available}>
</DropdownMenuItem>
<DropdownMenuItem onSelect={onOpenGithub}> GitHub </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{!skill?.available && (
@@ -1,4 +1,5 @@
import { type ReactElement } from 'react'
import { Button } from '../../../components/ui'
import type { SkillStatus } from '../model/types'
export function SkillDetails({
@@ -40,15 +41,15 @@ export function SkillDetails({
</div>
</dl>
<div>
<button type="button" onClick={onOpenFolder} disabled={!skill?.available}>
<Button size="sm" variant="outline" onClick={onOpenFolder} disabled={!skill?.available}>
</button>
<button type="button" onClick={onPreview} disabled={!skill?.available}>
</Button>
<Button size="sm" variant="outline" onClick={onPreview} disabled={!skill?.available}>
SKILL.md
</button>
<button type="button" onClick={onGithub}>
</Button>
<Button size="sm" variant="outline" onClick={onGithub}>
GitHub
</button>
</Button>
</div>
</details>
)
@@ -1,4 +1,5 @@
import { type ReactElement } from 'react'
import { Button } from '../../../components/ui'
import type { ApiServiceState, SkillStatus } from '../model/types'
import { type AgentInstallTarget } from '../model/skillDistribution'
import { SkillTargetSelector } from './SkillTargetSelector'
@@ -52,9 +53,9 @@ export function SkillInstallFlow({
{skill?.available ? '可用' : '不可用'}
</p>
{!service?.running && (
<button type="button" className="api-primary-button" onClick={onStart}>
<Button size="sm" onClick={onStart}>
</button>
</Button>
)}
{!skill?.available && (
<p className="api-inline-error">{skill?.error || '本地 Skill 文件不可用'}</p>
@@ -74,17 +75,12 @@ export function SkillInstallFlow({
<h3></h3>
<p> Agent health </p>
<div className="skill-flow-actions">
<button
type="button"
className="api-primary-button"
disabled={!ready}
onClick={onCopyInstruction}
>
<Button size="sm" disabled={!ready} onClick={onCopyInstruction}>
{buttonLabel[target]}
</button>
<button type="button" disabled={!ready} onClick={onCopyVerification}>
</Button>
<Button size="sm" variant="outline" disabled={!ready} onClick={onCopyVerification}>
</button>
</Button>
</div>
</div>
</section>
@@ -1,4 +1,5 @@
import { type ReactElement } from 'react'
import { SegmentedControl, SegmentedControlItem } from '../../../components/ui'
import { AGENT_INSTALL_TARGETS, type AgentInstallTarget } from '../model/skillDistribution'
export function SkillTargetSelector({
@@ -9,17 +10,17 @@ export function SkillTargetSelector({
onChange: (value: AgentInstallTarget) => void
}): ReactElement {
return (
<div className="skill-target-selector" role="group" aria-label="选择目标 Agent">
<SegmentedControl
className="mt-2"
value={value}
onValueChange={(nextValue) => onChange(nextValue as AgentInstallTarget)}
aria-label="选择目标 Agent"
>
{AGENT_INSTALL_TARGETS.map((target) => (
<button
key={target.value}
type="button"
className={value === target.value ? 'active' : ''}
onClick={() => onChange(target.value)}
>
<SegmentedControlItem key={target.value} value={target.value}>
{target.label}
</button>
</SegmentedControlItem>
))}
</div>
</SegmentedControl>
)
}
@@ -1,5 +1,6 @@
import type { SettingsSelfInfo } from '../model/types'
import type { ConnectionOverviewStatus } from './types'
import { Button, IconButton } from '../../../components/ui'
function CopyIcon(): React.ReactElement {
return (
@@ -69,41 +70,29 @@ export function AccountOverview({
</dl>
<div className="settings-account-actions">
<button
type="button"
className="api-primary-button"
onClick={onCheck}
disabled={isChecking}
>
<Button onClick={onCheck} disabled={isChecking} aria-busy={isChecking}>
{isChecking ? '正在检测...' : '重新检测'}
</button>
<button
type="button"
className="api-secondary-button"
onClick={onOpenDirectory}
disabled={!accountRoot}
>
</Button>
<Button variant="outline" onClick={onOpenDirectory} disabled={!accountRoot}>
</button>
<button type="button" className="api-secondary-button" onClick={onSwitchAccount}>
</Button>
<Button variant="outline" onClick={onSwitchAccount}>
</button>
</Button>
</div>
<div className="settings-account-root">
<span></span>
<div>
<code title={accountRoot || undefined}>{accountRoot || '暂无数据'}</code>
<button
type="button"
className="settings-icon-button"
title="复制账号目录"
aria-label="复制账号目录"
<IconButton
variant="outline"
label="复制账号目录"
onClick={onCopyDirectory}
disabled={!accountRoot}
>
<CopyIcon />
</button>
</IconButton>
</div>
</div>
</section>
@@ -1,4 +1,5 @@
import type { AIProviderSummary, AIRuntimeModelConfig } from '../../../../../shared/ai-provider'
import { Button, Textarea } from '../../../components/ui'
import type { AIVisionTestState } from './types'
export function AIImageUnderstandingTest({
@@ -67,7 +68,7 @@ export function AIImageUnderstandingTest({
<label className="ai-vision-prompt">
<textarea
<Textarea
value={state.prompt}
onChange={(event) => onPromptChange(event.target.value)}
rows={3}
@@ -101,14 +102,17 @@ export function AIImageUnderstandingTest({
) : null}
<footer>
{state.image ? <button onClick={onClear}></button> : null}
<button
className="database-key-primary"
{state.image ? (
<Button variant="outline" onClick={onClear}>
</Button>
) : null}
<Button
disabled={!runtime?.configured || !state.image || testing || !state.prompt.trim()}
onClick={onTest}
>
{testing ? '识别中…' : '开始识别'}
</button>
</Button>
</footer>
</section>
)
@@ -1,4 +1,5 @@
import type { AIProviderSummary } from '../../../../../shared/ai-provider'
import { Button } from '../../../components/ui'
import { PROVIDER_TYPE_LABELS } from './presets'
const STATUS_LABELS = { untested: '未测试', connected: '已连接', error: '连接失败' }
@@ -48,16 +49,18 @@ export function AIProviderCard({
</dl>
{provider.lastError ? <p className="ai-provider-error">{provider.lastError}</p> : null}
<footer>
<button onClick={onEdit}></button>
<button disabled={testing} onClick={onTest}>
<Button variant="outline" size="sm" onClick={onEdit}>
</Button>
<Button variant="outline" size="sm" disabled={testing} onClick={onTest}>
{testing ? '测试中…' : '测试连接'}
</button>
<button disabled={provider.isDefault} onClick={onDefault}>
</Button>
<Button variant="outline" size="sm" disabled={provider.isDefault} onClick={onDefault}>
{provider.isDefault ? '当前默认' : '设为默认'}
</button>
<button className="danger" onClick={onDelete}>
</Button>
<Button variant="destructive" size="sm" onClick={onDelete}>
</button>
</Button>
</footer>
</article>
)
@@ -3,6 +3,19 @@ import type {
AIProviderConfig,
AIProviderType
} from '../../../../../shared/ai-provider'
import {
Button,
Checkbox,
Input,
RadioGroup,
RadioGroupItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea
} from '../../../components/ui'
import { PROVIDER_PRESETS, PROVIDER_TYPE_LABELS } from './presets'
export function AIProviderEditor({
@@ -49,24 +62,31 @@ export function AIProviderEditor({
<h2>{editing ? '编辑供应商' : '新增供应商'}</h2>
<p>API Key </p>
</div>
<button onClick={onCancel}></button>
<Button variant="ghost" size="sm" onClick={onCancel}>
</Button>
</header>
{!editing ? (
<label>
<select value={presetId} onChange={(event) => onPreset(event.target.value)}>
{PROVIDER_PRESETS.map((preset) => (
<option key={preset.id} value={preset.id}>
{preset.label}
</option>
))}
</select>
<Select value={presetId} onValueChange={onPreset}>
<SelectTrigger aria-label="快速模板">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDER_PRESETS.map((preset) => (
<SelectItem key={preset.id} value={preset.id}>
{preset.label}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
) : null}
<div className="ai-provider-form-grid">
<label>
<input
<Input
id="ai-provider-name"
value={provider.name}
onChange={(event) => patch({ name: event.target.value })}
@@ -74,7 +94,7 @@ export function AIProviderEditor({
</label>
<label>
ID
<input
<Input
value={provider.id}
disabled={editing}
onChange={(event) => patch({ id: event.target.value })}
@@ -82,39 +102,49 @@ export function AIProviderEditor({
</label>
<label>
<select
<Select
value={provider.type}
onChange={(event) => patch({ type: event.target.value as AIProviderType })}
onValueChange={(value) => patch({ type: value as AIProviderType })}
>
{Object.entries(PROVIDER_TYPE_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
<SelectTrigger aria-label="供应商类型">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(PROVIDER_TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</label>
<label>
<select
<Select
value={provider.auth.type}
onChange={(event) =>
onValueChange={(value) =>
patch({
auth: {
...provider.auth,
type: event.target.value as AIProviderConfig['auth']['type']
type: value as AIProviderConfig['auth']['type']
}
})
}
>
<option value="bearer">Authorization Bearer</option>
<option value="x-api-key">X-API-Key</option>
<option value="custom-header"> Header</option>
<option value="none"></option>
</select>
<SelectTrigger aria-label="认证方式">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bearer">Authorization Bearer</SelectItem>
<SelectItem value="x-api-key">X-API-Key</SelectItem>
<SelectItem value="custom-header"> Header</SelectItem>
<SelectItem value="none"></SelectItem>
</SelectContent>
</Select>
</label>
<label className="wide">
Base URL
<input
<Input
value={provider.baseUrl}
onChange={(event) => patch({ baseUrl: event.target.value })}
placeholder="https://api.example.com/v1"
@@ -122,7 +152,7 @@ export function AIProviderEditor({
</label>
<label>
API Key
<input
<Input
type="password"
value={provider.apiKey || ''}
onChange={(event) => patch({ apiKey: event.target.value })}
@@ -132,7 +162,7 @@ export function AIProviderEditor({
</label>
<label>
<input
<Input
value={provider.auth.headerName || ''}
disabled={provider.auth.type !== 'custom-header'}
onChange={(event) =>
@@ -145,16 +175,25 @@ export function AIProviderEditor({
<div className="ai-model-table-heading">
<h3></h3>
<button onClick={() => patch({ models: [...provider.models, emptyModel()] })}>
<Button
variant="outline"
size="sm"
onClick={() => patch({ models: [...provider.models, emptyModel()] })}
>
</button>
</Button>
</div>
<div className="ai-model-table">
<RadioGroup
className="ai-model-table"
aria-label="默认模型"
value={provider.defaultModel}
onValueChange={(value) => patch({ defaultModel: value })}
>
{provider.models.map((model, index) => (
<div className="ai-model-row" key={`${index}-${model.id}`}>
<label className="ai-model-identity-field">
<span></span>
<input
<Input
value={model.name}
onChange={(event) => patchModel(index, { name: event.target.value })}
placeholder="例如:DeepSeek Chat"
@@ -162,30 +201,32 @@ export function AIProviderEditor({
</label>
<label className="ai-model-identity-field">
<span> ID</span>
<input
<Input
value={model.id}
onChange={(event) => patchModel(index, { id: event.target.value })}
placeholder="例如:deepseek-chat"
/>
</label>
<label>
<input
type="checkbox"
<label htmlFor={`ai-model-chat-${index}`}>
<Checkbox
id={`ai-model-chat-${index}`}
aria-label={`聊天 ${model.name || index + 1}`}
checked={model.capabilities.chat}
onChange={(event) =>
onCheckedChange={(checked) =>
patchModel(index, {
capabilities: { ...model.capabilities, chat: event.target.checked }
capabilities: { ...model.capabilities, chat: checked === true }
})
}
/>
</label>
<label>
<input
type="checkbox"
<label htmlFor={`ai-model-vision-${index}`}>
<Checkbox
id={`ai-model-vision-${index}`}
aria-label={`图片理解 ${model.name || index + 1}`}
checked={model.capabilities.vision}
onChange={(event) => {
const vision = event.target.checked
onCheckedChange={(checked) => {
const vision = checked === true
// OCR 是 vision 的派生能力:勾 vision 时自动带 OCR
patchModel(index, {
capabilities: {
@@ -198,31 +239,34 @@ export function AIProviderEditor({
/>
</label>
<label title="图片文字识别,跟随图片理解能力">
<input
type="checkbox"
<label htmlFor={`ai-model-ocr-${index}`} title="图片文字识别,跟随图片理解能力">
<Checkbox
id={`ai-model-ocr-${index}`}
aria-label={`图片文字识别 ${model.name || index + 1}`}
checked={model.capabilities.ocr}
onChange={(event) =>
onCheckedChange={(checked) =>
patchModel(index, {
capabilities: { ...model.capabilities, ocr: event.target.checked }
capabilities: { ...model.capabilities, ocr: checked === true }
})
}
/>
</label>
<label>
<input
type="checkbox"
<label htmlFor={`ai-model-long-context-${index}`}>
<Checkbox
id={`ai-model-long-context-${index}`}
aria-label={`长上下文 ${model.name || index + 1}`}
checked={model.capabilities.longContext}
onChange={(event) =>
onCheckedChange={(checked) =>
patchModel(index, {
capabilities: { ...model.capabilities, longContext: event.target.checked }
capabilities: { ...model.capabilities, longContext: checked === true }
})
}
/>
</label>
<input
<Input
aria-label={`最大 Token ${index + 1}`}
type="number"
value={model.maxTokens || ''}
onChange={(event) =>
@@ -230,34 +274,34 @@ export function AIProviderEditor({
}
placeholder="最大 Token"
/>
<label className="ai-model-default">
<input
type="radio"
name="default-model"
checked={provider.defaultModel === model.id}
onChange={() => patch({ defaultModel: model.id })}
<label className="ai-model-default" htmlFor={`ai-default-model-${index}`}>
<RadioGroupItem
id={`ai-default-model-${index}`}
value={model.id}
aria-label={`默认 ${model.name || index + 1}`}
/>
</label>
<button
className="danger"
<Button
variant="destructive"
size="sm"
disabled={provider.models.length === 1}
onClick={() =>
patch({ models: provider.models.filter((_, itemIndex) => itemIndex !== index) })
}
>
</button>
</Button>
</div>
))}
</div>
</RadioGroup>
<details className="ai-provider-advanced">
<summary></summary>
<div>
<label>
ms
<input
<Input
type="number"
value={provider.advanced.timeoutMs}
onChange={(event) =>
@@ -267,7 +311,7 @@ export function AIProviderEditor({
</label>
<label>
Temperature
<input
<Input
type="number"
step="0.1"
value={provider.advanced.temperature ?? ''}
@@ -280,7 +324,7 @@ export function AIProviderEditor({
</label>
<label>
Max Tokens
<input
<Input
type="number"
value={provider.advanced.maxTokens ?? ''}
onChange={(event) =>
@@ -290,7 +334,7 @@ export function AIProviderEditor({
</label>
<label className="wide">
HeadersJSON
<textarea
<Textarea
key={JSON.stringify(provider.advanced.extraHeaders)}
defaultValue={JSON.stringify(provider.advanced.extraHeaders, null, 2)}
onBlur={(event) => {
@@ -314,10 +358,12 @@ export function AIProviderEditor({
<pre>{preview}</pre>
</details>
<footer>
<button onClick={onCancel}></button>
<button className="database-key-primary" disabled={saving} onClick={onSave}>
<Button variant="outline" onClick={onCancel}>
</Button>
<Button disabled={saving} onClick={onSave}>
{saving ? '保存中…' : '保存供应商'}
</button>
</Button>
</footer>
</section>
)
@@ -1,5 +1,6 @@
import type { DatabaseKeyState } from './types'
import { runtimePlatform } from '../../../utils/runtime-environment'
import { Button } from '../../../components/ui'
const PHASES = ['查找微信进程', '识别微信版本', '扫描候选密钥', '验证数据库', '获取完成']
@@ -33,14 +34,9 @@ export function DatabaseKeyAutoDetect({
<strong>Windows </strong>
<p>TraceMemo </p>
</div>
<button
type="button"
className="database-key-secondary"
onClick={onDetect}
disabled={disabled}
>
<Button variant="outline" onClick={onDetect} disabled={disabled}>
{state.status === 'auto-detecting' ? '正在获取…' : '自动获取密钥'}
</button>
</Button>
</div>
<ul className="database-key-prerequisites">
<li className={environment?.wechatRunning ? 'ok' : ''}>
@@ -67,9 +63,14 @@ export function DatabaseKeyAutoDetect({
<strong></strong>
<span>{state.error}</span>
<p></p>
<button type="button" onClick={onRefresh}>
<Button
variant="link"
size="sm"
className="h-auto justify-self-start p-0"
onClick={onRefresh}
>
</button>
</Button>
</div>
)}
</section>
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useRef, useState } from 'react'
import {
AlertDialog,
AlertDialogAction,
@@ -24,6 +24,8 @@ export function DatabaseKeyDangerZone({
}): React.ReactElement {
const [confirming, setConfirming] = useState(false)
const [confirmingReturn, setConfirmingReturn] = useState(false)
const clearTriggerRef = useRef<HTMLButtonElement>(null)
const returnTriggerRef = useRef<HTMLButtonElement>(null)
return (
<>
<section className="database-key-connection-actions">
@@ -33,7 +35,12 @@ export function DatabaseKeyDangerZone({
<strong></strong>
<small></small>
</span>
<Button type="button" variant="outline" onClick={() => setConfirmingReturn(true)}>
<Button
ref={returnTriggerRef}
type="button"
variant="outline"
onClick={() => setConfirmingReturn(true)}
>
</Button>
</div>
@@ -46,6 +53,7 @@ export function DatabaseKeyDangerZone({
<small></small>
</span>
<Button
ref={clearTriggerRef}
type="button"
variant="destructive"
onClick={() => setConfirming(true)}
@@ -65,7 +73,12 @@ export function DatabaseKeyDangerZone({
</div>
</section>
<AlertDialog open={confirming} onOpenChange={setConfirming}>
<AlertDialogContent>
<AlertDialogContent
onCloseAutoFocus={(event) => {
event.preventDefault()
window.queueMicrotask(() => clearTriggerRef.current?.focus())
}}
>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
@@ -85,7 +98,12 @@ export function DatabaseKeyDangerZone({
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={confirmingReturn} onOpenChange={setConfirmingReturn}>
<AlertDialogContent>
<AlertDialogContent
onCloseAutoFocus={(event) => {
event.preventDefault()
window.queueMicrotask(() => returnTriggerRef.current?.focus())
}}
>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
@@ -1,5 +1,6 @@
import type { DatabaseKeyState } from './types'
import { formatValidationTime, isDatabaseKeyFormatValid } from './utils'
import { Button } from '../../../components/ui'
export function DatabaseKeyDiagnostics({
state,
@@ -57,9 +58,9 @@ export function DatabaseKeyDiagnostics({
<dd>{state.environment?.autoDetectSupported ? '支持' : '不支持'}</dd>
</div>
</dl>
<button type="button" onClick={onCopy}>
<Button variant="ghost" size="sm" className="mx-4 mb-4" onClick={onCopy}>
</button>
</Button>
</details>
)
}
@@ -81,22 +81,10 @@ export function DatabaseKeyEditor({
</div>
<p></p>
<div className="database-key-actions">
<Button
variant="secondary"
type="button"
className="database-key-secondary"
onClick={onValidate}
disabled={disabled || !value}
>
<Button variant="outline" type="button" onClick={onValidate} disabled={disabled || !value}>
</Button>
<Button
variant="default"
type="button"
className="database-key-primary"
onClick={onSave}
disabled={disabled || !canSave}
>
<Button variant="default" type="button" onClick={onSave} disabled={disabled || !canSave}>
</Button>
</div>
@@ -1,6 +1,7 @@
import type { SettingsSelfInfo } from '../model/types'
import type { DatabaseKeyState } from './types'
import { formatValidationTime } from './utils'
import { Button } from '../../../components/ui'
export function DatabaseKeyStatus({
state,
@@ -45,14 +46,9 @@ export function DatabaseKeyStatus({
</dd>
</div>
</dl>
<button
type="button"
className="database-key-secondary"
onClick={onValidate}
disabled={disabled}
>
<Button variant="outline" onClick={onValidate} disabled={disabled}>
</button>
</Button>
</section>
)
}
@@ -1,3 +1,4 @@
import { Button } from '../../../components/ui'
import type { ImageDecryptionState } from './types'
const PHASE_LABELS = {
@@ -46,13 +47,11 @@ export function AutoDetectImageKeySection({
? '扫描本机微信缓存并通过图片模板验证候选密钥。'
: '扫描微信进程内存并通过本地图片模板验证候选密钥。'}
<br />
<small className="image-auto-scope-hint">
WeChat 4.0V3
</small>
<small className="image-auto-scope-hint"> WeChat 4.0V3 </small>
</p>
</div>
<button
className="database-key-secondary"
<Button
variant="outline"
disabled={
disabled ||
autoBusy ||
@@ -63,7 +62,7 @@ export function AutoDetectImageKeySection({
onClick={onDetect}
>
{state.autoPhase === 'scanning' ? '扫描中…' : '开始自动获取'}
</button>
</Button>
</div>
<ul>
<li className={state.status.wechatRunning ? 'ok' : ''}>
@@ -95,9 +94,9 @@ export function AutoDetectImageKeySection({
<strong></strong>
<span>{state.autoAccount || '当前微信账号'}</span>
<span></span>
<button className="database-key-primary" disabled={!canSave} onClick={onSave}>
<Button disabled={!canSave} onClick={onSave}>
{state.autoPhase === 'saved' ? '已保存' : '保存图片密钥'}
</button>
</Button>
</div>
) : null}
</section>
@@ -1,4 +1,5 @@
import type { SettingsSelfInfo } from '../model/types'
import { Button } from '../../../components/ui'
import type { ImageDecryptionState } from './types'
import { formatImageConfigTime } from './utils'
@@ -54,9 +55,9 @@ export function ImageDecryptStatus({
</div>
</dl>
{state.config?.configured ? (
<button className="database-key-secondary" disabled={disabled} onClick={onValidate}>
<Button variant="outline" disabled={disabled} onClick={onValidate}>
</button>
</Button>
) : null}
</section>
)
@@ -1,5 +1,18 @@
import { useEffect, useMemo, useState } from 'react'
import type { Contact } from '../../../../../shared/types'
import {
Button,
Input,
SegmentedControl,
SegmentedControlItem,
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue
} from '../../../components/ui'
import type { ImageBatchTestItemStatus, ImageBatchTestState, ImageDecryptionState } from './types'
type StepState = 'pending' | 'ok' | 'fail' | 'skipped'
@@ -201,17 +214,18 @@ export function ImageTestSection({
</span>
</span>
<button
className="database-key-secondary image-test-copy"
disabled={!result?.diagnosticLog}
onClick={onCopyLog}
>
<Button variant="outline" size="sm" disabled={!result?.diagnosticLog} onClick={onCopyLog}>
</button>
</Button>
</div>
</div>
<div className="image-test-filter-bar" aria-label="会话类型筛选">
<SegmentedControl
className="mt-[18px]"
aria-label="会话类型筛选"
value={contactFilter}
onValueChange={(value) => setContactFilter(value as ContactFilter)}
>
{(
[
['all', '全部'],
@@ -219,23 +233,15 @@ export function ImageTestSection({
['user', '联系人']
] as const
).map(([value, label]) => (
<button
key={value}
type="button"
className={contactFilter === value ? 'active' : ''}
aria-pressed={contactFilter === value}
disabled={disabled}
onClick={() => setContactFilter(value)}
>
<SegmentedControlItem key={value} value={value} disabled={disabled}>
{label} {contactCounts[value]}
</button>
</SegmentedControlItem>
))}
</div>
</SegmentedControl>
<label htmlFor="image-test-search"></label>
<input
<Input
id="image-test-search"
className="image-test-search"
type="search"
value={searchValue}
disabled={disabled}
@@ -244,61 +250,59 @@ export function ImageTestSection({
/>
<label htmlFor="image-test-chat"></label>
<select
id="image-test-chat"
value={state.selectedUserMd5}
disabled={disabled}
onChange={(event) => onSelect(event.target.value)}
>
<option value=""></option>
{filteredGroups.length > 0 && (
<optgroup label={`群聊(${filteredGroups.length}`}>
{filteredGroups.map((contact) => (
<option key={contact.md5} value={contact.md5}>
{contactName(contact)}
</option>
))}
</optgroup>
)}
{filteredUsers.length > 0 && (
<optgroup label={`联系人(${filteredUsers.length}`}>
{filteredUsers.map((contact) => (
<option key={contact.md5} value={contact.md5}>
{contactName(contact)}
</option>
))}
</optgroup>
)}
</select>
<Select value={state.selectedUserMd5} disabled={disabled} onValueChange={onSelect}>
<SelectTrigger id="image-test-chat">
<SelectValue placeholder="请选择包含图片的会话" />
</SelectTrigger>
<SelectContent>
{filteredGroups.length > 0 && (
<SelectGroup>
<SelectLabel>{`群聊(${filteredGroups.length}`}</SelectLabel>
{filteredGroups.map((contact) => (
<SelectItem key={contact.md5} value={contact.md5}>
{contactName(contact)}
</SelectItem>
))}
</SelectGroup>
)}
{filteredUsers.length > 0 && (
<SelectGroup>
<SelectLabel>{`联系人(${filteredUsers.length}`}</SelectLabel>
{filteredUsers.map((contact) => (
<SelectItem key={contact.md5} value={contact.md5}>
{contactName(contact)}
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
{filteredContacts.length === 0 && <p className="image-test-empty"></p>}
<div className="image-test-actions">
<button
className="database-key-primary"
disabled={disabled || !state.selectedUserMd5}
onClick={onTest}
>
<Button disabled={disabled || !state.selectedUserMd5} onClick={onTest}>
{state.phase === 'testing' ? '正在测试…' : '测试图片解析'}
</button>
<button className="database-key-secondary" disabled={!canSave} onClick={onSave}>
</Button>
<Button variant="outline" disabled={!canSave} onClick={onSave}>
</button>
</Button>
{batchTest.running ? (
<button
className="database-key-secondary image-batch-stop"
<Button
variant="outline"
className="image-batch-stop"
disabled={batchTest.stopRequested}
onClick={onStopBatchTest}
>
{batchTest.stopRequested ? '正在停止…' : '停止测试'}
</button>
</Button>
) : (
<button
className="database-key-secondary"
<Button
variant="outline"
disabled={disabled || filteredContacts.length === 0}
onClick={handleBatchTest}
>
{batchButtonLabel}
</button>
</Button>
)}
</div>
<p className="image-batch-warning">
@@ -1,5 +1,6 @@
import { useEffect, useRef } from 'react'
import type { AIRuntimeModelConfig } from '../../../../../shared/ai-provider'
import { Button } from '../../../components/ui'
import { AIProviderCard } from '../ai-model/AIProviderCard'
import { AIProviderEditor } from '../ai-model/AIProviderEditor'
import { AIImageUnderstandingTest } from '../ai-model/AIImageUnderstandingTest'
@@ -39,9 +40,7 @@ export function AIModelPage({
<h1>AI </h1>
<p></p>
</div>
<button className="database-key-primary" onClick={openNewProvider}>
</button>
<Button onClick={openNewProvider}></Button>
</header>
<div className="settings-page-scroll">
<div className="settings-page-content ai-model-content">
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import type { AppUpdateState } from '../../../../../shared/app-update'
import { Button } from '../../../components/ui'
const REPOSITORY_URL = 'https://github.com/Wxw-Gu/WechatExplorer'
const RELEASES_URL = `${REPOSITORY_URL}/releases`
@@ -10,8 +11,15 @@ function formatBytes(value?: number): string {
return `${(value / 1024 / 1024).toFixed(1)} MB/s`
}
export function AboutPage({ onNotice }: { onNotice: (message: string) => void }): React.ReactElement {
const [update, setUpdate] = useState<AppUpdateState>({ status: 'idle', currentVersion: '读取中...' })
export function AboutPage({
onNotice
}: {
onNotice: (message: string) => void
}): React.ReactElement {
const [update, setUpdate] = useState<AppUpdateState>({
status: 'idle',
currentVersion: '读取中...'
})
const [busy, setBusy] = useState(false)
useEffect(() => {
@@ -60,30 +68,57 @@ export function AboutPage({ onNotice }: { onNotice: (message: string) => void })
<div className="settings-page-scroll">
<div className="settings-page-content">
<section className="settings-card about-identity-card">
<div><span className="settings-card-kicker"></span><strong>TraceMemo</strong><small>v{update.currentVersion}</small></div>
<a href={REPOSITORY_URL} target="_blank" rel="noreferrer">GitHub </a>
<div>
<span className="settings-card-kicker"></span>
<strong>TraceMemo</strong>
<small>v{update.currentVersion}</small>
</div>
<a href={REPOSITORY_URL} target="_blank" rel="noreferrer">
GitHub
</a>
</section>
<h2 className="settings-section-heading"></h2>
<section className={`settings-card update-card status-${update.status}`}>
<div className="update-card-copy">
<strong>{update.status === 'available' || update.status === 'downloaded' ? `发现 v${update.version}` : update.message || '检查 GitHub Releases 获取最新版本'}</strong>
<strong>
{update.status === 'available' || update.status === 'downloaded'
? `发现 v${update.version}`
: update.message || '检查 GitHub Releases 获取最新版本'}
</strong>
<span>
{update.status === 'downloading'
? `正在下载 ${Math.round(update.percent || 0)}% · ${formatBytes(update.bytesPerSecond)}`
: '会根据当前系统和 CPU 自动选择对应安装包,安装前会等待你的确认。'}
</span>
{update.status === 'downloading' && <div className="update-progress"><i style={{ width: `${update.percent || 0}%` }} /></div>}
{update.status === 'downloading' && (
<div className="update-progress">
<i style={{ width: `${update.percent || 0}%` }} />
</div>
)}
</div>
<button type="button" className="settings-primary-button" disabled={busy || update.status === 'checking' || update.status === 'downloading'} onClick={() => void runUpdate()}>{action}</button>
<Button
size="sm"
disabled={busy || update.status === 'checking' || update.status === 'downloading'}
aria-busy={busy || update.status === 'checking' || update.status === 'downloading'}
onClick={() => void runUpdate()}
>
{action}
</Button>
</section>
<h2 className="settings-section-heading"></h2>
<section className="settings-card about-links-card">
<a href={RELEASES_URL} target="_blank" rel="noreferrer"></a>
<button type="button" onClick={() => void window.api.revealAppLog()}></button>
<a href={RELEASES_URL} target="_blank" rel="noreferrer">
</a>
<Button variant="outline" size="sm" onClick={() => void window.api.revealAppLog()}>
</Button>
</section>
<p className="settings-footnote"> AI </p>
<p className="settings-footnote">
AI
</p>
</div>
</div>
</div>
@@ -6,6 +6,7 @@ import { useAccountDatabaseController } from '../account-database/useAccountData
import type { ConnectionOverviewStatus } from '../account-database/types'
import type { SettingsSelfInfo } from '../model/types'
import type { WechatAccountCandidate } from '../../../../../shared/database-key'
import { Button, Switch } from '../../../components/ui'
const STATUS_LABELS: Record<ConnectionOverviewStatus, string> = {
checking: '正在检测',
@@ -126,13 +127,9 @@ export function AccountDatabasePage({
</span>
</button>
))}
<button
type="button"
className="api-secondary-button"
onClick={() => setSwitching(false)}
>
<Button variant="outline" onClick={() => setSwitching(false)}>
</button>
</Button>
</section>
)}
<h2 className="settings-section-heading"></h2>
@@ -151,10 +148,10 @@ export function AccountDatabasePage({
<b></b>
<small>使</small>
</span>
<input
type="checkbox"
<Switch
checked={autoLogin}
onChange={(event) => void changeAutoLogin(event.target.checked)}
onCheckedChange={(checked) => void changeAutoLogin(checked)}
aria-label="启动时自动连接数据库"
/>
</label>
</section>
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react'
import { Button, Switch } from '../../../components/ui'
export function AdvancedPage({
onNotice
@@ -40,16 +41,16 @@ export function AdvancedPage({
<b></b>
<small></small>
</span>
<input
type="checkbox"
<Switch
checked={debugEnabled}
onChange={(event) => void changeDebugEnabled(event.target.checked)}
onCheckedChange={(checked) => void changeDebugEnabled(checked)}
aria-label="显示诊断日志"
/>
</label>
<div className="settings-debug-actions">
<button type="button" onClick={() => void window.api.revealAppLog()}>
<Button variant="outline" size="sm" onClick={() => void window.api.revealAppLog()}>
</button>
</Button>
<small></small>
</div>
</section>
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from 'react'
import type { CacheSummary } from '../../../../../shared/cache'
import { Button } from '../../../components/ui'
const SEARCH_CACHE_KEYS = [
'wxe_ai_search_cache_v8',
@@ -84,9 +85,9 @@ export function CacheCleanupPage({
<h1></h1>
<p></p>
</div>
<button type="button" className="settings-header-action" onClick={() => void refresh()}>
<Button variant="outline" size="sm" onClick={() => void refresh()}>
</button>
</Button>
</header>
<div className="settings-page-scroll">
<div className="settings-page-content">
@@ -96,14 +97,15 @@ export function CacheCleanupPage({
<strong>{formatBytes(summary?.totalBytes || 0)}</strong>
<small></small>
</div>
<button
type="button"
className="settings-danger-button"
<Button
variant="destructive"
size="sm"
disabled={busyScope !== null}
aria-busy={busyScope === 'all'}
onClick={() => void clear('all')}
>
{busyScope === 'all' ? '清理中...' : '清理全部'}
</button>
</Button>
</section>
<h2 className="settings-section-heading"></h2>
@@ -119,21 +121,25 @@ export function CacheCleanupPage({
</div>
<div className="settings-cache-actions">
{item.id === 'knowledge' && (
<button
type="button"
<Button
variant="outline"
size="sm"
disabled={busyScope !== null}
aria-busy={busyScope === 'knowledge-directory'}
onClick={() => void openKnowledge()}
>
{busyScope === 'knowledge-directory' ? '打开中...' : '打开文件夹'}
</button>
</Button>
)}
<button
type="button"
<Button
variant="outline"
size="sm"
disabled={busyScope !== null}
aria-busy={busyScope === item.id}
onClick={() => void clear(item.id)}
>
{busyScope === item.id ? '清理中...' : '清理'}
</button>
</Button>
</div>
</section>
))}
@@ -143,9 +149,15 @@ export function CacheCleanupPage({
<p></p>
<small></small>
</div>
<button type="button" disabled={busyScope !== null} onClick={clearLocal}>
<Button
variant="outline"
size="sm"
disabled={busyScope !== null}
aria-busy={busyScope === 'local'}
onClick={clearLocal}
>
{busyScope === 'local' ? '清理中...' : '清理'}
</button>
</Button>
</section>
</div>
@@ -1,6 +1,11 @@
import { useEffect, useState } from 'react'
import { Switch } from '../../../components/ui'
export function RecallProtectionPage({ onNotice }: { onNotice: (message: string) => void }): React.ReactElement {
export function RecallProtectionPage({
onNotice
}: {
onNotice: (message: string) => void
}): React.ReactElement {
const [enabled, setEnabled] = useState(false)
useEffect(() => {
@@ -37,10 +42,10 @@ export function RecallProtectionPage({ onNotice }: { onNotice: (message: string)
<b></b>
<small>便</small>
</span>
<input
type="checkbox"
<Switch
checked={enabled}
onChange={(event) => void changeEnabled(event.target.checked)}
onCheckedChange={(checked) => void changeEnabled(checked)}
aria-label="开启防撤回"
/>
</label>
<aside className="settings-recall-notice">
@@ -11,7 +11,13 @@ import {
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
DialogTitle,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '../../../components/ui'
import { isMac, isWindows } from '../../../utils/runtime-environment'
@@ -611,52 +617,57 @@ export function TextToSpeechPage({
<div className="tts-runtime-actions">
{personalWechatRuntimeSupported && runtimeStatus?.state === 'downloading' ? (
<button type="button" onClick={() => void cancelRuntimeDownload()}>
<Button variant="outline" size="sm" onClick={() => void cancelRuntimeDownload()}>
</button>
</Button>
) : personalWechatRuntimeSupported && runtimeStatus?.state === 'ready' ? (
<>
{runtimeStatus.directory ? (
<button type="button" onClick={() => void openRuntimeDirectory()}>
<Button
variant="outline"
size="sm"
onClick={() => void openRuntimeDirectory()}
>
</button>
</Button>
) : null}
{runtimeStatus.removable ? (
<button
type="button"
className="settings-danger-button"
<Button
variant="destructive"
size="sm"
disabled={runtimeBusy}
onClick={() => void removeRuntime()}
>
</button>
</Button>
) : null}
</>
) : personalWechatRuntimeSupported ? (
<button
type="button"
className="settings-primary-button"
disabled={runtimeBusy}
onClick={() => void downloadRuntime()}
>
<Button size="sm" disabled={runtimeBusy} onClick={() => void downloadRuntime()}>
{runtimeStatus?.state === 'invalid' || runtimeStatus?.state === 'error'
? '重新下载'
: '下载组件'}
</button>
</Button>
) : null}
<button type="button" disabled={runtimeBusy} onClick={() => void refreshRuntime()}>
<Button
variant="outline"
size="sm"
disabled={runtimeBusy}
onClick={() => void refreshRuntime()}
>
</button>
</Button>
{personalWechatRuntimeSupported ? (
<button
type="button"
<Button
variant="outline"
size="sm"
onClick={(event) => {
wechatVersionsTriggerRef.current = event.currentTarget
setShowWechatVersions(true)
}}
>
</button>
</Button>
) : null}
</div>
@@ -686,14 +697,14 @@ export function TextToSpeechPage({
<strong>API Key</strong>
<p></p>
</div>
<button type="button" onClick={() => void openApiKeys()}>
<Button variant="outline" size="sm" onClick={() => void openApiKeys()}>
api.fish.audio Key
</button>
</Button>
</div>
<label className="tts-api-input">
<span>API Key</span>
<div>
<input
<Input
type={showApiKey ? 'text' : 'password'}
value={apiKey}
disabled={savingKey || !settings?.encryptionAvailable}
@@ -707,42 +718,47 @@ export function TextToSpeechPage({
autoComplete="off"
onChange={(event) => setApiKey(event.target.value)}
/>
<button type="button" onClick={() => setShowApiKey((current) => !current)}>
<Button variant="outline" onClick={() => setShowApiKey((current) => !current)}>
{showApiKey ? '隐藏' : '显示'}
</button>
<button
type="button"
</Button>
<Button
className="tts-save-key-button"
disabled={!apiKey.trim() || savingKey || !settings?.encryptionAvailable}
onClick={() => void saveApiKey()}
>
{savingKey ? '保存中…' : '保存 Key'}
</button>
</Button>
</div>
</label>
<div className="tts-api-footer">
<span>{keyStatusText}</span>
{settings?.hasStoredApiKey ? (
<button
type="button"
className="settings-danger-button"
<Button
variant="destructive"
size="sm"
disabled={savingKey}
onClick={() => void clearApiKey()}
>
Key
</button>
</Button>
) : null}
</div>
<label className="tts-model-select">
<div className="tts-model-select">
<span></span>
<select
<Select
value={settings?.model || 's2.1-pro-free'}
disabled={!settings}
onChange={(event) => void changeModel(event.target.value as TextToSpeechModel)}
onValueChange={(value) => void changeModel(value as TextToSpeechModel)}
>
<option value="s2.1-pro-free">s2.1-pro-free</option>
<option value="s2.1-pro">s2.1-pro</option>
</select>
</label>
<SelectTrigger aria-label="合成模型">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="s2.1-pro-free">s2.1-pro-free</SelectItem>
<SelectItem value="s2.1-pro">s2.1-pro</SelectItem>
</SelectContent>
</Select>
</div>
</section>
<div className="tts-voice-heading">
@@ -760,16 +776,22 @@ export function TextToSpeechPage({
}}
>
<span aria-hidden></span>
<input
<Input
type="search"
value={query}
aria-label="按音色名称搜索"
placeholder="按音色名称搜索"
disabled={!settings?.hasApiKey || loadingVoices}
onChange={(event) => setQuery(event.target.value)}
/>
<button type="submit" disabled={!settings?.hasApiKey || loadingVoices}>
<Button
type="submit"
variant="outline"
size="sm"
disabled={!settings?.hasApiKey || loadingVoices}
>
</button>
</Button>
</form>
</div>
@@ -870,14 +892,14 @@ export function TextToSpeechPage({
{savingVoiceId === voice.id ? '…' : selected ? '✓' : ''}
</span>
</button>
<button
type="button"
className="tts-voice-preview"
<Button
variant="outline"
size="sm"
disabled={!voice.previewUrl}
onClick={() => playPreview(voice)}
>
{playingVoiceId === voice.id ? '停止' : '试听'}
</button>
</Button>
</article>
)
})}
@@ -891,14 +913,14 @@ export function TextToSpeechPage({
</div>
) : null}
{hasMore ? (
<button
type="button"
<Button
variant="outline"
className="tts-load-more"
disabled={loadingVoices}
onClick={() => void loadVoices(pageNumber + 1, appliedQuery, true, selectedTags)}
>
</button>
</Button>
) : null}
{error ? <p className="tts-settings-error">{error}</p> : null}
</div>
@@ -6,6 +6,19 @@ import type {
VoiceBatchRange,
VoiceModelStatus
} from '../../../../../shared/voice-recognition'
import {
Button,
Checkbox,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Tabs,
TabsList,
TabsTrigger
} from '../../../components/ui'
const SENSEVOICE_URL = 'https://github.com/FunAudioLLM/SenseVoice'
const SHERPA_URL = 'https://github.com/k2-fsa/sherpa-onnx'
@@ -396,38 +409,37 @@ export function VoiceRecognitionPage({
</div>
<div className="voice-model-actions">
{status?.state === 'downloading' ? (
<button type="button" onClick={() => void cancelDownload()}>
<Button variant="outline" size="sm" onClick={() => void cancelDownload()}>
</button>
</Button>
) : status?.state === 'ready' ? (
<>
<button type="button" onClick={() => void openDirectory()}>
<Button variant="outline" size="sm" onClick={() => void openDirectory()}>
</button>
<button
type="button"
className="settings-danger-button"
</Button>
<Button
variant="destructive"
size="sm"
disabled={busy}
onClick={() => void removeModel()}
>
</button>
</Button>
</>
) : (
<button
type="button"
className="settings-primary-button"
<Button
size="sm"
disabled={busy || !status?.supported}
onClick={() => void download()}
>
{status?.state === 'invalid' || status?.state === 'error'
? '重新下载模型'
: '下载模型'}
</button>
</Button>
)}
<button type="button" disabled={busy} onClick={() => void refresh()}>
<Button variant="outline" size="sm" disabled={busy} onClick={() => void refresh()}>
</button>
</Button>
</div>
{status?.state === 'downloading' && (
<div className="voice-model-progress">
@@ -460,29 +472,21 @@ export function VoiceRecognitionPage({
<div className="voice-batch-workspace">
<div className="voice-conversation-picker">
<div className="voice-conversation-toolbar">
<div className="voice-conversation-tabs" role="tablist" aria-label="会话类别">
<button
type="button"
role="tab"
aria-selected={conversationCategory === 'group'}
className={conversationCategory === 'group' ? 'active' : ''}
onClick={() => setConversationCategory('group')}
>
{' '}
<span>{contacts.filter((contact) => contact.type === 'group').length}</span>
</button>
<button
type="button"
role="tab"
aria-selected={conversationCategory === 'user'}
className={conversationCategory === 'user' ? 'active' : ''}
onClick={() => setConversationCategory('user')}
>
{' '}
<span>{contacts.filter((contact) => contact.type === 'user').length}</span>
</button>
</div>
<input
<Tabs
value={conversationCategory}
onValueChange={(value) => setConversationCategory(value as 'group' | 'user')}
>
<TabsList aria-label="会话类别">
<TabsTrigger value="group">
{contacts.filter((contact) => contact.type === 'group').length}
</TabsTrigger>
<TabsTrigger value="user">
{contacts.filter((contact) => contact.type === 'user').length}
</TabsTrigger>
</TabsList>
</Tabs>
<Input
className="h-8 w-[min(190px,42%)]"
type="search"
value={conversationQuery}
aria-label="搜索会话"
@@ -501,11 +505,10 @@ export function VoiceRecognitionPage({
className={`voice-conversation-row ${selected ? 'selected' : ''}`}
key={contact.md5}
>
<input
type="checkbox"
<Checkbox
checked={selected}
disabled={Boolean(batchRunning)}
onChange={() => toggleConversation(contact.md5)}
onCheckedChange={() => toggleConversation(contact.md5)}
/>
<span className="voice-conversation-avatar" aria-hidden>
{contactName(contact).slice(0, 1)}
@@ -531,47 +534,51 @@ export function VoiceRecognitionPage({
{pageCount > 1 && (
<div className="voice-conversation-pagination" aria-label="会话分页">
<button
type="button"
<Button
variant="ghost"
size="sm"
aria-label="上一页"
disabled={conversationPage === 0 || Boolean(batchRunning)}
onClick={() => setConversationPage((current) => current - 1)}
>
</button>
</Button>
<span>
{conversationPage + 1} / {pageCount}
</span>
<button
type="button"
<Button
variant="ghost"
size="sm"
aria-label="下一页"
disabled={conversationPage >= pageCount - 1 || Boolean(batchRunning)}
onClick={() => setConversationPage((current) => current + 1)}
>
</button>
</Button>
</div>
)}
</div>
<aside className="voice-batch-summary" aria-label="本次转写">
<label className="voice-batch-field">
<div className="voice-batch-field">
<span></span>
<select
aria-label="语音转写时间范围"
<Select
value={batchRange}
disabled={Boolean(batchRunning)}
onChange={(event) =>
setBatchRange(event.currentTarget.value as VoiceBatchRange)
}
onValueChange={(value) => setBatchRange(value as VoiceBatchRange)}
>
{Object.entries(BATCH_RANGE_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
<SelectTrigger aria-label="语音转写时间范围">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(BATCH_RANGE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<dl className="voice-batch-selection-stats">
<div>
<dt></dt>
@@ -596,23 +603,21 @@ export function VoiceRecognitionPage({
</p>
)}
<div className="voice-batch-summary-actions">
<button
type="button"
className="settings-primary-button"
<Button
disabled={
batchBusy || !selectedConversationIds.length || status?.state !== 'ready'
}
onClick={() => void startBatch()}
>
</button>
<button
type="button"
</Button>
<Button
variant="outline"
disabled={batchBusy || !selectedConversationIds.length || Boolean(batchRunning)}
onClick={() => setSelectedConversationIds([])}
>
</button>
</Button>
</div>
</aside>
</div>
@@ -641,14 +646,19 @@ export function VoiceRecognitionPage({
{(batchRunning || batchProgress?.state === 'partially_failed') && (
<div className="voice-batch-actions">
{batchRunning && (
<button type="button" disabled={batchBusy} onClick={() => void cancelBatch()}>
<Button
variant="outline"
size="sm"
disabled={batchBusy}
onClick={() => void cancelBatch()}
>
</button>
</Button>
)}
{batchProgress?.state === 'partially_failed' && (
<button type="button" disabled={batchBusy} onClick={() => void retryFailed()}>
<Button size="sm" disabled={batchBusy} onClick={() => void retryFailed()}>
</button>
</Button>
)}
</div>
)}
+8 -123
View File
@@ -118,6 +118,10 @@
padding-bottom: 16px;
border-bottom: 1px solid var(--wxex-border);
}
.api-workspace-heading > div:first-child {
min-width: 0;
flex: 1 1 auto;
}
.api-title-line {
display: flex;
flex-wrap: wrap;
@@ -154,34 +158,13 @@
}
.api-header-actions,
.api-tester-actions,
.api-runtime-actions,
.api-skill-file-actions {
.api-runtime-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.api-header-actions button,
.api-tester-actions button,
.api-runtime-actions button,
.api-endpoint-row button,
.api-response-summary + pre + p + button,
.api-skill-file-actions button {
min-height: 32px;
padding: 0 11px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 12px/18px var(--wxex-font);
}
.api-header-actions button:hover,
.api-tester-actions button:hover,
.api-runtime-actions button:hover,
.api-endpoint-row button:hover,
.api-skill-file-actions button:hover {
border-color: var(--wxex-brand);
color: var(--wxex-brand);
.api-header-actions {
flex: 0 0 auto;
}
.api-primary-button {
border-color: var(--wxex-brand) !important;
@@ -322,43 +305,6 @@
gap: 8px;
margin-top: 10px;
}
.skill-flow-actions button,
.api-skill-details button {
min-height: 30px;
padding: 0 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: #fff;
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 12px/18px var(--wxex-font);
}
.skill-flow-actions button:disabled,
.api-skill-details button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.skill-target-selector {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.skill-target-selector button {
min-height: 30px;
padding: 0 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: #fff;
color: var(--wxex-text-secondary);
cursor: pointer;
font: 600 12px/18px var(--wxex-font);
}
.skill-target-selector button.active {
border-color: var(--wxex-brand);
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
.api-skill-details {
margin-top: 16px;
border: 1px solid var(--wxex-border);
@@ -388,37 +334,6 @@
margin: 0;
color: var(--wxex-text-primary);
}
.api-skill-more {
position: relative;
}
.api-skill-more summary {
display: grid;
min-height: 32px;
place-items: center;
padding: 0 11px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 12px/18px var(--wxex-font);
list-style: none;
}
.api-skill-more[open] > button {
position: absolute;
z-index: 2;
top: 36px;
right: 0;
width: 170px;
padding: 9px 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: #fff;
color: var(--wxex-text-primary);
box-shadow: var(--wxex-shadow-popover);
font: 12px/18px var(--wxex-font);
text-align: left;
}
.api-section-heading {
display: flex;
align-items: baseline;
@@ -472,11 +387,6 @@
display: flex;
gap: 4px;
}
.api-endpoint-row button {
min-height: 26px;
padding: 0 7px;
font-size: 11px;
}
.api-method {
display: inline-block;
color: var(--wxex-brand);
@@ -524,30 +434,9 @@
color: var(--wxex-danger);
font-size: 11px;
}
.api-param-grid input,
.api-json-input textarea {
box-sizing: border-box;
width: 100%;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
outline: none;
background: #fff;
color: var(--wxex-text-primary);
font: 12px/18px var(--wxex-font);
}
.api-param-grid input {
height: 34px;
padding: 0 9px;
}
.api-json-input {
margin-top: 10px;
}
.api-json-input textarea {
min-height: 180px;
padding: 10px;
resize: vertical;
font-family: ui-monospace, monospace;
}
.api-tester-actions {
justify-content: flex-end;
margin-top: 12px;
@@ -619,10 +508,6 @@
display: grid;
grid-template-columns: 1fr 1fr;
}
.api-runtime-actions button {
min-height: 30px;
padding: 0 6px;
}
.api-security-warning {
margin: 12px 16px;
padding: 9px 10px;
@@ -645,7 +530,7 @@
monospace;
user-select: text;
}
.api-token-section .api-runtime-actions button:last-child {
.api-token-section .api-runtime-actions > :last-child {
grid-column: 1 / -1;
}
.api-response-summary {
+24 -472
View File
@@ -101,134 +101,22 @@
-webkit-app-region: no-drag;
}
.chat-icon-button,
.chat-tool-button,
.chat-ai-button {
box-sizing: border-box;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid transparent;
border-radius: var(--wxex-radius-md);
background: transparent;
color: var(--wxex-text-secondary);
cursor: pointer;
font: 13px/18px var(--wxex-font);
}
.chat-archive-header.is-searching {
.chat-title-block {
display: none;
}
.chat-icon-button {
width: 32px;
padding: 0;
}
.chat-tool-button {
padding: 0 9px;
border-color: var(--wxex-border);
background: var(--wxex-bg-elevated);
}
.chat-tool-button-wrapper {
position: relative;
display: inline-flex;
}
.chat-tool-button-wrapper .chat-tool-button:disabled {
pointer-events: none;
}
.chat-tool-button-wrapper::after {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 20;
width: max-content;
max-width: min(240px, calc(100vw - 24px));
padding: 6px 9px;
border: 1px solid var(--wxex-border);
border-radius: 6px;
background: var(--wxex-text-primary);
color: var(--wxex-bg-elevated);
content: attr(title);
font: 12px/17px var(--wxex-font);
opacity: 0;
pointer-events: none;
text-align: left;
transform: translateY(-3px);
transition:
opacity 0.15s ease,
transform 0.15s ease;
white-space: normal;
}
.chat-tool-button-wrapper:hover::after,
.chat-tool-button-wrapper:focus-visible::after,
.chat-tool-button-wrapper:focus-within::after {
opacity: 1;
transform: translateY(0);
}
.chat-icon-button:hover,
.chat-tool-button:hover {
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
.chat-tool-button:disabled,
.chat-ai-button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.chat-ai-button {
padding: 0 12px;
border-color: var(--wxex-ai);
background: var(--wxex-ai);
color: #ffffff;
font-weight: 600;
}
.chat-ai-button:hover:not(:disabled) {
background: #5a60c6;
.chat-header-actions {
width: 100%;
}
}
.personal-wechat-send-dialog {
> footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
> footer {
justify-content: flex-end;
button {
min-height: 36px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
padding: 0 15px;
cursor: pointer;
font: 13px/18px var(--wxex-font);
}
button.secondary {
background: var(--wxex-bg-elevated);
color: var(--wxex-text-secondary);
}
button.primary {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
color: #fff;
font-weight: 600;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
gap: 12px;
}
}
@@ -345,15 +233,6 @@
color: #b15338;
word-break: break-all;
}
button {
border: 0;
background: transparent;
color: var(--wxex-brand);
cursor: pointer;
font: 12px/18px var(--wxex-font);
white-space: nowrap;
}
}
.personal-wechat-send-status-actions {
@@ -396,33 +275,7 @@
}
.personal-wechat-send-mode {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px;
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-main);
padding: 4px;
button {
min-height: 34px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--wxex-text-secondary);
cursor: pointer;
font: 600 13px/18px var(--wxex-font);
}
button.active {
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
box-shadow: 0 1px 4px rgba(18, 31, 26, 0.1);
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.personal-wechat-send-status-dot {
@@ -453,21 +306,7 @@
}
textarea {
box-sizing: border-box;
width: 100%;
resize: vertical;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
outline: 0;
background: var(--wxex-bg-main);
color: var(--wxex-text-primary);
padding: 10px 12px;
font: 14px/1.6 var(--wxex-font);
&:focus {
border-color: var(--wxex-brand);
box-shadow: 0 0 0 3px rgba(36, 122, 99, 0.1);
}
}
> small {
@@ -489,16 +328,6 @@
font-weight: 600;
}
> button {
border: 1px solid var(--wxex-brand);
border-radius: var(--wxex-radius-md);
background: transparent;
color: var(--wxex-brand);
cursor: pointer;
padding: 7px 12px;
font: 12px/18px var(--wxex-font);
}
> div,
> small {
grid-column: 1 / -1;
@@ -542,28 +371,7 @@
}
.personal-wechat-voice-source {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-main);
padding: 4px;
button {
min-height: 32px;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--wxex-text-secondary);
cursor: pointer;
font: 600 12px/18px var(--wxex-font);
}
button.active {
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
box-shadow: 0 1px 4px rgba(18, 31, 26, 0.1);
}
}
.personal-wechat-generated-voice {
@@ -592,16 +400,6 @@
color: var(--wxex-text-primary);
font-size: 13px;
}
button {
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
cursor: pointer;
padding: 7px 10px;
font: 11px/17px var(--wxex-font);
}
}
.personal-wechat-tts-readiness {
@@ -730,30 +528,6 @@
display: flex;
align-items: center;
gap: 7px;
button {
min-height: 32px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
cursor: pointer;
padding: 0 11px;
font: 11px/17px var(--wxex-font);
white-space: nowrap;
}
button.primary {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
color: #fff;
font-weight: 600;
}
button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
@media (max-width: 560px) {
@@ -790,23 +564,17 @@
}
}
.chat-icon-button svg,
.chat-tool-button svg,
.chat-ai-button svg,
.chat-header-actions svg,
.chat-content-search svg,
.data-trust-bar svg {
width: 17px;
height: 17px;
}
.chat-icon-button path,
.chat-tool-button path,
.chat-ai-button path,
.chat-header-actions path,
.chat-content-search path,
.data-trust-bar path,
.chat-icon-button circle,
.chat-tool-button circle,
.chat-ai-button circle,
.chat-header-actions circle,
.chat-content-search circle,
.data-trust-bar circle {
fill: none;
@@ -818,27 +586,11 @@
.chat-content-search {
box-sizing: border-box;
width: 280px;
height: 34px;
display: flex;
align-items: center;
gap: 7px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
padding: 0 5px 0 10px;
width: clamp(200px, 24vw, 280px);
position: relative;
flex: 0 0 auto;
color: var(--wxex-text-muted);
input {
min-width: 0;
flex: 1;
border: 0;
outline: 0;
background: transparent;
color: var(--wxex-text-primary);
font: 13px/18px var(--wxex-font);
}
svg {
width: 17px;
height: 17px;
@@ -1563,24 +1315,6 @@
align-items: center;
justify-content: flex-end;
gap: 10px;
button {
border: 0;
background: transparent;
color: var(--wxex-brand);
cursor: pointer;
font: 12px/18px var(--wxex-font);
padding: 0;
&:hover {
color: var(--wxex-brand-hover);
}
&:disabled {
color: var(--wxex-text-muted);
cursor: default;
}
}
}
.chat-avatar-toggle {
@@ -1591,11 +1325,6 @@
white-space: nowrap;
}
.chat-avatar-toggle input {
margin: 0;
accent-color: var(--wxex-brand);
}
.chat-avatar-note {
max-width: 320px;
color: var(--wxex-text-muted);
@@ -1605,9 +1334,6 @@
}
.chat-avatar-reload {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
@@ -1643,6 +1369,13 @@
}
}
@media (max-width: 900px) {
.chat-content-search-count,
.chat-header-text-action > span {
display: none;
}
}
.empty-conversation-state {
display: flex;
flex-direction: column;
@@ -1881,41 +1614,7 @@
}
.database-login-tabs {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px;
padding: 4px;
margin-bottom: 18px;
border: 1px solid var(--login-border);
border-radius: 8px;
background: #e9eeeb;
}
.database-login-tabs button {
min-height: 38px;
border: 0;
border-radius: 5px;
color: #66716c;
background: transparent;
cursor: pointer;
font-size: 13px;
font-weight: 550;
}
.database-login-tabs button.active {
color: var(--login-primary-dark);
background: #fff;
box-shadow: 0 1px 3px rgba(24, 28, 27, 0.08);
}
.database-login-tabs .database-login-manual-tab {
color: #7a8580;
font-size: 11px;
font-weight: 500;
}
.database-login-tabs .database-login-manual-tab.active {
color: #58645f;
}
.database-login-state-card {
@@ -2034,32 +1733,14 @@
margin-top: 14px;
}
.database-login-guide-actions button,
.database-login-text-action {
padding: 5px 0;
border: 0;
color: var(--login-primary-dark);
background: transparent;
cursor: pointer;
font-size: 11px;
}
.database-login-text-action {
display: block;
width: 100%;
margin-top: 10px;
text-align: center;
}
.database-login-path-select {
margin-top: 5px;
padding: 4px 8px;
border: 1px solid var(--login-border);
border-radius: 5px;
color: var(--login-primary-dark);
background: #fff;
cursor: pointer;
font-size: 11px;
}
.database-login-path-input-wrap {
@@ -2068,19 +1749,6 @@
width: 100%;
}
.database-login-diagnostics input {
width: 100%;
height: 36px;
padding: 7px 0;
border: 0;
outline: 0;
color: #4c5752;
background: transparent;
font: inherit;
text-align: right;
text-overflow: ellipsis;
}
.database-login-path-value {
position: absolute;
right: 0;
@@ -2114,30 +1782,7 @@
.database-login-primary,
.database-login-secondary {
width: 100%;
min-height: 44px;
margin-top: 22px;
border-radius: 7px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
.database-login-primary {
border: 1px solid var(--login-primary);
color: #fff;
background: var(--login-primary);
box-shadow: 0 2px 5px rgba(0, 96, 76, 0.16);
}
.database-login-primary:hover:not(:disabled) {
border-color: var(--login-primary-dark);
background: var(--login-primary-dark);
}
.database-login-primary:disabled {
cursor: not-allowed;
opacity: 0.52;
box-shadow: none;
}
.database-login-auto > a {
@@ -2247,87 +1892,16 @@
transform: translate(-50%, 0);
}
.database-login-field > input,
.database-login-root-control,
.database-login-key-input {
width: 100%;
min-height: 42px;
border: 1px solid var(--login-border);
border-radius: 7px;
background: #fff;
}
.database-login-root-control {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
overflow: hidden;
}
.database-login-root-control input {
min-width: 0;
padding: 0 12px;
border: 0;
outline: 0;
color: var(--login-text);
background: transparent;
font-size: 13px;
}
.database-login-root-control button {
padding: 0 12px;
border: 0;
border-left: 1px solid var(--login-border);
color: var(--login-primary-dark);
background: var(--login-surface-low);
cursor: pointer;
}
.database-login-root-control button:disabled,
.database-login-path-select:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.database-login-field > input {
padding: 0 12px;
color: var(--login-text);
font-size: 13px;
outline: none;
gap: 8px;
}
.database-login-key-input {
display: flex;
align-items: center;
}
.database-login-key-input:focus-within,
.database-login-root-control:focus-within,
.database-login-field > input:focus {
border-color: var(--login-primary);
box-shadow: 0 0 0 3px rgba(36, 122, 99, 0.1);
}
.database-login-key-input input {
flex: 1;
min-width: 0;
height: 40px;
padding: 0 4px 0 12px;
border: 0;
outline: 0;
color: var(--login-text);
background: transparent;
font-size: 13px;
}
.database-login-key-input button {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border: 0;
color: #65706b;
background: transparent;
cursor: pointer;
gap: 8px;
}
.database-login-key-input svg {
@@ -2369,15 +1943,6 @@
.database-login-secondary {
margin-top: 10px;
border: 1px solid var(--login-border);
color: #3f4945;
background: #fff;
}
.database-login-secondary:hover {
border-color: var(--login-primary);
color: var(--login-primary-dark);
background: var(--login-surface);
}
.database-login-footer-actions {
@@ -2391,19 +1956,6 @@
font-size: 11px;
}
.database-login-footer-actions button {
padding: 0;
border: 0;
color: #78827e;
background: transparent;
cursor: pointer;
font-size: 11px;
}
.database-login-footer-actions button:hover {
color: #93000a;
}
@media (max-width: 900px), (max-height: 620px) {
.database-login-brand {
padding: 30px;
+8 -46
View File
@@ -49,32 +49,10 @@
}
.conversation-refresh-button {
display: grid;
width: 26px;
height: 26px;
place-items: center;
border: 0;
border-radius: 7px;
background: transparent;
color: var(--wxex-text-muted);
cursor: pointer;
-webkit-app-region: no-drag;
&:hover:not(:disabled) {
background: rgba(36, 122, 99, 0.1);
color: var(--wxex-brand);
}
&:focus-visible {
outline: 2px solid rgba(36, 122, 99, 0.35);
outline-offset: 1px;
}
&:disabled {
cursor: wait;
opacity: 0.72;
}
svg {
width: 16px;
height: 16px;
@@ -94,35 +72,14 @@
.conversation-search {
box-sizing: border-box;
display: flex;
height: 34px;
align-items: center;
gap: 8px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
padding: 0 10px;
position: relative;
display: block;
color: var(--wxex-text-muted);
-webkit-app-region: no-drag;
&:focus-within {
border-color: rgba(36, 122, 99, 0.58);
color: var(--wxex-brand);
}
input {
width: 100%;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--wxex-text-primary);
font: 13px/18px var(--wxex-font);
&::placeholder {
color: var(--wxex-text-muted);
}
}
}
.conversation-search-icon {
@@ -130,7 +87,12 @@
height: 17px;
display: grid;
place-items: center;
flex: 0 0 auto;
position: absolute;
z-index: 1;
top: 50%;
left: 11px;
pointer-events: none;
transform: translateY(-50%);
}
.conversation-search-icon svg {
+18 -282
View File
@@ -74,20 +74,18 @@
}
.report-source-search {
display: flex;
position: relative;
display: block;
flex: 0 0 auto;
align-items: center;
gap: 8px;
margin: 12px 14px;
padding: 0 10px;
height: 36px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-muted);
}
.report-source-search svg {
position: absolute;
z-index: 1;
left: 12px;
top: 10px;
width: 16px;
height: 16px;
}
@@ -101,16 +99,6 @@
stroke-linejoin: round;
}
.report-source-search input {
min-width: 0;
flex: 1;
border: 0;
outline: 0;
background: transparent;
color: var(--wxex-text-primary);
font: 13px/18px var(--wxex-font);
}
.report-source-list {
flex: 1 1 auto;
min-height: 0;
@@ -124,6 +112,7 @@
width: 100%;
min-width: 0;
align-items: center;
justify-content: flex-start;
gap: 10px;
padding: 10px 10px 10px 12px;
border: 0;
@@ -131,8 +120,10 @@
background: transparent;
color: var(--wxex-text-primary);
text-align: left;
white-space: normal;
cursor: pointer;
font-family: var(--wxex-font);
height: auto;
}
.report-source-item:hover {
@@ -298,17 +289,6 @@
font-size: 13px;
}
.report-timeout-section input {
width: 96px;
height: 36px;
padding: 0 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: #fff;
color: var(--wxex-text-primary);
font: 500 13px var(--wxex-font);
}
.report-empty-state p,
.report-privacy-note p {
margin: 6px 0 0;
@@ -344,37 +324,8 @@
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.report-range-options button,
.report-density-options button,
.report-model-summary button,
.report-result-actions button,
.report-task-error button,
.ai-report-footer button {
min-height: 34px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 13px/18px var(--wxex-font);
}
.report-range-options button.active,
.report-density-options button.active {
border-color: var(--wxex-brand);
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
.report-range-options button:disabled,
.report-density-options button:disabled {
color: var(--wxex-text-muted);
cursor: not-allowed;
opacity: 0.75;
}
.report-range-options button span,
.report-density-options button span {
.report-range-options small,
.report-density-options small {
display: block;
margin-top: 2px;
font: 11px/14px var(--wxex-font);
@@ -398,11 +349,6 @@
color: var(--wxex-text-primary);
}
.report-check-row input {
margin-top: 2px;
accent-color: var(--wxex-brand);
}
.report-check-row span {
display: grid;
min-width: 0;
@@ -487,24 +433,6 @@
font: 12px/17px var(--wxex-font);
}
.report-model-selects select {
width: 100%;
min-width: 0;
height: 34px;
padding: 0 30px 0 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
font: 13px/18px var(--wxex-font);
}
.report-model-selects select:disabled {
color: var(--wxex-text-muted);
cursor: not-allowed;
opacity: 0.75;
}
.report-model-summary p {
margin: 4px 0 0;
color: var(--wxex-text-secondary);
@@ -518,12 +446,6 @@
font: 11px/17px var(--wxex-font);
}
.report-model-summary button,
.report-result-actions button,
.report-task-error button {
padding: 0 12px;
}
.report-result-preview {
max-height: 460px;
overflow: auto;
@@ -541,6 +463,7 @@
.report-result-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
@@ -591,28 +514,6 @@
white-space: nowrap;
}
.ai-report-footer button {
min-width: 132px;
padding: 0 16px;
border-color: var(--wxex-ai);
background: var(--wxex-ai);
color: #fff;
}
.ai-report-footer button.secondary {
min-width: 92px;
border-color: var(--wxex-border);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
}
.ai-report-footer button:disabled {
border-color: var(--wxex-border);
background: #e8ebe9;
color: var(--wxex-text-muted);
cursor: not-allowed;
}
.report-task-panel {
display: flex;
min-width: 0;
@@ -799,25 +700,6 @@
gap: 8px;
}
.report-image-decision button,
.report-model-retry button {
min-height: 34px;
padding: 0 12px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
cursor: pointer;
font: 600 12px/18px var(--wxex-font);
}
.report-image-decision button:first-child,
.report-model-retry button {
border-color: var(--wxex-ai);
background: var(--wxex-ai);
color: #fff;
}
.report-model-retry {
display: grid;
gap: 7px;
@@ -829,18 +711,6 @@
font-weight: 700;
}
.report-model-retry select {
width: 100%;
min-width: 0;
min-height: 34px;
padding: 0 9px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: #fff;
color: var(--wxex-text-primary);
font: 12px/18px var(--wxex-font);
}
.report-model-retry small {
color: var(--wxex-text-secondary);
}
@@ -849,11 +719,6 @@
color: var(--wxex-danger);
}
.report-model-retry button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.report-voice-progress {
margin-top: 14px;
padding: 12px;
@@ -887,17 +752,6 @@
grid-template-columns: minmax(120px, 1fr) minmax(150px, 1.2fr);
gap: 8px;
margin-top: 10px;
input,
select {
min-width: 0;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
font: inherit;
padding: 8px 10px;
}
}
.report-member-status {
@@ -964,10 +818,6 @@
margin: 4px 0 10px;
}
.report-task-error button + button {
margin-left: 8px;
}
.report-task-log-path {
display: block;
margin-top: 8px;
@@ -1175,32 +1025,6 @@
min-height: 38px;
padding-top: 18px;
}
.agent-hub-button {
height: 38px;
padding: 0 16px;
border: 1px solid transparent;
border-radius: 9px;
font: 650 13px/20px var(--wxex-font);
cursor: pointer;
}
.agent-hub-button:disabled {
opacity: 0.55;
cursor: default;
}
.agent-hub-button.primary {
background: var(--wxex-brand);
color: #fff;
}
.agent-hub-button.secondary {
border-color: var(--wxex-border-strong);
background: #fff;
color: var(--wxex-text-primary);
}
.agent-hub-button.danger {
border-color: #f0c6c2;
background: #fff8f7;
color: var(--wxex-danger);
}
.agent-hub-qr-panel {
flex: 1;
display: flex;
@@ -1235,9 +1059,6 @@
.agent-hub-qr-copy h3 {
margin-top: 0;
}
.agent-hub-qr-copy .agent-hub-button {
margin-top: 18px;
}
.agent-hub-connected {
flex: 1;
min-height: 300px;
@@ -1330,26 +1151,6 @@
align-items: center;
gap: 8px;
}
.agent-hub-log-actions select,
.agent-hub-log-actions button {
height: 32px;
border: 1px solid var(--wxex-border-strong);
border-radius: 8px;
background: #fff;
color: var(--wxex-text-secondary);
font: 600 12px/18px var(--wxex-font);
}
.agent-hub-log-actions select {
padding: 0 28px 0 10px;
}
.agent-hub-log-actions button {
padding: 0 11px;
cursor: pointer;
}
.agent-hub-log-actions button:disabled {
opacity: 0.45;
cursor: default;
}
.agent-hub-log-body {
height: 250px;
overflow: auto;
@@ -1531,23 +1332,6 @@
font-weight: 600;
}
.ai-vision-prompt textarea {
width: 100%;
border: 1px solid #d5ddda;
border-radius: 8px;
outline: none;
padding: 10px;
resize: vertical;
background: #f4f7f5;
color: #202724;
font: 13px/1.6 var(--wxex-font);
}
.ai-vision-prompt textarea:focus {
border-color: #247a63;
box-shadow: 0 0 0 2px rgba(36, 122, 99, 0.08);
}
.ai-vision-privacy {
color: #66706b;
font-size: 12px;
@@ -1601,35 +1385,6 @@
justify-content: flex-end;
}
.ai-vision-test > footer button {
min-height: 34px;
border: 1px solid #d5ddda;
border-radius: 7px;
padding: 0 13px;
background: #fff;
color: #46514c;
cursor: pointer;
}
.ai-vision-test > footer button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.report-density-options button:disabled:hover {
border-color: var(--wxex-border);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-muted);
}
.report-density-options button.active:disabled {
border-color: var(--wxex-brand);
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
opacity: 1;
cursor: default;
}
.report-history-sidebar {
display: flex;
min-width: 0;
@@ -1676,20 +1431,18 @@
}
.report-history-search {
display: flex;
position: relative;
display: block;
flex: 0 0 auto;
align-items: center;
gap: 8px;
margin: 12px 14px 8px;
padding: 0 10px;
height: 36px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-muted);
}
.report-history-search svg {
position: absolute;
z-index: 1;
left: 12px;
top: 10px;
width: 16px;
height: 16px;
}
@@ -1703,26 +1456,9 @@
stroke-linejoin: round;
}
.report-history-search input {
min-width: 0;
flex: 1;
border: 0;
outline: 0;
background: transparent;
color: var(--wxex-text-primary);
font: 13px/18px var(--wxex-font);
}
.report-history-create {
flex: 0 0 auto;
height: 36px;
margin: 0 14px 12px;
border: 1px solid var(--wxex-brand);
border-radius: var(--wxex-radius-md);
background: var(--wxex-brand);
color: #fff;
cursor: pointer;
font: 700 13px/18px var(--wxex-font);
}
.report-history-list {
+14 -91
View File
@@ -7,6 +7,7 @@
}
.api-header-actions {
max-width: 260px;
flex-shrink: 0;
justify-content: flex-end;
}
.api-endpoint-head,
@@ -21,6 +22,17 @@
}
}
@media (max-width: 900px) {
.api-workspace-heading {
flex-direction: column;
}
.api-header-actions {
max-width: none;
flex-wrap: wrap;
justify-content: flex-start;
}
}
.report-history-list-title {
padding: 4px 4px 8px;
color: var(--wxex-text-secondary);
@@ -152,8 +164,7 @@
font: 13px/18px var(--wxex-font);
}
.report-history-empty button,
.report-center-empty button {
.report-history-empty button {
justify-self: start;
min-height: 32px;
padding: 0 12px;
@@ -213,9 +224,7 @@
gap: 8px;
}
.report-viewer-toolbar button,
.report-zoom-bar button,
.report-settings-section button {
.report-viewer-toolbar button {
min-height: 32px;
padding: 0 12px;
border: 1px solid var(--wxex-border);
@@ -371,10 +380,6 @@
font: 13px/20px var(--wxex-font);
}
.report-center-empty button {
justify-self: center;
}
.report-settings-panel {
display: flex;
min-width: 0;
@@ -583,10 +588,6 @@
text-transform: uppercase;
}
.report-template-item input[type='radio'] {
accent-color: var(--wxex-primary, #07c160);
}
.report-template-body {
flex: 1;
min-width: 0;
@@ -789,49 +790,6 @@
line-height: 1.5;
}
.report-template-preview-btn {
padding: 6px 12px;
font-size: 12px;
font-weight: 600;
border-radius: 8px;
border: 1px solid var(--wxex-border);
background: #fff;
color: var(--wxex-text-primary, #1f2933);
cursor: pointer;
transition:
background 0.2s,
color 0.2s,
border-color 0.2s;
}
.report-template-preview-btn:hover {
background: var(--wxex-primary, #07c160);
color: #fff;
border-color: var(--wxex-primary, #07c160);
}
/* 预览遮罩 */
.report-template-preview-mask {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
padding: 20px;
}
.report-template-preview-card {
background: #f3f5f7;
border-radius: 18px;
padding: 18px;
width: min(520px, 100%);
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 16px 60px rgba(15, 23, 42, 0.25);
}
.report-template-preview-card > .report-template-diagram {
width: 100%;
height: 260px;
@@ -900,19 +858,6 @@
font-style: normal;
}
.report-template-preview-card h4 {
margin: 0 0 6px;
font-size: 17px;
font-weight: 800;
}
.report-template-preview-card p.muted {
margin: 0 0 14px;
font-size: 12px;
color: var(--wxex-text-secondary, #485465);
line-height: 1.5;
}
.report-template-preview-frame {
display: grid;
gap: 8px;
@@ -974,22 +919,6 @@
margin-top: 14px;
}
.report-template-preview-actions button {
padding: 9px 14px;
border-radius: 10px;
border: none;
background: var(--wxex-primary, #07c160);
color: #fff;
font-weight: 700;
cursor: pointer;
}
.report-template-preview-actions button.secondary {
border: 1px solid var(--wxex-border);
background: #fff;
color: var(--wxex-text-primary);
}
.settings-auto-login-card {
padding: 18px 20px;
}
@@ -1046,12 +975,6 @@
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
.settings-recall-option input {
width: 18px;
height: 18px;
flex: 0 0 auto;
accent-color: var(--wxex-brand);
}
.settings-recall-notice {
display: grid;
gap: 3px;
-89
View File
@@ -78,23 +78,6 @@
text-overflow: ellipsis;
white-space: nowrap;
}
button {
padding: 5px 8px;
color: var(--wxex-brand);
white-space: nowrap;
}
}
.ai-search-model-status button,
.ai-search-result-actions button,
.ai-search-insufficient button {
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
cursor: pointer;
font: inherit;
}
.ai-search-grid {
@@ -340,24 +323,6 @@
font-weight: 600;
}
.ai-search-knowledge-action {
min-height: 30px;
padding: 6px 9px;
border: 1px solid var(--wxex-brand);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-brand);
color: #fff;
cursor: pointer;
font: inherit;
font-size: 11px;
font-weight: 700;
}
.ai-search-knowledge-action:disabled {
cursor: wait;
opacity: 0.65;
}
.ai-search-knowledge-more {
color: var(--wxex-text-secondary);
font-size: 10px;
@@ -651,20 +616,6 @@
font-size: 28px;
}
.ai-search-insufficient {
button {
margin-top: 20px;
padding: 8px 12px;
font-size: 11px;
&.primary {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
color: #fff;
}
}
}
.ai-search-prompts {
display: flex;
flex-wrap: wrap;
@@ -906,17 +857,6 @@
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
button {
padding: 7px 9px;
font-size: 11px;
&.primary {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
color: #fff;
}
}
}
.ai-search-summary-block {
@@ -1273,24 +1213,6 @@
gap: 12px;
}
.ai-search-debug-button {
padding: 7px 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-secondary);
cursor: pointer;
font: inherit;
font-size: 11px;
white-space: nowrap;
}
.ai-search-debug-button.active,
.ai-search-debug-button:hover {
border-color: var(--wxex-brand);
color: var(--wxex-brand);
}
.ai-search-debug-panel {
flex: 0 0 auto;
padding: 10px 22px 12px;
@@ -1326,17 +1248,6 @@
gap: 6px;
}
.ai-search-debug-actions button {
padding: 5px 8px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
cursor: pointer;
font: inherit;
font-size: 10px;
}
.ai-search-debug-path {
display: block;
margin-top: 6px;
@@ -32,15 +32,4 @@
gap: 10px;
padding-top: 12px;
border-top: 1px solid var(--wxex-border);
button {
padding: 7px 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
cursor: pointer;
font: inherit;
font-size: 11px;
}
}
@@ -1,9 +1,5 @@
.settings-header-action,
.settings-primary-button,
.settings-danger-button,
.settings-cache-item > button,
.settings-cache-actions button,
.about-links-card button {
.settings-danger-button {
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
@@ -223,28 +219,6 @@
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
> button {
min-height: 34px;
padding: 7px 12px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
cursor: pointer;
font: 12px/18px var(--wxex-font);
&:disabled {
cursor: not-allowed;
opacity: 0.55;
}
}
> .settings-primary-button {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
color: #fff;
}
}
.voice-model-progress {
@@ -314,55 +288,6 @@
border-bottom: 1px solid var(--wxex-border);
}
.voice-conversation-tabs {
display: flex;
align-self: stretch;
gap: 16px;
button {
position: relative;
padding: 0;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--wxex-text-muted);
cursor: pointer;
font: 12px/46px var(--wxex-font);
span {
margin-left: 4px;
color: inherit;
font-size: 11px;
}
&.active {
border-bottom-color: var(--wxex-brand);
color: var(--wxex-brand);
font-weight: 600;
}
&:disabled {
cursor: not-allowed;
}
}
}
.voice-conversation-toolbar input {
width: min(190px, 42%);
min-height: 30px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-main);
color: var(--wxex-text-primary);
font: 12px/18px var(--wxex-font);
padding: 5px 8px;
&:focus {
border-color: var(--wxex-brand);
outline: 0;
}
}
.voice-conversation-list {
min-height: 336px;
max-height: 432px;
@@ -387,13 +312,6 @@
&.selected {
background: var(--wxex-brand-soft);
}
input {
width: 15px;
height: 15px;
margin: 0;
accent-color: var(--wxex-brand);
}
}
.voice-conversation-avatar {
@@ -458,24 +376,6 @@
border-top: 1px solid var(--wxex-border);
color: var(--wxex-text-muted);
font-size: 11px;
button {
border: 0;
background: transparent;
color: var(--wxex-text-secondary);
cursor: pointer;
font: 11px/18px var(--wxex-font);
padding: 4px 0;
&:hover:not(:disabled) {
color: var(--wxex-brand);
}
&:disabled {
cursor: not-allowed;
opacity: 0.45;
}
}
}
.voice-batch-summary {
@@ -493,11 +393,6 @@
font-size: 13px;
}
.voice-batch-field select {
min-height: 36px;
width: 100%;
}
.voice-batch-selection-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -173,28 +173,6 @@
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
> button {
min-height: 34px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
cursor: pointer;
padding: 7px 12px;
font: 12px/18px var(--wxex-font);
&:disabled {
cursor: not-allowed;
opacity: 0.55;
}
}
> .settings-primary-button {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
color: #fff;
}
}
.tts-runtime-progress {
@@ -241,25 +219,6 @@
}
}
.tts-api-heading > button,
.tts-api-footer button,
.tts-api-input button,
.tts-voice-search button,
.tts-load-more {
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
cursor: pointer;
padding: 8px 12px;
font: 12px/18px var(--wxex-font);
&:disabled {
cursor: default;
opacity: 0.5;
}
}
.tts-api-input {
display: grid;
gap: 8px;
@@ -273,33 +232,7 @@
> div {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
}
input {
min-width: 0;
border: 1px solid var(--wxex-border);
border-right: 0;
border-radius: var(--wxex-radius-sm) 0 0 var(--wxex-radius-sm);
outline: 0;
background: var(--wxex-bg-main);
color: var(--wxex-text-primary);
padding: 10px 12px;
font: 13px/20px var(--wxex-font);
&:focus {
border-color: var(--wxex-brand);
box-shadow: 0 0 0 3px rgba(36, 122, 99, 0.1);
}
}
button {
border-radius: 0;
}
.tts-save-key-button {
border-radius: 0 var(--wxex-radius-sm) var(--wxex-radius-sm) 0;
background: var(--wxex-brand);
color: #fff;
gap: 8px;
}
}
@@ -326,16 +259,6 @@
font-size: 12px;
font-weight: 600;
}
select {
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
outline: 0;
background: var(--wxex-bg-main);
color: var(--wxex-text-primary);
padding: 9px 11px;
font: 12px/18px var(--wxex-font);
}
}
.tts-voice-heading {
@@ -363,26 +286,8 @@
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 7px;
border: 1px solid var(--wxex-border);
border-radius: 999px;
background: var(--wxex-bg-elevated);
padding: 3px 4px 3px 12px;
padding-left: 12px;
color: var(--wxex-text-muted);
input {
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--wxex-text-primary);
padding: 7px 0;
font: 12px/18px var(--wxex-font);
}
button {
border-radius: 999px;
padding: 5px 12px;
}
}
.tts-voice-filters {
@@ -634,23 +539,6 @@
}
}
.tts-voice-preview {
min-width: 42px;
border: 1px solid var(--wxex-border);
border-radius: 999px;
background: var(--wxex-bg-elevated);
color: var(--wxex-brand);
cursor: pointer;
padding: 5px 9px;
font: 10px/16px var(--wxex-font);
&:disabled {
cursor: default;
color: var(--wxex-text-muted);
opacity: 0.5;
}
}
.tts-voice-empty {
display: grid;
gap: 5px;
@@ -682,8 +570,6 @@
.tts-load-more {
display: block;
margin: 18px auto 0;
border-radius: 999px;
padding-inline: 24px;
}
@media (max-width: 920px) {
@@ -716,7 +602,6 @@
.tts-api-input .tts-save-key-button {
grid-column: 1 / -1;
margin-top: 7px;
border-radius: var(--wxex-radius-sm);
}
.tts-voice-search {
+17 -172
View File
@@ -373,27 +373,7 @@
Consolas,
monospace;
}
.settings-icon-button {
display: grid;
width: 36px;
height: 36px;
flex: 0 0 auto;
place-items: center;
border: 1px solid #dde3e0;
border-radius: 8px;
background: #fff;
color: #66706b;
cursor: pointer;
}
.settings-icon-button:hover:not(:disabled) {
border-color: #247a63;
color: #247a63;
}
.settings-icon-button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.settings-icon-button svg {
.settings-account-root svg {
width: 17px;
fill: none;
stroke: currentColor;
@@ -752,15 +732,6 @@
.database-key-auto-error p {
color: #79501f;
}
.database-key-auto-error button {
justify-self: start;
border: 0;
padding: 4px 0;
background: transparent;
color: #247a63;
cursor: pointer;
font: 600 12px inherit;
}
.database-key-security-info {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -803,15 +774,6 @@
margin: 0;
padding: 2px 16px 16px;
}
.database-key-diagnostics button {
margin: 0 16px 16px;
border: 0;
padding: 0;
background: transparent;
color: #247a63;
cursor: pointer;
font: 600 12px inherit;
}
.database-key-connection-actions {
margin-top: 30px;
}
@@ -842,19 +804,6 @@
color: #66706b;
font-size: 12px;
}
.database-key-connection-actions button {
min-height: 34px;
flex: 0 0 auto;
border: 1px solid #247a63;
border-radius: 8px;
padding: 0 13px;
background: #fff;
color: #247a63;
cursor: pointer;
}
.database-key-connection-actions button:hover:not(:disabled) {
background: #f0f7f4;
}
.database-key-danger {
margin-top: 30px;
padding-bottom: 1px;
@@ -892,20 +841,6 @@
color: #786868;
font-size: 12px;
}
.database-key-danger button {
min-height: 34px;
flex: 0 0 auto;
border: 1px solid #e8baba;
border-radius: 8px;
padding: 0 13px;
background: #fff;
color: #c85a5a;
cursor: pointer;
}
.database-key-danger button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.database-key-confirm-backdrop {
position: fixed;
z-index: 30;
@@ -1058,9 +993,7 @@
font-size: 12px;
font-weight: 600;
}
.image-key-editor input,
.image-test-section select,
.image-test-search {
.image-key-editor input {
box-sizing: border-box;
width: 100%;
height: 40px;
@@ -1076,9 +1009,7 @@
Consolas,
monospace;
}
.image-key-editor input:focus,
.image-test-section select:focus,
.image-test-search:focus {
.image-key-editor input:focus {
border-color: #247a63;
box-shadow: 0 0 0 2px rgba(36, 122, 99, 0.09);
}
@@ -1143,9 +1074,6 @@
font-size: 12px;
font-weight: 800;
}
.image-test-copy {
flex: 0 0 auto;
}
.image-test-section > label {
display: block;
margin: 18px 0 8px;
@@ -1153,43 +1081,6 @@
font-size: 12px;
font-weight: 600;
}
.image-test-filter-bar {
display: inline-flex;
gap: 3px;
margin-top: 18px;
padding: 3px;
border: 1px solid #dbe4e0;
border-radius: 9px;
background: #f1f5f3;
}
.image-test-filter-bar button {
min-width: 72px;
height: 30px;
border: 0;
border-radius: 6px;
background: transparent;
color: #66706b;
padding: 0 10px;
font-size: 12px;
cursor: pointer;
}
.image-test-filter-bar button:hover:not(:disabled) {
color: #247a63;
}
.image-test-filter-bar button.active {
background: #fff;
color: #247a63;
box-shadow: 0 1px 4px rgba(35, 69, 57, 0.12);
font-weight: 700;
}
.image-test-filter-bar button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.image-test-search {
display: block;
font-family: inherit;
}
.image-test-empty {
margin: 8px 0 0;
color: #8b938f;
@@ -1652,31 +1543,6 @@
padding-top: 14px;
border-top: 1px solid #edf0ee;
}
.ai-provider-card button,
.ai-provider-editor button {
min-height: 32px;
border: 1px solid #d5ddda;
border-radius: 7px;
padding: 0 11px;
background: #fff;
color: #46514c;
cursor: pointer;
font: 600 12px inherit;
}
.ai-provider-card button:hover,
.ai-provider-editor button:hover {
border-color: #247a63;
color: #247a63;
}
.ai-provider-card button:disabled,
.ai-provider-editor button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.ai-provider-card button.danger,
.ai-provider-editor button.danger {
color: #c85a5a;
}
.ai-provider-error {
margin: 0 0 12px;
color: #a84444;
@@ -1719,29 +1585,6 @@
font-size: 12px;
font-weight: 600;
}
.ai-provider-editor input:not([type='checkbox']):not([type='radio']),
.ai-provider-editor select,
.ai-provider-editor textarea {
box-sizing: border-box;
width: 100%;
border: 1px solid #d5ddda;
border-radius: 7px;
outline: 0;
padding: 9px 10px;
background: #f4f7f5;
color: #202724;
font:
12px/1.4 ui-monospace,
SFMono-Regular,
Consolas,
monospace;
}
.ai-provider-editor input:focus,
.ai-provider-editor select:focus,
.ai-provider-editor textarea:focus {
border-color: #247a63;
box-shadow: 0 0 0 2px rgba(36, 122, 99, 0.08);
}
.ai-provider-form-grid,
.ai-provider-advanced > div {
display: grid;
@@ -1829,6 +1672,20 @@
transform: rotate(360deg);
}
}
@media (max-width: 1120px) {
.settings-account-overview {
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px;
}
.settings-account-facts {
grid-column: 1/-1;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-account-actions {
grid-row: 1;
grid-column: 2;
}
}
@media (max-width: 900px) {
.settings-sidebar {
width: 248px;
@@ -1849,18 +1706,6 @@
border-top: 2px solid #e5b45f;
border-left: 0;
}
.settings-account-overview {
grid-template-columns: minmax(0, 1fr) auto;
gap: 18px;
}
.settings-account-facts {
grid-column: 1/-1;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.settings-account-actions {
grid-row: 1;
grid-column: 2;
}
.database-key-security-info {
grid-template-columns: 1fr;
}
@@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { AccountOverview } from '../../src/renderer/src/features/settings/account-database/AccountOverview'
import { TooltipProvider } from '../../src/renderer/src/components/ui'
describe('account database controls', () => {
it('keeps overview actions and disabled state connected', async () => {
const user = userEvent.setup()
const onCheck = vi.fn()
const onOpenDirectory = vi.fn()
const onCopyDirectory = vi.fn()
const onSwitchAccount = vi.fn()
render(
<TooltipProvider>
<AccountOverview
selfInfo={{
wxid: 'wxid_fixture',
nickname: '测试账号',
accountRoot: '/fixture/account'
}}
connectionStatus="success"
lastCheckedLabel="刚刚"
isChecking={false}
onCheck={onCheck}
onOpenDirectory={onOpenDirectory}
onCopyDirectory={onCopyDirectory}
onSwitchAccount={onSwitchAccount}
/>
</TooltipProvider>
)
await user.click(screen.getByRole('button', { name: '重新检测' }))
await user.click(screen.getByRole('button', { name: '打开账号目录' }))
await user.click(screen.getByRole('button', { name: '切换账号' }))
await user.click(screen.getByRole('button', { name: '复制账号目录' }))
expect(onCheck).toHaveBeenCalledOnce()
expect(onOpenDirectory).toHaveBeenCalledOnce()
expect(onSwitchAccount).toHaveBeenCalledOnce()
expect(onCopyDirectory).toHaveBeenCalledOnce()
})
it('disables directory actions when no account root is available', () => {
render(
<TooltipProvider>
<AccountOverview
selfInfo={null}
connectionStatus="unavailable"
lastCheckedLabel="尚未检测"
isChecking
onCheck={vi.fn()}
onOpenDirectory={vi.fn()}
onCopyDirectory={vi.fn()}
onSwitchAccount={vi.fn()}
/>
</TooltipProvider>
)
expect(screen.getByRole('button', { name: '正在检测...' })).toBeDisabled()
expect(screen.getByRole('button', { name: '打开账号目录' })).toBeDisabled()
expect(screen.getByRole('button', { name: '复制账号目录' })).toBeDisabled()
})
})
@@ -0,0 +1,78 @@
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AgentHubWorkspace } from '../../src/renderer/src/features/agent-hub/AgentHubWorkspace'
import type { AgentHubStatus } from '../../src/shared/agent-hub'
const onlineStatus: AgentHubStatus = {
hub: 'online',
connector: 'online',
updatedAt: 1,
dataApi: 'online',
databaseReady: true,
accountId: 'fixture-agent'
}
describe('Agent Hub controls', () => {
beforeEach(() => {
window.api = {
getAgentHubStatus: vi.fn().mockResolvedValue(onlineStatus),
getAgentHubLogs: vi.fn().mockResolvedValue([
{ id: 1, timestamp: 1, source: 'system', level: 'info', message: '系统就绪' },
{
id: 2,
timestamp: 2,
source: 'wechat-connector',
level: 'warn',
message: '等待连接'
}
]),
onAgentHubStatus: vi.fn(() => () => undefined),
onAgentHubLog: vi.fn(() => () => undefined),
copyText: vi.fn().mockResolvedValue(undefined),
clearAgentHubLogs: vi.fn().mockResolvedValue({ success: true }),
startAgentHubLogin: vi.fn().mockResolvedValue({ status: onlineStatus }),
cancelAgentHubLogin: vi.fn().mockResolvedValue({ status: onlineStatus }),
disconnectAgentHub: vi.fn().mockResolvedValue({
status: { ...onlineStatus, connector: 'disconnected' }
})
} as typeof window.api
})
it('filters, copies, and clears logs through shared controls', async () => {
const user = userEvent.setup()
render(<AgentHubWorkspace />)
await screen.findByText('系统就绪')
await user.click(screen.getByRole('combobox', { name: '筛选日志来源' }))
await user.click(screen.getByRole('option', { name: '系统', exact: true }))
expect(screen.getByText('系统就绪')).toBeInTheDocument()
expect(screen.queryByText('等待连接')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '复制日志' }))
expect(window.api.copyText).toHaveBeenCalledWith(expect.stringContaining('系统就绪'))
expect(window.api.copyText).toHaveBeenCalledWith(expect.not.stringContaining('等待连接'))
await user.click(screen.getByRole('button', { name: '清空' }))
expect(window.api.clearAgentHubLogs).toHaveBeenCalledOnce()
expect(screen.getByText(/暂无运行日志/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: '复制日志' })).toBeDisabled()
})
it('keeps reconnect and destructive disconnect actions separate', async () => {
const user = userEvent.setup()
render(<AgentHubWorkspace />)
await screen.findByText('微信机器人已连接')
await user.click(screen.getByRole('button', { name: '重新扫码登录' }))
expect(window.api.startAgentHubLogin).toHaveBeenCalledOnce()
const disconnect = screen.getByRole('button', { name: '断开连接' })
expect(disconnect).toHaveClass('bg-destructive')
await user.click(disconnect)
await act(async () => {
await Promise.resolve()
})
expect(window.api.disconnectAgentHub).toHaveBeenCalledOnce()
})
})
+113 -1
View File
@@ -1,7 +1,11 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AIImageUnderstandingTest } from '../../src/renderer/src/features/settings/ai-model/AIImageUnderstandingTest'
import { AIProviderCard } from '../../src/renderer/src/features/settings/ai-model/AIProviderCard'
import { AIModelPage } from '../../src/renderer/src/features/settings/pages/AIModelPage'
import type { AIRuntimeModelConfig } from '../../src/shared/ai-provider'
import type { AIProviderSummary, AIRuntimeModelConfig } from '../../src/shared/ai-provider'
import type { AIVisionTestState } from '../../src/renderer/src/features/settings/ai-model/types'
const runtime: AIRuntimeModelConfig = {
providerName: 'Not configured',
@@ -11,6 +15,37 @@ const runtime: AIRuntimeModelConfig = {
status: 'untested'
}
const provider: AIProviderSummary = {
id: 'fixture-provider',
name: '本地假服务',
type: 'openai-compatible',
baseUrl: 'http://127.0.0.1:1/v1',
auth: { type: 'none' },
models: [
{
id: 'fixture-model',
name: '固定响应模型',
capabilities: { chat: true, vision: true, ocr: true, longContext: true }
}
],
defaultModel: 'fixture-model',
advanced: { timeoutMs: 5000, extraHeaders: {} },
hasApiKey: true,
isDefault: false,
status: 'connected'
}
const visionState: AIVisionTestState = {
status: 'ready',
prompt: '描述图片',
image: {
dataUrl: 'data:image/png;base64,fixture',
fileName: 'fixture.png',
mimeType: 'image/png',
size: 1024
}
}
describe('AI model settings', () => {
const scrollIntoView = vi.fn()
@@ -62,4 +97,81 @@ describe('AI model settings', () => {
expect(window.api.getAIRuntimeConfig).toHaveBeenCalledOnce()
expect(secondRuntimeChange).not.toHaveBeenCalled()
})
it('keeps preset selection and derived vision capabilities intact', async () => {
const user = userEvent.setup()
render(<AIModelPage onRuntimeChange={vi.fn()} onNotice={vi.fn()} />)
await act(async () => {
await Promise.resolve()
})
await user.click(screen.getByRole('button', { name: '添加供应商' }))
await user.click(screen.getByRole('combobox', { name: '快速模板' }))
await user.click(screen.getByRole('option', { name: 'OpenAI', exact: true }))
expect(screen.getByLabelText('供应商名称')).toHaveValue('OpenAI')
expect(screen.getByLabelText('模型名称')).toHaveValue('gpt-4o-mini')
const vision = screen.getByRole('checkbox', { name: '图片理解 gpt-4o-mini' })
const ocr = screen.getByRole('checkbox', { name: '图片文字识别 gpt-4o-mini' })
expect(vision).not.toBeChecked()
expect(ocr).not.toBeChecked()
await user.click(vision)
expect(vision).toBeChecked()
expect(ocr).toBeChecked()
expect(screen.getByRole('radio', { name: '默认 gpt-4o-mini' })).toBeChecked()
})
it('uses shared button variants for provider card actions', async () => {
const user = userEvent.setup()
const onEdit = vi.fn()
const onTest = vi.fn()
const onDefault = vi.fn()
const onDelete = vi.fn()
render(
<AIProviderCard
provider={provider}
testing={false}
onEdit={onEdit}
onTest={onTest}
onDefault={onDefault}
onDelete={onDelete}
/>
)
await user.click(screen.getByRole('button', { name: '编辑' }))
await user.click(screen.getByRole('button', { name: '测试连接' }))
await user.click(screen.getByRole('button', { name: '设为默认' }))
await user.click(screen.getByRole('button', { name: '删除' }))
expect(onEdit).toHaveBeenCalledOnce()
expect(onTest).toHaveBeenCalledOnce()
expect(onDefault).toHaveBeenCalledOnce()
expect(onDelete).toHaveBeenCalledOnce()
expect(screen.getByRole('button', { name: '删除' })).toHaveClass('bg-destructive')
})
it('keeps vision file input native while using shared prompt and action controls', async () => {
const user = userEvent.setup()
const onPromptChange = vi.fn()
const onTest = vi.fn()
const onClear = vi.fn()
render(
<AIImageUnderstandingTest
runtime={{ ...runtime, configured: true }}
provider={provider}
state={visionState}
onSelectImage={vi.fn()}
onPromptChange={onPromptChange}
onTest={onTest}
onClear={onClear}
/>
)
expect(screen.getByRole('img', { name: '图片理解测试预览' })).toBeInTheDocument()
expect(screen.getByLabelText('识别提示词')).toHaveValue('描述图片')
await user.click(screen.getByRole('button', { name: '移除图片' }))
await user.click(screen.getByRole('button', { name: '开始识别' }))
expect(onClear).toHaveBeenCalledOnce()
expect(onTest).toHaveBeenCalledOnce()
expect(screen.getByRole('button', { name: '开始识别' })).not.toHaveClass('database-key-primary')
})
})
@@ -156,6 +156,50 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
void user
})
it('keeps result actions and model settings callbacks unchanged after UI migration', async () => {
const onOpenAISettings = vi.fn()
const user = userEvent.setup()
const view = renderWorkspace({ onOpenAISettings })
await submitQuery()
await screen.findByText('测试搜索答案')
await user.click(screen.getByRole('button', { name: '复制摘要' }))
expect(api.copyText).toHaveBeenCalledWith('测试搜索答案')
view.rerender(
<AISearchWorkspace
{...(makeProps({
onOpenAISettings,
aiModelConfig: {
configured: false,
providerName: '',
model: '',
modelName: '',
status: 'unconfigured' as const
}
}) as never)}
/>
)
await user.click(screen.getByRole('button', { name: '配置模型' }))
expect(onOpenAISettings).toHaveBeenCalledOnce()
})
it('exposes the diagnostics action as an expanded control and keeps log access unchanged', async () => {
api.getSettings.mockResolvedValue({ settings: { debugEnabled: true } })
api.getAppLogPath.mockResolvedValue('/fixture/app.log')
renderWorkspace()
const user = userEvent.setup()
const diagnostics = await screen.findByRole('button', { name: '诊断日志' })
expect(diagnostics).toHaveAttribute('aria-expanded', 'false')
await user.click(diagnostics)
expect(diagnostics).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText('/fixture/app.log')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '打开日志文件夹' }))
expect(api.revealAppLog).toHaveBeenCalledOnce()
})
it('renders a failed retrieval without producing a Summary', async () => {
api.runAiSearch.mockResolvedValue(
makeSearchResult({
@@ -0,0 +1,178 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ApiRequestTester } from '../../src/renderer/src/features/api-center/components/ApiRequestTester'
import { ReaderSkillOverview } from '../../src/renderer/src/features/api-center/components/ReaderSkillOverview'
import type { ApiEndpoint } from '../../src/renderer/src/features/api-center/model/types'
const requestEndpoint: ApiEndpoint = {
id: 'report',
method: 'POST',
path: '/v1/report',
name: '测试接口',
description: '测试统一表单控件',
parameters: [
{
key: 'talker',
label: '会话标识',
required: true,
placeholder: '群昵称'
}
],
body: true
}
describe('API Center unified controls', () => {
it('uses the unified menu for Reader Skill actions and restores trigger focus', async () => {
const user = userEvent.setup()
const onOpenFolder = vi.fn()
const onOpenGithub = vi.fn()
render(
<ReaderSkillOverview
skill={{
available: true,
version: 'v1.2',
source: 'bundled',
githubUrl: 'https://example.com/tracememo'
}}
service={{ running: true, host: '127.0.0.1', port: 6131 }}
dbReady
target="codex"
onTargetChange={vi.fn()}
onPreview={vi.fn()}
onOpenFolder={onOpenFolder}
onOpenGithub={onOpenGithub}
onStart={vi.fn()}
onCopyInstruction={vi.fn()}
onCopyVerification={vi.fn()}
/>
)
const trigger = screen.getByRole('button', { name: '更多' })
await user.click(trigger)
await user.click(screen.getByRole('menuitem', { name: '打开本地文件夹' }))
expect(onOpenFolder).toHaveBeenCalledOnce()
await user.click(trigger)
expect(screen.getByRole('menuitem', { name: '查看 GitHub 最新版本' })).toBeVisible()
await user.keyboard('{Escape}')
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
await waitFor(() => expect(trigger).toHaveFocus())
expect(onOpenGithub).not.toHaveBeenCalled()
})
it('exposes the Agent target as a real single-select control', async () => {
const user = userEvent.setup()
const onTargetChange = vi.fn()
render(
<ReaderSkillOverview
skill={{
available: true,
version: 'v1.2',
source: 'bundled',
githubUrl: 'https://example.com/tracememo'
}}
service={{ running: true, host: '127.0.0.1', port: 6131 }}
dbReady
target="codex"
onTargetChange={onTargetChange}
onPreview={vi.fn()}
onOpenFolder={vi.fn()}
onOpenGithub={vi.fn()}
onStart={vi.fn()}
onCopyInstruction={vi.fn()}
onCopyVerification={vi.fn()}
/>
)
expect(screen.getByRole('radio', { name: 'Codex' })).toBeChecked()
await user.click(screen.getByRole('radio', { name: 'Claude Code' }))
expect(onTargetChange).toHaveBeenCalledWith('claude-code')
})
it('preserves request editing and action callbacks with unified form controls', async () => {
const user = userEvent.setup()
const onParams = vi.fn()
const onBody = vi.fn()
const onClear = vi.fn()
const onCopyCurl = vi.fn(async () => undefined)
const onSend = vi.fn()
render(
<ApiRequestTester
endpoint={requestEndpoint}
settings={{ apiEnabled: true, apiHost: '127.0.0.1', apiPort: 6131 }}
service={{ running: true, host: '127.0.0.1', port: 6131 }}
params={{}}
body="{}"
state="idle"
error=""
onParams={onParams}
onBody={onBody}
onSend={onSend}
onClear={onClear}
onCopyCurl={onCopyCurl}
/>
)
fireEvent.change(screen.getByPlaceholderText('群昵称'), {
target: { value: '产品群' }
})
expect(onParams).toHaveBeenLastCalledWith({ talker: '产品群' })
fireEvent.change(screen.getByLabelText('JSON 请求体'), {
target: { value: '{"range":"today"}' }
})
expect(onBody).toHaveBeenLastCalledWith('{"range":"today"}')
await user.click(screen.getByRole('button', { name: '清空' }))
await user.click(screen.getByRole('button', { name: '复制 curl' }))
await user.click(screen.getByRole('button', { name: '发送请求' }))
expect(onClear).toHaveBeenCalledOnce()
expect(onCopyCurl).toHaveBeenCalledOnce()
expect(onSend).toHaveBeenCalledOnce()
})
it('keeps the send action disabled and exposes loading state accessibly', () => {
const { rerender } = render(
<ApiRequestTester
endpoint={requestEndpoint}
settings={{ apiEnabled: true, apiHost: '127.0.0.1', apiPort: 6131 }}
service={{ running: false, host: '127.0.0.1', port: 6131 }}
params={{}}
body="{}"
state="idle"
error=""
onParams={vi.fn()}
onBody={vi.fn()}
onSend={vi.fn()}
onClear={vi.fn()}
onCopyCurl={vi.fn(async () => undefined)}
/>
)
expect(screen.getByRole('button', { name: '请先启动本地 API 服务' })).toBeDisabled()
rerender(
<ApiRequestTester
endpoint={requestEndpoint}
settings={{ apiEnabled: true, apiHost: '127.0.0.1', apiPort: 6131 }}
service={{ running: true, host: '127.0.0.1', port: 6131 }}
params={{}}
body="{}"
state="loading"
error=""
onParams={vi.fn()}
onBody={vi.fn()}
onSend={vi.fn()}
onClear={vi.fn()}
onCopyCurl={vi.fn(async () => undefined)}
/>
)
const sending = screen.getByRole('button', { name: '发送中…' })
expect(sending).toBeDisabled()
expect(sending).toHaveAttribute('aria-busy', 'true')
})
})
+11
View File
@@ -27,6 +27,7 @@ describe('CacheCleanupPage', () => {
updatedAt: Date.now()
})
api.openKnowledgeDirectory.mockResolvedValue({ success: true })
api.clearCache.mockResolvedValue({ items: [], totalBytes: 0, updatedAt: Date.now() })
})
it('opens the real knowledge directory without clearing it', async () => {
@@ -39,4 +40,14 @@ describe('CacheCleanupPage', () => {
expect(api.clearCache).not.toHaveBeenCalled()
expect(onNotice).toHaveBeenCalledWith('已打开知识库文件夹')
})
it('keeps the clear-all scope and notice unchanged', async () => {
const onNotice = vi.fn()
render(<CacheCleanupPage onNotice={onNotice} />)
await userEvent.click(await screen.findByRole('button', { name: '清理全部' }))
await waitFor(() => expect(api.clearCache).toHaveBeenCalledWith('all'))
expect(onNotice).toHaveBeenCalledWith('已清理全部可恢复缓存和检索记录')
})
})
+91 -3
View File
@@ -1,8 +1,11 @@
import { render, screen } from '@testing-library/react'
import type { ReactElement } from 'react'
import { render, screen, type RenderResult } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ChatHeader } from '../../src/renderer/src/components/chat/ChatHeader'
import { ChatStatusBar } from '../../src/renderer/src/components/chat/ChatStatusBar'
import { ExportMenu } from '../../src/renderer/src/components/chat/ExportMenu'
import { TooltipProvider } from '../../src/renderer/src/components/ui'
import type { Contact } from '../../src/shared/types'
const contact: Contact = {
@@ -12,11 +15,15 @@ const contact: Contact = {
type: 'group'
}
function renderWithTooltip(element: ReactElement): RenderResult {
return render(<TooltipProvider>{element}</TooltipProvider>)
}
describe('chat menus', () => {
it('runs the selected export range and closes the menu', async () => {
const user = userEvent.setup()
const onExport = vi.fn()
render(<ExportMenu disabled={false} onExport={onExport} />)
renderWithTooltip(<ExportMenu disabled={false} onExport={onExport} />)
const trigger = screen.getByRole('button', { name: '导出' })
await user.click(trigger)
@@ -30,7 +37,7 @@ describe('chat menus', () => {
it('closes the chat More menu with Escape and invokes refresh data once', async () => {
const user = userEvent.setup()
const onRefreshData = vi.fn()
render(
renderWithTooltip(
<ChatHeader
contact={contact}
isGroupChat
@@ -57,4 +64,85 @@ describe('chat menus', () => {
await user.click(screen.getByRole('menuitem', { name: '刷新数据' }))
expect(onRefreshData).toHaveBeenCalledOnce()
})
it('keeps header search and actions wired to their existing callbacks', async () => {
const user = userEvent.setup()
const onContentFilterChange = vi.fn()
const onRefresh = vi.fn()
const onOpenAiSettings = vi.fn()
renderWithTooltip(
<ChatHeader
contact={contact}
isGroupChat
loadedCount={8}
filteredCount={2}
contentFilter=""
isAiLoading={false}
onContentFilterChange={onContentFilterChange}
onRefresh={onRefresh}
onRefreshData={vi.fn()}
onTestSend={vi.fn()}
onOpenAiSettings={onOpenAiSettings}
/>
)
await user.click(screen.getByRole('button', { name: '搜索当前聊天' }))
const searchInput = screen.getByRole('textbox', { name: '搜索当前聊天内容' })
expect(searchInput).toHaveFocus()
await user.type(searchInput, '测试')
expect(onContentFilterChange).toHaveBeenCalledTimes(2)
expect(screen.getByRole('button', { name: '发送消息' })).toBeDisabled()
await user.click(screen.getByRole('button', { name: '关闭搜索' }))
expect(onContentFilterChange).toHaveBeenLastCalledWith('')
expect(screen.queryByRole('textbox', { name: '搜索当前聊天内容' })).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '刷新聊天记录' }))
await user.click(screen.getByRole('button', { name: '生成 AI 日报' }))
expect(onRefresh).toHaveBeenCalledOnce()
expect(onOpenAiSettings).toHaveBeenCalledOnce()
})
it('preserves status switch, reload, and jump callbacks', async () => {
const user = userEvent.setup()
const onShowAvatarChange = vi.fn()
const onReloadAvatars = vi.fn()
const onJumpToLatest = vi.fn()
renderWithTooltip(
<ChatStatusBar
count={5}
showAvatar={false}
isAtLatest={false}
isReloadingAvatars={false}
onShowAvatarChange={onShowAvatarChange}
onReloadAvatars={onReloadAvatars}
onJumpToLatest={onJumpToLatest}
/>
)
await user.click(screen.getByRole('switch', { name: '显示头像' }))
await user.click(screen.getByRole('button', { name: '重新加载头像' }))
await user.click(screen.getByRole('button', { name: '跳转到最新消息' }))
expect(onShowAvatarChange).toHaveBeenCalledWith(true)
expect(onReloadAvatars).toHaveBeenCalledOnce()
expect(onJumpToLatest).toHaveBeenCalledOnce()
})
it('keeps loading and latest status actions disabled', () => {
renderWithTooltip(
<ChatStatusBar
count={5}
showAvatar
isAtLatest
isReloadingAvatars
onShowAvatarChange={vi.fn()}
onReloadAvatars={vi.fn()}
onJumpToLatest={vi.fn()}
/>
)
expect(screen.getByRole('button', { name: '加载中' })).toBeDisabled()
expect(screen.getByRole('button', { name: '已是最新消息' })).toBeDisabled()
})
})
@@ -2,19 +2,29 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ConversationSidebarHeader } from '../../src/renderer/src/components/conversation/ConversationSidebarHeader'
import { TooltipProvider } from '../../src/renderer/src/components/ui'
describe('ConversationSidebarHeader', () => {
it('refreshes the conversation list from the archive header', async () => {
const onRefresh = vi.fn()
render(
function renderHeader(
overrides: Partial<React.ComponentProps<typeof ConversationSidebarHeader>> = {}
): void {
render(
<TooltipProvider>
<ConversationSidebarHeader
totalCount={1306}
searchValue=""
onSearchChange={vi.fn()}
refreshing={false}
onRefresh={onRefresh}
onRefresh={vi.fn()}
{...overrides}
/>
)
</TooltipProvider>
)
}
describe('ConversationSidebarHeader', () => {
it('refreshes the conversation list from the archive header', async () => {
const onRefresh = vi.fn()
renderHeader({ onRefresh })
expect(screen.getByText('1306 个会话')).toBeInTheDocument()
await userEvent.click(screen.getByRole('button', { name: '刷新会话列表' }))
@@ -22,17 +32,20 @@ describe('ConversationSidebarHeader', () => {
})
it('disables the refresh button while contacts are loading', () => {
render(
<ConversationSidebarHeader
totalCount={12}
searchValue="测试"
onSearchChange={vi.fn()}
refreshing
onRefresh={vi.fn()}
/>
)
renderHeader({ totalCount: 12, searchValue: '测试', refreshing: true })
expect(screen.getByRole('button', { name: '正在刷新会话列表' })).toBeDisabled()
expect(screen.getByDisplayValue('测试')).toBeInTheDocument()
})
it('keeps the controlled conversation search callback and search semantics', async () => {
const onSearchChange = vi.fn()
renderHeader({ onSearchChange })
const search = screen.getByRole('searchbox', { name: '搜索会话' })
await userEvent.type(search, '产品')
expect(onSearchChange).toHaveBeenCalled()
expect(onSearchChange.mock.calls[0]).toEqual(['产'])
})
})
+29 -26
View File
@@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ConversationSidebar } from '../../src/renderer/src/components/conversation/ConversationSidebar'
import { TooltipProvider } from '../../src/renderer/src/components/ui'
vi.mock('@tanstack/react-virtual', () => ({
useVirtualizer: ({ count }: { count: number }) => ({
@@ -19,32 +20,34 @@ vi.mock('@tanstack/react-virtual', () => ({
describe('ConversationSidebar', () => {
it('keeps official accounts in their own collapsible section', async () => {
render(
<ConversationSidebar
contacts={[
{
m_nsUsrName: 'gh_fixture',
m_nsNickName: '测试公众号',
md5: 'official-md5',
type: 'user',
isOfficialAccount: true
},
{
m_nsUsrName: 'wxid_fixture',
m_nsNickName: '测试联系人',
md5: 'contact-md5',
type: 'user'
}
]}
selectedContact={null}
onSelectContact={vi.fn()}
onSearch={vi.fn()}
onContentFilter={vi.fn()}
width={320}
selfInfo={null}
dbReady
onOpenSettings={vi.fn()}
onRefresh={vi.fn(async () => undefined)}
/>
<TooltipProvider>
<ConversationSidebar
contacts={[
{
m_nsUsrName: 'gh_fixture',
m_nsNickName: '测试公众号',
md5: 'official-md5',
type: 'user',
isOfficialAccount: true
},
{
m_nsUsrName: 'wxid_fixture',
m_nsNickName: '测试联系人',
md5: 'contact-md5',
type: 'user'
}
]}
selectedContact={null}
onSelectContact={vi.fn()}
onSearch={vi.fn()}
onContentFilter={vi.fn()}
width={320}
selfInfo={null}
dbReady
onOpenSettings={vi.fn()}
onRefresh={vi.fn(async () => undefined)}
/>
</TooltipProvider>
)
expect(screen.getByRole('button', { name: '公众号 (1)' })).toBeInTheDocument()
+45 -4
View File
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'
import type { ComponentProps } from 'react'
import { describe, expect, it, vi } from 'vitest'
import { DatabaseConnectionPage } from '../../src/renderer/src/components/DatabaseConnectionPage'
import { TooltipProvider } from '../../src/renderer/src/components/ui'
function renderPage(
overrides: Partial<ComponentProps<typeof DatabaseConnectionPage>> = {}
@@ -65,7 +66,14 @@ function renderPage(
onClearKey: vi.fn(),
...overrides
}
return { props, ...render(<DatabaseConnectionPage {...props} />) }
return {
props,
...render(
<TooltipProvider>
<DatabaseConnectionPage {...props} />
</TooltipProvider>
)
}
}
describe('DatabaseConnectionPage', () => {
@@ -119,7 +127,11 @@ describe('DatabaseConnectionPage', () => {
it('keeps connect disabled until a valid 64-character key is supplied', () => {
const { rerender, props } = renderPage()
expect(screen.getByRole('button', { name: '连接数据库' })).toBeDisabled()
rerender(<DatabaseConnectionPage {...props} dbKey={'a'.repeat(64)} />)
rerender(
<TooltipProvider>
<DatabaseConnectionPage {...props} dbKey={'a'.repeat(64)} />
</TooltipProvider>
)
expect(screen.getByRole('button', { name: '连接数据库' })).toBeEnabled()
})
@@ -175,11 +187,41 @@ describe('DatabaseConnectionPage', () => {
expect(onCopyDiagnostics).toHaveBeenCalledOnce()
expect(onGuideNext).toHaveBeenCalledOnce()
rerender(<DatabaseConnectionPage {...props} mode="automatic" guideStep={2} />)
rerender(
<TooltipProvider>
<DatabaseConnectionPage {...props} mode="automatic" guideStep={2} />
</TooltipProvider>
)
expect(screen.getByRole('button', { name: '我已准备好' })).toBeEnabled()
expect(screen.getByRole('button', { name: '返回上一步' })).toBeEnabled()
expect(screen.getByRole('button', { name: '取消并重新检查' })).toBeEnabled()
})
it('keeps mode, account and key visibility callbacks unchanged', async () => {
const onModeChange = vi.fn()
const onSelectAccount = vi.fn()
const onToggleDbKey = vi.fn()
const { props, rerender } = renderPage({
mode: 'automatic',
onModeChange,
onSelectAccount,
onToggleDbKey
})
await userEvent.click(screen.getByRole('tab', { name: /高级用户/ }))
expect(onModeChange).toHaveBeenCalledWith('manual')
await userEvent.click(screen.getByRole('button', { name: /脱敏账号 A/ }))
expect(onSelectAccount).toHaveBeenCalledWith(props.accounts[0])
rerender(
<TooltipProvider>
<DatabaseConnectionPage {...props} mode="manual" />
</TooltipProvider>
)
await userEvent.click(screen.getByRole('button', { name: '显示密钥' }))
expect(onToggleDbKey).toHaveBeenCalledOnce()
})
it('provides the official Visual C++ runtime download on Windows', () => {
renderPage({
status: '当前 Windows 缺少 Microsoft Visual C++ 运行库',
@@ -191,5 +233,4 @@ describe('DatabaseConnectionPage', () => {
'https://aka.ms/vc14/vc_redist.x64.exe'
)
})
})
@@ -0,0 +1,130 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { DatabaseKeyAutoDetect } from '../../src/renderer/src/features/settings/database-key/DatabaseKeyAutoDetect'
import { DatabaseKeyDiagnostics } from '../../src/renderer/src/features/settings/database-key/DatabaseKeyDiagnostics'
import { DatabaseKeyDangerZone } from '../../src/renderer/src/features/settings/database-key/DatabaseKeyDangerZone'
import { DatabaseKeyEditor } from '../../src/renderer/src/features/settings/database-key/DatabaseKeyEditor'
import { DatabaseKeyStatus } from '../../src/renderer/src/features/settings/database-key/DatabaseKeyStatus'
import type { DatabaseKeyState } from '../../src/renderer/src/features/settings/database-key/types'
import { TooltipProvider } from '../../src/renderer/src/components/ui'
const state: DatabaseKeyState = {
status: 'saved',
saved: true,
encryptionAvailable: true,
autoPhase: 0,
environment: {
platform: 'win32',
osVersion: 'Windows 11',
appVersion: '2.2.2',
wechatVersion: '4.1.0',
dataStructureVersion: 'WCDB',
dataDirectoryDetected: true,
diagnosticSummary: 'fixture',
autoDetectSupported: true,
wechatRunning: true,
accountIdentified: true,
dbConnected: true,
encryptionAvailable: true
}
}
describe('database key controls', () => {
it('keeps auto-detect and refresh callbacks separate', async () => {
const user = userEvent.setup()
const onDetect = vi.fn()
const onRefresh = vi.fn()
render(
<DatabaseKeyAutoDetect
state={{ ...state, status: 'auto-detect-error', error: '没有找到候选密钥' }}
disabled={false}
onDetect={onDetect}
onRefresh={onRefresh}
/>
)
await user.click(screen.getByRole('button', { name: '自动获取密钥' }))
await user.click(screen.getByRole('button', { name: '刷新前置状态' }))
expect(onDetect).toHaveBeenCalledOnce()
expect(onRefresh).toHaveBeenCalledOnce()
})
it('keeps status validation and diagnostics copy actions available', async () => {
const user = userEvent.setup()
const onValidate = vi.fn()
const onCopy = vi.fn()
const { rerender } = render(
<DatabaseKeyStatus
state={state}
dbReady
selfInfo={{ wxid: 'wxid_fixture', nickname: '测试账号', accountRoot: 'fixture-account' }}
disabled={false}
onValidate={onValidate}
/>
)
await user.click(screen.getByRole('button', { name: '重新验证' }))
expect(onValidate).toHaveBeenCalledOnce()
rerender(
<DatabaseKeyDiagnostics
state={state}
input={'a'.repeat(64)}
wxid="wxid_fixture"
accountRoot="fixture-account"
onCopy={onCopy}
/>
)
await user.click(screen.getByText('查看密钥诊断'))
await user.click(screen.getByRole('button', { name: '复制诊断信息' }))
expect(onCopy).toHaveBeenCalledOnce()
})
it('uses shared button variants for editor actions without legacy visual classes', async () => {
const user = userEvent.setup()
const onValidate = vi.fn()
const onSave = vi.fn()
render(
<TooltipProvider>
<DatabaseKeyEditor
value={'a'.repeat(64)}
disabled={false}
canSave
onChange={vi.fn()}
onPaste={vi.fn()}
onValidate={onValidate}
onSave={onSave}
/>
</TooltipProvider>
)
const validate = screen.getByRole('button', { name: '验证密钥' })
const save = screen.getByRole('button', { name: '保存密钥' })
expect(validate).not.toHaveClass('database-key-secondary')
expect(save).not.toHaveClass('database-key-primary')
await user.click(validate)
await user.click(save)
expect(onValidate).toHaveBeenCalledOnce()
expect(onSave).toHaveBeenCalledOnce()
})
it('restores focus to the actual destructive dialog trigger', async () => {
const user = userEvent.setup()
render(
<DatabaseKeyDangerZone
disabled={false}
onClear={vi.fn()}
onReplace={vi.fn()}
onReturnToLogin={vi.fn()}
/>
)
const clearKey = screen.getByRole('button', { name: '清除密钥' })
await user.click(clearKey)
expect(screen.getByRole('alertdialog', { name: '确认清除数据库密钥?' })).toBeVisible()
await user.keyboard('{Escape}')
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
expect(clearKey).toHaveFocus()
})
})
+102 -2
View File
@@ -1,9 +1,12 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AutoDetectImageKeySection } from '../../src/renderer/src/features/settings/image-decryption/AutoDetectImageKeySection'
import { ImageDecryptStatus } from '../../src/renderer/src/features/settings/image-decryption/ImageDecryptStatus'
import { ImageTestSection } from '../../src/renderer/src/features/settings/image-decryption/ImageTestSection'
import { initialImageDecryptionState } from '../../src/renderer/src/features/settings/image-decryption/imageDecryptionReducer'
import type { ImageBatchTestState } from '../../src/renderer/src/features/settings/image-decryption/types'
import type { ImageDecryptionStatus } from '../../src/shared/image-decryption'
const emptyBatchTest: ImageBatchTestState = {
running: false,
@@ -12,11 +15,103 @@ const emptyBatchTest: ImageBatchTestState = {
items: []
}
const imageStatus: ImageDecryptionStatus = {
configured: true,
saved: true,
encryptionAvailable: true,
source: 'secure-storage',
resourceRoot: '/fixture/images',
platform: 'darwin',
autoDetectSupported: true,
wechatRunning: true,
accountIdentified: true,
cacheState: 'normal',
decoder: {
installed: true,
available: true,
source: 'bundled',
selected: false
},
resources: {
imageIndex: { state: 'available', detail: '正常' },
imageDirectory: { state: 'available', detail: '正常' },
thumbnail: { state: 'available', detail: '正常' },
original: { state: 'available', detail: '正常' },
sticker: { state: 'available', detail: '正常' },
video: { state: 'available', detail: '正常' }
}
}
afterEach(() => {
vi.restoreAllMocks()
})
describe('ImageTestSection', () => {
it('uses shared buttons for status validation and automatic key actions', async () => {
const user = userEvent.setup()
const onValidate = vi.fn()
const onDetect = vi.fn()
const onSave = vi.fn()
const configuredState = {
...initialImageDecryptionState,
config: {
success: true,
configured: true,
saved: true,
encryptionAvailable: true,
source: 'secure-storage' as const,
resourceRoot: '/fixture/images'
},
status: imageStatus
}
const { rerender } = render(
<ImageDecryptStatus
state={configuredState}
selfInfo={null}
disabled={false}
onValidate={onValidate}
/>
)
const validate = screen.getByRole('button', { name: '重新验证' })
expect(validate).not.toHaveClass('database-key-secondary')
await user.click(validate)
expect(onValidate).toHaveBeenCalledOnce()
rerender(
<AutoDetectImageKeySection
state={{ ...configuredState, autoPhase: 'success', autoAccount: '测试账号' }}
disabled={false}
canSave
onDetect={onDetect}
onSave={onSave}
/>
)
const detect = screen.getByRole('button', { name: '开始自动获取' })
const save = screen.getByRole('button', { name: '保存图片密钥' })
expect(detect).not.toHaveClass('database-key-secondary')
expect(save).not.toHaveClass('database-key-primary')
await user.click(detect)
await user.click(save)
expect(onDetect).toHaveBeenCalledOnce()
expect(onSave).toHaveBeenCalledOnce()
})
it('keeps automatic detection disabled while scanning', () => {
render(
<AutoDetectImageKeySection
state={{ ...initialImageDecryptionState, status: imageStatus, autoPhase: 'scanning' }}
disabled={false}
canSave={false}
onDetect={vi.fn()}
onSave={vi.fn()}
/>
)
expect(screen.getByRole('button', { name: '扫描中…' })).toBeDisabled()
})
it('keeps the file step successful when decryption fails and copies diagnostics', async () => {
const onCopyLog = vi.fn()
render(
@@ -88,6 +183,7 @@ describe('ImageTestSection', () => {
it('filters groups and contacts before starting a batch test', async () => {
const onBatchTest = vi.fn()
const onSelect = vi.fn()
vi.spyOn(window, 'confirm').mockReturnValue(true)
render(
<ImageTestSection
@@ -111,7 +207,7 @@ describe('ImageTestSection', () => {
batchTest={emptyBatchTest}
disabled={false}
canSave={false}
onSelect={vi.fn()}
onSelect={onSelect}
onTest={vi.fn()}
onBatchTest={onBatchTest}
onStopBatchTest={vi.fn()}
@@ -120,9 +216,13 @@ describe('ImageTestSection', () => {
/>
)
await userEvent.click(screen.getByRole('button', { name: '群聊 1' }))
await userEvent.click(screen.getByRole('radio', { name: '群聊 1' }))
expect(screen.getByRole('radio', { name: '群聊 1' })).toBeChecked()
await userEvent.click(screen.getByRole('combobox', { name: '选择会话' }))
expect(screen.getByRole('option', { name: '测试群聊' })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: '测试联系人' })).not.toBeInTheDocument()
await userEvent.click(screen.getByRole('option', { name: '测试群聊' }))
expect(onSelect).toHaveBeenCalledWith('group-md5')
await userEvent.click(screen.getByRole('button', { name: '测试筛选结果(1' }))
expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining('测试 1 个会话'))
@@ -187,6 +187,21 @@ describe('PersonalWechatSendDialog', () => {
expect(screen.queryByRole('radio', { name: '文字' })).not.toBeInTheDocument()
})
it('supports segmented keyboard navigation for the message type', async () => {
const user = userEvent.setup()
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
await screen.findByText('技术交流群')
const imageMode = screen.getByRole('radio', { name: '图片' })
const voiceMode = screen.getByRole('radio', { name: '语音' })
imageMode.focus()
await user.keyboard('{ArrowRight}')
expect(voiceMode).toHaveFocus()
await user.keyboard(' ')
expect(voiceMode).toBeChecked()
expect(screen.getByRole('textbox', { name: '要生成的文字' })).toBeVisible()
})
it('switches to image mode and sends only the selected image', async () => {
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
await screen.findByText('技术交流群')
@@ -262,6 +277,8 @@ describe('PersonalWechatSendDialog', () => {
await user.click(screen.getByRole('button', { name: '选择图片' }))
await user.click(screen.getByRole('button', { name: '测试发送图片到群聊' }))
expect(screen.getByRole('button', { name: '正在发送…' })).toBeDisabled()
expect(screen.getByRole('radio', { name: '图片' })).toBeDisabled()
expect(screen.getByRole('radio', { name: '语音' })).toBeDisabled()
await user.keyboard('{Escape}')
expect(screen.getByRole('dialog', { name: '个人微信测试发送' })).toBeVisible()
+78 -11
View File
@@ -1,4 +1,4 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ReportGroupMemberSelector } from '../../src/renderer/src/components/reports/ReportGroupMemberSelector'
@@ -9,6 +9,10 @@ import { ReportInfoPanel } from '../../src/renderer/src/components/reports/Repor
import { ReportToolbar } from '../../src/renderer/src/components/reports/ReportToolbar'
import { ReportHistorySidebar } from '../../src/renderer/src/components/reports/ReportHistorySidebar'
import { ModelSummary } from '../../src/renderer/src/components/reports/ModelSummary'
import { MessageTypeSelector } from '../../src/renderer/src/components/reports/MessageTypeSelector'
import { ReportDensitySelector } from '../../src/renderer/src/components/reports/ReportDensitySelector'
import { ReportRangeSelector } from '../../src/renderer/src/components/reports/ReportRangeSelector'
import { SUMMARY_TYPE_OPTIONS } from '../../src/renderer/src/utils/group-report'
import type { Contact } from '../../src/shared/types'
import type { GeneratedReportRecord } from '../../src/shared/report-history'
@@ -106,6 +110,44 @@ describe('daily report controls', () => {
})
})
it('uses unified option and checkbox controls while preserving report selection rules', async () => {
const user = userEvent.setup()
const onRangeChange = vi.fn()
const onTypesChange = vi.fn()
render(
<>
<ReportRangeSelector
value="today"
messageCount={4}
rangeState={{ status: 'success', error: '' }}
disabled={false}
onChange={onRangeChange}
/>
<MessageTypeSelector
value={['text']}
counts={Object.fromEntries(SUMMARY_TYPE_OPTIONS.map((option) => [option.value, 1]))}
disabled={false}
onChange={onTypesChange}
/>
<ReportDensitySelector />
</>
)
expect(screen.getByRole('radio', { name: '今天' })).toBeChecked()
await user.click(screen.getByRole('radio', { name: '近 7 天' }))
expect(onRangeChange).toHaveBeenCalledWith('7days')
expect(screen.getAllByRole('checkbox')).toHaveLength(SUMMARY_TYPE_OPTIONS.length)
const textCheckbox = screen.getAllByRole('checkbox')[0]
expect(textCheckbox).toBeChecked()
expect(textCheckbox).toBeDisabled()
await user.click(screen.getAllByRole('checkbox')[1])
expect(onTypesChange).toHaveBeenCalledWith(['text', 'image'])
expect(screen.getByRole('radio', { name: /标准/ })).toBeChecked()
screen.getAllByRole('radio', { name: /简洁|标准|深度/ }).forEach((item) => {
expect(item).toBeDisabled()
})
})
it('shows a separate voice progress bar only when voice is selected', () => {
const progress = { processed: 2, total: 3, succeeded: 2, failed: 0 }
const { rerender } = render(
@@ -203,6 +245,7 @@ describe('daily report controls', () => {
})
it('allows switching models and retrying only the model step', async () => {
const user = userEvent.setup()
const onRetry = vi.fn()
window.api.listAIProviders = vi.fn(async () => ({
success: true,
@@ -246,7 +289,10 @@ describe('daily report controls', () => {
)
const retryButton = await screen.findByRole('button', { name: '使用所选模型重新生成' })
const retryModel = screen.getByRole('combobox', { name: '切换模型' })
await user.click(retryModel)
expect(screen.getByRole('option', { name: '备用服务 · 备用模型' })).toBeVisible()
await user.click(screen.getByRole('option', { name: '备用服务 · 备用模型' }))
fireEvent.click(retryButton)
expect(onRetry).toHaveBeenCalledWith(
expect.objectContaining({ providerId: 'provider-2', model: 'model-2' })
@@ -254,7 +300,8 @@ describe('daily report controls', () => {
expect(screen.getByText(/从第三步继续/)).toBeVisible()
})
it('selects separate text-summary and image-understanding models with the 10-minute cache rule', () => {
it('selects separate text-summary and image-understanding models with the 10-minute cache rule', async () => {
const user = userEvent.setup()
const onTextModelChange = vi.fn()
const onVisionModelChange = vi.fn()
const textModels = [
@@ -283,6 +330,14 @@ describe('daily report controls', () => {
modelName: 'GPT-5.6 Sol',
configured: true as const,
status: 'connected' as const
},
{
providerId: 'vision-provider',
providerName: 'Vision',
model: 'vision-model',
modelName: 'Vision Model',
configured: true as const,
status: 'connected' as const
}
]
render(
@@ -299,13 +354,14 @@ describe('daily report controls', () => {
const textSelect = screen.getByRole('combobox', { name: '文字总结模型' })
const visionSelect = screen.getByRole('combobox', { name: '图片理解模型' })
expect(textSelect).toHaveValue('deepseek::deepseek-chat')
expect(visionSelect).toHaveValue('sol-provider::gpt-5.6-sol')
expect(screen.getAllByRole('option', { name: 'OpenAI · GPT-5.6 Sol' })).toHaveLength(2)
fireEvent.change(textSelect, { target: { value: 'openai::gpt-5.6-sol' } })
fireEvent.change(visionSelect, { target: { value: 'sol-provider::gpt-5.6-sol' } })
expect(textSelect).toHaveTextContent('DeepSeek · DeepSeek Chat')
expect(visionSelect).toHaveTextContent('OpenAI · GPT-5.6 Sol')
await user.click(textSelect)
await user.click(screen.getByRole('option', { name: 'OpenAI · GPT-5.6 Sol' }))
await user.click(visionSelect)
await user.click(screen.getByRole('option', { name: 'Vision · Vision Model' }))
expect(onTextModelChange).toHaveBeenCalledWith(textModels[1])
expect(onVisionModelChange).toHaveBeenCalledWith(visionModels[0])
expect(onVisionModelChange).toHaveBeenCalledWith(visionModels[1])
expect(screen.getByText(/图片识别缓存 10 分钟/)).toBeVisible()
})
@@ -676,12 +732,17 @@ describe('daily report controls', () => {
})
it('loads and displays group nickname, WeChat nickname, and remark separately', async () => {
const user = userEvent.setup()
render(<ReportGroupMemberSelector sourceContact={groupContact} />)
await waitFor(() => expect(screen.getAllByText('群内昵称一')).toHaveLength(2))
expect(screen.getByText('微信昵称一')).toBeVisible()
expect(screen.getByText('通讯录备注一')).toBeVisible()
expect(screen.getByText('wxid-one')).toBeVisible()
await user.click(screen.getByRole('combobox', { name: '选择群成员' }))
await user.click(screen.getByRole('option', { name: '群内昵称二' }))
expect(screen.getByText('微信昵称二')).toBeVisible()
expect(screen.getByText('wxid-two')).toBeVisible()
})
it('cancels report deletion and restores focus to the delete trigger', async () => {
@@ -774,7 +835,8 @@ describe('daily report controls', () => {
)
})
it('offers the classic default plus three mobile and two desktop report templates', () => {
it('offers the classic default plus three mobile and two desktop report templates', async () => {
const user = userEvent.setup()
const onChange = vi.fn()
render(<ReportTemplateSelector value="v1" onChange={onChange} />)
@@ -792,8 +854,13 @@ describe('daily report controls', () => {
const previewButtons = screen.getAllByRole('button', { name: '查看版式' })
expect(previewButtons).toHaveLength(6)
fireEvent.click(previewButtons[2])
fireEvent.click(screen.getByRole('button', { name: '选择此模板' }))
await user.click(previewButtons[2])
let dialog = screen.getByRole('dialog', { name: 'AI Magazine' })
await user.click(within(dialog).getAllByRole('button', { name: '关闭' })[1])
expect(document.activeElement).toBe(previewButtons[2])
await user.click(previewButtons[2])
dialog = screen.getByRole('dialog', { name: 'AI Magazine' })
await user.click(within(dialog).getByRole('button', { name: '选择此模板' }))
expect(onChange).toHaveBeenCalledWith('mobile-magazine')
})
@@ -0,0 +1,74 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AboutPage } from '../../src/renderer/src/features/settings/pages/AboutPage'
import { AdvancedPage } from '../../src/renderer/src/features/settings/pages/AdvancedPage'
import { RecallProtectionPage } from '../../src/renderer/src/features/settings/pages/RecallProtectionPage'
const api = {
checkAppUpdate: vi.fn(),
downloadAppUpdate: vi.fn(),
getAppUpdateState: vi.fn(),
getSettings: vi.fn(),
installAppUpdate: vi.fn(),
onAppUpdateState: vi.fn(),
revealAppLog: vi.fn(),
setSettings: vi.fn()
}
describe('basic settings pages', () => {
beforeEach(() => {
vi.clearAllMocks()
Object.defineProperty(window, 'api', { configurable: true, value: api })
api.getAppUpdateState.mockResolvedValue({ status: 'idle', currentVersion: '2.2.2' })
api.onAppUpdateState.mockReturnValue(vi.fn())
api.checkAppUpdate.mockResolvedValue(undefined)
api.revealAppLog.mockResolvedValue(undefined)
api.getSettings.mockResolvedValue({ settings: { debugEnabled: false } })
api.setSettings.mockResolvedValue({ settings: { debugEnabled: true } })
})
it('keeps update and log actions connected to the desktop APIs', async () => {
const user = userEvent.setup()
render(<AboutPage onNotice={vi.fn()} />)
await screen.findByText('v2.2.2')
await user.click(screen.getByRole('button', { name: '检查更新' }))
await user.click(screen.getByRole('button', { name: '打开诊断日志目录' }))
await waitFor(() => expect(api.checkAppUpdate).toHaveBeenCalledOnce())
expect(api.revealAppLog).toHaveBeenCalledOnce()
})
it('uses the shared switch without changing the debug settings contract', async () => {
const user = userEvent.setup()
const onNotice = vi.fn()
render(<AdvancedPage onNotice={onNotice} />)
const debugSwitch = await screen.findByRole('switch', { name: '显示诊断日志' })
expect(debugSwitch).not.toBeChecked()
await user.click(debugSwitch)
await waitFor(() => expect(api.setSettings).toHaveBeenCalledWith({ debugEnabled: true }))
expect(debugSwitch).toBeChecked()
expect(onNotice).toHaveBeenCalledWith('已开启调试日志')
})
it('uses the shared switch without changing the recall protection settings contract', async () => {
api.getSettings.mockResolvedValue({ settings: { recallProtectionEnabled: false } })
api.setSettings.mockResolvedValue({ settings: { recallProtectionEnabled: true } })
const user = userEvent.setup()
const onNotice = vi.fn()
render(<RecallProtectionPage onNotice={onNotice} />)
const recallSwitch = await screen.findByRole('switch', { name: '开启防撤回' })
expect(recallSwitch).not.toBeChecked()
await user.click(recallSwitch)
await waitFor(() =>
expect(api.setSettings).toHaveBeenCalledWith({ recallProtectionEnabled: true })
)
expect(recallSwitch).toBeChecked()
expect(onNotice).toHaveBeenCalledWith('已开启防撤回')
})
})
+46 -2
View File
@@ -16,6 +16,8 @@ const listVoices = vi.fn()
const openApiKeys = vi.fn()
const getRuntimeStatus = vi.fn()
const onRuntimeProgress = vi.fn(() => vi.fn())
const openRuntimeDirectory = vi.fn()
const removeRuntime = vi.fn()
const response = {
success: true,
@@ -82,9 +84,22 @@ describe('TextToSpeechPage', () => {
platform: 'darwin',
architecture: 'arm64',
supported: true,
removable: true
removable: true,
directory: '/tmp/personal-wechat-runtime'
})
onRuntimeProgress.mockReset().mockReturnValue(vi.fn())
openRuntimeDirectory.mockReset().mockResolvedValue({ success: true })
removeRuntime.mockReset().mockResolvedValue({
version: 'v0.0.18',
state: 'missing',
downloadedBytes: 0,
totalBytes: 100,
progress: 0,
platform: 'darwin',
architecture: 'arm64',
supported: true,
removable: false
})
Object.defineProperty(window, 'api', {
configurable: true,
value: {
@@ -93,7 +108,9 @@ describe('TextToSpeechPage', () => {
listTextToSpeechVoices: listVoices,
openFishAudioApiKeys: openApiKeys,
getPersonalWechatRuntimeStatus: getRuntimeStatus,
onPersonalWechatRuntimeProgress: onRuntimeProgress
onPersonalWechatRuntimeProgress: onRuntimeProgress,
openPersonalWechatRuntimeDirectory: openRuntimeDirectory,
removePersonalWechatRuntime: removeRuntime
}
})
})
@@ -136,6 +153,33 @@ describe('TextToSpeechPage', () => {
await waitFor(() => expect(openApiKeys).toHaveBeenCalledTimes(1))
})
it('keeps model selection and API key actions on their existing settings callbacks', async () => {
const user = userEvent.setup()
render(<TextToSpeechPage onNotice={vi.fn()} />)
const apiKeyInput = await screen.findByPlaceholderText('已安全保存;输入新 Key 可替换')
expect(apiKeyInput).toHaveAttribute('type', 'password')
await user.click(screen.getByRole('button', { name: '显示' }))
expect(apiKeyInput).toHaveAttribute('type', 'text')
await user.click(screen.getByRole('combobox', { name: '合成模型' }))
await user.click(screen.getByRole('option', { name: 's2.1-pro', exact: true }))
await waitFor(() => expect(saveSettings).toHaveBeenCalledWith({ model: 's2.1-pro' }))
await user.click(screen.getByRole('button', { name: '清除应用内 Key' }))
await waitFor(() => expect(saveSettings).toHaveBeenCalledWith({ clearApiKey: true }))
})
it('keeps runtime directory and refresh actions wired to the existing API', async () => {
const user = userEvent.setup()
render(<TextToSpeechPage onNotice={vi.fn()} />)
await user.click(await screen.findByRole('button', { name: '打开目录' }))
expect(openRuntimeDirectory).toHaveBeenCalledOnce()
await user.click(screen.getByRole('button', { name: '重新检测' }))
expect(getRuntimeStatus).toHaveBeenCalledTimes(2)
})
it('opens supported versions from both triggers and restores focus after closing', async () => {
const user = userEvent.setup()
render(<TextToSpeechPage onNotice={vi.fn()} />)
@@ -146,6 +146,14 @@ describe('voice recognition settings', () => {
conversationIds: ['group-b'],
range: 'recent_30_days'
})
await userEvent.click(screen.getByRole('combobox', { name: '语音转写时间范围' }))
await userEvent.click(screen.getByRole('option', { name: '所选会话全部历史' }))
await waitFor(() =>
expect(window.api.getVoiceBatchConversationSummaries).toHaveBeenCalledWith({
conversationIds: ['group-b'],
range: 'selected_history'
})
)
await userEvent.click(screen.getByRole('checkbox', { name: /群聊 B/ }))
await userEvent.click(screen.getByRole('tab', { name: /联系人 1/ }))
await userEvent.click(await screen.findByRole('checkbox', { name: /联系人 A/ }))
@@ -157,7 +165,7 @@ describe('voice recognition settings', () => {
await waitFor(() =>
expect(window.api.startVoiceBatch).toHaveBeenCalledWith({
conversationIds: ['group-b', 'contact-a'],
range: 'recent_30_days'
range: 'selected_history'
})
)
})
+343 -11
View File
@@ -16,8 +16,12 @@ test('APP-01 first launch renders a usable connection screen without uncaught er
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
await expect(fixture.page.getByRole('heading', { name: 'TraceMemo(迹忆)' })).toBeVisible()
await expect(fixture.page.getByRole('main')).not.toBeEmpty()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
} finally {
await fixture.close()
@@ -132,7 +136,12 @@ test('CHAT-01 archive More menu is keyboard-safe and keeps the page usable', asy
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 1000, height: 650 })
await fixture.setWindowContentSize({ width: 820, height: 600 })
const conversationSearch = fixture.page.getByRole('searchbox', { name: '搜索会话' })
await conversationSearch.fill('产品')
await expect(fixture.page.getByText('产品测试群', { exact: true })).toBeVisible()
await conversationSearch.fill('')
await fixture.page.getByRole('button', { name: '刷新会话列表' }).click()
await fixture.page.getByText('产品测试群', { exact: true }).click()
const moreButton = fixture.page.getByRole('button', { name: '更多' })
await moreButton.click()
@@ -144,6 +153,24 @@ test('CHAT-01 archive More menu is keyboard-safe and keeps the page usable', asy
await moreButton.click()
await fixture.page.getByRole('menuitem', { name: '刷新数据' }).click()
await expect(fixture.page.getByRole('heading', { name: '产品测试群' })).toBeVisible()
await fixture.page.getByRole('button', { name: '搜索当前聊天' }).click()
const searchInput = fixture.page.getByRole('textbox', { name: '搜索当前聊天内容' })
await expect(searchInput).toBeFocused()
await searchInput.fill('测试')
await expect(searchInput).toHaveValue('测试')
expect(
await fixture.page
.locator('.chat-archive-header')
.evaluate((element) => element.scrollWidth <= element.clientWidth)
).toBe(true)
await fixture.page.getByRole('button', { name: '关闭搜索' }).click()
await expect(searchInput).toHaveCount(0)
const avatarSwitch = fixture.page.getByRole('switch', { name: '显示头像' })
await expect(avatarSwitch).toBeChecked()
await avatarSwitch.click()
await expect(avatarSwitch).not.toBeChecked()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
@@ -159,7 +186,7 @@ test('CHAT-02 personal WeChat send dialog is keyboard-safe and fits the viewport
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 1000, height: 650 })
await fixture.setWindowContentSize({ width: 820, height: 600 })
await fixture.page.getByText('产品测试群', { exact: true }).click()
const trigger = fixture.page.getByRole('button', { name: '发送消息' })
await trigger.click()
@@ -177,9 +204,20 @@ test('CHAT-02 personal WeChat send dialog is keyboard-safe and fits the viewport
const bounds = await dialog.boundingBox()
expect(bounds).not.toBeNull()
expect(bounds!.y).toBeGreaterThanOrEqual(0)
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(650)
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(600)
expect(pageErrors).toEqual([])
const imageMode = dialog.getByRole('radio', { name: '图片' })
const voiceMode = dialog.getByRole('radio', { name: '语音' })
await imageMode.focus()
await fixture.page.keyboard.press('ArrowRight')
await expect(voiceMode).toBeFocused()
await fixture.page.keyboard.press('Space')
await expect(voiceMode).toBeChecked()
expect(await dialog.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(
true
)
await fixture.page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0)
await expect(trigger).toBeFocused()
@@ -218,7 +256,7 @@ test('SETTINGS-01 supported WeChat versions dialog is keyboard-safe and fits the
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 1000, height: 650 })
await fixture.setWindowContentSize({ width: 820, height: 600 })
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
@@ -240,6 +278,278 @@ test('SETTINGS-01 supported WeChat versions dialog is keyboard-safe and fits the
await fixture.page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0)
await expect(trigger).toBeFocused()
const modelSelect = fixture.page.getByRole('combobox', { name: '合成模型' })
await modelSelect.scrollIntoViewIfNeeded()
await modelSelect.click()
await expect(fixture.page.getByRole('option', { name: 's2.1-pro', exact: true })).toBeVisible()
await fixture.page.keyboard.press('Escape')
await expect(modelSelect).toBeFocused()
await expect(fixture.page.getByRole('searchbox', { name: '按音色名称搜索' })).toBeDisabled()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
} finally {
await fixture.close()
}
})
test('SETTINGS-02 basic settings controls fit a narrow viewport and keep their semantics', async () => {
const fixture = await launchTestApp()
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
.click()
await expect(fixture.page.getByRole('heading', { name: '账号与数据库' })).toBeVisible()
const autoLoginSwitch = fixture.page.getByRole('switch', { name: '启动时自动连接数据库' })
await expect(autoLoginSwitch).toBeVisible()
await autoLoginSwitch.click()
await expect(autoLoginSwitch).toBeChecked()
await fixture.page.getByRole('button', { name: '缓存与清理' }).click()
await expect(fixture.page.getByRole('heading', { name: '缓存与清理' })).toBeVisible()
await expect(fixture.page.getByRole('button', { name: '清理全部' })).toBeEnabled()
await fixture.page.getByRole('button', { name: '高级' }).click()
const debugSwitch = fixture.page.getByRole('switch', { name: '显示诊断日志' })
await expect(debugSwitch).toBeVisible()
await debugSwitch.click()
await expect(debugSwitch).toBeChecked()
await fixture.page.getByRole('button', { name: '防撤回' }).click()
const recallSwitch = fixture.page.getByRole('switch', { name: '开启防撤回' })
await expect(recallSwitch).toBeVisible()
await recallSwitch.click()
await expect(recallSwitch).toBeChecked()
await fixture.page.getByRole('button', { name: '关于' }).click()
await expect(fixture.page.getByRole('button', { name: '检查更新' })).toBeVisible()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
} finally {
await fixture.close()
}
})
test('SETTINGS-03 database key actions keep destructive confirmation keyboard-safe', async () => {
const fixture = await launchTestApp()
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
.click()
await fixture.page.getByRole('button', { name: '数据库密钥' }).click()
await expect(fixture.page.getByRole('heading', { name: '数据库密钥' })).toBeVisible()
await expect(fixture.page.getByRole('button', { name: '重新验证' })).toBeVisible()
const clearKey = fixture.page.getByRole('button', { name: '清除密钥' })
await clearKey.scrollIntoViewIfNeeded()
await clearKey.click()
const dialog = fixture.page.getByRole('alertdialog', { name: '确认清除数据库密钥?' })
await expect(dialog).toBeVisible()
await fixture.page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0)
await expect(clearKey).toBeFocused()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
} finally {
await fixture.close()
}
})
test('SETTINGS-04 image decryption controls keep filtering and selection keyboard-safe', async () => {
const fixture = await launchTestApp()
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
.click()
await fixture.page.getByRole('button', { name: '图片解密' }).click()
await expect(fixture.page.getByRole('heading', { name: '图片解密', exact: true })).toBeVisible()
const filterGroup = fixture.page.getByRole('radiogroup', { name: '会话类型筛选' })
await filterGroup.scrollIntoViewIfNeeded()
const allFilter = filterGroup.getByRole('radio', { name: '全部 3' })
await expect(allFilter).toBeChecked()
await allFilter.focus()
await fixture.page.keyboard.press('ArrowRight')
const groupFilter = filterGroup.getByRole('radio', { name: '群聊 2' })
await expect(groupFilter).toBeFocused()
await fixture.page.keyboard.press('Space')
await expect(groupFilter).toBeChecked()
await fixture.page.getByRole('searchbox', { name: '搜索会话' }).fill('产品')
const conversationSelect = fixture.page.getByRole('combobox', { name: '选择会话' })
await conversationSelect.click()
await expect(fixture.page.getByRole('option', { name: '产品测试群' })).toBeVisible()
await expect(fixture.page.getByRole('option', { name: '折叠群聊样本' })).toHaveCount(0)
await fixture.page.keyboard.press('Escape')
await expect(conversationSelect).toBeFocused()
await conversationSelect.click()
await fixture.page.getByRole('option', { name: '产品测试群' }).click()
await expect(conversationSelect).toContainText('产品测试群')
const testAction = fixture.page.getByRole('button', { name: '测试图片解析' })
await expect(testAction).toBeEnabled()
await testAction.click()
await expect(fixture.page.getByText('图片可以读取')).toBeVisible()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(
await fixture.page
.locator('.settings-page-scroll')
.evaluate((element) => element.scrollWidth <= element.clientWidth)
).toBe(true)
expect(pageErrors).toEqual([])
} finally {
await fixture.close()
}
})
test('SETTINGS-05 AI model editor keeps provider and capability semantics', async () => {
const fixture = await launchTestApp()
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
.click()
await fixture.page.getByRole('button', { name: 'AI 模型' }).click()
await expect(fixture.page.getByRole('heading', { name: 'AI 模型' })).toBeVisible()
await expect(fixture.page.getByRole('button', { name: '当前默认' })).toBeDisabled()
await fixture.page.getByRole('button', { name: '添加供应商' }).click()
await expect(fixture.page.getByRole('heading', { name: '新增供应商' })).toBeVisible()
await expect(fixture.page.getByLabel('供应商名称')).toBeFocused()
await fixture.page.getByLabel('供应商 ID').fill('fixture-new-provider')
const presetSelect = fixture.page.getByRole('combobox', { name: '快速模板' })
await presetSelect.click()
await fixture.page.getByRole('option', { name: 'OpenAI', exact: true }).click()
await expect(fixture.page.getByLabel('供应商名称')).toHaveValue('OpenAI')
await expect(fixture.page.getByLabel('模型名称')).toHaveValue('gpt-4o-mini')
const providerType = fixture.page.getByRole('combobox', { name: '供应商类型' })
await providerType.click()
await expect(fixture.page.getByRole('option', { name: 'Anthropic Messages' })).toBeVisible()
await fixture.page.keyboard.press('Escape')
await expect(providerType).toBeFocused()
const vision = fixture.page.getByRole('checkbox', { name: '图片理解 gpt-4o-mini' })
const ocr = fixture.page.getByRole('checkbox', { name: '图片文字识别 gpt-4o-mini' })
await vision.click()
await expect(vision).toBeChecked()
await expect(ocr).toBeChecked()
await expect(fixture.page.getByRole('radio', { name: '默认 gpt-4o-mini' })).toBeChecked()
await fixture.page.getByText('高级设置', { exact: true }).click()
await expect(fixture.page.getByLabel('请求超时(ms')).toHaveValue('120000')
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(
await fixture.page
.locator('.settings-page-scroll')
.evaluate((element) => element.scrollWidth <= element.clientWidth)
).toBe(true)
expect(pageErrors).toEqual([])
} finally {
await fixture.close()
}
})
test('SETTINGS-07 voice recognition controls keep selection semantics at a narrow viewport', async () => {
const fixture = await launchTestApp()
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
.click()
await fixture.page.getByRole('button', { name: '语音转文字' }).click()
await expect(fixture.page.getByRole('heading', { name: '语音转文字' })).toBeVisible()
const categoryTabs = fixture.page.getByRole('tablist', { name: '会话类别' })
await categoryTabs.scrollIntoViewIfNeeded()
await expect(categoryTabs.getByRole('tab', { name: /群聊/ })).toHaveAttribute(
'data-state',
'active'
)
const conversation = fixture.page.getByRole('checkbox', { name: /产品测试群/ })
await conversation.click()
await expect(conversation).toBeChecked()
const rangeSelect = fixture.page.getByRole('combobox', { name: '语音转写时间范围' })
await rangeSelect.click()
await expect(fixture.page.getByRole('option', { name: '所选会话全部历史' })).toBeVisible()
await fixture.page.keyboard.press('Escape')
await expect(rangeSelect).toBeFocused()
await expect(fixture.page.getByRole('button', { name: '开始转写' })).toBeDisabled()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(
await fixture.page
.locator('.settings-page-scroll')
.evaluate((element) => element.scrollWidth <= element.clientWidth)
).toBe(true)
expect(pageErrors).toEqual([])
} finally {
await fixture.close()
}
})
test('AGENT-01 Agent Hub controls stay usable in the offline narrow layout', async () => {
const fixture = await launchTestApp()
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
await fixture.page.getByRole('button', { name: 'Agent' }).click()
await expect(fixture.page.getByRole('heading', { name: 'Agent Hub' })).toBeVisible()
await expect(fixture.page.getByText('Agent Hub 未运行')).toBeVisible()
await expect(fixture.page.getByRole('button', { name: '扫码登录微信机器人' })).toBeDisabled()
const logSource = fixture.page.getByRole('combobox', { name: '筛选日志来源' })
await logSource.scrollIntoViewIfNeeded()
await logSource.click()
await expect(fixture.page.getByRole('option', { name: '微信连接器' })).toBeVisible()
await fixture.page.keyboard.press('Escape')
await expect(logSource).toBeFocused()
await expect(fixture.page.getByRole('button', { name: '复制日志' })).toBeDisabled()
await fixture.page.getByRole('button', { name: '清空' }).click()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(
await fixture.page
.locator('.agent-hub-workspace')
.evaluate((element) => element.scrollWidth <= element.clientWidth)
).toBe(true)
expect(pageErrors).toEqual([])
} finally {
await fixture.close()
}
@@ -263,6 +573,15 @@ test('API-01 manages the local API token and previews the Reader Skill safely',
await fixture.page.getByRole('button', { name: '重新生成 Token' }).click()
await expect(fixture.page.getByText('Token 已生成')).toBeVisible()
const moreTrigger = fixture.page
.locator('#api-reader-skill')
.getByRole('button', { name: '更多' })
await moreTrigger.click()
await expect(fixture.page.getByRole('menuitem', { name: '打开本地文件夹' })).toBeVisible()
await fixture.page.keyboard.press('Escape')
await expect(fixture.page.getByRole('menu')).toHaveCount(0)
await expect(moreTrigger).toBeFocused()
const previewTrigger = fixture.page
.locator('#api-reader-skill')
.getByRole('button', { name: '预览 Skill' })
@@ -337,7 +656,7 @@ test('LAYOUT-01 core workspaces fit a narrow desktop viewport without page error
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
const navigation = fixture.page.getByRole('navigation', { name: '一级导航' })
for (const pageName of ['档案', '问问微信', '日报', '导出', '设置']) {
for (const pageName of ['档案', '问问微信', '日报', '导出', 'API', '设置']) {
await navigation.getByRole('button', { name: pageName }).click()
await expect(fixture.page.locator('main.app-shell-main')).not.toBeEmpty()
expect(
@@ -436,13 +755,20 @@ test('MEDIA-02 MEDIA-04 return accurate unsupported and HTTP 403 reasons', async
test('ASK-01 uses the local fixed AI service and keeps evidence in the UI', async () => {
const fixture = await launchTestApp()
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
await fixture.page.getByRole('button', { name: '问问微信' }).click()
await fixture.page.getByPlaceholder(/例如:技术交流群/).fill('测试群讨论了什么?')
await fixture.page.getByRole('button', { name: '开始分析' }).click()
await expect(fixture.page.getByText(/固定假回答:测试数据中的核心流程正常/)).toBeVisible({
timeout: 15_000
})
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
} finally {
await fixture.close()
}
@@ -470,12 +796,18 @@ test('REPORT-01 REPORT-02 generates a fixed report with non-empty local assets',
await expect(fixture.page.getByRole('heading', { name: '生成群聊日报' })).toBeVisible()
const textModel = fixture.page.getByRole('combobox', { name: '文字总结模型' })
const visionModel = fixture.page.getByRole('combobox', { name: '图片理解模型' })
await expect(textModel).toHaveValue('fixture-provider::fixture-model')
await expect(visionModel).toHaveValue('fixture-provider::fixture-vision-model')
await expect(textModel.locator('option')).toHaveCount(2)
await expect(visionModel.locator('option')).toHaveCount(1)
await expect(textModel).toContainText('固定响应模型')
await expect(visionModel).toContainText('固定图片识别模型')
await textModel.click()
await expect(fixture.page.getByRole('option')).toHaveCount(2)
await fixture.page.keyboard.press('Escape')
await expect(textModel).toBeFocused()
await visionModel.click()
await expect(fixture.page.getByRole('option')).toHaveCount(1)
await fixture.page.keyboard.press('Escape')
await expect(visionModel).toBeFocused()
await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click()
await fixture.page.getByRole('button', { name: '近 7 天' }).click()
await fixture.page.getByRole('radio', { name: '近 7 天' }).click()
const generate = fixture.page.getByRole('button', { name: '开始生成日报' })
await expect(generate).toBeEnabled()
await generate.click()
@@ -530,7 +862,7 @@ test('REPORT-03 report failure is retryable and leaves other pages usable', asyn
await fixture.page.getByRole('button', { name: '日报' }).click()
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click()
await fixture.page.getByRole('button', { name: '近 7 天' }).click()
await fixture.page.getByRole('radio', { name: '近 7 天' }).click()
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
await expect(fixture.page.getByText(/本地假服务错误 401/).first()).toBeVisible()
await expect(fixture.page.getByRole('button', { name: '使用所选模型重新生成' })).toBeEnabled()
+69 -4
View File
@@ -445,6 +445,33 @@ handle('voice:downloadModel', () => ({ success: true, status: voiceModelStatus('
handle('voice:cancelModelDownload', () => ({ success: true }))
handle('voice:removeModel', () => voiceModelStatus())
handle('voice:openModelDirectory', () => ({ success: true }))
handle('voice:getBatchProgress', () => ({
accountIdentity: 'fixture-account',
state: 'idle',
total: 0,
processed: 0,
cached: 0,
succeeded: 0,
failed: 0,
elapsedMs: 0,
estimatedRemainingMs: null
}))
handle('voice:getBatchConversationSummaries', (request) =>
request.conversationIds.map((conversationId, index) => ({
conversationId,
voiceMessageCount: index + 3
}))
)
handle('voice:getBatchPreflight', (request) => ({
accountIdentity: 'fixture-account',
conversationCount: request.conversationIds.length,
voiceMessageCount: request.conversationIds.length * 3,
cachedCount: 0,
pendingCount: request.conversationIds.length * 3,
failedCount: 0,
estimatedDurationMs: null,
modelReady: false
}))
handle('voice:recognize', () => ({ success: true, transcript: '固定脱敏转写文本', language: 'zh' }))
handle('voice:cancelRecognition', () => ({ success: true }))
handle('db:getSticker', (url) =>
@@ -612,8 +639,35 @@ handle('app-log:write', (entry) => {
})
handle('app-log:getPath', () => path.join(userData, 'logs', 'e2e.log'))
handle('app-log:reveal', () => undefined)
handle('cache:getSummary', () => ({ bootstrapBytes: 0, electronBytes: 0, totalBytes: 0 }))
handle('cache:clear', () => ({ bootstrapBytes: 0, electronBytes: 0, totalBytes: 0 }))
const cacheSummary = () => ({
items: [
{
id: 'bootstrap',
label: '启动缓存',
description: '联系人和会话的本地启动快照',
sizeBytes: 4096,
fileCount: 2
},
{
id: 'electron',
label: '界面缓存',
description: 'Electron 页面资源和网络缓存',
sizeBytes: 2 * 1024 * 1024,
fileCount: 12
},
{
id: 'knowledge',
label: '本地知识库索引',
description: '问问微信使用的本地检索索引',
sizeBytes: 12 * 1024 * 1024,
fileCount: 8
}
],
totalBytes: 14 * 1024 * 1024 + 4096,
updatedAt: fixtureNowMs
})
handle('cache:getSummary', cacheSummary)
handle('cache:clear', cacheSummary)
handle('api:getStatus', () => ({ running: false, host: settings.apiHost, port: settings.apiPort }))
handle('api:tokenStatus', () => ({
success: true,
@@ -660,6 +714,19 @@ handle('api:toggle', (enabled) => ({
host: settings.apiHost,
port: settings.apiPort
}))
const agentHubStatus = () => ({
hub: 'offline',
connector: 'disconnected',
updatedAt: fixtureNowMs,
dataApi: 'online',
databaseReady: true
})
handle('agent-hub:getStatus', agentHubStatus)
handle('agent-hub:getLogs', () => [])
handle('agent-hub:clearLogs', () => ({ success: true }))
handle('agent-hub:startLogin', () => ({ status: agentHubStatus() }))
handle('agent-hub:cancelLogin', () => ({ status: agentHubStatus() }))
handle('agent-hub:disconnect', () => ({ status: agentHubStatus() }))
handle('image:getConfig', () => ({
success: true,
configured: true,
@@ -734,8 +801,6 @@ handle('accounts:discover', (inputPath) =>
]
}
)
handle('agent-hub:getStatus', () => ({ state: 'disconnected', connected: false }))
handle('agent-hub:getLogs', () => [])
handle('app-update:getState', () => ({ status: 'idle', currentVersion: '2.2.0' }))
for (const channel of [
+513 -6
View File
@@ -49,6 +49,33 @@ test('ARCH-01 archive page visual @visual', async () => {
}
})
test('CHAT-01 archive search controls visual @visual', async () => {
const fixture = await launchTestApp({ now: visualNow })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByText('产品测试群', { exact: true }).click()
await fixture.page.getByRole('button', { name: '搜索当前聊天' }).click()
const searchInput = fixture.page.getByRole('textbox', { name: '搜索当前聊天内容' })
await searchInput.fill('测试')
await expect(searchInput).toHaveValue('测试')
expect(
await fixture.page
.locator('.chat-archive-header')
.evaluate((element) => element.scrollWidth <= element.clientWidth)
).toBe(true)
expect(pageErrors).toEqual([])
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot('chat-content-search.png', {
animations: 'disabled',
caret: 'hide'
})
} finally {
await fixture.close()
}
})
test('ASK-01 AI Search idle page visual @visual', async () => {
const fixture = await launchTestApp({ now: visualNow })
const pageErrors: Error[] = []
@@ -100,6 +127,79 @@ test('ASK-03 AI Search result page visual @visual', async () => {
}
})
test('ASK-04 AI Search idle page dark visual @visual', async () => {
const fixture = await launchTestApp({ now: visualNow, appearanceTheme: 'dark' })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: '问问微信' }).click()
await expect(fixture.page.getByRole('heading', { name: '问问你的微信' })).toBeVisible()
await expect(fixture.page.locator('html')).toHaveAttribute('data-theme', 'dark')
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot('ai-search-idle-page-dark.png', {
animations: 'disabled',
caret: 'hide'
})
} finally {
await fixture.close()
}
})
test('ASK-05 AI Search result page dark visual @visual', async () => {
const fixture = await launchTestApp({ now: visualNow, appearanceTheme: 'dark' })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: '问问微信' }).click()
await fixture.page.getByPlaceholder(/例如:技术交流群/).fill('测试群讨论了什么?')
await fixture.page.getByRole('button', { name: '开始分析' }).click()
await expect(fixture.page.getByText(/固定假回答:测试数据中的核心流程正常/)).toBeVisible({
timeout: 15_000
})
await expect(fixture.page.locator('html')).toHaveAttribute('data-theme', 'dark')
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot('ai-search-result-page-dark.png', {
animations: 'disabled',
caret: 'hide'
})
} finally {
await fixture.close()
}
})
test('API-00 Reader Skill page visual @visual', async () => {
const fixture = await launchTestApp({ now: visualNow })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: 'API' }).click()
await expect(fixture.page.getByRole('heading', { name: 'TraceMemo Reader' })).toBeVisible()
await expect(fixture.page.getByText('API Token', { exact: true })).toBeVisible()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot('api-center-page.png', {
animations: 'disabled',
caret: 'hide'
})
} finally {
await fixture.close()
}
})
test('API-01 Reader Skill preview visual @visual', async () => {
const fixture = await launchTestApp({ now: visualNow })
const pageErrors: Error[] = []
@@ -128,22 +228,29 @@ test('API-01 Reader Skill preview visual @visual', async () => {
}
})
test('CHAT-02 personal WeChat send dialog visual @visual', async () => {
test.skip(process.platform !== 'darwin', 'Personal WeChat sending is currently macOS-only')
test('API-02 Agent target segmented control visual @visual', async () => {
const fixture = await launchTestApp({ now: visualNow })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByText('产品测试群', { exact: true }).click()
await fixture.page.getByRole('button', { name: '发送消息' }).click()
await expect(fixture.page.getByRole('dialog', { name: '个人微信测试发送' })).toBeVisible()
await fixture.page.getByRole('button', { name: 'API' }).click()
const targetGroup = fixture.page.getByRole('radiogroup', { name: '选择目标 Agent' })
await targetGroup.evaluate((element) => element.scrollIntoView({ block: 'center' }))
const codexTarget = fixture.page.getByRole('radio', { name: 'Codex' })
await expect(codexTarget).toBeChecked()
await codexTarget.focus()
await fixture.page.keyboard.press('ArrowRight')
const claudeTarget = fixture.page.getByRole('radio', { name: 'Claude Code' })
await expect(claudeTarget).toBeFocused()
await fixture.page.keyboard.press('Space')
await expect(claudeTarget).toBeChecked()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot('personal-wechat-send-dialog.png', {
await expect(fixture.page).toHaveScreenshot('api-agent-target.png', {
animations: 'disabled',
caret: 'hide'
})
@@ -152,6 +259,124 @@ test('CHAT-02 personal WeChat send dialog visual @visual', async () => {
}
})
test('REPORT-00 report configuration controls visual @visual', async () => {
const fixture = await launchTestApp({ now: visualNow })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: '日报' }).click()
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click()
await expect(fixture.page.getByRole('heading', { name: '生成群聊日报' })).toBeVisible()
await expect(fixture.page.getByRole('radiogroup', { name: '总结范围' })).toBeVisible()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot('report-config-page.png', {
animations: 'disabled',
caret: 'hide'
})
const modelHeading = fixture.page.getByRole('heading', { name: '模型配置' })
await modelHeading.scrollIntoViewIfNeeded()
await expect(modelHeading).toBeVisible()
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot('report-model-controls.png', {
animations: 'disabled',
caret: 'hide'
})
const templateSection = fixture.page.getByRole('heading', { name: '日报模板' }).locator('..')
await templateSection.getByRole('button', { name: '查看版式' }).first().click()
const templateDialog = fixture.page.getByRole('dialog', { name: '经典日报' })
await expect(templateDialog).toBeVisible()
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot('report-template-dialog.png', {
animations: 'disabled',
caret: 'hide'
})
} finally {
await fixture.close()
}
})
test('REPORT-01 report retry controls visual @visual', async () => {
const fixture = await launchTestApp({ now: visualNow, aiFailure: '401' })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: '日报' }).click()
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click()
await fixture.page.getByRole('radio', { name: '近 7 天' }).click()
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
await expect(fixture.page.getByText(/本地假服务错误 401/).first()).toBeVisible()
await expect(fixture.page.getByRole('combobox', { name: '切换模型' })).toBeVisible()
await expect(fixture.page.getByRole('button', { name: '使用所选模型重新生成' })).toBeEnabled()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot('report-retry-controls.png', {
animations: 'disabled',
caret: 'hide'
})
} finally {
await fixture.close()
}
})
for (const appearanceTheme of ['light', 'dark'] as const) {
test(`CHAT-02 personal WeChat send dialog ${appearanceTheme} visual @visual`, async () => {
test.skip(process.platform !== 'darwin', 'Personal WeChat sending is currently macOS-only')
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByText('产品测试群', { exact: true }).click()
await fixture.page.getByRole('button', { name: '发送消息' }).click()
const dialog = fixture.page.getByRole('dialog', { name: '个人微信测试发送' })
await expect(dialog).toBeVisible()
await expect(fixture.page.locator('html')).toHaveAttribute('data-theme', appearanceTheme)
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
await fixture.page.mouse.move(0, 0)
await fixture.page.waitForTimeout(700)
await clearScreenshotFocus(fixture.page)
const screenshotName =
appearanceTheme === 'light'
? 'personal-wechat-send-dialog.png'
: 'personal-wechat-send-dialog-dark.png'
await expect(fixture.page).toHaveScreenshot(screenshotName, {
animations: 'disabled',
caret: 'hide'
})
await dialog.getByRole('radio', { name: '语音' }).click()
await expect(dialog.getByRole('textbox', { name: '要生成的文字' })).toBeVisible()
await dialog.locator('footer').scrollIntoViewIfNeeded()
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot(
`personal-wechat-voice-controls-${appearanceTheme}.png`,
{
animations: 'disabled',
caret: 'hide'
}
)
} finally {
await fixture.close()
}
})
}
test('CHAT-03 image viewer visual @visual', async () => {
const fixture = await launchTestApp({ now: visualNow })
const pageErrors: Error[] = []
@@ -175,6 +400,288 @@ test('CHAT-03 image viewer visual @visual', async () => {
}
})
test('SETTINGS-02 basic settings controls visual @visual', async () => {
const fixture = await launchTestApp({ now: visualNow })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
.click()
await expect(fixture.page.getByRole('heading', { name: '账号与数据库' })).toBeVisible()
await expect(fixture.page.getByRole('switch', { name: '启动时自动连接数据库' })).toBeVisible()
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot('settings-account-database.png', {
animations: 'disabled',
caret: 'hide'
})
await fixture.page.getByRole('button', { name: '缓存与清理' }).click()
await expect(fixture.page.getByRole('heading', { name: '缓存与清理' })).toBeVisible()
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot('settings-cache-cleanup.png', {
animations: 'disabled',
caret: 'hide'
})
await fixture.page.getByRole('button', { name: '高级' }).click()
await expect(fixture.page.getByRole('switch', { name: '显示诊断日志' })).toBeVisible()
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot('settings-advanced.png', {
animations: 'disabled',
caret: 'hide'
})
await fixture.page.getByRole('button', { name: '关于' }).click()
await expect(fixture.page.getByRole('button', { name: '检查更新' })).toBeVisible()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot('settings-about.png', {
animations: 'disabled',
caret: 'hide'
})
} finally {
await fixture.close()
}
})
test('SETTINGS-03 database key controls visual @visual', async () => {
const fixture = await launchTestApp({ now: visualNow })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
.click()
await fixture.page.getByRole('button', { name: '数据库密钥' }).click()
await expect(fixture.page.getByRole('heading', { name: '数据库密钥' })).toBeVisible()
await expect(fixture.page.getByRole('button', { name: '重新验证' })).toBeVisible()
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot('settings-database-key.png', {
animations: 'disabled',
caret: 'hide'
})
const clearKey = fixture.page.getByRole('button', { name: '清除密钥' })
await clearKey.scrollIntoViewIfNeeded()
await expect(clearKey).toBeVisible()
expect(pageErrors).toEqual([])
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot('settings-database-key-danger.png', {
animations: 'disabled',
caret: 'hide'
})
} finally {
await fixture.close()
}
})
for (const appearanceTheme of ['light', 'dark'] as const) {
test(`SETTINGS-08 recall protection ${appearanceTheme} visual @visual`, async () => {
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
.click()
await fixture.page.getByRole('button', { name: '防撤回' }).click()
await expect(fixture.page.getByRole('switch', { name: '开启防撤回' })).toBeVisible()
await expect(fixture.page.locator('html')).toHaveAttribute('data-theme', appearanceTheme)
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot(
`settings-recall-protection-${appearanceTheme}.png`,
{
animations: 'disabled',
caret: 'hide'
}
)
} finally {
await fixture.close()
}
})
}
for (const appearanceTheme of ['light', 'dark'] as const) {
test(`SETTINGS-04 image decryption controls ${appearanceTheme} visual @visual`, async () => {
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
.click()
await fixture.page.getByRole('button', { name: '图片解密' }).click()
await expect(
fixture.page.getByRole('heading', { name: '图片解密', exact: true })
).toBeVisible()
const filterGroup = fixture.page.getByRole('radiogroup', { name: '会话类型筛选' })
await filterGroup.scrollIntoViewIfNeeded()
await expect(filterGroup.getByRole('radio', { name: '全部 3' })).toBeChecked()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot(
`settings-image-decryption-${appearanceTheme}.png`,
{
animations: 'disabled',
caret: 'hide'
}
)
} finally {
await fixture.close()
}
})
}
for (const appearanceTheme of ['light', 'dark'] as const) {
test(`SETTINGS-05 AI model editor ${appearanceTheme} visual @visual`, async () => {
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
.click()
await fixture.page.getByRole('button', { name: 'AI 模型' }).click()
await fixture.page.getByRole('button', { name: '添加供应商' }).click()
await expect(fixture.page.getByRole('heading', { name: '新增供应商' })).toBeVisible()
await fixture.page.getByLabel('供应商 ID').fill('fixture-new-provider')
await expect(fixture.page.getByRole('combobox', { name: '快速模板' })).toBeVisible()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot(
`settings-ai-model-editor-${appearanceTheme}.png`,
{
animations: 'disabled',
caret: 'hide'
}
)
} finally {
await fixture.close()
}
})
}
for (const appearanceTheme of ['light', 'dark'] as const) {
test(`SETTINGS-06 text-to-speech controls ${appearanceTheme} visual @visual`, async () => {
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
.click()
await fixture.page.getByRole('button', { name: '文字转语音' }).click()
const modelSelect = fixture.page.getByRole('combobox', { name: '合成模型' })
await modelSelect.scrollIntoViewIfNeeded()
await expect(modelSelect).toBeVisible()
await expect(fixture.page.getByRole('searchbox', { name: '按音色名称搜索' })).toBeDisabled()
await expect(fixture.page.locator('html')).toHaveAttribute('data-theme', appearanceTheme)
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot(
`settings-text-to-speech-controls-${appearanceTheme}.png`,
{
animations: 'disabled',
caret: 'hide'
}
)
} finally {
await fixture.close()
}
})
}
for (const appearanceTheme of ['light', 'dark'] as const) {
test(`SETTINGS-07 voice recognition controls ${appearanceTheme} visual @visual`, async () => {
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
.click()
await fixture.page.getByRole('button', { name: '语音转文字' }).click()
const categoryTabs = fixture.page.getByRole('tablist', { name: '会话类别' })
await categoryTabs.scrollIntoViewIfNeeded()
await expect(categoryTabs).toBeVisible()
await expect(fixture.page.getByRole('combobox', { name: '语音转写时间范围' })).toBeVisible()
await expect(fixture.page.locator('html')).toHaveAttribute('data-theme', appearanceTheme)
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot(
`settings-voice-recognition-controls-${appearanceTheme}.png`,
{
animations: 'disabled',
caret: 'hide'
}
)
} finally {
await fixture.close()
}
})
}
for (const appearanceTheme of ['light', 'dark'] as const) {
test(`AGENT-01 Agent Hub ${appearanceTheme} visual @visual`, async () => {
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: 'Agent' }).click()
await expect(fixture.page.getByRole('heading', { name: 'Agent Hub' })).toBeVisible()
await expect(fixture.page.getByText('Agent Hub 未运行')).toBeVisible()
await expect(fixture.page.getByRole('combobox', { name: '筛选日志来源' })).toBeVisible()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot(`agent-hub-${appearanceTheme}.png`, {
animations: 'disabled',
caret: 'hide'
})
} finally {
await fixture.close()
}
})
}
test('EXPORT-01 export workspace idle visual @visual', async () => {
const fixture = await launchTestApp({ now: visualNow })
const pageErrors: Error[] = []
+15
View File
@@ -2,6 +2,21 @@ import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach, vi } from 'vitest'
if (!HTMLElement.prototype.hasPointerCapture) {
Object.defineProperties(HTMLElement.prototype, {
hasPointerCapture: { configurable: true, value: () => false },
releasePointerCapture: { configurable: true, value: () => undefined },
setPointerCapture: { configurable: true, value: () => undefined }
})
}
if (!HTMLElement.prototype.scrollIntoView) {
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: () => undefined
})
}
afterEach(() => cleanup())
function createMemoryStorage(): Storage {