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;
}