feat: 优化全局 UI 交互与页面视觉体验

统一按钮、输入框、选择控件和开关的交互状态
优化日报、导出、设置、Agent Hub 和 API Center 的UI
完善组件、单元及 Electron E2E 测试
This commit is contained in:
Wxw-Gu
2026-08-21 15:05:06 +08:00
parent 67b1df7e80
commit 262b15db84
41 changed files with 245 additions and 225 deletions
+1
View File
@@ -24,3 +24,4 @@ findings.md
progress.md
task_plan.md
.agents
*__screenshots__
@@ -69,7 +69,7 @@ interface ExportConfigurationPanelProps {
const sectionClassName = 'mb-6'
const sectionTitleClassName = 'mb-3 text-xs font-bold tracking-normal text-foreground'
const helperClassName = 'mb-0 mt-2 text-[11px] leading-[17px] text-muted-foreground'
const choicePanelClassName = 'rounded-lg bg-muted p-3'
const choicePanelClassName = 'rounded-md bg-muted/70 p-3'
export function ExportConfigurationPanel({
taskCenter,
@@ -322,7 +322,7 @@ export function ExportConfigurationPanel({
<section className={sectionClassName}>
<h3 className={sectionTitleClassName}></h3>
<label className="flex cursor-pointer items-center justify-between gap-3 rounded-lg border border-border bg-surface px-3.5 py-3 text-xs text-foreground">
<label className="flex cursor-pointer items-center justify-between gap-3 border-b border-border-subtle py-2.5 text-xs text-foreground">
<span></span>
<Checkbox
checked={includeMedia}
@@ -387,7 +387,7 @@ export function ExportConfigurationPanel({
))}
</div>
<p className={helperClassName}></p>
<label className="mt-2 flex cursor-pointer items-center justify-between gap-3 rounded-lg border border-border bg-surface px-3.5 py-3 text-xs text-foreground">
<label className="mt-2 flex cursor-pointer items-center justify-between gap-3 border-b border-border-subtle py-2.5 text-xs text-foreground">
<span></span>
<Checkbox
checked={includeAvatars}
@@ -424,7 +424,7 @@ export function ExportConfigurationPanel({
</section>
</div>
<footer className="flex h-[58px] shrink-0 items-center gap-2 border-t border-border bg-surface px-5.5 text-xs text-muted-foreground">
<footer className="flex px-4 h-[58px] shrink-0 items-center gap-2 border-t border-border bg-surface px-5.5 text-xs text-muted-foreground">
<span
className={`h-2 w-2 shrink-0 rounded-full ${status === 'completed' ? 'bg-primary' : 'bg-success'}`}
aria-hidden
@@ -40,7 +40,7 @@ export function ModelSummary({
}
return (
<section className="report-config-section">
<section className="report-config-section report-model-config-section">
<div className="report-model-summary">
<div className="report-model-summary-content">
<h3></h3>
@@ -86,7 +86,7 @@ export function ReportToolbar({
<Button variant="outline" size="sm" onClick={onRegenerate}>
</Button>
<Button variant="outline" size="sm" disabled={!canCopyImage} onClick={onCopyImage}>
<Button variant="ghost" size="sm" disabled={!canCopyImage} onClick={onCopyImage}>
</Button>
<span
@@ -359,12 +359,10 @@ export function AISearchWorkspace({
const renderIdle = (): React.ReactElement => (
<div className="ai-search-empty">
<div className="ai-search-empty-mark" aria-hidden>
<span></span>
</div>
<span className="ai-search-kicker">LOCAL AI WORKSPACE</span>
<span className="ai-search-kicker"></span>
<h2></h2>
<p>使 AI </p>
<span className="ai-search-prompt-label"></span>
<div className="ai-search-prompts">
{[
'交友群"张三"最近聊了什么?',
@@ -372,9 +370,16 @@ export function AISearchWorkspace({
'我和"老李"最近聊了什么话题?',
'全局搜一下 我和谁聊过 去健身?'
].map((prompt) => (
<button key={prompt} type="button" onClick={() => setQuery(prompt)}>
<Button
key={prompt}
type="button"
variant="ghost"
size="sm"
className="h-auto w-full justify-start rounded-none px-0 py-2.5 text-left font-normal"
onClick={() => setQuery(prompt)}
>
{prompt}
</button>
</Button>
))}
</div>
</div>
@@ -781,7 +786,7 @@ export function AISearchWorkspace({
<div className="ai-search-workspace">
<header className="ai-search-header">
<div>
<span className="ai-search-kicker">TraceMemo · LOCAL INTELLIGENCE</span>
<span className="ai-search-kicker">TraceMemo </span>
<h1></h1>
<p></p>
</div>
+1 -1
View File
@@ -3,7 +3,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/cn'
const buttonVariants = cva(
'inline-flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border-0 bg-transparent text-sm font-medium transition-[color,background-color,border-color,transform] duration-fast ease-tm-standard active:translate-y-px disabled:pointer-events-none disabled:translate-y-0 disabled:!text-disabled-foreground disabled:opacity-100',
'inline-flex shrink-0 items-center justify-center gap-1.5 whitespace-nowrap rounded-md border-0 bg-transparent text-sm font-medium transition-[color,background-color,border-color,transform] duration-fast ease-tm-standard focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background active:translate-y-px disabled:pointer-events-none disabled:translate-y-0 disabled:!text-disabled-foreground disabled:opacity-100',
{
variants: {
variant: {
+1 -1
View File
@@ -9,7 +9,7 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-muted-foreground/60 bg-surface transition-colors duration-fast ease-tm-standard hover:border-primary/70 disabled:cursor-not-allowed disabled:border-disabled-border disabled:bg-disabled-surface disabled:text-disabled-foreground disabled:opacity-100 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground disabled:data-[state=checked]:border-disabled-border disabled:data-[state=checked]:bg-primary/40',
'peer h-4 w-4 shrink-0 rounded-sm border border-muted-foreground/60 bg-surface transition-colors duration-fast ease-tm-standard hover:border-primary/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:border-disabled-border disabled:bg-disabled-surface disabled:text-disabled-foreground disabled:opacity-100 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground disabled:data-[state=checked]:border-disabled-border disabled:data-[state=checked]:bg-primary/40',
className
)}
{...props}
+1 -1
View File
@@ -47,7 +47,7 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-md p-1 text-muted-foreground opacity-70 transition-opacity hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none">
<DialogPrimitive.Close className="absolute right-4 top-4 inline-flex h-8 w-8 items-center justify-center rounded-md border-0 bg-transparent p-0 text-muted-foreground opacity-70 shadow-none transition-colors duration-fast hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:pointer-events-none">
<span aria-hidden="true" className="text-lg leading-none">
×
</span>
@@ -15,7 +15,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent',
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-[state=open]:bg-accent',
inset && 'pl-8',
className
)}
@@ -67,7 +67,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-[disabled]:pointer-events-none data-[disabled]:text-disabled-foreground data-[disabled]:opacity-100',
inset && 'pl-8',
className
)}
@@ -84,7 +84,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
ref={ref}
checked={checked}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-[disabled]:pointer-events-none data-[disabled]:text-disabled-foreground data-[disabled]:opacity-100',
className
)}
{...props}
@@ -104,7 +104,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-[disabled]:pointer-events-none data-[disabled]:text-disabled-foreground data-[disabled]:opacity-100',
className
)}
{...props}
@@ -16,7 +16,7 @@ export function EmptyState({
return (
<div
className={cn(
'flex min-h-40 flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-border bg-surface-muted/50 px-6 py-8 text-center',
'flex min-h-40 flex-col items-center justify-center gap-3 rounded-md border border-dashed border-border-subtle bg-surface-muted/40 px-6 py-8 text-center',
className
)}
>
+1 -1
View File
@@ -10,7 +10,7 @@ export const Input = React.forwardRef<
ref={ref}
type={type}
className={cn(
'flex h-control-form w-full rounded-md border border-border bg-surface px-3 py-1 text-sm text-foreground transition-colors duration-fast ease-tm-standard placeholder:text-muted-foreground hover:border-primary/25 disabled:cursor-not-allowed disabled:border-disabled-border disabled:bg-disabled-surface disabled:text-disabled-foreground disabled:opacity-100 disabled:placeholder:text-disabled-foreground',
'flex h-control-form w-full rounded-md border border-border-subtle bg-surface px-3 py-1 text-sm text-foreground transition-colors duration-fast ease-tm-standard placeholder:text-muted-foreground hover:border-primary/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:border-disabled-border disabled:bg-disabled-surface disabled:text-disabled-foreground disabled:opacity-100 disabled:placeholder:text-disabled-foreground',
className
)}
{...props}
@@ -17,7 +17,7 @@ const RadioGroupItem = React.forwardRef<
<RadioGroupPrimitive.Item
ref={ref}
className={cn(
'aspect-square h-4 w-4 rounded-full border border-muted-foreground/60 bg-surface text-primary transition-colors duration-fast ease-tm-standard hover:border-primary/70 disabled:cursor-not-allowed disabled:border-disabled-border disabled:bg-disabled-surface disabled:text-disabled-foreground disabled:opacity-100 data-[state=checked]:border-primary disabled:data-[state=checked]:border-disabled-border',
'aspect-square h-4 w-4 rounded-full border border-muted-foreground/60 bg-surface text-primary transition-colors duration-fast ease-tm-standard hover:border-primary/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:border-disabled-border disabled:bg-disabled-surface disabled:text-disabled-foreground disabled:opacity-100 data-[state=checked]:border-primary disabled:data-[state=checked]:border-disabled-border',
className
)}
{...props}
@@ -21,7 +21,7 @@ const SegmentedControlItem = React.forwardRef<
<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-[color,background-color,font-weight] 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:text-disabled-foreground disabled:opacity-100 data-[state=checked]:bg-accent data-[state=checked]:font-semibold data-[state=checked]:text-accent-foreground',
'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-[color,background-color,font-weight,transform] duration-fast ease-tm-standard hover:bg-accent hover:text-foreground active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:text-disabled-foreground disabled:opacity-100 data-[state=checked]:bg-accent data-[state=checked]:font-semibold data-[state=checked]:text-accent-foreground',
className
)}
{...props}
+1 -1
View File
@@ -13,7 +13,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-control-form w-full items-center justify-between rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground transition-colors duration-fast ease-tm-standard hover:border-primary/25 disabled:cursor-not-allowed disabled:border-disabled-border disabled:bg-disabled-surface disabled:text-disabled-foreground disabled:opacity-100 [&>span]:line-clamp-1',
'flex h-control-form w-full items-center justify-between rounded-md border border-border-subtle bg-surface px-3 py-2 text-sm text-foreground transition-colors duration-fast ease-tm-standard hover:border-primary/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:border-disabled-border disabled:bg-disabled-surface disabled:text-disabled-foreground disabled:opacity-100 [&>span]:line-clamp-1',
className
)}
{...props}
+2 -2
View File
@@ -9,12 +9,12 @@ const Switch = React.forwardRef<
<SwitchPrimitive.Root
ref={ref}
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent bg-muted transition-colors duration-fast ease-tm-standard hover:bg-muted/80 disabled:cursor-not-allowed disabled:bg-disabled-surface disabled:opacity-100 data-[state=checked]:bg-primary disabled:data-[state=checked]:bg-primary/40',
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-0 bg-muted-foreground/25 p-0.5 transition-colors duration-fast ease-tm-standard hover:bg-muted-foreground/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:bg-disabled-border disabled:opacity-100 data-[state=checked]:bg-primary data-[state=checked]:hover:bg-primary-hover disabled:data-[state=checked]:bg-primary/40',
className
)}
{...props}
>
<SwitchPrimitive.Thumb className="pointer-events-none block h-4 w-4 rounded-full bg-white shadow-surface ring-0 transition-transform duration-fast ease-tm-standard data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0" />
<SwitchPrimitive.Thumb className="pointer-events-none block h-4 w-4 rounded-full bg-background shadow-surface ring-0 transition-transform duration-fast ease-tm-standard data-[state=checked]:translate-x-4 data-[state=checked]:bg-primary-foreground data-[state=unchecked]:translate-x-0" />
</SwitchPrimitive.Root>
))
Switch.displayName = SwitchPrimitive.Root.displayName
+1 -1
View File
@@ -26,7 +26,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center rounded-sm border-0 bg-transparent px-3 py-1.5 text-xs font-medium text-muted-foreground transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-surface data-[state=active]:text-foreground data-[state=active]:shadow-surface',
'inline-flex items-center justify-center rounded-sm border-0 bg-transparent px-3 py-1.5 text-xs font-medium text-muted-foreground transition-[color,background-color,box-shadow,transform] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:text-disabled-foreground disabled:opacity-100 active:scale-[0.98] data-[state=active]:bg-surface data-[state=active]:text-foreground data-[state=active]:shadow-surface',
className
)}
{...props}
+1 -1
View File
@@ -9,7 +9,7 @@ export const Textarea = React.forwardRef<
<textarea
ref={ref}
className={cn(
'flex min-h-20 w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground shadow-surface transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
'flex min-h-20 w-full rounded-md border border-border-subtle bg-surface px-3 py-2 text-sm text-foreground transition-colors duration-fast ease-tm-standard placeholder:text-muted-foreground hover:border-primary/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:border-disabled-border disabled:bg-disabled-surface disabled:text-disabled-foreground disabled:opacity-100 disabled:placeholder:text-disabled-foreground',
className
)}
{...props}
+2 -2
View File
@@ -64,12 +64,12 @@ const ToastClose = React.forwardRef<
<ToastPrimitive.Close
ref={ref}
className={cn(
'rounded-md p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring group-hover:opacity-100',
'inline-flex h-7 w-7 shrink-0 appearance-none items-center justify-center rounded-md border-0 bg-transparent p-0 text-lg leading-none text-muted-foreground opacity-70 transition-colors duration-fast hover:bg-accent hover:text-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-surface group-hover:opacity-100',
className
)}
{...props}
>
×
<span aria-hidden>×</span>
</ToastPrimitive.Close>
))
ToastClose.displayName = ToastPrimitive.Close.displayName
@@ -194,37 +194,19 @@ export function AgentHubWorkspace(): React.ReactElement {
</section>
<aside className="agent-hub-card agent-hub-capability-card">
<span className="agent-hub-card-kicker"></span>
<h2></h2>
<span className="agent-hub-card-kicker"></span>
<h2></h2>
<p> Agent Hub TraceMemo</p>
<div className="agent-hub-example">
<span></span>
<strong> 5 </strong>
<strong>xx聊了些什么</strong>
<strong></strong>
</div>
<ul>
<li>
<i />
HTTP
</li>
<li>
<i />
</li>
<li>
<i />
</li>
<li>
<i />
使 AI
</li>
<li>
<li> HTTP </li>
<li></li>
<li></li>
<li>使 AI </li>
<li className="agent-hub-capability-status">
<i className={status.dataApi === 'online' ? '' : 'offline'} />
API{status.dataApi === 'online' ? '已连接' : '未连接'}
</li>
<li>
<li className="agent-hub-capability-status">
<i className={status.databaseReady ? '' : 'offline'} />
{status.databaseReady ? '可查询' : '未就绪'}
</li>
@@ -87,13 +87,13 @@ export function ApiRuntimePanel({
</Button>
)}
<Button size="sm" variant="outline" onClick={() => onControl('restart')}>
<Button size="sm" variant="ghost" onClick={() => onControl('restart')}>
</Button>
<Button size="sm" variant="outline" onClick={copyAddress}>
<Button size="sm" variant="ghost" onClick={copyAddress}>
</Button>
<Button size="sm" variant="outline" onClick={onOpenSettings}>
<Button size="sm" variant="ghost" onClick={onOpenSettings}>
API
</Button>
</div>
@@ -117,7 +117,7 @@ export function ApiRuntimePanel({
<div className="api-runtime-actions">
<Button
size="sm"
variant="outline"
variant="ghost"
disabled={!tokenStatus?.hasToken}
onClick={() => void (revealedToken ? onHideToken() : onRevealToken())}
>
@@ -125,7 +125,7 @@ export function ApiRuntimePanel({
</Button>
<Button
size="sm"
variant="outline"
variant="ghost"
disabled={!tokenStatus?.hasToken}
onClick={() => void onCopyToken()}
>
@@ -162,7 +162,7 @@ export function ApiRuntimePanel({
</p>
<Button
size="sm"
variant="outline"
variant="ghost"
onClick={() => void onCopy(formatJson(response.text), 'JSON 响应已复制')}
>
JSON
@@ -47,7 +47,7 @@ export function SkillDetails({
<Button size="sm" variant="outline" onClick={onPreview} disabled={!skill?.available}>
SKILL.md
</Button>
<Button size="sm" variant="outline" onClick={onGithub}>
<Button size="sm" variant="ghost" onClick={onGithub}>
GitHub
</Button>
</div>
@@ -100,6 +100,13 @@ export function SettingsWorkspace({
return <AdvancedPage onNotice={onNotice} />
case 'cache-cleanup':
return <CacheCleanupPage onNotice={onNotice} />
case 'storage-export':
return (
<SettingsEmptyState
label={SETTINGS_CATEGORY_LABELS[selectedCategory]}
description="导出格式与范围请前往「导出」工作区设置。"
/>
)
case 'appearance':
return <AppearancePage onNotice={onNotice} onAppearanceChange={onAppearanceChange} />
case 'about':
@@ -1,8 +1,14 @@
export function SettingsEmptyState({ label }: { label: string }): React.ReactElement {
export function SettingsEmptyState({
label,
description = '当前分类暂无额外设置。'
}: {
label: string
description?: string
}): React.ReactElement {
return (
<div className="settings-empty-state">
<h2>{label}</h2>
<p></p>
<p>{description}</p>
</div>
)
}
@@ -25,7 +25,8 @@ export const SETTINGS_NAVIGATION: SettingsNavigationGroup[] = [
{
label: '数据管理',
items: [
{ id: 'storage-export', label: '存储与导出' },
// “存储与导出”暂不开放;保留 category/render case 以兼容已有页面状态。
// { id: 'storage-export', label: '存储与导出' },
{ id: 'cache-cleanup', label: '缓存与清理' }
]
},
@@ -40,6 +41,9 @@ export const SETTINGS_NAVIGATION: SettingsNavigationGroup[] = [
}
]
export const SETTINGS_CATEGORY_LABELS = Object.fromEntries(
export const SETTINGS_CATEGORY_LABELS = {
...Object.fromEntries(
SETTINGS_NAVIGATION.flatMap((group) => group.items.map((item) => [item.id, item.label]))
) as Record<SettingsCategoryId, string>
),
'storage-export': '存储与导出'
} as Record<SettingsCategoryId, string>
@@ -14,25 +14,30 @@ export function AIModelPage({
onNotice: (message: string) => void
}): React.ReactElement {
const controller = useAIModelSettingsController({ onRuntimeChange, onNotice })
const shouldRevealNewEditor = useRef(false)
const shouldRevealEditor = useRef(false)
const runtime = controller.state.runtime
const defaultProvider = controller.state.providers.find(
(provider) => provider.id === runtime?.providerId
)
useEffect(() => {
if (!controller.state.editor || !shouldRevealNewEditor.current) return
shouldRevealNewEditor.current = false
if (!controller.state.editor || !shouldRevealEditor.current) return
shouldRevealEditor.current = false
const editor = document.getElementById('ai-provider-editor')
editor?.scrollIntoView({ behavior: 'smooth', block: 'start' })
editor?.querySelector<HTMLInputElement>('#ai-provider-name')?.focus({ preventScroll: true })
}, [controller.state.editor])
const openNewProvider = (): void => {
shouldRevealNewEditor.current = true
shouldRevealEditor.current = true
controller.openNew()
}
const openProviderEditor = (provider: (typeof controller.state.providers)[number]): void => {
shouldRevealEditor.current = true
controller.openEdit(provider)
}
return (
<div className="settings-page ai-model-page">
<header className="settings-page-header">
@@ -85,7 +90,7 @@ export function AIModelPage({
key={provider.id}
provider={provider}
testing={controller.state.testingId === provider.id}
onEdit={() => controller.openEdit(provider)}
onEdit={() => openProviderEditor(provider)}
onTest={() => void controller.test(provider.id)}
onDefault={() => void controller.setDefault(provider.id)}
onDelete={() => void controller.remove(provider.id)}
@@ -511,7 +511,11 @@ export function VoiceRecognitionPage({
onCheckedChange={() => toggleConversation(contact.md5)}
/>
<span className="voice-conversation-avatar" aria-hidden>
{contactName(contact).slice(0, 1)}
{contact.avatar ? (
<img src={contact.avatar} alt="" referrerPolicy="no-referrer" />
) : (
contactName(contact).slice(0, 1)
)}
</span>
<span className="voice-conversation-copy">
<strong>{contactName(contact)}</strong>
+11 -11
View File
@@ -172,7 +172,7 @@
gap: 10px;
margin-top: 16px;
padding: 10px 12px;
border: 1px solid var(--wxex-border);
border: 1px solid hsl(var(--tm-border-subtle));
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-sidebar);
color: var(--wxex-text-secondary);
@@ -187,7 +187,7 @@
.api-introduction {
margin-top: 24px;
padding: 18px 20px;
border: 1px solid var(--wxex-border);
border: 1px solid hsl(var(--tm-border-subtle));
border-radius: var(--wxex-radius-lg);
background: var(--wxex-bg-elevated);
}
@@ -202,7 +202,7 @@
justify-content: space-between;
gap: 10px;
padding: 12px;
border: 1px dashed var(--wxex-border);
border: 1px dashed hsl(var(--tm-border-subtle));
border-radius: var(--wxex-radius-md);
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
@@ -210,7 +210,7 @@
.api-flow span,
.api-flow strong {
padding: 7px 10px;
border: 1px solid var(--wxex-border);
border: 1px solid hsl(var(--tm-border-subtle));
border-radius: 5px;
background: var(--wxex-bg-elevated);
white-space: nowrap;
@@ -230,7 +230,7 @@
.skill-install-flow {
margin-top: 26px;
padding: 18px 20px;
border: 1px solid var(--wxex-border);
border: 1px solid hsl(var(--tm-border-subtle));
border-radius: var(--wxex-radius-lg);
background: var(--wxex-bg-elevated);
}
@@ -262,7 +262,7 @@
width: 24px;
height: 24px;
place-items: center;
border: 1px solid var(--wxex-border);
border: 1px solid hsl(var(--tm-border-subtle));
border-radius: 50%;
background: var(--wxex-bg-elevated);
font: 700 12px/1 var(--wxex-font);
@@ -291,7 +291,7 @@
}
.api-skill-details {
margin-top: 16px;
border: 1px solid var(--wxex-border);
border: 1px solid hsl(var(--tm-border-subtle));
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
}
@@ -331,7 +331,7 @@
.api-endpoint-table {
margin-top: 12px;
overflow: hidden;
border: 1px solid var(--wxex-border);
border: 1px solid hsl(var(--tm-border-subtle));
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
}
@@ -350,7 +350,7 @@
}
.api-endpoint-row {
min-height: 54px;
border-top: 1px solid var(--wxex-border);
border-top: 1px solid hsl(var(--tm-border-subtle));
color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font);
}
@@ -387,7 +387,7 @@
gap: 10px;
margin: 12px 0;
padding: 10px 12px;
border: 1px solid var(--wxex-border);
border: 1px solid hsl(var(--tm-border-subtle));
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-sidebar);
overflow: hidden;
@@ -505,7 +505,7 @@
overflow-wrap: anywhere;
margin: 10px 0;
padding: 9px 10px;
border: 1px solid var(--wxex-border);
border: 1px solid hsl(var(--tm-border-subtle));
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-subtle);
color: var(--wxex-text-primary);
+8
View File
@@ -9,6 +9,14 @@
background: var(--wxex-bg-main);
}
.export-config-panel {
position: relative;
z-index: 1;
box-shadow:
-10px 0 18px -16px color-mix(in srgb, var(--wxex-text-primary) 32%, transparent),
10px 0 18px -16px color-mix(in srgb, var(--wxex-text-primary) 32%, transparent);
}
@media (max-width: 1100px) {
.export-workspace {
grid-template-columns: 248px minmax(460px, 1fr);
+24 -30
View File
@@ -262,6 +262,10 @@
padding: 0;
}
.report-model-config-section {
margin-top: 12px;
}
.report-empty-state h2,
.report-privacy-note h3,
.report-config-section h3,
@@ -342,11 +346,11 @@
.report-type-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
gap: 0;
overflow: hidden;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-border);
background: var(--wxex-bg-elevated);
}
.report-check-row {
@@ -357,12 +361,22 @@
min-width: 0;
padding: 11px 12px;
border: 0;
border-bottom: 1px solid var(--wxex-border);
border-radius: 0;
background: var(--wxex-bg-elevated);
color: var(--wxex-text-primary);
transition: background-color var(--tm-motion-fast) var(--tm-ease-standard);
}
.report-check-row:nth-child(odd):not(:last-child) {
border-right: 1px solid var(--wxex-border);
}
.report-check-row:last-child {
grid-column: 1 / -1;
border-bottom: 0;
}
.report-check-row:hover {
background: var(--wxex-bg-sidebar);
}
@@ -930,7 +944,7 @@
.agent-hub-card {
box-sizing: border-box;
min-width: 0;
border: 1px solid var(--wxex-border);
border: 1px solid hsl(var(--tm-border-subtle));
border-radius: var(--wxex-radius-lg);
background: var(--wxex-bg-elevated);
}
@@ -950,7 +964,7 @@
justify-content: space-between;
gap: 16px;
padding-bottom: 16px;
border-bottom: 1px solid var(--wxex-border);
border-bottom: 1px solid hsl(var(--tm-border-subtle));
}
.agent-hub-card h2 {
margin: 4px 0 0;
@@ -1110,39 +1124,21 @@
.agent-hub-capability-card > p {
margin-top: 8px;
}
.agent-hub-example {
margin: 18px 0 14px;
padding: 14px;
border-radius: var(--wxex-radius-md);
background: var(--wxex-brand-soft);
}
.agent-hub-example span {
display: block;
color: var(--wxex-text-muted);
font: 600 11px/16px var(--wxex-font);
}
.agent-hub-example strong {
display: block;
margin-top: 4px;
color: var(--wxex-brand);
font: 700 14px/21px var(--wxex-font);
}
.agent-hub-capability-card ul {
margin: 0;
margin: 18px 0 0;
padding: 0;
list-style: none;
}
.agent-hub-capability-card li {
display: flex;
align-items: center;
gap: 9px;
gap: 8px;
padding: 7px 0;
border-bottom: 1px solid var(--wxex-border);
color: var(--wxex-text-secondary);
font: 13px/19px var(--wxex-font);
}
.agent-hub-capability-card li:last-child {
border-bottom: 0;
.agent-hub-capability-card li + li {
margin-top: 2px;
}
.agent-hub-capability-card li i {
width: 7px;
@@ -1150,10 +1146,8 @@
border-radius: 50%;
background: var(--wxex-brand);
}
.agent-hub-example strong + strong {
margin-top: 9px;
padding-top: 9px;
border-top: 1px solid rgba(36, 122, 99, 0.15);
.agent-hub-capability-card li:not(.agent-hub-capability-status) {
padding-left: 15px;
}
.agent-hub-capability-card li i.offline {
background: var(--wxex-danger);
@@ -224,29 +224,6 @@
gap: 8px;
}
.report-viewer-toolbar button {
min-height: 32px;
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 13px/18px var(--wxex-font);
}
.report-viewer-toolbar button.primary {
border-color: var(--wxex-ai);
background: var(--wxex-ai);
color: #fff;
}
.report-viewer-toolbar button:disabled {
color: var(--wxex-text-muted);
cursor: not-allowed;
opacity: 0.72;
}
.report-toolbar-button-hint {
position: relative;
display: inline-flex;
+26 -78
View File
@@ -208,7 +208,7 @@
gap: 10px;
margin-top: auto;
padding: 12px;
border: 1px solid var(--wxex-border);
border: 1px solid hsl(var(--tm-border-subtle));
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
}
@@ -388,39 +388,34 @@
}
}
.ai-search-empty-mark {
width: 62px;
height: 62px;
display: grid;
place-items: center;
margin-bottom: 20px;
border-radius: 50%;
background: var(--wxex-brand-soft);
color: var(--wxex-ai);
font-size: 28px;
.ai-search-empty {
align-items: flex-start;
text-align: left;
> h2,
> p {
width: 100%;
max-width: 520px;
}
}
.ai-search-prompt-label {
margin-top: 28px;
color: var(--wxex-text-muted);
font-size: 11px;
font-weight: 600;
line-height: 16px;
}
.ai-search-prompts {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 7px;
margin-top: 22px;
width: 100%;
max-width: 520px;
display: grid;
margin-top: 6px;
border-top: 1px solid hsl(var(--tm-border-subtle));
button {
padding: 7px 10px;
border: 1px solid var(--wxex-border);
border-radius: 999px;
background: var(--wxex-bg-elevated);
color: var(--wxex-text-secondary);
cursor: pointer;
font: inherit;
font-size: 11px;
&:hover {
border-color: var(--wxex-brand);
color: var(--wxex-brand);
}
> * {
border-bottom: 1px solid hsl(var(--tm-border-subtle));
}
}
@@ -562,18 +557,6 @@
width: min(820px, calc(100% - 48px));
margin: 0 auto;
padding: 28px 0 34px;
animation: ai-search-result-in 260ms ease-out both;
}
@keyframes ai-search-result-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ai-search-result-header {
@@ -610,41 +593,6 @@
.ai-search-summary-block {
margin-top: 22px;
animation: ai-search-result-in 340ms 45ms ease-out both;
}
.ai-search-answer > * {
animation: ai-search-answer-line-in 260ms ease-out both;
}
.ai-search-answer > *:nth-child(1) {
animation-delay: 70ms;
}
.ai-search-answer > *:nth-child(2) {
animation-delay: 105ms;
}
.ai-search-answer > *:nth-child(3) {
animation-delay: 140ms;
}
.ai-search-answer > *:nth-child(4) {
animation-delay: 175ms;
}
.ai-search-answer > *:nth-child(5) {
animation-delay: 210ms;
}
.ai-search-answer > *:nth-child(n + 6) {
animation-delay: 245ms;
}
@keyframes ai-search-answer-line-in {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.ai-search-section-heading {
@@ -688,7 +636,7 @@
.ai-search-answer-evidence {
padding: 9px 12px;
border: 1px solid var(--wxex-border);
border: 1px solid hsl(var(--tm-border-subtle));
border-top: 0;
background: var(--wxex-bg-elevated);
}
@@ -283,6 +283,12 @@
background: var(--wxex-bg-sidebar);
color: var(--wxex-text-secondary);
font-size: 12px;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.voice-conversation-copy {
+1 -1
View File
@@ -244,7 +244,7 @@
font-size: 16px;
}
.settings-card {
border: 1px solid var(--wxex-border);
border: 1px solid hsl(var(--tm-border-subtle));
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
padding: 24px 26px;
@@ -74,6 +74,23 @@ describe('AI model settings', () => {
expect(screen.getByLabelText('模型 ID')).toHaveAttribute('placeholder', '例如:deepseek-chat')
})
it('reveals and focuses the provider editor when editing an existing provider', async () => {
vi.mocked(window.api.listAIProviders).mockResolvedValue({
success: true,
providers: [provider]
})
render(<AIModelPage onRuntimeChange={vi.fn()} onNotice={vi.fn()} />)
await act(async () => {
await Promise.resolve()
})
await userEvent.click(screen.getByRole('button', { name: '编辑' }))
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' })
expect(screen.getByLabelText('供应商名称')).toHaveFocus()
expect(screen.getByLabelText('供应商名称')).toHaveValue(provider.name)
})
it('does not reload providers when the parent callback identity changes', async () => {
const firstRuntimeChange = vi.fn()
const secondRuntimeChange = vi.fn()
+3 -3
View File
@@ -11,8 +11,8 @@ describe('empty states', () => {
})
it('labels an unavailable settings section explicitly', () => {
render(<SettingsEmptyState label="测试设置" />)
expect(screen.getByRole('heading', { name: '测试设置' })).toBeVisible()
expect(screen.getByText('该设置将在后续阶段接入。')).toBeVisible()
render(<SettingsEmptyState label="存储与导出" description="请前往导出工作区设置" />)
expect(screen.getByRole('heading', { name: '存储与导出' })).toBeVisible()
expect(screen.getByText('请前往导出工作区设置。')).toBeVisible()
})
})
+15 -1
View File
@@ -12,7 +12,8 @@ import {
SelectItem,
SelectTrigger,
SelectValue,
Switch
Switch,
Textarea
} from '../../src/renderer/src/components/ui'
import { IconButton } from '../../src/renderer/src/components/ui/icon-button'
import {
@@ -79,6 +80,7 @@ describe('TraceMemo UI control states', () => {
<>
<Button disabled></Button>
<Input aria-label="不可用输入" value="仍可读取" disabled readOnly />
<Textarea aria-label="不可用多行输入" value="仍可读取" disabled readOnly />
<Checkbox aria-label="不可用多选" disabled />
<RadioGroup aria-label="不可用单选组">
<RadioGroupItem value="disabled" aria-label="不可用单选" disabled />
@@ -90,6 +92,7 @@ describe('TraceMemo UI control states', () => {
for (const control of [
screen.getByRole('button', { name: '不可用操作' }),
screen.getByRole('textbox', { name: '不可用输入' }),
screen.getByRole('textbox', { name: '不可用多行输入' }),
screen.getByRole('checkbox', { name: '不可用多选' }),
screen.getByRole('radio', { name: '不可用单选' }),
screen.getByRole('switch', { name: '不可用开关' })
@@ -103,6 +106,15 @@ describe('TraceMemo UI control states', () => {
'disabled:text-disabled-foreground',
'disabled:!text-disabled-foreground'
)
expect(screen.getByRole('textbox', { name: '不可用多行输入' })).toHaveClass(
'border-border-subtle',
'disabled:border-disabled-border',
'disabled:bg-disabled-surface',
'disabled:text-disabled-foreground'
)
expect(screen.getByRole('textbox', { name: '不可用多行输入' })).not.toHaveClass(
'shadow-surface'
)
expect(screen.getByRole('checkbox', { name: '不可用多选' })).toHaveClass(
'border-muted-foreground/60'
)
@@ -148,6 +160,8 @@ describe('TraceMemo UI control states', () => {
expect(screen.getByRole('radio', { name: '舒适密度' })).toBeChecked()
const toggle = screen.getByRole('switch', { name: '显示头像' })
expect(toggle).toHaveClass('h-5', 'w-9', 'border-0', 'p-0.5')
expect(toggle.firstElementChild).toHaveClass('h-4', 'w-4')
await user.click(toggle)
expect(toggle).toBeChecked()
})
@@ -79,5 +79,10 @@ describe('TraceMemo UI infrastructure', () => {
await user.click(screen.getByRole('button', { name: '显示通知' }))
expect(screen.getByText('已保存')).toBeVisible()
expect(screen.getByText('设置已更新')).toBeVisible()
const close = screen.getByRole('button', { name: '关闭通知' })
expect(close).toHaveClass('h-7', 'w-7', 'border-0', 'bg-transparent')
await user.click(close)
expect(screen.queryByText('已保存')).not.toBeInTheDocument()
})
})
@@ -93,7 +93,13 @@ describe('voice recognition settings', () => {
...window.api,
getContacts: vi.fn().mockResolvedValue([
{ md5: 'contact-a', m_nsNickName: '联系人 A', m_nsUsrName: 'contact-a', type: 'user' },
{ md5: 'group-b', m_nsNickName: '群聊 B', m_nsUsrName: 'group-b@chatroom', type: 'group' }
{
md5: 'group-b',
m_nsNickName: '群聊 B',
m_nsUsrName: 'group-b@chatroom',
type: 'group',
avatar: 'data:image/png;base64,voice-fixture-avatar'
}
]),
getSelf: vi.fn().mockResolvedValue({
ready: true,
@@ -142,6 +148,9 @@ describe('voice recognition settings', () => {
expect(await screen.findByText('群聊 B')).toBeInTheDocument()
expect(await screen.findByText('7 条语音')).toBeInTheDocument()
expect(
document.querySelector('img[src="data:image/png;base64,voice-fixture-avatar"]')
).toBeInTheDocument()
expect(window.api.getVoiceBatchConversationSummaries).toHaveBeenCalledWith({
conversationIds: ['group-b'],
range: 'recent_30_days'
+17
View File
@@ -307,6 +307,7 @@ test('SETTINGS-02 basic settings controls fit a narrow viewport and keep their s
.click()
await expect(fixture.page.getByRole('heading', { name: '账号与数据库' })).toBeVisible()
await expect(fixture.page.getByRole('button', { name: '存储与导出' })).toHaveCount(0)
const autoLoginSwitch = fixture.page.getByRole('switch', { name: '启动时自动连接数据库' })
await expect(autoLoginSwitch).toBeVisible()
await autoLoginSwitch.click()
@@ -438,6 +439,12 @@ test('SETTINGS-05 AI model editor keeps provider and capability semantics', asyn
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 expect(fixture.page.locator('#ai-provider-editor')).toBeInViewport()
await fixture.page.getByRole('button', { name: '关闭' }).click()
await fixture.page.getByRole('button', { name: '添加供应商' }).click()
await expect(fixture.page.getByRole('heading', { name: '新增供应商' })).toBeVisible()
await expect(fixture.page.getByLabel('供应商名称')).toBeFocused()
@@ -493,6 +500,7 @@ test('SETTINGS-07 voice recognition controls keep selection semantics at a narro
const categoryTabs = fixture.page.getByRole('tablist', { name: '会话类别' })
await categoryTabs.scrollIntoViewIfNeeded()
await expect(fixture.page.locator('.voice-conversation-avatar img')).toHaveCount(1)
await expect(categoryTabs.getByRole('tab', { name: /群聊/ })).toHaveAttribute(
'data-state',
'active'
@@ -826,6 +834,15 @@ test('ASK-01 uses the local fixed AI service and keeps evidence in the UI', asyn
await expect(fixture.page.getByText(/固定假回答:测试数据中的核心流程正常/)).toBeVisible({
timeout: 15_000
})
await fixture.page.getByRole('button', { name: /历史提问/ }).click()
await fixture.page.getByRole('button', { name: '测试群讨论了什么?', exact: true }).click()
const toastClose = fixture.page.getByRole('button', { name: '关闭通知' })
await expect(toastClose).toBeVisible()
expect(await toastClose.boundingBox()).toMatchObject({ width: 28, height: 28 })
await toastClose.click()
await expect(toastClose).toHaveCount(0)
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
+1
View File
@@ -55,6 +55,7 @@ export async function launchTestApp(
})
const page = await app.firstWindow()
await page.waitForLoadState('domcontentloaded')
if (options.now) await page.clock.setFixedTime(options.now)
const setWindowContentSize = async (size: { width: number; height: number }): Promise<void> => {
await app.evaluate(({ BrowserWindow, screen }, nextSize) => {
const [window] = BrowserWindow.getAllWindows()
+11 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { SETTINGS_NAVIGATION } from '../../src/renderer/src/features/settings/model/settingsNavigation'
import {
SETTINGS_CATEGORY_LABELS,
SETTINGS_NAVIGATION
} from '../../src/renderer/src/features/settings/model/settingsNavigation'
describe('settings navigation', () => {
it('places text-to-speech under intelligent capabilities', () => {
@@ -10,4 +13,11 @@ describe('settings navigation', () => {
'ai-model'
])
})
it('temporarily hides storage and export from visible navigation', () => {
expect(SETTINGS_NAVIGATION.flatMap((group) => group.items)).not.toEqual(
expect.arrayContaining([{ id: 'storage-export', label: '存储与导出' }])
)
expect(SETTINGS_CATEGORY_LABELS['storage-export']).toBe('存储与导出')
})
})