mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-21 21:47:00 +08:00
refactor(ui): 统一迁移原生按钮、输入框及交互控件
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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('手机端 · 375–414 px', mobileTemplates)}
|
||||
{renderGroup('电脑端 · 1280–1920 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&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>
|
||||
|
||||
@@ -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 }
|
||||
@@ -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">
|
||||
额外 Headers(JSON)
|
||||
<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.0,V3 及以下无法解析
|
||||
</small>
|
||||
<small className="image-auto-scope-hint">仅支持 WeChat 4.0,V3 及以下无法解析</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>
|
||||
)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -373,27 +373,7 @@
|
||||
Consolas,
|
||||
monospace;
|
||||
}
|
||||
.settings-icon-button {
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border: 1px solid #dde3e0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #66706b;
|
||||
cursor: pointer;
|
||||
}
|
||||
.settings-icon-button:hover:not(:disabled) {
|
||||
border-color: #247a63;
|
||||
color: #247a63;
|
||||
}
|
||||
.settings-icon-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.settings-icon-button svg {
|
||||
.settings-account-root svg {
|
||||
width: 17px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
@@ -752,15 +732,6 @@
|
||||
.database-key-auto-error p {
|
||||
color: #79501f;
|
||||
}
|
||||
.database-key-auto-error button {
|
||||
justify-self: start;
|
||||
border: 0;
|
||||
padding: 4px 0;
|
||||
background: transparent;
|
||||
color: #247a63;
|
||||
cursor: pointer;
|
||||
font: 600 12px inherit;
|
||||
}
|
||||
.database-key-security-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -803,15 +774,6 @@
|
||||
margin: 0;
|
||||
padding: 2px 16px 16px;
|
||||
}
|
||||
.database-key-diagnostics button {
|
||||
margin: 0 16px 16px;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: #247a63;
|
||||
cursor: pointer;
|
||||
font: 600 12px inherit;
|
||||
}
|
||||
.database-key-connection-actions {
|
||||
margin-top: 30px;
|
||||
}
|
||||
@@ -842,19 +804,6 @@
|
||||
color: #66706b;
|
||||
font-size: 12px;
|
||||
}
|
||||
.database-key-connection-actions button {
|
||||
min-height: 34px;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid #247a63;
|
||||
border-radius: 8px;
|
||||
padding: 0 13px;
|
||||
background: #fff;
|
||||
color: #247a63;
|
||||
cursor: pointer;
|
||||
}
|
||||
.database-key-connection-actions button:hover:not(:disabled) {
|
||||
background: #f0f7f4;
|
||||
}
|
||||
.database-key-danger {
|
||||
margin-top: 30px;
|
||||
padding-bottom: 1px;
|
||||
@@ -892,20 +841,6 @@
|
||||
color: #786868;
|
||||
font-size: 12px;
|
||||
}
|
||||
.database-key-danger button {
|
||||
min-height: 34px;
|
||||
flex: 0 0 auto;
|
||||
border: 1px solid #e8baba;
|
||||
border-radius: 8px;
|
||||
padding: 0 13px;
|
||||
background: #fff;
|
||||
color: #c85a5a;
|
||||
cursor: pointer;
|
||||
}
|
||||
.database-key-danger button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.database-key-confirm-backdrop {
|
||||
position: fixed;
|
||||
z-index: 30;
|
||||
@@ -1058,9 +993,7 @@
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.image-key-editor input,
|
||||
.image-test-section select,
|
||||
.image-test-search {
|
||||
.image-key-editor input {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
@@ -1076,9 +1009,7 @@
|
||||
Consolas,
|
||||
monospace;
|
||||
}
|
||||
.image-key-editor input:focus,
|
||||
.image-test-section select:focus,
|
||||
.image-test-search:focus {
|
||||
.image-key-editor input:focus {
|
||||
border-color: #247a63;
|
||||
box-shadow: 0 0 0 2px rgba(36, 122, 99, 0.09);
|
||||
}
|
||||
@@ -1143,9 +1074,6 @@
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.image-test-copy {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.image-test-section > label {
|
||||
display: block;
|
||||
margin: 18px 0 8px;
|
||||
@@ -1153,43 +1081,6 @@
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.image-test-filter-bar {
|
||||
display: inline-flex;
|
||||
gap: 3px;
|
||||
margin-top: 18px;
|
||||
padding: 3px;
|
||||
border: 1px solid #dbe4e0;
|
||||
border-radius: 9px;
|
||||
background: #f1f5f3;
|
||||
}
|
||||
.image-test-filter-bar button {
|
||||
min-width: 72px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #66706b;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.image-test-filter-bar button:hover:not(:disabled) {
|
||||
color: #247a63;
|
||||
}
|
||||
.image-test-filter-bar button.active {
|
||||
background: #fff;
|
||||
color: #247a63;
|
||||
box-shadow: 0 1px 4px rgba(35, 69, 57, 0.12);
|
||||
font-weight: 700;
|
||||
}
|
||||
.image-test-filter-bar button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.image-test-search {
|
||||
display: block;
|
||||
font-family: inherit;
|
||||
}
|
||||
.image-test-empty {
|
||||
margin: 8px 0 0;
|
||||
color: #8b938f;
|
||||
@@ -1652,31 +1543,6 @@
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #edf0ee;
|
||||
}
|
||||
.ai-provider-card button,
|
||||
.ai-provider-editor button {
|
||||
min-height: 32px;
|
||||
border: 1px solid #d5ddda;
|
||||
border-radius: 7px;
|
||||
padding: 0 11px;
|
||||
background: #fff;
|
||||
color: #46514c;
|
||||
cursor: pointer;
|
||||
font: 600 12px inherit;
|
||||
}
|
||||
.ai-provider-card button:hover,
|
||||
.ai-provider-editor button:hover {
|
||||
border-color: #247a63;
|
||||
color: #247a63;
|
||||
}
|
||||
.ai-provider-card button:disabled,
|
||||
.ai-provider-editor button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.ai-provider-card button.danger,
|
||||
.ai-provider-editor button.danger {
|
||||
color: #c85a5a;
|
||||
}
|
||||
.ai-provider-error {
|
||||
margin: 0 0 12px;
|
||||
color: #a84444;
|
||||
@@ -1719,29 +1585,6 @@
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ai-provider-editor input:not([type='checkbox']):not([type='radio']),
|
||||
.ai-provider-editor select,
|
||||
.ai-provider-editor textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
border: 1px solid #d5ddda;
|
||||
border-radius: 7px;
|
||||
outline: 0;
|
||||
padding: 9px 10px;
|
||||
background: #f4f7f5;
|
||||
color: #202724;
|
||||
font:
|
||||
12px/1.4 ui-monospace,
|
||||
SFMono-Regular,
|
||||
Consolas,
|
||||
monospace;
|
||||
}
|
||||
.ai-provider-editor input:focus,
|
||||
.ai-provider-editor select:focus,
|
||||
.ai-provider-editor textarea:focus {
|
||||
border-color: #247a63;
|
||||
box-shadow: 0 0 0 2px rgba(36, 122, 99, 0.08);
|
||||
}
|
||||
.ai-provider-form-grid,
|
||||
.ai-provider-advanced > div {
|
||||
display: grid;
|
||||
@@ -1829,6 +1672,20 @@
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@media (max-width: 1120px) {
|
||||
.settings-account-overview {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 18px;
|
||||
}
|
||||
.settings-account-facts {
|
||||
grid-column: 1/-1;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.settings-account-actions {
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
}
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.settings-sidebar {
|
||||
width: 248px;
|
||||
@@ -1849,18 +1706,6 @@
|
||||
border-top: 2px solid #e5b45f;
|
||||
border-left: 0;
|
||||
}
|
||||
.settings-account-overview {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 18px;
|
||||
}
|
||||
.settings-account-facts {
|
||||
grid-column: 1/-1;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.settings-account-actions {
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
}
|
||||
.database-key-security-info {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { AccountOverview } from '../../src/renderer/src/features/settings/account-database/AccountOverview'
|
||||
import { TooltipProvider } from '../../src/renderer/src/components/ui'
|
||||
|
||||
describe('account database controls', () => {
|
||||
it('keeps overview actions and disabled state connected', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCheck = vi.fn()
|
||||
const onOpenDirectory = vi.fn()
|
||||
const onCopyDirectory = vi.fn()
|
||||
const onSwitchAccount = vi.fn()
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<AccountOverview
|
||||
selfInfo={{
|
||||
wxid: 'wxid_fixture',
|
||||
nickname: '测试账号',
|
||||
accountRoot: '/fixture/account'
|
||||
}}
|
||||
connectionStatus="success"
|
||||
lastCheckedLabel="刚刚"
|
||||
isChecking={false}
|
||||
onCheck={onCheck}
|
||||
onOpenDirectory={onOpenDirectory}
|
||||
onCopyDirectory={onCopyDirectory}
|
||||
onSwitchAccount={onSwitchAccount}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '重新检测' }))
|
||||
await user.click(screen.getByRole('button', { name: '打开账号目录' }))
|
||||
await user.click(screen.getByRole('button', { name: '切换账号' }))
|
||||
await user.click(screen.getByRole('button', { name: '复制账号目录' }))
|
||||
|
||||
expect(onCheck).toHaveBeenCalledOnce()
|
||||
expect(onOpenDirectory).toHaveBeenCalledOnce()
|
||||
expect(onSwitchAccount).toHaveBeenCalledOnce()
|
||||
expect(onCopyDirectory).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('disables directory actions when no account root is available', () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<AccountOverview
|
||||
selfInfo={null}
|
||||
connectionStatus="unavailable"
|
||||
lastCheckedLabel="尚未检测"
|
||||
isChecking
|
||||
onCheck={vi.fn()}
|
||||
onOpenDirectory={vi.fn()}
|
||||
onCopyDirectory={vi.fn()}
|
||||
onSwitchAccount={vi.fn()}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: '正在检测...' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '打开账号目录' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '复制账号目录' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AgentHubWorkspace } from '../../src/renderer/src/features/agent-hub/AgentHubWorkspace'
|
||||
import type { AgentHubStatus } from '../../src/shared/agent-hub'
|
||||
|
||||
const onlineStatus: AgentHubStatus = {
|
||||
hub: 'online',
|
||||
connector: 'online',
|
||||
updatedAt: 1,
|
||||
dataApi: 'online',
|
||||
databaseReady: true,
|
||||
accountId: 'fixture-agent'
|
||||
}
|
||||
|
||||
describe('Agent Hub controls', () => {
|
||||
beforeEach(() => {
|
||||
window.api = {
|
||||
getAgentHubStatus: vi.fn().mockResolvedValue(onlineStatus),
|
||||
getAgentHubLogs: vi.fn().mockResolvedValue([
|
||||
{ id: 1, timestamp: 1, source: 'system', level: 'info', message: '系统就绪' },
|
||||
{
|
||||
id: 2,
|
||||
timestamp: 2,
|
||||
source: 'wechat-connector',
|
||||
level: 'warn',
|
||||
message: '等待连接'
|
||||
}
|
||||
]),
|
||||
onAgentHubStatus: vi.fn(() => () => undefined),
|
||||
onAgentHubLog: vi.fn(() => () => undefined),
|
||||
copyText: vi.fn().mockResolvedValue(undefined),
|
||||
clearAgentHubLogs: vi.fn().mockResolvedValue({ success: true }),
|
||||
startAgentHubLogin: vi.fn().mockResolvedValue({ status: onlineStatus }),
|
||||
cancelAgentHubLogin: vi.fn().mockResolvedValue({ status: onlineStatus }),
|
||||
disconnectAgentHub: vi.fn().mockResolvedValue({
|
||||
status: { ...onlineStatus, connector: 'disconnected' }
|
||||
})
|
||||
} as typeof window.api
|
||||
})
|
||||
|
||||
it('filters, copies, and clears logs through shared controls', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<AgentHubWorkspace />)
|
||||
await screen.findByText('系统就绪')
|
||||
|
||||
await user.click(screen.getByRole('combobox', { name: '筛选日志来源' }))
|
||||
await user.click(screen.getByRole('option', { name: '系统', exact: true }))
|
||||
expect(screen.getByText('系统就绪')).toBeInTheDocument()
|
||||
expect(screen.queryByText('等待连接')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '复制日志' }))
|
||||
expect(window.api.copyText).toHaveBeenCalledWith(expect.stringContaining('系统就绪'))
|
||||
expect(window.api.copyText).toHaveBeenCalledWith(expect.not.stringContaining('等待连接'))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '清空' }))
|
||||
expect(window.api.clearAgentHubLogs).toHaveBeenCalledOnce()
|
||||
expect(screen.getByText(/暂无运行日志/)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '复制日志' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('keeps reconnect and destructive disconnect actions separate', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<AgentHubWorkspace />)
|
||||
await screen.findByText('微信机器人已连接')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '重新扫码登录' }))
|
||||
expect(window.api.startAgentHubLogin).toHaveBeenCalledOnce()
|
||||
|
||||
const disconnect = screen.getByRole('button', { name: '断开连接' })
|
||||
expect(disconnect).toHaveClass('bg-destructive')
|
||||
await user.click(disconnect)
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(window.api.disconnectAgentHub).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,11 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AIImageUnderstandingTest } from '../../src/renderer/src/features/settings/ai-model/AIImageUnderstandingTest'
|
||||
import { AIProviderCard } from '../../src/renderer/src/features/settings/ai-model/AIProviderCard'
|
||||
import { AIModelPage } from '../../src/renderer/src/features/settings/pages/AIModelPage'
|
||||
import type { AIRuntimeModelConfig } from '../../src/shared/ai-provider'
|
||||
import type { AIProviderSummary, AIRuntimeModelConfig } from '../../src/shared/ai-provider'
|
||||
import type { AIVisionTestState } from '../../src/renderer/src/features/settings/ai-model/types'
|
||||
|
||||
const runtime: AIRuntimeModelConfig = {
|
||||
providerName: 'Not configured',
|
||||
@@ -11,6 +15,37 @@ const runtime: AIRuntimeModelConfig = {
|
||||
status: 'untested'
|
||||
}
|
||||
|
||||
const provider: AIProviderSummary = {
|
||||
id: 'fixture-provider',
|
||||
name: '本地假服务',
|
||||
type: 'openai-compatible',
|
||||
baseUrl: 'http://127.0.0.1:1/v1',
|
||||
auth: { type: 'none' },
|
||||
models: [
|
||||
{
|
||||
id: 'fixture-model',
|
||||
name: '固定响应模型',
|
||||
capabilities: { chat: true, vision: true, ocr: true, longContext: true }
|
||||
}
|
||||
],
|
||||
defaultModel: 'fixture-model',
|
||||
advanced: { timeoutMs: 5000, extraHeaders: {} },
|
||||
hasApiKey: true,
|
||||
isDefault: false,
|
||||
status: 'connected'
|
||||
}
|
||||
|
||||
const visionState: AIVisionTestState = {
|
||||
status: 'ready',
|
||||
prompt: '描述图片',
|
||||
image: {
|
||||
dataUrl: 'data:image/png;base64,fixture',
|
||||
fileName: 'fixture.png',
|
||||
mimeType: 'image/png',
|
||||
size: 1024
|
||||
}
|
||||
}
|
||||
|
||||
describe('AI model settings', () => {
|
||||
const scrollIntoView = vi.fn()
|
||||
|
||||
@@ -62,4 +97,81 @@ describe('AI model settings', () => {
|
||||
expect(window.api.getAIRuntimeConfig).toHaveBeenCalledOnce()
|
||||
expect(secondRuntimeChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps preset selection and derived vision capabilities intact', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<AIModelPage onRuntimeChange={vi.fn()} onNotice={vi.fn()} />)
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
await user.click(screen.getByRole('button', { name: '添加供应商' }))
|
||||
await user.click(screen.getByRole('combobox', { name: '快速模板' }))
|
||||
await user.click(screen.getByRole('option', { name: 'OpenAI', exact: true }))
|
||||
|
||||
expect(screen.getByLabelText('供应商名称')).toHaveValue('OpenAI')
|
||||
expect(screen.getByLabelText('模型名称')).toHaveValue('gpt-4o-mini')
|
||||
const vision = screen.getByRole('checkbox', { name: '图片理解 gpt-4o-mini' })
|
||||
const ocr = screen.getByRole('checkbox', { name: '图片文字识别 gpt-4o-mini' })
|
||||
expect(vision).not.toBeChecked()
|
||||
expect(ocr).not.toBeChecked()
|
||||
await user.click(vision)
|
||||
expect(vision).toBeChecked()
|
||||
expect(ocr).toBeChecked()
|
||||
expect(screen.getByRole('radio', { name: '默认 gpt-4o-mini' })).toBeChecked()
|
||||
})
|
||||
|
||||
it('uses shared button variants for provider card actions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onEdit = vi.fn()
|
||||
const onTest = vi.fn()
|
||||
const onDefault = vi.fn()
|
||||
const onDelete = vi.fn()
|
||||
render(
|
||||
<AIProviderCard
|
||||
provider={provider}
|
||||
testing={false}
|
||||
onEdit={onEdit}
|
||||
onTest={onTest}
|
||||
onDefault={onDefault}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '编辑' }))
|
||||
await user.click(screen.getByRole('button', { name: '测试连接' }))
|
||||
await user.click(screen.getByRole('button', { name: '设为默认' }))
|
||||
await user.click(screen.getByRole('button', { name: '删除' }))
|
||||
expect(onEdit).toHaveBeenCalledOnce()
|
||||
expect(onTest).toHaveBeenCalledOnce()
|
||||
expect(onDefault).toHaveBeenCalledOnce()
|
||||
expect(onDelete).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('button', { name: '删除' })).toHaveClass('bg-destructive')
|
||||
})
|
||||
|
||||
it('keeps vision file input native while using shared prompt and action controls', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onPromptChange = vi.fn()
|
||||
const onTest = vi.fn()
|
||||
const onClear = vi.fn()
|
||||
render(
|
||||
<AIImageUnderstandingTest
|
||||
runtime={{ ...runtime, configured: true }}
|
||||
provider={provider}
|
||||
state={visionState}
|
||||
onSelectImage={vi.fn()}
|
||||
onPromptChange={onPromptChange}
|
||||
onTest={onTest}
|
||||
onClear={onClear}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('img', { name: '图片理解测试预览' })).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('识别提示词')).toHaveValue('描述图片')
|
||||
await user.click(screen.getByRole('button', { name: '移除图片' }))
|
||||
await user.click(screen.getByRole('button', { name: '开始识别' }))
|
||||
expect(onClear).toHaveBeenCalledOnce()
|
||||
expect(onTest).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('button', { name: '开始识别' })).not.toHaveClass('database-key-primary')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -156,6 +156,50 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
|
||||
void user
|
||||
})
|
||||
|
||||
it('keeps result actions and model settings callbacks unchanged after UI migration', async () => {
|
||||
const onOpenAISettings = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
const view = renderWorkspace({ onOpenAISettings })
|
||||
await submitQuery()
|
||||
await screen.findByText('测试搜索答案')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '复制摘要' }))
|
||||
expect(api.copyText).toHaveBeenCalledWith('测试搜索答案')
|
||||
|
||||
view.rerender(
|
||||
<AISearchWorkspace
|
||||
{...(makeProps({
|
||||
onOpenAISettings,
|
||||
aiModelConfig: {
|
||||
configured: false,
|
||||
providerName: '',
|
||||
model: '',
|
||||
modelName: '',
|
||||
status: 'unconfigured' as const
|
||||
}
|
||||
}) as never)}
|
||||
/>
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: '配置模型' }))
|
||||
expect(onOpenAISettings).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('exposes the diagnostics action as an expanded control and keeps log access unchanged', async () => {
|
||||
api.getSettings.mockResolvedValue({ settings: { debugEnabled: true } })
|
||||
api.getAppLogPath.mockResolvedValue('/fixture/app.log')
|
||||
renderWorkspace()
|
||||
const user = userEvent.setup()
|
||||
const diagnostics = await screen.findByRole('button', { name: '诊断日志' })
|
||||
|
||||
expect(diagnostics).toHaveAttribute('aria-expanded', 'false')
|
||||
await user.click(diagnostics)
|
||||
expect(diagnostics).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(screen.getByText('/fixture/app.log')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '打开日志文件夹' }))
|
||||
expect(api.revealAppLog).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders a failed retrieval without producing a Summary', async () => {
|
||||
api.runAiSearch.mockResolvedValue(
|
||||
makeSearchResult({
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ApiRequestTester } from '../../src/renderer/src/features/api-center/components/ApiRequestTester'
|
||||
import { ReaderSkillOverview } from '../../src/renderer/src/features/api-center/components/ReaderSkillOverview'
|
||||
import type { ApiEndpoint } from '../../src/renderer/src/features/api-center/model/types'
|
||||
|
||||
const requestEndpoint: ApiEndpoint = {
|
||||
id: 'report',
|
||||
method: 'POST',
|
||||
path: '/v1/report',
|
||||
name: '测试接口',
|
||||
description: '测试统一表单控件',
|
||||
parameters: [
|
||||
{
|
||||
key: 'talker',
|
||||
label: '会话标识',
|
||||
required: true,
|
||||
placeholder: '群昵称'
|
||||
}
|
||||
],
|
||||
body: true
|
||||
}
|
||||
|
||||
describe('API Center unified controls', () => {
|
||||
it('uses the unified menu for Reader Skill actions and restores trigger focus', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onOpenFolder = vi.fn()
|
||||
const onOpenGithub = vi.fn()
|
||||
|
||||
render(
|
||||
<ReaderSkillOverview
|
||||
skill={{
|
||||
available: true,
|
||||
version: 'v1.2',
|
||||
source: 'bundled',
|
||||
githubUrl: 'https://example.com/tracememo'
|
||||
}}
|
||||
service={{ running: true, host: '127.0.0.1', port: 6131 }}
|
||||
dbReady
|
||||
target="codex"
|
||||
onTargetChange={vi.fn()}
|
||||
onPreview={vi.fn()}
|
||||
onOpenFolder={onOpenFolder}
|
||||
onOpenGithub={onOpenGithub}
|
||||
onStart={vi.fn()}
|
||||
onCopyInstruction={vi.fn()}
|
||||
onCopyVerification={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: '更多' })
|
||||
await user.click(trigger)
|
||||
await user.click(screen.getByRole('menuitem', { name: '打开本地文件夹' }))
|
||||
expect(onOpenFolder).toHaveBeenCalledOnce()
|
||||
|
||||
await user.click(trigger)
|
||||
expect(screen.getByRole('menuitem', { name: '查看 GitHub 最新版本' })).toBeVisible()
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('menu')).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(trigger).toHaveFocus())
|
||||
expect(onOpenGithub).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('exposes the Agent target as a real single-select control', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onTargetChange = vi.fn()
|
||||
render(
|
||||
<ReaderSkillOverview
|
||||
skill={{
|
||||
available: true,
|
||||
version: 'v1.2',
|
||||
source: 'bundled',
|
||||
githubUrl: 'https://example.com/tracememo'
|
||||
}}
|
||||
service={{ running: true, host: '127.0.0.1', port: 6131 }}
|
||||
dbReady
|
||||
target="codex"
|
||||
onTargetChange={onTargetChange}
|
||||
onPreview={vi.fn()}
|
||||
onOpenFolder={vi.fn()}
|
||||
onOpenGithub={vi.fn()}
|
||||
onStart={vi.fn()}
|
||||
onCopyInstruction={vi.fn()}
|
||||
onCopyVerification={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'Codex' })).toBeChecked()
|
||||
await user.click(screen.getByRole('radio', { name: 'Claude Code' }))
|
||||
expect(onTargetChange).toHaveBeenCalledWith('claude-code')
|
||||
})
|
||||
|
||||
it('preserves request editing and action callbacks with unified form controls', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onParams = vi.fn()
|
||||
const onBody = vi.fn()
|
||||
const onClear = vi.fn()
|
||||
const onCopyCurl = vi.fn(async () => undefined)
|
||||
const onSend = vi.fn()
|
||||
|
||||
render(
|
||||
<ApiRequestTester
|
||||
endpoint={requestEndpoint}
|
||||
settings={{ apiEnabled: true, apiHost: '127.0.0.1', apiPort: 6131 }}
|
||||
service={{ running: true, host: '127.0.0.1', port: 6131 }}
|
||||
params={{}}
|
||||
body="{}"
|
||||
state="idle"
|
||||
error=""
|
||||
onParams={onParams}
|
||||
onBody={onBody}
|
||||
onSend={onSend}
|
||||
onClear={onClear}
|
||||
onCopyCurl={onCopyCurl}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText('群昵称'), {
|
||||
target: { value: '产品群' }
|
||||
})
|
||||
expect(onParams).toHaveBeenLastCalledWith({ talker: '产品群' })
|
||||
|
||||
fireEvent.change(screen.getByLabelText('JSON 请求体'), {
|
||||
target: { value: '{"range":"today"}' }
|
||||
})
|
||||
expect(onBody).toHaveBeenLastCalledWith('{"range":"today"}')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '清空' }))
|
||||
await user.click(screen.getByRole('button', { name: '复制 curl' }))
|
||||
await user.click(screen.getByRole('button', { name: '发送请求' }))
|
||||
expect(onClear).toHaveBeenCalledOnce()
|
||||
expect(onCopyCurl).toHaveBeenCalledOnce()
|
||||
expect(onSend).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the send action disabled and exposes loading state accessibly', () => {
|
||||
const { rerender } = render(
|
||||
<ApiRequestTester
|
||||
endpoint={requestEndpoint}
|
||||
settings={{ apiEnabled: true, apiHost: '127.0.0.1', apiPort: 6131 }}
|
||||
service={{ running: false, host: '127.0.0.1', port: 6131 }}
|
||||
params={{}}
|
||||
body="{}"
|
||||
state="idle"
|
||||
error=""
|
||||
onParams={vi.fn()}
|
||||
onBody={vi.fn()}
|
||||
onSend={vi.fn()}
|
||||
onClear={vi.fn()}
|
||||
onCopyCurl={vi.fn(async () => undefined)}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: '请先启动本地 API 服务' })).toBeDisabled()
|
||||
|
||||
rerender(
|
||||
<ApiRequestTester
|
||||
endpoint={requestEndpoint}
|
||||
settings={{ apiEnabled: true, apiHost: '127.0.0.1', apiPort: 6131 }}
|
||||
service={{ running: true, host: '127.0.0.1', port: 6131 }}
|
||||
params={{}}
|
||||
body="{}"
|
||||
state="loading"
|
||||
error=""
|
||||
onParams={vi.fn()}
|
||||
onBody={vi.fn()}
|
||||
onSend={vi.fn()}
|
||||
onClear={vi.fn()}
|
||||
onCopyCurl={vi.fn(async () => undefined)}
|
||||
/>
|
||||
)
|
||||
|
||||
const sending = screen.getByRole('button', { name: '发送中…' })
|
||||
expect(sending).toBeDisabled()
|
||||
expect(sending).toHaveAttribute('aria-busy', 'true')
|
||||
})
|
||||
})
|
||||
@@ -27,6 +27,7 @@ describe('CacheCleanupPage', () => {
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
api.openKnowledgeDirectory.mockResolvedValue({ success: true })
|
||||
api.clearCache.mockResolvedValue({ items: [], totalBytes: 0, updatedAt: Date.now() })
|
||||
})
|
||||
|
||||
it('opens the real knowledge directory without clearing it', async () => {
|
||||
@@ -39,4 +40,14 @@ describe('CacheCleanupPage', () => {
|
||||
expect(api.clearCache).not.toHaveBeenCalled()
|
||||
expect(onNotice).toHaveBeenCalledWith('已打开知识库文件夹')
|
||||
})
|
||||
|
||||
it('keeps the clear-all scope and notice unchanged', async () => {
|
||||
const onNotice = vi.fn()
|
||||
render(<CacheCleanupPage onNotice={onNotice} />)
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: '清理全部' }))
|
||||
|
||||
await waitFor(() => expect(api.clearCache).toHaveBeenCalledWith('all'))
|
||||
expect(onNotice).toHaveBeenCalledWith('已清理全部可恢复缓存和检索记录')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import type { ReactElement } from 'react'
|
||||
import { render, screen, type RenderResult } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ChatHeader } from '../../src/renderer/src/components/chat/ChatHeader'
|
||||
import { ChatStatusBar } from '../../src/renderer/src/components/chat/ChatStatusBar'
|
||||
import { ExportMenu } from '../../src/renderer/src/components/chat/ExportMenu'
|
||||
import { TooltipProvider } from '../../src/renderer/src/components/ui'
|
||||
import type { Contact } from '../../src/shared/types'
|
||||
|
||||
const contact: Contact = {
|
||||
@@ -12,11 +15,15 @@ const contact: Contact = {
|
||||
type: 'group'
|
||||
}
|
||||
|
||||
function renderWithTooltip(element: ReactElement): RenderResult {
|
||||
return render(<TooltipProvider>{element}</TooltipProvider>)
|
||||
}
|
||||
|
||||
describe('chat menus', () => {
|
||||
it('runs the selected export range and closes the menu', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onExport = vi.fn()
|
||||
render(<ExportMenu disabled={false} onExport={onExport} />)
|
||||
renderWithTooltip(<ExportMenu disabled={false} onExport={onExport} />)
|
||||
|
||||
const trigger = screen.getByRole('button', { name: '导出' })
|
||||
await user.click(trigger)
|
||||
@@ -30,7 +37,7 @@ describe('chat menus', () => {
|
||||
it('closes the chat More menu with Escape and invokes refresh data once', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRefreshData = vi.fn()
|
||||
render(
|
||||
renderWithTooltip(
|
||||
<ChatHeader
|
||||
contact={contact}
|
||||
isGroupChat
|
||||
@@ -57,4 +64,85 @@ describe('chat menus', () => {
|
||||
await user.click(screen.getByRole('menuitem', { name: '刷新数据' }))
|
||||
expect(onRefreshData).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps header search and actions wired to their existing callbacks', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onContentFilterChange = vi.fn()
|
||||
const onRefresh = vi.fn()
|
||||
const onOpenAiSettings = vi.fn()
|
||||
renderWithTooltip(
|
||||
<ChatHeader
|
||||
contact={contact}
|
||||
isGroupChat
|
||||
loadedCount={8}
|
||||
filteredCount={2}
|
||||
contentFilter=""
|
||||
isAiLoading={false}
|
||||
onContentFilterChange={onContentFilterChange}
|
||||
onRefresh={onRefresh}
|
||||
onRefreshData={vi.fn()}
|
||||
onTestSend={vi.fn()}
|
||||
onOpenAiSettings={onOpenAiSettings}
|
||||
/>
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '搜索当前聊天' }))
|
||||
const searchInput = screen.getByRole('textbox', { name: '搜索当前聊天内容' })
|
||||
expect(searchInput).toHaveFocus()
|
||||
await user.type(searchInput, '测试')
|
||||
expect(onContentFilterChange).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getByRole('button', { name: '发送消息' })).toBeDisabled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '关闭搜索' }))
|
||||
expect(onContentFilterChange).toHaveBeenLastCalledWith('')
|
||||
expect(screen.queryByRole('textbox', { name: '搜索当前聊天内容' })).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '刷新聊天记录' }))
|
||||
await user.click(screen.getByRole('button', { name: '生成 AI 日报' }))
|
||||
expect(onRefresh).toHaveBeenCalledOnce()
|
||||
expect(onOpenAiSettings).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('preserves status switch, reload, and jump callbacks', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onShowAvatarChange = vi.fn()
|
||||
const onReloadAvatars = vi.fn()
|
||||
const onJumpToLatest = vi.fn()
|
||||
renderWithTooltip(
|
||||
<ChatStatusBar
|
||||
count={5}
|
||||
showAvatar={false}
|
||||
isAtLatest={false}
|
||||
isReloadingAvatars={false}
|
||||
onShowAvatarChange={onShowAvatarChange}
|
||||
onReloadAvatars={onReloadAvatars}
|
||||
onJumpToLatest={onJumpToLatest}
|
||||
/>
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('switch', { name: '显示头像' }))
|
||||
await user.click(screen.getByRole('button', { name: '重新加载头像' }))
|
||||
await user.click(screen.getByRole('button', { name: '跳转到最新消息' }))
|
||||
|
||||
expect(onShowAvatarChange).toHaveBeenCalledWith(true)
|
||||
expect(onReloadAvatars).toHaveBeenCalledOnce()
|
||||
expect(onJumpToLatest).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps loading and latest status actions disabled', () => {
|
||||
renderWithTooltip(
|
||||
<ChatStatusBar
|
||||
count={5}
|
||||
showAvatar
|
||||
isAtLatest
|
||||
isReloadingAvatars
|
||||
onShowAvatarChange={vi.fn()}
|
||||
onReloadAvatars={vi.fn()}
|
||||
onJumpToLatest={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: '加载中' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '已是最新消息' })).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,19 +2,29 @@ import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ConversationSidebarHeader } from '../../src/renderer/src/components/conversation/ConversationSidebarHeader'
|
||||
import { TooltipProvider } from '../../src/renderer/src/components/ui'
|
||||
|
||||
describe('ConversationSidebarHeader', () => {
|
||||
it('refreshes the conversation list from the archive header', async () => {
|
||||
const onRefresh = vi.fn()
|
||||
render(
|
||||
function renderHeader(
|
||||
overrides: Partial<React.ComponentProps<typeof ConversationSidebarHeader>> = {}
|
||||
): void {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<ConversationSidebarHeader
|
||||
totalCount={1306}
|
||||
searchValue=""
|
||||
onSearchChange={vi.fn()}
|
||||
refreshing={false}
|
||||
onRefresh={onRefresh}
|
||||
onRefresh={vi.fn()}
|
||||
{...overrides}
|
||||
/>
|
||||
)
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ConversationSidebarHeader', () => {
|
||||
it('refreshes the conversation list from the archive header', async () => {
|
||||
const onRefresh = vi.fn()
|
||||
renderHeader({ onRefresh })
|
||||
|
||||
expect(screen.getByText('1306 个会话')).toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('button', { name: '刷新会话列表' }))
|
||||
@@ -22,17 +32,20 @@ describe('ConversationSidebarHeader', () => {
|
||||
})
|
||||
|
||||
it('disables the refresh button while contacts are loading', () => {
|
||||
render(
|
||||
<ConversationSidebarHeader
|
||||
totalCount={12}
|
||||
searchValue="测试"
|
||||
onSearchChange={vi.fn()}
|
||||
refreshing
|
||||
onRefresh={vi.fn()}
|
||||
/>
|
||||
)
|
||||
renderHeader({ totalCount: 12, searchValue: '测试', refreshing: true })
|
||||
|
||||
expect(screen.getByRole('button', { name: '正在刷新会话列表' })).toBeDisabled()
|
||||
expect(screen.getByDisplayValue('测试')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps the controlled conversation search callback and search semantics', async () => {
|
||||
const onSearchChange = vi.fn()
|
||||
renderHeader({ onSearchChange })
|
||||
|
||||
const search = screen.getByRole('searchbox', { name: '搜索会话' })
|
||||
await userEvent.type(search, '产品')
|
||||
|
||||
expect(onSearchChange).toHaveBeenCalled()
|
||||
expect(onSearchChange.mock.calls[0]).toEqual(['产'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ConversationSidebar } from '../../src/renderer/src/components/conversation/ConversationSidebar'
|
||||
import { TooltipProvider } from '../../src/renderer/src/components/ui'
|
||||
|
||||
vi.mock('@tanstack/react-virtual', () => ({
|
||||
useVirtualizer: ({ count }: { count: number }) => ({
|
||||
@@ -19,32 +20,34 @@ vi.mock('@tanstack/react-virtual', () => ({
|
||||
describe('ConversationSidebar', () => {
|
||||
it('keeps official accounts in their own collapsible section', async () => {
|
||||
render(
|
||||
<ConversationSidebar
|
||||
contacts={[
|
||||
{
|
||||
m_nsUsrName: 'gh_fixture',
|
||||
m_nsNickName: '测试公众号',
|
||||
md5: 'official-md5',
|
||||
type: 'user',
|
||||
isOfficialAccount: true
|
||||
},
|
||||
{
|
||||
m_nsUsrName: 'wxid_fixture',
|
||||
m_nsNickName: '测试联系人',
|
||||
md5: 'contact-md5',
|
||||
type: 'user'
|
||||
}
|
||||
]}
|
||||
selectedContact={null}
|
||||
onSelectContact={vi.fn()}
|
||||
onSearch={vi.fn()}
|
||||
onContentFilter={vi.fn()}
|
||||
width={320}
|
||||
selfInfo={null}
|
||||
dbReady
|
||||
onOpenSettings={vi.fn()}
|
||||
onRefresh={vi.fn(async () => undefined)}
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<ConversationSidebar
|
||||
contacts={[
|
||||
{
|
||||
m_nsUsrName: 'gh_fixture',
|
||||
m_nsNickName: '测试公众号',
|
||||
md5: 'official-md5',
|
||||
type: 'user',
|
||||
isOfficialAccount: true
|
||||
},
|
||||
{
|
||||
m_nsUsrName: 'wxid_fixture',
|
||||
m_nsNickName: '测试联系人',
|
||||
md5: 'contact-md5',
|
||||
type: 'user'
|
||||
}
|
||||
]}
|
||||
selectedContact={null}
|
||||
onSelectContact={vi.fn()}
|
||||
onSearch={vi.fn()}
|
||||
onContentFilter={vi.fn()}
|
||||
width={320}
|
||||
selfInfo={null}
|
||||
dbReady
|
||||
onOpenSettings={vi.fn()}
|
||||
onRefresh={vi.fn(async () => undefined)}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: '公众号 (1)' })).toBeInTheDocument()
|
||||
|
||||
@@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DatabaseConnectionPage } from '../../src/renderer/src/components/DatabaseConnectionPage'
|
||||
import { TooltipProvider } from '../../src/renderer/src/components/ui'
|
||||
|
||||
function renderPage(
|
||||
overrides: Partial<ComponentProps<typeof DatabaseConnectionPage>> = {}
|
||||
@@ -65,7 +66,14 @@ function renderPage(
|
||||
onClearKey: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
return { props, ...render(<DatabaseConnectionPage {...props} />) }
|
||||
return {
|
||||
props,
|
||||
...render(
|
||||
<TooltipProvider>
|
||||
<DatabaseConnectionPage {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
describe('DatabaseConnectionPage', () => {
|
||||
@@ -119,7 +127,11 @@ describe('DatabaseConnectionPage', () => {
|
||||
it('keeps connect disabled until a valid 64-character key is supplied', () => {
|
||||
const { rerender, props } = renderPage()
|
||||
expect(screen.getByRole('button', { name: '连接数据库' })).toBeDisabled()
|
||||
rerender(<DatabaseConnectionPage {...props} dbKey={'a'.repeat(64)} />)
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<DatabaseConnectionPage {...props} dbKey={'a'.repeat(64)} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
expect(screen.getByRole('button', { name: '连接数据库' })).toBeEnabled()
|
||||
})
|
||||
|
||||
@@ -175,11 +187,41 @@ describe('DatabaseConnectionPage', () => {
|
||||
expect(onCopyDiagnostics).toHaveBeenCalledOnce()
|
||||
expect(onGuideNext).toHaveBeenCalledOnce()
|
||||
|
||||
rerender(<DatabaseConnectionPage {...props} mode="automatic" guideStep={2} />)
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<DatabaseConnectionPage {...props} mode="automatic" guideStep={2} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
expect(screen.getByRole('button', { name: '我已准备好' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: '返回上一步' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: '取消并重新检查' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('keeps mode, account and key visibility callbacks unchanged', async () => {
|
||||
const onModeChange = vi.fn()
|
||||
const onSelectAccount = vi.fn()
|
||||
const onToggleDbKey = vi.fn()
|
||||
const { props, rerender } = renderPage({
|
||||
mode: 'automatic',
|
||||
onModeChange,
|
||||
onSelectAccount,
|
||||
onToggleDbKey
|
||||
})
|
||||
|
||||
await userEvent.click(screen.getByRole('tab', { name: /高级用户/ }))
|
||||
expect(onModeChange).toHaveBeenCalledWith('manual')
|
||||
await userEvent.click(screen.getByRole('button', { name: /脱敏账号 A/ }))
|
||||
expect(onSelectAccount).toHaveBeenCalledWith(props.accounts[0])
|
||||
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<DatabaseConnectionPage {...props} mode="manual" />
|
||||
</TooltipProvider>
|
||||
)
|
||||
await userEvent.click(screen.getByRole('button', { name: '显示密钥' }))
|
||||
expect(onToggleDbKey).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('provides the official Visual C++ runtime download on Windows', () => {
|
||||
renderPage({
|
||||
status: '当前 Windows 缺少 Microsoft Visual C++ 运行库',
|
||||
@@ -191,5 +233,4 @@ describe('DatabaseConnectionPage', () => {
|
||||
'https://aka.ms/vc14/vc_redist.x64.exe'
|
||||
)
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DatabaseKeyAutoDetect } from '../../src/renderer/src/features/settings/database-key/DatabaseKeyAutoDetect'
|
||||
import { DatabaseKeyDiagnostics } from '../../src/renderer/src/features/settings/database-key/DatabaseKeyDiagnostics'
|
||||
import { DatabaseKeyDangerZone } from '../../src/renderer/src/features/settings/database-key/DatabaseKeyDangerZone'
|
||||
import { DatabaseKeyEditor } from '../../src/renderer/src/features/settings/database-key/DatabaseKeyEditor'
|
||||
import { DatabaseKeyStatus } from '../../src/renderer/src/features/settings/database-key/DatabaseKeyStatus'
|
||||
import type { DatabaseKeyState } from '../../src/renderer/src/features/settings/database-key/types'
|
||||
import { TooltipProvider } from '../../src/renderer/src/components/ui'
|
||||
|
||||
const state: DatabaseKeyState = {
|
||||
status: 'saved',
|
||||
saved: true,
|
||||
encryptionAvailable: true,
|
||||
autoPhase: 0,
|
||||
environment: {
|
||||
platform: 'win32',
|
||||
osVersion: 'Windows 11',
|
||||
appVersion: '2.2.2',
|
||||
wechatVersion: '4.1.0',
|
||||
dataStructureVersion: 'WCDB',
|
||||
dataDirectoryDetected: true,
|
||||
diagnosticSummary: 'fixture',
|
||||
autoDetectSupported: true,
|
||||
wechatRunning: true,
|
||||
accountIdentified: true,
|
||||
dbConnected: true,
|
||||
encryptionAvailable: true
|
||||
}
|
||||
}
|
||||
|
||||
describe('database key controls', () => {
|
||||
it('keeps auto-detect and refresh callbacks separate', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDetect = vi.fn()
|
||||
const onRefresh = vi.fn()
|
||||
render(
|
||||
<DatabaseKeyAutoDetect
|
||||
state={{ ...state, status: 'auto-detect-error', error: '没有找到候选密钥' }}
|
||||
disabled={false}
|
||||
onDetect={onDetect}
|
||||
onRefresh={onRefresh}
|
||||
/>
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '自动获取密钥' }))
|
||||
await user.click(screen.getByRole('button', { name: '刷新前置状态' }))
|
||||
expect(onDetect).toHaveBeenCalledOnce()
|
||||
expect(onRefresh).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps status validation and diagnostics copy actions available', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onValidate = vi.fn()
|
||||
const onCopy = vi.fn()
|
||||
const { rerender } = render(
|
||||
<DatabaseKeyStatus
|
||||
state={state}
|
||||
dbReady
|
||||
selfInfo={{ wxid: 'wxid_fixture', nickname: '测试账号', accountRoot: 'fixture-account' }}
|
||||
disabled={false}
|
||||
onValidate={onValidate}
|
||||
/>
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '重新验证' }))
|
||||
expect(onValidate).toHaveBeenCalledOnce()
|
||||
|
||||
rerender(
|
||||
<DatabaseKeyDiagnostics
|
||||
state={state}
|
||||
input={'a'.repeat(64)}
|
||||
wxid="wxid_fixture"
|
||||
accountRoot="fixture-account"
|
||||
onCopy={onCopy}
|
||||
/>
|
||||
)
|
||||
await user.click(screen.getByText('查看密钥诊断'))
|
||||
await user.click(screen.getByRole('button', { name: '复制诊断信息' }))
|
||||
expect(onCopy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses shared button variants for editor actions without legacy visual classes', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onValidate = vi.fn()
|
||||
const onSave = vi.fn()
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<DatabaseKeyEditor
|
||||
value={'a'.repeat(64)}
|
||||
disabled={false}
|
||||
canSave
|
||||
onChange={vi.fn()}
|
||||
onPaste={vi.fn()}
|
||||
onValidate={onValidate}
|
||||
onSave={onSave}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
const validate = screen.getByRole('button', { name: '验证密钥' })
|
||||
const save = screen.getByRole('button', { name: '保存密钥' })
|
||||
expect(validate).not.toHaveClass('database-key-secondary')
|
||||
expect(save).not.toHaveClass('database-key-primary')
|
||||
await user.click(validate)
|
||||
await user.click(save)
|
||||
expect(onValidate).toHaveBeenCalledOnce()
|
||||
expect(onSave).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('restores focus to the actual destructive dialog trigger', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(
|
||||
<DatabaseKeyDangerZone
|
||||
disabled={false}
|
||||
onClear={vi.fn()}
|
||||
onReplace={vi.fn()}
|
||||
onReturnToLogin={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
const clearKey = screen.getByRole('button', { name: '清除密钥' })
|
||||
await user.click(clearKey)
|
||||
expect(screen.getByRole('alertdialog', { name: '确认清除数据库密钥?' })).toBeVisible()
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
|
||||
expect(clearKey).toHaveFocus()
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,12 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AutoDetectImageKeySection } from '../../src/renderer/src/features/settings/image-decryption/AutoDetectImageKeySection'
|
||||
import { ImageDecryptStatus } from '../../src/renderer/src/features/settings/image-decryption/ImageDecryptStatus'
|
||||
import { ImageTestSection } from '../../src/renderer/src/features/settings/image-decryption/ImageTestSection'
|
||||
import { initialImageDecryptionState } from '../../src/renderer/src/features/settings/image-decryption/imageDecryptionReducer'
|
||||
import type { ImageBatchTestState } from '../../src/renderer/src/features/settings/image-decryption/types'
|
||||
import type { ImageDecryptionStatus } from '../../src/shared/image-decryption'
|
||||
|
||||
const emptyBatchTest: ImageBatchTestState = {
|
||||
running: false,
|
||||
@@ -12,11 +15,103 @@ const emptyBatchTest: ImageBatchTestState = {
|
||||
items: []
|
||||
}
|
||||
|
||||
const imageStatus: ImageDecryptionStatus = {
|
||||
configured: true,
|
||||
saved: true,
|
||||
encryptionAvailable: true,
|
||||
source: 'secure-storage',
|
||||
resourceRoot: '/fixture/images',
|
||||
platform: 'darwin',
|
||||
autoDetectSupported: true,
|
||||
wechatRunning: true,
|
||||
accountIdentified: true,
|
||||
cacheState: 'normal',
|
||||
decoder: {
|
||||
installed: true,
|
||||
available: true,
|
||||
source: 'bundled',
|
||||
selected: false
|
||||
},
|
||||
resources: {
|
||||
imageIndex: { state: 'available', detail: '正常' },
|
||||
imageDirectory: { state: 'available', detail: '正常' },
|
||||
thumbnail: { state: 'available', detail: '正常' },
|
||||
original: { state: 'available', detail: '正常' },
|
||||
sticker: { state: 'available', detail: '正常' },
|
||||
video: { state: 'available', detail: '正常' }
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('ImageTestSection', () => {
|
||||
it('uses shared buttons for status validation and automatic key actions', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onValidate = vi.fn()
|
||||
const onDetect = vi.fn()
|
||||
const onSave = vi.fn()
|
||||
const configuredState = {
|
||||
...initialImageDecryptionState,
|
||||
config: {
|
||||
success: true,
|
||||
configured: true,
|
||||
saved: true,
|
||||
encryptionAvailable: true,
|
||||
source: 'secure-storage' as const,
|
||||
resourceRoot: '/fixture/images'
|
||||
},
|
||||
status: imageStatus
|
||||
}
|
||||
const { rerender } = render(
|
||||
<ImageDecryptStatus
|
||||
state={configuredState}
|
||||
selfInfo={null}
|
||||
disabled={false}
|
||||
onValidate={onValidate}
|
||||
/>
|
||||
)
|
||||
|
||||
const validate = screen.getByRole('button', { name: '重新验证' })
|
||||
expect(validate).not.toHaveClass('database-key-secondary')
|
||||
await user.click(validate)
|
||||
expect(onValidate).toHaveBeenCalledOnce()
|
||||
|
||||
rerender(
|
||||
<AutoDetectImageKeySection
|
||||
state={{ ...configuredState, autoPhase: 'success', autoAccount: '测试账号' }}
|
||||
disabled={false}
|
||||
canSave
|
||||
onDetect={onDetect}
|
||||
onSave={onSave}
|
||||
/>
|
||||
)
|
||||
|
||||
const detect = screen.getByRole('button', { name: '开始自动获取' })
|
||||
const save = screen.getByRole('button', { name: '保存图片密钥' })
|
||||
expect(detect).not.toHaveClass('database-key-secondary')
|
||||
expect(save).not.toHaveClass('database-key-primary')
|
||||
await user.click(detect)
|
||||
await user.click(save)
|
||||
expect(onDetect).toHaveBeenCalledOnce()
|
||||
expect(onSave).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps automatic detection disabled while scanning', () => {
|
||||
render(
|
||||
<AutoDetectImageKeySection
|
||||
state={{ ...initialImageDecryptionState, status: imageStatus, autoPhase: 'scanning' }}
|
||||
disabled={false}
|
||||
canSave={false}
|
||||
onDetect={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: '扫描中…' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('keeps the file step successful when decryption fails and copies diagnostics', async () => {
|
||||
const onCopyLog = vi.fn()
|
||||
render(
|
||||
@@ -88,6 +183,7 @@ describe('ImageTestSection', () => {
|
||||
|
||||
it('filters groups and contacts before starting a batch test', async () => {
|
||||
const onBatchTest = vi.fn()
|
||||
const onSelect = vi.fn()
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
render(
|
||||
<ImageTestSection
|
||||
@@ -111,7 +207,7 @@ describe('ImageTestSection', () => {
|
||||
batchTest={emptyBatchTest}
|
||||
disabled={false}
|
||||
canSave={false}
|
||||
onSelect={vi.fn()}
|
||||
onSelect={onSelect}
|
||||
onTest={vi.fn()}
|
||||
onBatchTest={onBatchTest}
|
||||
onStopBatchTest={vi.fn()}
|
||||
@@ -120,9 +216,13 @@ describe('ImageTestSection', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '群聊 1' }))
|
||||
await userEvent.click(screen.getByRole('radio', { name: '群聊 1' }))
|
||||
expect(screen.getByRole('radio', { name: '群聊 1' })).toBeChecked()
|
||||
await userEvent.click(screen.getByRole('combobox', { name: '选择会话' }))
|
||||
expect(screen.getByRole('option', { name: '测试群聊' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('option', { name: '测试联系人' })).not.toBeInTheDocument()
|
||||
await userEvent.click(screen.getByRole('option', { name: '测试群聊' }))
|
||||
expect(onSelect).toHaveBeenCalledWith('group-md5')
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '测试筛选结果(1)' }))
|
||||
expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining('测试 1 个会话'))
|
||||
|
||||
@@ -187,6 +187,21 @@ describe('PersonalWechatSendDialog', () => {
|
||||
expect(screen.queryByRole('radio', { name: '文字' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('supports segmented keyboard navigation for the message type', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
||||
|
||||
await screen.findByText('技术交流群')
|
||||
const imageMode = screen.getByRole('radio', { name: '图片' })
|
||||
const voiceMode = screen.getByRole('radio', { name: '语音' })
|
||||
imageMode.focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
expect(voiceMode).toHaveFocus()
|
||||
await user.keyboard(' ')
|
||||
expect(voiceMode).toBeChecked()
|
||||
expect(screen.getByRole('textbox', { name: '要生成的文字' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('switches to image mode and sends only the selected image', async () => {
|
||||
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
||||
await screen.findByText('技术交流群')
|
||||
@@ -262,6 +277,8 @@ describe('PersonalWechatSendDialog', () => {
|
||||
await user.click(screen.getByRole('button', { name: '选择图片' }))
|
||||
await user.click(screen.getByRole('button', { name: '测试发送图片到群聊' }))
|
||||
expect(screen.getByRole('button', { name: '正在发送…' })).toBeDisabled()
|
||||
expect(screen.getByRole('radio', { name: '图片' })).toBeDisabled()
|
||||
expect(screen.getByRole('radio', { name: '语音' })).toBeDisabled()
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.getByRole('dialog', { name: '个人微信测试发送' })).toBeVisible()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ReportGroupMemberSelector } from '../../src/renderer/src/components/reports/ReportGroupMemberSelector'
|
||||
@@ -9,6 +9,10 @@ import { ReportInfoPanel } from '../../src/renderer/src/components/reports/Repor
|
||||
import { ReportToolbar } from '../../src/renderer/src/components/reports/ReportToolbar'
|
||||
import { ReportHistorySidebar } from '../../src/renderer/src/components/reports/ReportHistorySidebar'
|
||||
import { ModelSummary } from '../../src/renderer/src/components/reports/ModelSummary'
|
||||
import { MessageTypeSelector } from '../../src/renderer/src/components/reports/MessageTypeSelector'
|
||||
import { ReportDensitySelector } from '../../src/renderer/src/components/reports/ReportDensitySelector'
|
||||
import { ReportRangeSelector } from '../../src/renderer/src/components/reports/ReportRangeSelector'
|
||||
import { SUMMARY_TYPE_OPTIONS } from '../../src/renderer/src/utils/group-report'
|
||||
import type { Contact } from '../../src/shared/types'
|
||||
import type { GeneratedReportRecord } from '../../src/shared/report-history'
|
||||
|
||||
@@ -106,6 +110,44 @@ describe('daily report controls', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('uses unified option and checkbox controls while preserving report selection rules', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRangeChange = vi.fn()
|
||||
const onTypesChange = vi.fn()
|
||||
render(
|
||||
<>
|
||||
<ReportRangeSelector
|
||||
value="today"
|
||||
messageCount={4}
|
||||
rangeState={{ status: 'success', error: '' }}
|
||||
disabled={false}
|
||||
onChange={onRangeChange}
|
||||
/>
|
||||
<MessageTypeSelector
|
||||
value={['text']}
|
||||
counts={Object.fromEntries(SUMMARY_TYPE_OPTIONS.map((option) => [option.value, 1]))}
|
||||
disabled={false}
|
||||
onChange={onTypesChange}
|
||||
/>
|
||||
<ReportDensitySelector />
|
||||
</>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('radio', { name: '今天' })).toBeChecked()
|
||||
await user.click(screen.getByRole('radio', { name: '近 7 天' }))
|
||||
expect(onRangeChange).toHaveBeenCalledWith('7days')
|
||||
expect(screen.getAllByRole('checkbox')).toHaveLength(SUMMARY_TYPE_OPTIONS.length)
|
||||
const textCheckbox = screen.getAllByRole('checkbox')[0]
|
||||
expect(textCheckbox).toBeChecked()
|
||||
expect(textCheckbox).toBeDisabled()
|
||||
await user.click(screen.getAllByRole('checkbox')[1])
|
||||
expect(onTypesChange).toHaveBeenCalledWith(['text', 'image'])
|
||||
expect(screen.getByRole('radio', { name: /标准/ })).toBeChecked()
|
||||
screen.getAllByRole('radio', { name: /简洁|标准|深度/ }).forEach((item) => {
|
||||
expect(item).toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows a separate voice progress bar only when voice is selected', () => {
|
||||
const progress = { processed: 2, total: 3, succeeded: 2, failed: 0 }
|
||||
const { rerender } = render(
|
||||
@@ -203,6 +245,7 @@ describe('daily report controls', () => {
|
||||
})
|
||||
|
||||
it('allows switching models and retrying only the model step', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRetry = vi.fn()
|
||||
window.api.listAIProviders = vi.fn(async () => ({
|
||||
success: true,
|
||||
@@ -246,7 +289,10 @@ describe('daily report controls', () => {
|
||||
)
|
||||
|
||||
const retryButton = await screen.findByRole('button', { name: '使用所选模型重新生成' })
|
||||
const retryModel = screen.getByRole('combobox', { name: '切换模型' })
|
||||
await user.click(retryModel)
|
||||
expect(screen.getByRole('option', { name: '备用服务 · 备用模型' })).toBeVisible()
|
||||
await user.click(screen.getByRole('option', { name: '备用服务 · 备用模型' }))
|
||||
fireEvent.click(retryButton)
|
||||
expect(onRetry).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providerId: 'provider-2', model: 'model-2' })
|
||||
@@ -254,7 +300,8 @@ describe('daily report controls', () => {
|
||||
expect(screen.getByText(/从第三步继续/)).toBeVisible()
|
||||
})
|
||||
|
||||
it('selects separate text-summary and image-understanding models with the 10-minute cache rule', () => {
|
||||
it('selects separate text-summary and image-understanding models with the 10-minute cache rule', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onTextModelChange = vi.fn()
|
||||
const onVisionModelChange = vi.fn()
|
||||
const textModels = [
|
||||
@@ -283,6 +330,14 @@ describe('daily report controls', () => {
|
||||
modelName: 'GPT-5.6 Sol',
|
||||
configured: true as const,
|
||||
status: 'connected' as const
|
||||
},
|
||||
{
|
||||
providerId: 'vision-provider',
|
||||
providerName: 'Vision',
|
||||
model: 'vision-model',
|
||||
modelName: 'Vision Model',
|
||||
configured: true as const,
|
||||
status: 'connected' as const
|
||||
}
|
||||
]
|
||||
render(
|
||||
@@ -299,13 +354,14 @@ describe('daily report controls', () => {
|
||||
|
||||
const textSelect = screen.getByRole('combobox', { name: '文字总结模型' })
|
||||
const visionSelect = screen.getByRole('combobox', { name: '图片理解模型' })
|
||||
expect(textSelect).toHaveValue('deepseek::deepseek-chat')
|
||||
expect(visionSelect).toHaveValue('sol-provider::gpt-5.6-sol')
|
||||
expect(screen.getAllByRole('option', { name: 'OpenAI · GPT-5.6 Sol' })).toHaveLength(2)
|
||||
fireEvent.change(textSelect, { target: { value: 'openai::gpt-5.6-sol' } })
|
||||
fireEvent.change(visionSelect, { target: { value: 'sol-provider::gpt-5.6-sol' } })
|
||||
expect(textSelect).toHaveTextContent('DeepSeek · DeepSeek Chat')
|
||||
expect(visionSelect).toHaveTextContent('OpenAI · GPT-5.6 Sol')
|
||||
await user.click(textSelect)
|
||||
await user.click(screen.getByRole('option', { name: 'OpenAI · GPT-5.6 Sol' }))
|
||||
await user.click(visionSelect)
|
||||
await user.click(screen.getByRole('option', { name: 'Vision · Vision Model' }))
|
||||
expect(onTextModelChange).toHaveBeenCalledWith(textModels[1])
|
||||
expect(onVisionModelChange).toHaveBeenCalledWith(visionModels[0])
|
||||
expect(onVisionModelChange).toHaveBeenCalledWith(visionModels[1])
|
||||
expect(screen.getByText(/图片识别缓存 10 分钟/)).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -676,12 +732,17 @@ describe('daily report controls', () => {
|
||||
})
|
||||
|
||||
it('loads and displays group nickname, WeChat nickname, and remark separately', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<ReportGroupMemberSelector sourceContact={groupContact} />)
|
||||
|
||||
await waitFor(() => expect(screen.getAllByText('群内昵称一')).toHaveLength(2))
|
||||
expect(screen.getByText('微信昵称一')).toBeVisible()
|
||||
expect(screen.getByText('通讯录备注一')).toBeVisible()
|
||||
expect(screen.getByText('wxid-one')).toBeVisible()
|
||||
await user.click(screen.getByRole('combobox', { name: '选择群成员' }))
|
||||
await user.click(screen.getByRole('option', { name: '群内昵称二' }))
|
||||
expect(screen.getByText('微信昵称二')).toBeVisible()
|
||||
expect(screen.getByText('wxid-two')).toBeVisible()
|
||||
})
|
||||
|
||||
it('cancels report deletion and restores focus to the delete trigger', async () => {
|
||||
@@ -774,7 +835,8 @@ describe('daily report controls', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('offers the classic default plus three mobile and two desktop report templates', () => {
|
||||
it('offers the classic default plus three mobile and two desktop report templates', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onChange = vi.fn()
|
||||
render(<ReportTemplateSelector value="v1" onChange={onChange} />)
|
||||
|
||||
@@ -792,8 +854,13 @@ describe('daily report controls', () => {
|
||||
|
||||
const previewButtons = screen.getAllByRole('button', { name: '查看版式' })
|
||||
expect(previewButtons).toHaveLength(6)
|
||||
fireEvent.click(previewButtons[2])
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择此模板' }))
|
||||
await user.click(previewButtons[2])
|
||||
let dialog = screen.getByRole('dialog', { name: 'AI Magazine' })
|
||||
await user.click(within(dialog).getAllByRole('button', { name: '关闭' })[1])
|
||||
expect(document.activeElement).toBe(previewButtons[2])
|
||||
await user.click(previewButtons[2])
|
||||
dialog = screen.getByRole('dialog', { name: 'AI Magazine' })
|
||||
await user.click(within(dialog).getByRole('button', { name: '选择此模板' }))
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('mobile-magazine')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AboutPage } from '../../src/renderer/src/features/settings/pages/AboutPage'
|
||||
import { AdvancedPage } from '../../src/renderer/src/features/settings/pages/AdvancedPage'
|
||||
import { RecallProtectionPage } from '../../src/renderer/src/features/settings/pages/RecallProtectionPage'
|
||||
|
||||
const api = {
|
||||
checkAppUpdate: vi.fn(),
|
||||
downloadAppUpdate: vi.fn(),
|
||||
getAppUpdateState: vi.fn(),
|
||||
getSettings: vi.fn(),
|
||||
installAppUpdate: vi.fn(),
|
||||
onAppUpdateState: vi.fn(),
|
||||
revealAppLog: vi.fn(),
|
||||
setSettings: vi.fn()
|
||||
}
|
||||
|
||||
describe('basic settings pages', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
Object.defineProperty(window, 'api', { configurable: true, value: api })
|
||||
api.getAppUpdateState.mockResolvedValue({ status: 'idle', currentVersion: '2.2.2' })
|
||||
api.onAppUpdateState.mockReturnValue(vi.fn())
|
||||
api.checkAppUpdate.mockResolvedValue(undefined)
|
||||
api.revealAppLog.mockResolvedValue(undefined)
|
||||
api.getSettings.mockResolvedValue({ settings: { debugEnabled: false } })
|
||||
api.setSettings.mockResolvedValue({ settings: { debugEnabled: true } })
|
||||
})
|
||||
|
||||
it('keeps update and log actions connected to the desktop APIs', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<AboutPage onNotice={vi.fn()} />)
|
||||
|
||||
await screen.findByText('v2.2.2')
|
||||
await user.click(screen.getByRole('button', { name: '检查更新' }))
|
||||
await user.click(screen.getByRole('button', { name: '打开诊断日志目录' }))
|
||||
|
||||
await waitFor(() => expect(api.checkAppUpdate).toHaveBeenCalledOnce())
|
||||
expect(api.revealAppLog).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('uses the shared switch without changing the debug settings contract', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onNotice = vi.fn()
|
||||
render(<AdvancedPage onNotice={onNotice} />)
|
||||
|
||||
const debugSwitch = await screen.findByRole('switch', { name: '显示诊断日志' })
|
||||
expect(debugSwitch).not.toBeChecked()
|
||||
await user.click(debugSwitch)
|
||||
|
||||
await waitFor(() => expect(api.setSettings).toHaveBeenCalledWith({ debugEnabled: true }))
|
||||
expect(debugSwitch).toBeChecked()
|
||||
expect(onNotice).toHaveBeenCalledWith('已开启调试日志')
|
||||
})
|
||||
|
||||
it('uses the shared switch without changing the recall protection settings contract', async () => {
|
||||
api.getSettings.mockResolvedValue({ settings: { recallProtectionEnabled: false } })
|
||||
api.setSettings.mockResolvedValue({ settings: { recallProtectionEnabled: true } })
|
||||
const user = userEvent.setup()
|
||||
const onNotice = vi.fn()
|
||||
render(<RecallProtectionPage onNotice={onNotice} />)
|
||||
|
||||
const recallSwitch = await screen.findByRole('switch', { name: '开启防撤回' })
|
||||
expect(recallSwitch).not.toBeChecked()
|
||||
await user.click(recallSwitch)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(api.setSettings).toHaveBeenCalledWith({ recallProtectionEnabled: true })
|
||||
)
|
||||
expect(recallSwitch).toBeChecked()
|
||||
expect(onNotice).toHaveBeenCalledWith('已开启防撤回')
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,8 @@ const listVoices = vi.fn()
|
||||
const openApiKeys = vi.fn()
|
||||
const getRuntimeStatus = vi.fn()
|
||||
const onRuntimeProgress = vi.fn(() => vi.fn())
|
||||
const openRuntimeDirectory = vi.fn()
|
||||
const removeRuntime = vi.fn()
|
||||
|
||||
const response = {
|
||||
success: true,
|
||||
@@ -82,9 +84,22 @@ describe('TextToSpeechPage', () => {
|
||||
platform: 'darwin',
|
||||
architecture: 'arm64',
|
||||
supported: true,
|
||||
removable: true
|
||||
removable: true,
|
||||
directory: '/tmp/personal-wechat-runtime'
|
||||
})
|
||||
onRuntimeProgress.mockReset().mockReturnValue(vi.fn())
|
||||
openRuntimeDirectory.mockReset().mockResolvedValue({ success: true })
|
||||
removeRuntime.mockReset().mockResolvedValue({
|
||||
version: 'v0.0.18',
|
||||
state: 'missing',
|
||||
downloadedBytes: 0,
|
||||
totalBytes: 100,
|
||||
progress: 0,
|
||||
platform: 'darwin',
|
||||
architecture: 'arm64',
|
||||
supported: true,
|
||||
removable: false
|
||||
})
|
||||
Object.defineProperty(window, 'api', {
|
||||
configurable: true,
|
||||
value: {
|
||||
@@ -93,7 +108,9 @@ describe('TextToSpeechPage', () => {
|
||||
listTextToSpeechVoices: listVoices,
|
||||
openFishAudioApiKeys: openApiKeys,
|
||||
getPersonalWechatRuntimeStatus: getRuntimeStatus,
|
||||
onPersonalWechatRuntimeProgress: onRuntimeProgress
|
||||
onPersonalWechatRuntimeProgress: onRuntimeProgress,
|
||||
openPersonalWechatRuntimeDirectory: openRuntimeDirectory,
|
||||
removePersonalWechatRuntime: removeRuntime
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -136,6 +153,33 @@ describe('TextToSpeechPage', () => {
|
||||
await waitFor(() => expect(openApiKeys).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it('keeps model selection and API key actions on their existing settings callbacks', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TextToSpeechPage onNotice={vi.fn()} />)
|
||||
|
||||
const apiKeyInput = await screen.findByPlaceholderText('已安全保存;输入新 Key 可替换')
|
||||
expect(apiKeyInput).toHaveAttribute('type', 'password')
|
||||
await user.click(screen.getByRole('button', { name: '显示' }))
|
||||
expect(apiKeyInput).toHaveAttribute('type', 'text')
|
||||
|
||||
await user.click(screen.getByRole('combobox', { name: '合成模型' }))
|
||||
await user.click(screen.getByRole('option', { name: 's2.1-pro', exact: true }))
|
||||
await waitFor(() => expect(saveSettings).toHaveBeenCalledWith({ model: 's2.1-pro' }))
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '清除应用内 Key' }))
|
||||
await waitFor(() => expect(saveSettings).toHaveBeenCalledWith({ clearApiKey: true }))
|
||||
})
|
||||
|
||||
it('keeps runtime directory and refresh actions wired to the existing API', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TextToSpeechPage onNotice={vi.fn()} />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '打开目录' }))
|
||||
expect(openRuntimeDirectory).toHaveBeenCalledOnce()
|
||||
await user.click(screen.getByRole('button', { name: '重新检测' }))
|
||||
expect(getRuntimeStatus).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('opens supported versions from both triggers and restores focus after closing', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<TextToSpeechPage onNotice={vi.fn()} />)
|
||||
|
||||
@@ -146,6 +146,14 @@ describe('voice recognition settings', () => {
|
||||
conversationIds: ['group-b'],
|
||||
range: 'recent_30_days'
|
||||
})
|
||||
await userEvent.click(screen.getByRole('combobox', { name: '语音转写时间范围' }))
|
||||
await userEvent.click(screen.getByRole('option', { name: '所选会话全部历史' }))
|
||||
await waitFor(() =>
|
||||
expect(window.api.getVoiceBatchConversationSummaries).toHaveBeenCalledWith({
|
||||
conversationIds: ['group-b'],
|
||||
range: 'selected_history'
|
||||
})
|
||||
)
|
||||
await userEvent.click(screen.getByRole('checkbox', { name: /群聊 B/ }))
|
||||
await userEvent.click(screen.getByRole('tab', { name: /联系人 1/ }))
|
||||
await userEvent.click(await screen.findByRole('checkbox', { name: /联系人 A/ }))
|
||||
@@ -157,7 +165,7 @@ describe('voice recognition settings', () => {
|
||||
await waitFor(() =>
|
||||
expect(window.api.startVoiceBatch).toHaveBeenCalledWith({
|
||||
conversationIds: ['group-b', 'contact-a'],
|
||||
range: 'recent_30_days'
|
||||
range: 'selected_history'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
+343
-11
@@ -16,8 +16,12 @@ test('APP-01 first launch renders a usable connection screen without uncaught er
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize({ width: 820, height: 600 })
|
||||
await expect(fixture.page.getByRole('heading', { name: 'TraceMemo(迹忆)' })).toBeVisible()
|
||||
await expect(fixture.page.getByRole('main')).not.toBeEmpty()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
} finally {
|
||||
await fixture.close()
|
||||
@@ -132,7 +136,12 @@ test('CHAT-01 archive More menu is keyboard-safe and keeps the page usable', asy
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize({ width: 1000, height: 650 })
|
||||
await fixture.setWindowContentSize({ width: 820, height: 600 })
|
||||
const conversationSearch = fixture.page.getByRole('searchbox', { name: '搜索会话' })
|
||||
await conversationSearch.fill('产品')
|
||||
await expect(fixture.page.getByText('产品测试群', { exact: true })).toBeVisible()
|
||||
await conversationSearch.fill('')
|
||||
await fixture.page.getByRole('button', { name: '刷新会话列表' }).click()
|
||||
await fixture.page.getByText('产品测试群', { exact: true }).click()
|
||||
const moreButton = fixture.page.getByRole('button', { name: '更多' })
|
||||
await moreButton.click()
|
||||
@@ -144,6 +153,24 @@ test('CHAT-01 archive More menu is keyboard-safe and keeps the page usable', asy
|
||||
await moreButton.click()
|
||||
await fixture.page.getByRole('menuitem', { name: '刷新数据' }).click()
|
||||
await expect(fixture.page.getByRole('heading', { name: '产品测试群' })).toBeVisible()
|
||||
|
||||
await fixture.page.getByRole('button', { name: '搜索当前聊天' }).click()
|
||||
const searchInput = fixture.page.getByRole('textbox', { name: '搜索当前聊天内容' })
|
||||
await expect(searchInput).toBeFocused()
|
||||
await searchInput.fill('测试')
|
||||
await expect(searchInput).toHaveValue('测试')
|
||||
expect(
|
||||
await fixture.page
|
||||
.locator('.chat-archive-header')
|
||||
.evaluate((element) => element.scrollWidth <= element.clientWidth)
|
||||
).toBe(true)
|
||||
await fixture.page.getByRole('button', { name: '关闭搜索' }).click()
|
||||
await expect(searchInput).toHaveCount(0)
|
||||
|
||||
const avatarSwitch = fixture.page.getByRole('switch', { name: '显示头像' })
|
||||
await expect(avatarSwitch).toBeChecked()
|
||||
await avatarSwitch.click()
|
||||
await expect(avatarSwitch).not.toBeChecked()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
@@ -159,7 +186,7 @@ test('CHAT-02 personal WeChat send dialog is keyboard-safe and fits the viewport
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize({ width: 1000, height: 650 })
|
||||
await fixture.setWindowContentSize({ width: 820, height: 600 })
|
||||
await fixture.page.getByText('产品测试群', { exact: true }).click()
|
||||
const trigger = fixture.page.getByRole('button', { name: '发送消息' })
|
||||
await trigger.click()
|
||||
@@ -177,9 +204,20 @@ test('CHAT-02 personal WeChat send dialog is keyboard-safe and fits the viewport
|
||||
const bounds = await dialog.boundingBox()
|
||||
expect(bounds).not.toBeNull()
|
||||
expect(bounds!.y).toBeGreaterThanOrEqual(0)
|
||||
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(650)
|
||||
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(600)
|
||||
expect(pageErrors).toEqual([])
|
||||
|
||||
const imageMode = dialog.getByRole('radio', { name: '图片' })
|
||||
const voiceMode = dialog.getByRole('radio', { name: '语音' })
|
||||
await imageMode.focus()
|
||||
await fixture.page.keyboard.press('ArrowRight')
|
||||
await expect(voiceMode).toBeFocused()
|
||||
await fixture.page.keyboard.press('Space')
|
||||
await expect(voiceMode).toBeChecked()
|
||||
expect(await dialog.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(
|
||||
true
|
||||
)
|
||||
|
||||
await fixture.page.keyboard.press('Escape')
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(trigger).toBeFocused()
|
||||
@@ -218,7 +256,7 @@ test('SETTINGS-01 supported WeChat versions dialog is keyboard-safe and fits the
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize({ width: 1000, height: 650 })
|
||||
await fixture.setWindowContentSize({ width: 820, height: 600 })
|
||||
await fixture.page
|
||||
.getByRole('navigation', { name: '一级导航' })
|
||||
.getByRole('button', { name: '设置' })
|
||||
@@ -240,6 +278,278 @@ test('SETTINGS-01 supported WeChat versions dialog is keyboard-safe and fits the
|
||||
await fixture.page.keyboard.press('Escape')
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(trigger).toBeFocused()
|
||||
|
||||
const modelSelect = fixture.page.getByRole('combobox', { name: '合成模型' })
|
||||
await modelSelect.scrollIntoViewIfNeeded()
|
||||
await modelSelect.click()
|
||||
await expect(fixture.page.getByRole('option', { name: 's2.1-pro', exact: true })).toBeVisible()
|
||||
await fixture.page.keyboard.press('Escape')
|
||||
await expect(modelSelect).toBeFocused()
|
||||
await expect(fixture.page.getByRole('searchbox', { name: '按音色名称搜索' })).toBeDisabled()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('SETTINGS-02 basic settings controls fit a narrow viewport and keep their semantics', async () => {
|
||||
const fixture = await launchTestApp()
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize({ width: 820, height: 600 })
|
||||
await fixture.page
|
||||
.getByRole('navigation', { name: '一级导航' })
|
||||
.getByRole('button', { name: '设置' })
|
||||
.click()
|
||||
|
||||
await expect(fixture.page.getByRole('heading', { name: '账号与数据库' })).toBeVisible()
|
||||
const autoLoginSwitch = fixture.page.getByRole('switch', { name: '启动时自动连接数据库' })
|
||||
await expect(autoLoginSwitch).toBeVisible()
|
||||
await autoLoginSwitch.click()
|
||||
await expect(autoLoginSwitch).toBeChecked()
|
||||
|
||||
await fixture.page.getByRole('button', { name: '缓存与清理' }).click()
|
||||
await expect(fixture.page.getByRole('heading', { name: '缓存与清理' })).toBeVisible()
|
||||
await expect(fixture.page.getByRole('button', { name: '清理全部' })).toBeEnabled()
|
||||
|
||||
await fixture.page.getByRole('button', { name: '高级' }).click()
|
||||
const debugSwitch = fixture.page.getByRole('switch', { name: '显示诊断日志' })
|
||||
await expect(debugSwitch).toBeVisible()
|
||||
await debugSwitch.click()
|
||||
await expect(debugSwitch).toBeChecked()
|
||||
|
||||
await fixture.page.getByRole('button', { name: '防撤回' }).click()
|
||||
const recallSwitch = fixture.page.getByRole('switch', { name: '开启防撤回' })
|
||||
await expect(recallSwitch).toBeVisible()
|
||||
await recallSwitch.click()
|
||||
await expect(recallSwitch).toBeChecked()
|
||||
|
||||
await fixture.page.getByRole('button', { name: '关于' }).click()
|
||||
await expect(fixture.page.getByRole('button', { name: '检查更新' })).toBeVisible()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('SETTINGS-03 database key actions keep destructive confirmation keyboard-safe', async () => {
|
||||
const fixture = await launchTestApp()
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize({ width: 820, height: 600 })
|
||||
await fixture.page
|
||||
.getByRole('navigation', { name: '一级导航' })
|
||||
.getByRole('button', { name: '设置' })
|
||||
.click()
|
||||
await fixture.page.getByRole('button', { name: '数据库密钥' }).click()
|
||||
await expect(fixture.page.getByRole('heading', { name: '数据库密钥' })).toBeVisible()
|
||||
await expect(fixture.page.getByRole('button', { name: '重新验证' })).toBeVisible()
|
||||
|
||||
const clearKey = fixture.page.getByRole('button', { name: '清除密钥' })
|
||||
await clearKey.scrollIntoViewIfNeeded()
|
||||
await clearKey.click()
|
||||
const dialog = fixture.page.getByRole('alertdialog', { name: '确认清除数据库密钥?' })
|
||||
await expect(dialog).toBeVisible()
|
||||
await fixture.page.keyboard.press('Escape')
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(clearKey).toBeFocused()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('SETTINGS-04 image decryption controls keep filtering and selection keyboard-safe', async () => {
|
||||
const fixture = await launchTestApp()
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize({ width: 820, height: 600 })
|
||||
await fixture.page
|
||||
.getByRole('navigation', { name: '一级导航' })
|
||||
.getByRole('button', { name: '设置' })
|
||||
.click()
|
||||
await fixture.page.getByRole('button', { name: '图片解密' }).click()
|
||||
await expect(fixture.page.getByRole('heading', { name: '图片解密', exact: true })).toBeVisible()
|
||||
|
||||
const filterGroup = fixture.page.getByRole('radiogroup', { name: '会话类型筛选' })
|
||||
await filterGroup.scrollIntoViewIfNeeded()
|
||||
const allFilter = filterGroup.getByRole('radio', { name: '全部 3' })
|
||||
await expect(allFilter).toBeChecked()
|
||||
await allFilter.focus()
|
||||
await fixture.page.keyboard.press('ArrowRight')
|
||||
const groupFilter = filterGroup.getByRole('radio', { name: '群聊 2' })
|
||||
await expect(groupFilter).toBeFocused()
|
||||
await fixture.page.keyboard.press('Space')
|
||||
await expect(groupFilter).toBeChecked()
|
||||
|
||||
await fixture.page.getByRole('searchbox', { name: '搜索会话' }).fill('产品')
|
||||
const conversationSelect = fixture.page.getByRole('combobox', { name: '选择会话' })
|
||||
await conversationSelect.click()
|
||||
await expect(fixture.page.getByRole('option', { name: '产品测试群' })).toBeVisible()
|
||||
await expect(fixture.page.getByRole('option', { name: '折叠群聊样本' })).toHaveCount(0)
|
||||
await fixture.page.keyboard.press('Escape')
|
||||
await expect(conversationSelect).toBeFocused()
|
||||
|
||||
await conversationSelect.click()
|
||||
await fixture.page.getByRole('option', { name: '产品测试群' }).click()
|
||||
await expect(conversationSelect).toContainText('产品测试群')
|
||||
const testAction = fixture.page.getByRole('button', { name: '测试图片解析' })
|
||||
await expect(testAction).toBeEnabled()
|
||||
await testAction.click()
|
||||
await expect(fixture.page.getByText('图片可以读取')).toBeVisible()
|
||||
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(
|
||||
await fixture.page
|
||||
.locator('.settings-page-scroll')
|
||||
.evaluate((element) => element.scrollWidth <= element.clientWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('SETTINGS-05 AI model editor keeps provider and capability semantics', async () => {
|
||||
const fixture = await launchTestApp()
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize({ width: 820, height: 600 })
|
||||
await fixture.page
|
||||
.getByRole('navigation', { name: '一级导航' })
|
||||
.getByRole('button', { name: '设置' })
|
||||
.click()
|
||||
await fixture.page.getByRole('button', { name: 'AI 模型' }).click()
|
||||
await expect(fixture.page.getByRole('heading', { name: 'AI 模型' })).toBeVisible()
|
||||
await expect(fixture.page.getByRole('button', { name: '当前默认' })).toBeDisabled()
|
||||
|
||||
await fixture.page.getByRole('button', { name: '添加供应商' }).click()
|
||||
await expect(fixture.page.getByRole('heading', { name: '新增供应商' })).toBeVisible()
|
||||
await expect(fixture.page.getByLabel('供应商名称')).toBeFocused()
|
||||
await fixture.page.getByLabel('供应商 ID').fill('fixture-new-provider')
|
||||
|
||||
const presetSelect = fixture.page.getByRole('combobox', { name: '快速模板' })
|
||||
await presetSelect.click()
|
||||
await fixture.page.getByRole('option', { name: 'OpenAI', exact: true }).click()
|
||||
await expect(fixture.page.getByLabel('供应商名称')).toHaveValue('OpenAI')
|
||||
await expect(fixture.page.getByLabel('模型名称')).toHaveValue('gpt-4o-mini')
|
||||
|
||||
const providerType = fixture.page.getByRole('combobox', { name: '供应商类型' })
|
||||
await providerType.click()
|
||||
await expect(fixture.page.getByRole('option', { name: 'Anthropic Messages' })).toBeVisible()
|
||||
await fixture.page.keyboard.press('Escape')
|
||||
await expect(providerType).toBeFocused()
|
||||
|
||||
const vision = fixture.page.getByRole('checkbox', { name: '图片理解 gpt-4o-mini' })
|
||||
const ocr = fixture.page.getByRole('checkbox', { name: '图片文字识别 gpt-4o-mini' })
|
||||
await vision.click()
|
||||
await expect(vision).toBeChecked()
|
||||
await expect(ocr).toBeChecked()
|
||||
await expect(fixture.page.getByRole('radio', { name: '默认 gpt-4o-mini' })).toBeChecked()
|
||||
|
||||
await fixture.page.getByText('高级设置', { exact: true }).click()
|
||||
await expect(fixture.page.getByLabel('请求超时(ms)')).toHaveValue('120000')
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(
|
||||
await fixture.page
|
||||
.locator('.settings-page-scroll')
|
||||
.evaluate((element) => element.scrollWidth <= element.clientWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('SETTINGS-07 voice recognition controls keep selection semantics at a narrow viewport', async () => {
|
||||
const fixture = await launchTestApp()
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize({ width: 820, height: 600 })
|
||||
await fixture.page
|
||||
.getByRole('navigation', { name: '一级导航' })
|
||||
.getByRole('button', { name: '设置' })
|
||||
.click()
|
||||
await fixture.page.getByRole('button', { name: '语音转文字' }).click()
|
||||
await expect(fixture.page.getByRole('heading', { name: '语音转文字' })).toBeVisible()
|
||||
|
||||
const categoryTabs = fixture.page.getByRole('tablist', { name: '会话类别' })
|
||||
await categoryTabs.scrollIntoViewIfNeeded()
|
||||
await expect(categoryTabs.getByRole('tab', { name: /群聊/ })).toHaveAttribute(
|
||||
'data-state',
|
||||
'active'
|
||||
)
|
||||
const conversation = fixture.page.getByRole('checkbox', { name: /产品测试群/ })
|
||||
await conversation.click()
|
||||
await expect(conversation).toBeChecked()
|
||||
|
||||
const rangeSelect = fixture.page.getByRole('combobox', { name: '语音转写时间范围' })
|
||||
await rangeSelect.click()
|
||||
await expect(fixture.page.getByRole('option', { name: '所选会话全部历史' })).toBeVisible()
|
||||
await fixture.page.keyboard.press('Escape')
|
||||
await expect(rangeSelect).toBeFocused()
|
||||
await expect(fixture.page.getByRole('button', { name: '开始转写' })).toBeDisabled()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(
|
||||
await fixture.page
|
||||
.locator('.settings-page-scroll')
|
||||
.evaluate((element) => element.scrollWidth <= element.clientWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('AGENT-01 Agent Hub controls stay usable in the offline narrow layout', async () => {
|
||||
const fixture = await launchTestApp()
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize({ width: 820, height: 600 })
|
||||
await fixture.page.getByRole('button', { name: 'Agent' }).click()
|
||||
await expect(fixture.page.getByRole('heading', { name: 'Agent Hub' })).toBeVisible()
|
||||
await expect(fixture.page.getByText('Agent Hub 未运行')).toBeVisible()
|
||||
await expect(fixture.page.getByRole('button', { name: '扫码登录微信机器人' })).toBeDisabled()
|
||||
|
||||
const logSource = fixture.page.getByRole('combobox', { name: '筛选日志来源' })
|
||||
await logSource.scrollIntoViewIfNeeded()
|
||||
await logSource.click()
|
||||
await expect(fixture.page.getByRole('option', { name: '微信连接器' })).toBeVisible()
|
||||
await fixture.page.keyboard.press('Escape')
|
||||
await expect(logSource).toBeFocused()
|
||||
await expect(fixture.page.getByRole('button', { name: '复制日志' })).toBeDisabled()
|
||||
await fixture.page.getByRole('button', { name: '清空' }).click()
|
||||
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(
|
||||
await fixture.page
|
||||
.locator('.agent-hub-workspace')
|
||||
.evaluate((element) => element.scrollWidth <= element.clientWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
@@ -263,6 +573,15 @@ test('API-01 manages the local API token and previews the Reader Skill safely',
|
||||
await fixture.page.getByRole('button', { name: '重新生成 Token' }).click()
|
||||
await expect(fixture.page.getByText('Token 已生成')).toBeVisible()
|
||||
|
||||
const moreTrigger = fixture.page
|
||||
.locator('#api-reader-skill')
|
||||
.getByRole('button', { name: '更多' })
|
||||
await moreTrigger.click()
|
||||
await expect(fixture.page.getByRole('menuitem', { name: '打开本地文件夹' })).toBeVisible()
|
||||
await fixture.page.keyboard.press('Escape')
|
||||
await expect(fixture.page.getByRole('menu')).toHaveCount(0)
|
||||
await expect(moreTrigger).toBeFocused()
|
||||
|
||||
const previewTrigger = fixture.page
|
||||
.locator('#api-reader-skill')
|
||||
.getByRole('button', { name: '预览 Skill' })
|
||||
@@ -337,7 +656,7 @@ test('LAYOUT-01 core workspaces fit a narrow desktop viewport without page error
|
||||
try {
|
||||
await fixture.setWindowContentSize({ width: 820, height: 600 })
|
||||
const navigation = fixture.page.getByRole('navigation', { name: '一级导航' })
|
||||
for (const pageName of ['档案', '问问微信', '日报', '导出', '设置']) {
|
||||
for (const pageName of ['档案', '问问微信', '日报', '导出', 'API', '设置']) {
|
||||
await navigation.getByRole('button', { name: pageName }).click()
|
||||
await expect(fixture.page.locator('main.app-shell-main')).not.toBeEmpty()
|
||||
expect(
|
||||
@@ -436,13 +755,20 @@ test('MEDIA-02 MEDIA-04 return accurate unsupported and HTTP 403 reasons', async
|
||||
|
||||
test('ASK-01 uses the local fixed AI service and keeps evidence in the UI', async () => {
|
||||
const fixture = await launchTestApp()
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize({ width: 820, height: 600 })
|
||||
await fixture.page.getByRole('button', { name: '问问微信' }).click()
|
||||
await fixture.page.getByPlaceholder(/例如:技术交流群/).fill('测试群讨论了什么?')
|
||||
await fixture.page.getByRole('button', { name: '开始分析' }).click()
|
||||
await expect(fixture.page.getByText(/固定假回答:测试数据中的核心流程正常/)).toBeVisible({
|
||||
timeout: 15_000
|
||||
})
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
@@ -470,12 +796,18 @@ test('REPORT-01 REPORT-02 generates a fixed report with non-empty local assets',
|
||||
await expect(fixture.page.getByRole('heading', { name: '生成群聊日报' })).toBeVisible()
|
||||
const textModel = fixture.page.getByRole('combobox', { name: '文字总结模型' })
|
||||
const visionModel = fixture.page.getByRole('combobox', { name: '图片理解模型' })
|
||||
await expect(textModel).toHaveValue('fixture-provider::fixture-model')
|
||||
await expect(visionModel).toHaveValue('fixture-provider::fixture-vision-model')
|
||||
await expect(textModel.locator('option')).toHaveCount(2)
|
||||
await expect(visionModel.locator('option')).toHaveCount(1)
|
||||
await expect(textModel).toContainText('固定响应模型')
|
||||
await expect(visionModel).toContainText('固定图片识别模型')
|
||||
await textModel.click()
|
||||
await expect(fixture.page.getByRole('option')).toHaveCount(2)
|
||||
await fixture.page.keyboard.press('Escape')
|
||||
await expect(textModel).toBeFocused()
|
||||
await visionModel.click()
|
||||
await expect(fixture.page.getByRole('option')).toHaveCount(1)
|
||||
await fixture.page.keyboard.press('Escape')
|
||||
await expect(visionModel).toBeFocused()
|
||||
await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click()
|
||||
await fixture.page.getByRole('button', { name: '近 7 天' }).click()
|
||||
await fixture.page.getByRole('radio', { name: '近 7 天' }).click()
|
||||
const generate = fixture.page.getByRole('button', { name: '开始生成日报' })
|
||||
await expect(generate).toBeEnabled()
|
||||
await generate.click()
|
||||
@@ -530,7 +862,7 @@ test('REPORT-03 report failure is retryable and leaves other pages usable', asyn
|
||||
await fixture.page.getByRole('button', { name: '日报' }).click()
|
||||
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
|
||||
await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click()
|
||||
await fixture.page.getByRole('button', { name: '近 7 天' }).click()
|
||||
await fixture.page.getByRole('radio', { name: '近 7 天' }).click()
|
||||
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
|
||||
await expect(fixture.page.getByText(/本地假服务错误 401/).first()).toBeVisible()
|
||||
await expect(fixture.page.getByRole('button', { name: '使用所选模型重新生成' })).toBeEnabled()
|
||||
|
||||
@@ -445,6 +445,33 @@ handle('voice:downloadModel', () => ({ success: true, status: voiceModelStatus('
|
||||
handle('voice:cancelModelDownload', () => ({ success: true }))
|
||||
handle('voice:removeModel', () => voiceModelStatus())
|
||||
handle('voice:openModelDirectory', () => ({ success: true }))
|
||||
handle('voice:getBatchProgress', () => ({
|
||||
accountIdentity: 'fixture-account',
|
||||
state: 'idle',
|
||||
total: 0,
|
||||
processed: 0,
|
||||
cached: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
elapsedMs: 0,
|
||||
estimatedRemainingMs: null
|
||||
}))
|
||||
handle('voice:getBatchConversationSummaries', (request) =>
|
||||
request.conversationIds.map((conversationId, index) => ({
|
||||
conversationId,
|
||||
voiceMessageCount: index + 3
|
||||
}))
|
||||
)
|
||||
handle('voice:getBatchPreflight', (request) => ({
|
||||
accountIdentity: 'fixture-account',
|
||||
conversationCount: request.conversationIds.length,
|
||||
voiceMessageCount: request.conversationIds.length * 3,
|
||||
cachedCount: 0,
|
||||
pendingCount: request.conversationIds.length * 3,
|
||||
failedCount: 0,
|
||||
estimatedDurationMs: null,
|
||||
modelReady: false
|
||||
}))
|
||||
handle('voice:recognize', () => ({ success: true, transcript: '固定脱敏转写文本', language: 'zh' }))
|
||||
handle('voice:cancelRecognition', () => ({ success: true }))
|
||||
handle('db:getSticker', (url) =>
|
||||
@@ -612,8 +639,35 @@ handle('app-log:write', (entry) => {
|
||||
})
|
||||
handle('app-log:getPath', () => path.join(userData, 'logs', 'e2e.log'))
|
||||
handle('app-log:reveal', () => undefined)
|
||||
handle('cache:getSummary', () => ({ bootstrapBytes: 0, electronBytes: 0, totalBytes: 0 }))
|
||||
handle('cache:clear', () => ({ bootstrapBytes: 0, electronBytes: 0, totalBytes: 0 }))
|
||||
const cacheSummary = () => ({
|
||||
items: [
|
||||
{
|
||||
id: 'bootstrap',
|
||||
label: '启动缓存',
|
||||
description: '联系人和会话的本地启动快照',
|
||||
sizeBytes: 4096,
|
||||
fileCount: 2
|
||||
},
|
||||
{
|
||||
id: 'electron',
|
||||
label: '界面缓存',
|
||||
description: 'Electron 页面资源和网络缓存',
|
||||
sizeBytes: 2 * 1024 * 1024,
|
||||
fileCount: 12
|
||||
},
|
||||
{
|
||||
id: 'knowledge',
|
||||
label: '本地知识库索引',
|
||||
description: '问问微信使用的本地检索索引',
|
||||
sizeBytes: 12 * 1024 * 1024,
|
||||
fileCount: 8
|
||||
}
|
||||
],
|
||||
totalBytes: 14 * 1024 * 1024 + 4096,
|
||||
updatedAt: fixtureNowMs
|
||||
})
|
||||
handle('cache:getSummary', cacheSummary)
|
||||
handle('cache:clear', cacheSummary)
|
||||
handle('api:getStatus', () => ({ running: false, host: settings.apiHost, port: settings.apiPort }))
|
||||
handle('api:tokenStatus', () => ({
|
||||
success: true,
|
||||
@@ -660,6 +714,19 @@ handle('api:toggle', (enabled) => ({
|
||||
host: settings.apiHost,
|
||||
port: settings.apiPort
|
||||
}))
|
||||
const agentHubStatus = () => ({
|
||||
hub: 'offline',
|
||||
connector: 'disconnected',
|
||||
updatedAt: fixtureNowMs,
|
||||
dataApi: 'online',
|
||||
databaseReady: true
|
||||
})
|
||||
handle('agent-hub:getStatus', agentHubStatus)
|
||||
handle('agent-hub:getLogs', () => [])
|
||||
handle('agent-hub:clearLogs', () => ({ success: true }))
|
||||
handle('agent-hub:startLogin', () => ({ status: agentHubStatus() }))
|
||||
handle('agent-hub:cancelLogin', () => ({ status: agentHubStatus() }))
|
||||
handle('agent-hub:disconnect', () => ({ status: agentHubStatus() }))
|
||||
handle('image:getConfig', () => ({
|
||||
success: true,
|
||||
configured: true,
|
||||
@@ -734,8 +801,6 @@ handle('accounts:discover', (inputPath) =>
|
||||
]
|
||||
}
|
||||
)
|
||||
handle('agent-hub:getStatus', () => ({ state: 'disconnected', connected: false }))
|
||||
handle('agent-hub:getLogs', () => [])
|
||||
handle('app-update:getState', () => ({ status: 'idle', currentVersion: '2.2.0' }))
|
||||
|
||||
for (const channel of [
|
||||
|
||||
+513
-6
@@ -49,6 +49,33 @@ test('ARCH-01 archive page visual @visual', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('CHAT-01 archive search controls visual @visual', async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow })
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize(visualViewport)
|
||||
await fixture.page.getByText('产品测试群', { exact: true }).click()
|
||||
await fixture.page.getByRole('button', { name: '搜索当前聊天' }).click()
|
||||
const searchInput = fixture.page.getByRole('textbox', { name: '搜索当前聊天内容' })
|
||||
await searchInput.fill('测试')
|
||||
await expect(searchInput).toHaveValue('测试')
|
||||
expect(
|
||||
await fixture.page
|
||||
.locator('.chat-archive-header')
|
||||
.evaluate((element) => element.scrollWidth <= element.clientWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot('chat-content-search.png', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('ASK-01 AI Search idle page visual @visual', async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow })
|
||||
const pageErrors: Error[] = []
|
||||
@@ -100,6 +127,79 @@ test('ASK-03 AI Search result page visual @visual', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('ASK-04 AI Search idle page dark visual @visual', async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow, appearanceTheme: 'dark' })
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize(visualViewport)
|
||||
await fixture.page.getByRole('button', { name: '问问微信' }).click()
|
||||
await expect(fixture.page.getByRole('heading', { name: '问问你的微信' })).toBeVisible()
|
||||
await expect(fixture.page.locator('html')).toHaveAttribute('data-theme', 'dark')
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot('ai-search-idle-page-dark.png', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('ASK-05 AI Search result page dark visual @visual', async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow, appearanceTheme: 'dark' })
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize(visualViewport)
|
||||
await fixture.page.getByRole('button', { name: '问问微信' }).click()
|
||||
await fixture.page.getByPlaceholder(/例如:技术交流群/).fill('测试群讨论了什么?')
|
||||
await fixture.page.getByRole('button', { name: '开始分析' }).click()
|
||||
await expect(fixture.page.getByText(/固定假回答:测试数据中的核心流程正常/)).toBeVisible({
|
||||
timeout: 15_000
|
||||
})
|
||||
await expect(fixture.page.locator('html')).toHaveAttribute('data-theme', 'dark')
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot('ai-search-result-page-dark.png', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('API-00 Reader Skill page visual @visual', async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow })
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize(visualViewport)
|
||||
await fixture.page.getByRole('button', { name: 'API' }).click()
|
||||
await expect(fixture.page.getByRole('heading', { name: 'TraceMemo Reader' })).toBeVisible()
|
||||
await expect(fixture.page.getByText('API Token', { exact: true })).toBeVisible()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot('api-center-page.png', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('API-01 Reader Skill preview visual @visual', async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow })
|
||||
const pageErrors: Error[] = []
|
||||
@@ -128,22 +228,29 @@ test('API-01 Reader Skill preview visual @visual', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('CHAT-02 personal WeChat send dialog visual @visual', async () => {
|
||||
test.skip(process.platform !== 'darwin', 'Personal WeChat sending is currently macOS-only')
|
||||
test('API-02 Agent target segmented control visual @visual', async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow })
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize(visualViewport)
|
||||
await fixture.page.getByText('产品测试群', { exact: true }).click()
|
||||
await fixture.page.getByRole('button', { name: '发送消息' }).click()
|
||||
await expect(fixture.page.getByRole('dialog', { name: '个人微信测试发送' })).toBeVisible()
|
||||
await fixture.page.getByRole('button', { name: 'API' }).click()
|
||||
const targetGroup = fixture.page.getByRole('radiogroup', { name: '选择目标 Agent' })
|
||||
await targetGroup.evaluate((element) => element.scrollIntoView({ block: 'center' }))
|
||||
const codexTarget = fixture.page.getByRole('radio', { name: 'Codex' })
|
||||
await expect(codexTarget).toBeChecked()
|
||||
await codexTarget.focus()
|
||||
await fixture.page.keyboard.press('ArrowRight')
|
||||
const claudeTarget = fixture.page.getByRole('radio', { name: 'Claude Code' })
|
||||
await expect(claudeTarget).toBeFocused()
|
||||
await fixture.page.keyboard.press('Space')
|
||||
await expect(claudeTarget).toBeChecked()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot('personal-wechat-send-dialog.png', {
|
||||
await expect(fixture.page).toHaveScreenshot('api-agent-target.png', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
@@ -152,6 +259,124 @@ test('CHAT-02 personal WeChat send dialog visual @visual', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('REPORT-00 report configuration controls visual @visual', async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow })
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize(visualViewport)
|
||||
await fixture.page.getByRole('button', { name: '日报' }).click()
|
||||
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
|
||||
await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click()
|
||||
await expect(fixture.page.getByRole('heading', { name: '生成群聊日报' })).toBeVisible()
|
||||
await expect(fixture.page.getByRole('radiogroup', { name: '总结范围' })).toBeVisible()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot('report-config-page.png', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
|
||||
const modelHeading = fixture.page.getByRole('heading', { name: '模型配置' })
|
||||
await modelHeading.scrollIntoViewIfNeeded()
|
||||
await expect(modelHeading).toBeVisible()
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot('report-model-controls.png', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
|
||||
const templateSection = fixture.page.getByRole('heading', { name: '日报模板' }).locator('..')
|
||||
await templateSection.getByRole('button', { name: '查看版式' }).first().click()
|
||||
const templateDialog = fixture.page.getByRole('dialog', { name: '经典日报' })
|
||||
await expect(templateDialog).toBeVisible()
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot('report-template-dialog.png', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('REPORT-01 report retry controls visual @visual', async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow, aiFailure: '401' })
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize(visualViewport)
|
||||
await fixture.page.getByRole('button', { name: '日报' }).click()
|
||||
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
|
||||
await fixture.page.locator('.report-source-item').filter({ hasText: '产品测试群' }).click()
|
||||
await fixture.page.getByRole('radio', { name: '近 7 天' }).click()
|
||||
await fixture.page.getByRole('button', { name: '开始生成日报' }).click()
|
||||
await expect(fixture.page.getByText(/本地假服务错误 401/).first()).toBeVisible()
|
||||
await expect(fixture.page.getByRole('combobox', { name: '切换模型' })).toBeVisible()
|
||||
await expect(fixture.page.getByRole('button', { name: '使用所选模型重新生成' })).toBeEnabled()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot('report-retry-controls.png', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
for (const appearanceTheme of ['light', 'dark'] as const) {
|
||||
test(`CHAT-02 personal WeChat send dialog ${appearanceTheme} visual @visual`, async () => {
|
||||
test.skip(process.platform !== 'darwin', 'Personal WeChat sending is currently macOS-only')
|
||||
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize(visualViewport)
|
||||
await fixture.page.getByText('产品测试群', { exact: true }).click()
|
||||
await fixture.page.getByRole('button', { name: '发送消息' }).click()
|
||||
const dialog = fixture.page.getByRole('dialog', { name: '个人微信测试发送' })
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(fixture.page.locator('html')).toHaveAttribute('data-theme', appearanceTheme)
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await fixture.page.mouse.move(0, 0)
|
||||
await fixture.page.waitForTimeout(700)
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
const screenshotName =
|
||||
appearanceTheme === 'light'
|
||||
? 'personal-wechat-send-dialog.png'
|
||||
: 'personal-wechat-send-dialog-dark.png'
|
||||
await expect(fixture.page).toHaveScreenshot(screenshotName, {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
|
||||
await dialog.getByRole('radio', { name: '语音' }).click()
|
||||
await expect(dialog.getByRole('textbox', { name: '要生成的文字' })).toBeVisible()
|
||||
await dialog.locator('footer').scrollIntoViewIfNeeded()
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot(
|
||||
`personal-wechat-voice-controls-${appearanceTheme}.png`,
|
||||
{
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('CHAT-03 image viewer visual @visual', async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow })
|
||||
const pageErrors: Error[] = []
|
||||
@@ -175,6 +400,288 @@ test('CHAT-03 image viewer visual @visual', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('SETTINGS-02 basic settings controls visual @visual', async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow })
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize(visualViewport)
|
||||
await fixture.page
|
||||
.getByRole('navigation', { name: '一级导航' })
|
||||
.getByRole('button', { name: '设置' })
|
||||
.click()
|
||||
|
||||
await expect(fixture.page.getByRole('heading', { name: '账号与数据库' })).toBeVisible()
|
||||
await expect(fixture.page.getByRole('switch', { name: '启动时自动连接数据库' })).toBeVisible()
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot('settings-account-database.png', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
|
||||
await fixture.page.getByRole('button', { name: '缓存与清理' }).click()
|
||||
await expect(fixture.page.getByRole('heading', { name: '缓存与清理' })).toBeVisible()
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot('settings-cache-cleanup.png', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
|
||||
await fixture.page.getByRole('button', { name: '高级' }).click()
|
||||
await expect(fixture.page.getByRole('switch', { name: '显示诊断日志' })).toBeVisible()
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot('settings-advanced.png', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
|
||||
await fixture.page.getByRole('button', { name: '关于' }).click()
|
||||
await expect(fixture.page.getByRole('button', { name: '检查更新' })).toBeVisible()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot('settings-about.png', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('SETTINGS-03 database key controls visual @visual', async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow })
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize(visualViewport)
|
||||
await fixture.page
|
||||
.getByRole('navigation', { name: '一级导航' })
|
||||
.getByRole('button', { name: '设置' })
|
||||
.click()
|
||||
await fixture.page.getByRole('button', { name: '数据库密钥' }).click()
|
||||
await expect(fixture.page.getByRole('heading', { name: '数据库密钥' })).toBeVisible()
|
||||
await expect(fixture.page.getByRole('button', { name: '重新验证' })).toBeVisible()
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot('settings-database-key.png', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
|
||||
const clearKey = fixture.page.getByRole('button', { name: '清除密钥' })
|
||||
await clearKey.scrollIntoViewIfNeeded()
|
||||
await expect(clearKey).toBeVisible()
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot('settings-database-key-danger.png', {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
|
||||
for (const appearanceTheme of ['light', 'dark'] as const) {
|
||||
test(`SETTINGS-08 recall protection ${appearanceTheme} visual @visual`, async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize(visualViewport)
|
||||
await fixture.page
|
||||
.getByRole('navigation', { name: '一级导航' })
|
||||
.getByRole('button', { name: '设置' })
|
||||
.click()
|
||||
await fixture.page.getByRole('button', { name: '防撤回' }).click()
|
||||
await expect(fixture.page.getByRole('switch', { name: '开启防撤回' })).toBeVisible()
|
||||
await expect(fixture.page.locator('html')).toHaveAttribute('data-theme', appearanceTheme)
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot(
|
||||
`settings-recall-protection-${appearanceTheme}.png`,
|
||||
{
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const appearanceTheme of ['light', 'dark'] as const) {
|
||||
test(`SETTINGS-04 image decryption controls ${appearanceTheme} visual @visual`, async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize(visualViewport)
|
||||
await fixture.page
|
||||
.getByRole('navigation', { name: '一级导航' })
|
||||
.getByRole('button', { name: '设置' })
|
||||
.click()
|
||||
await fixture.page.getByRole('button', { name: '图片解密' }).click()
|
||||
await expect(
|
||||
fixture.page.getByRole('heading', { name: '图片解密', exact: true })
|
||||
).toBeVisible()
|
||||
const filterGroup = fixture.page.getByRole('radiogroup', { name: '会话类型筛选' })
|
||||
await filterGroup.scrollIntoViewIfNeeded()
|
||||
await expect(filterGroup.getByRole('radio', { name: '全部 3' })).toBeChecked()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot(
|
||||
`settings-image-decryption-${appearanceTheme}.png`,
|
||||
{
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const appearanceTheme of ['light', 'dark'] as const) {
|
||||
test(`SETTINGS-05 AI model editor ${appearanceTheme} visual @visual`, async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize(visualViewport)
|
||||
await fixture.page
|
||||
.getByRole('navigation', { name: '一级导航' })
|
||||
.getByRole('button', { name: '设置' })
|
||||
.click()
|
||||
await fixture.page.getByRole('button', { name: 'AI 模型' }).click()
|
||||
await fixture.page.getByRole('button', { name: '添加供应商' }).click()
|
||||
await expect(fixture.page.getByRole('heading', { name: '新增供应商' })).toBeVisible()
|
||||
await fixture.page.getByLabel('供应商 ID').fill('fixture-new-provider')
|
||||
await expect(fixture.page.getByRole('combobox', { name: '快速模板' })).toBeVisible()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot(
|
||||
`settings-ai-model-editor-${appearanceTheme}.png`,
|
||||
{
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const appearanceTheme of ['light', 'dark'] as const) {
|
||||
test(`SETTINGS-06 text-to-speech controls ${appearanceTheme} visual @visual`, async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize(visualViewport)
|
||||
await fixture.page
|
||||
.getByRole('navigation', { name: '一级导航' })
|
||||
.getByRole('button', { name: '设置' })
|
||||
.click()
|
||||
await fixture.page.getByRole('button', { name: '文字转语音' }).click()
|
||||
const modelSelect = fixture.page.getByRole('combobox', { name: '合成模型' })
|
||||
await modelSelect.scrollIntoViewIfNeeded()
|
||||
await expect(modelSelect).toBeVisible()
|
||||
await expect(fixture.page.getByRole('searchbox', { name: '按音色名称搜索' })).toBeDisabled()
|
||||
await expect(fixture.page.locator('html')).toHaveAttribute('data-theme', appearanceTheme)
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot(
|
||||
`settings-text-to-speech-controls-${appearanceTheme}.png`,
|
||||
{
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const appearanceTheme of ['light', 'dark'] as const) {
|
||||
test(`SETTINGS-07 voice recognition controls ${appearanceTheme} visual @visual`, async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize(visualViewport)
|
||||
await fixture.page
|
||||
.getByRole('navigation', { name: '一级导航' })
|
||||
.getByRole('button', { name: '设置' })
|
||||
.click()
|
||||
await fixture.page.getByRole('button', { name: '语音转文字' }).click()
|
||||
const categoryTabs = fixture.page.getByRole('tablist', { name: '会话类别' })
|
||||
await categoryTabs.scrollIntoViewIfNeeded()
|
||||
await expect(categoryTabs).toBeVisible()
|
||||
await expect(fixture.page.getByRole('combobox', { name: '语音转写时间范围' })).toBeVisible()
|
||||
await expect(fixture.page.locator('html')).toHaveAttribute('data-theme', appearanceTheme)
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot(
|
||||
`settings-voice-recognition-controls-${appearanceTheme}.png`,
|
||||
{
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const appearanceTheme of ['light', 'dark'] as const) {
|
||||
test(`AGENT-01 Agent Hub ${appearanceTheme} visual @visual`, async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
|
||||
const pageErrors: Error[] = []
|
||||
fixture.page.on('pageerror', (error) => pageErrors.push(error))
|
||||
try {
|
||||
await fixture.setWindowContentSize(visualViewport)
|
||||
await fixture.page.getByRole('button', { name: 'Agent' }).click()
|
||||
await expect(fixture.page.getByRole('heading', { name: 'Agent Hub' })).toBeVisible()
|
||||
await expect(fixture.page.getByText('Agent Hub 未运行')).toBeVisible()
|
||||
await expect(fixture.page.getByRole('combobox', { name: '筛选日志来源' })).toBeVisible()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
expect(pageErrors).toEqual([])
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot(`agent-hub-${appearanceTheme}.png`, {
|
||||
animations: 'disabled',
|
||||
caret: 'hide'
|
||||
})
|
||||
} finally {
|
||||
await fixture.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
test('EXPORT-01 export workspace idle visual @visual', async () => {
|
||||
const fixture = await launchTestApp({ now: visualNow })
|
||||
const pageErrors: Error[] = []
|
||||
|
||||
@@ -2,6 +2,21 @@ import '@testing-library/jest-dom/vitest'
|
||||
import { cleanup } from '@testing-library/react'
|
||||
import { afterEach, vi } from 'vitest'
|
||||
|
||||
if (!HTMLElement.prototype.hasPointerCapture) {
|
||||
Object.defineProperties(HTMLElement.prototype, {
|
||||
hasPointerCapture: { configurable: true, value: () => false },
|
||||
releasePointerCapture: { configurable: true, value: () => undefined },
|
||||
setPointerCapture: { configurable: true, value: () => undefined }
|
||||
})
|
||||
}
|
||||
|
||||
if (!HTMLElement.prototype.scrollIntoView) {
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: () => undefined
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
function createMemoryStorage(): Storage {
|
||||
|
||||
Reference in New Issue
Block a user