feat: 完成 UI 基础层与核心页面迁移

This commit is contained in:
Wxw-Gu
2026-08-19 17:43:07 +08:00
parent a72ae49e2c
commit e9cc996ebb
62 changed files with 4279 additions and 5178 deletions
+2 -117
View File
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from 'react' import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Message, Contact } from '../../../shared/types' import { Message, Contact } from '../../../shared/types'
import { ChatHeader } from './chat/ChatHeader' import { ChatHeader } from './chat/ChatHeader'
import { ChatImageViewer } from './chat/ChatImageViewer'
import { ChatStatusBar } from './chat/ChatStatusBar' import { ChatStatusBar } from './chat/ChatStatusBar'
import { DataTrustBar } from './chat/DataTrustBar' import { DataTrustBar } from './chat/DataTrustBar'
import { EmptyConversationState } from './chat/EmptyConversationState' import { EmptyConversationState } from './chat/EmptyConversationState'
@@ -46,13 +47,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
const messageListRef = useRef<HTMLDivElement>(null) const messageListRef = useRef<HTMLDivElement>(null)
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const [previewImage, setPreviewImage] = useState<string | null>(null) const [previewImage, setPreviewImage] = useState<string | null>(null)
const [imageScale, setImageScale] = useState(0.75)
const [imageRotation, setImageRotation] = useState(0)
const [imageOffset, setImageOffset] = useState({ x: 0, y: 0 })
const imageViewerStageRef = useRef<HTMLDivElement>(null)
const imageDragRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(
null
)
const [showAvatar, setShowAvatar] = useState(true) const [showAvatar, setShowAvatar] = useState(true)
const [isAtLatest, setIsAtLatest] = useState(true) const [isAtLatest, setIsAtLatest] = useState(true)
const [isReloadingAvatars, setIsReloadingAvatars] = useState(false) const [isReloadingAvatars, setIsReloadingAvatars] = useState(false)
@@ -101,53 +95,10 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
const openImagePreview = (imageUrl: string): void => { const openImagePreview = (imageUrl: string): void => {
setPreviewImage(imageUrl) setPreviewImage(imageUrl)
setImageScale(1)
setImageRotation(0)
setImageOffset({ x: 0, y: 0 })
} }
const closeImagePreview = (): void => { const closeImagePreview = (): void => {
setPreviewImage(null) setPreviewImage(null)
imageDragRef.current = null
}
const zoomImage = (delta: number): void => {
setImageScale((prev) => Math.min(8, Math.max(0.1, Number((prev + delta).toFixed(2)))))
}
const resetImageTransform = (): void => {
setImageScale(1)
setImageRotation(0)
setImageOffset({ x: 0, y: 0 })
}
const handleViewerWheel = (event: React.WheelEvent): void => {
event.preventDefault()
event.stopPropagation()
zoomImage(event.deltaY > 0 ? -0.1 : 0.1)
}
const handleViewerMouseDown = (event: React.MouseEvent): void => {
event.preventDefault()
imageDragRef.current = {
x: event.clientX,
y: event.clientY,
offsetX: imageOffset.x,
offsetY: imageOffset.y
}
}
const handleViewerMouseMove = (event: React.MouseEvent): void => {
if (!imageDragRef.current) return
const drag = imageDragRef.current
setImageOffset({
x: drag.offsetX + event.clientX - drag.x,
y: drag.offsetY + event.clientY - drag.y
})
}
const handleViewerMouseUp = (): void => {
imageDragRef.current = null
} }
const handleReloadAvatars = async (): Promise<void> => { const handleReloadAvatars = async (): Promise<void> => {
@@ -160,24 +111,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
} }
} }
useEffect(() => {
if (!previewImage) return
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const stage = imageViewerStageRef.current
const preventBackgroundWheel = (event: WheelEvent): void => {
event.preventDefault()
}
stage?.addEventListener('wheel', preventBackgroundWheel, { passive: false })
return () => {
document.body.style.overflow = previousOverflow
stage?.removeEventListener('wheel', preventBackgroundWheel)
}
}, [previewImage])
const filteredMessages = React.useMemo(() => { const filteredMessages = React.useMemo(() => {
return messages.filter((msg) => { return messages.filter((msg) => {
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '') const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
@@ -241,55 +174,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
/> />
)} )}
{previewImage && ( {previewImage && <ChatImageViewer imageUrl={previewImage} onClose={closeImagePreview} />}
<div className="image-viewer-overlay" onClick={closeImagePreview}>
<div className="image-viewer-window" onClick={(e) => e.stopPropagation()}>
<div className="image-viewer-titlebar">
<div className="image-viewer-tools">
<span className="image-viewer-title"></span>
<button onClick={() => zoomImage(-0.1)} title="缩小">
</button>
<span className="image-viewer-zoom">{Math.round(imageScale * 100)}%</span>
<button onClick={() => zoomImage(0.1)} title="放大">
+
</button>
<span className="image-viewer-divider" />
<button onClick={() => setImageRotation((prev) => prev - 90)} title="左旋转">
</button>
<button onClick={() => setImageRotation((prev) => prev + 90)} title="右旋转">
</button>
<button onClick={resetImageTransform} title="重置">
</button>
</div>
<button className="image-viewer-close" onClick={closeImagePreview} aria-label="关闭">
×
</button>
</div>
<div
ref={imageViewerStageRef}
className="image-viewer-stage"
onWheel={handleViewerWheel}
onMouseDown={handleViewerMouseDown}
onMouseMove={handleViewerMouseMove}
onMouseUp={handleViewerMouseUp}
onMouseLeave={handleViewerMouseUp}
>
<img
src={previewImage}
alt="图片预览"
draggable={false}
style={{
transform: `translate(${imageOffset.x}px, ${imageOffset.y}px) scale(${imageScale}) rotate(${imageRotation}deg)`
}}
/>
</div>
</div>
</div>
)}
</div> </div>
) )
} }
+56 -37
View File
@@ -1,4 +1,5 @@
import React from 'react' import React, { useRef } from 'react'
import { Button, Dialog, DialogContent, DialogDescription, DialogTitle } from './ui'
interface FirstUseWelcomeProps { interface FirstUseWelcomeProps {
onDismiss: () => void onDismiss: () => void
@@ -16,63 +17,81 @@ export function FirstUseWelcome({
onOpenReport, onOpenReport,
onOpenAISettings onOpenAISettings
}: FirstUseWelcomeProps): React.ReactElement { }: FirstUseWelcomeProps): React.ReactElement {
const restoreFocusRef = useRef<HTMLElement | null>(null)
const dismiss = (): void => {
const restoreFocus = restoreFocusRef.current
restoreFocusRef.current = null
onDismiss()
queueMicrotask(() => restoreFocus?.focus())
}
return ( return (
<div className="first-use-welcome-overlay" role="presentation"> <Dialog open onOpenChange={(open) => !open && dismiss()}>
<section <DialogContent
className="first-use-welcome" className="max-h-[calc(100vh-2rem)] max-w-[520px] overflow-y-auto p-6 sm:p-8"
role="dialog" onOpenAutoFocus={() => {
aria-modal="true" restoreFocusRef.current =
aria-labelledby="first-use-welcome-title" document.activeElement instanceof HTMLElement ? document.activeElement : null
}}
> >
<button <div
type="button" className="mb-4 grid h-10 w-10 place-items-center rounded-md bg-primary text-xl text-primary-foreground shadow-surface"
className="first-use-welcome-close" aria-hidden="true"
onClick={onDismiss}
aria-label="关闭欢迎提示"
> >
×
</button>
<div className="first-use-welcome-mark" aria-hidden="true">
</div> </div>
<p className="first-use-welcome-eyebrow"></p> <p className="text-xs font-semibold uppercase text-primary"></p>
<h2 id="first-use-welcome-title"></h2> <DialogTitle className="mt-1 text-2xl"></DialogTitle>
<p className="first-use-welcome-lead"> <DialogDescription className="mt-2 text-sm leading-relaxed">
AI AI
</p> </DialogDescription>
<button type="button" className="first-use-welcome-feature" onClick={onOpenReport}> <Button
<span className="first-use-welcome-feature-icon" aria-hidden="true"> className="mt-5 h-auto w-full justify-start whitespace-normal p-4 text-left"
onClick={onOpenReport}
>
<span
className="grid h-9 w-9 shrink-0 place-items-center rounded-md bg-surface text-lg text-primary"
aria-hidden="true"
>
</span> </span>
<span className="first-use-welcome-feature-copy"> <span className="min-w-0 flex-1">
<strong> AI </strong> <strong className="block text-sm"> AI </strong>
<small></small> <small className="mt-0.5 block text-xs text-primary-foreground/75">
</small>
</span> </span>
<span className="first-use-welcome-feature-arrow" aria-hidden="true"> <span className="shrink-0 text-xs" aria-hidden="true">
</span> </span>
</button> </Button>
<div className="first-use-welcome-secondary-actions"> <div className="mt-3 flex flex-wrap gap-2">
<button type="button" onClick={onDismiss}> <Button variant="ghost" size="sm" onClick={dismiss}>
</button> </Button>
<button type="button" onClick={onOpenSearch}> <Button variant="ghost" size="sm" onClick={onOpenSearch}>
</button> </Button>
</div> </div>
<div className="first-use-welcome-footer"> <div className="mt-5 flex flex-col items-start gap-2 border-t border-border pt-4 text-xs text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
<span> AI</span> <span> AI</span>
<button type="button" onClick={onOpenAISettings}> <Button className="h-auto p-0 text-xs" variant="link" onClick={onOpenAISettings}>
AI AI
</button> </Button>
<a href={GUIDE_URL} target="_blank" rel="noreferrer"> <a
className="text-primary hover:underline"
href={GUIDE_URL}
target="_blank"
rel="noreferrer"
>
使 使
</a> </a>
</div> </div>
</section> </DialogContent>
</div> </Dialog>
) )
} }
+15 -2
View File
@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useRef } from 'react' import { useState, useCallback, useEffect, useRef } from 'react'
import type { JSX, MouseEvent } from 'react' import type { JSX, KeyboardEvent, MouseEvent } from 'react'
import { getCachedLoadedImage, requestImage } from './image-loader' import { getCachedLoadedImage, requestImage } from './image-loader'
interface ImageBubbleProps { interface ImageBubbleProps {
@@ -156,6 +156,12 @@ export function ImageBubble({
if (imageUrl) onImageClick?.(imageUrl) if (imageUrl) onImageClick?.(imageUrl)
} }
const handlePreviewKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) return
event.preventDefault()
void handleClick()
}
if (loading) { if (loading) {
return ( return (
<div ref={containerRef} className="image-bubble image-loading" onClick={handleClick}> <div ref={containerRef} className="image-bubble image-loading" onClick={handleClick}>
@@ -184,7 +190,14 @@ export function ImageBubble({
} }
return ( return (
<div className="image-bubble image-loaded" onClick={handleClick}> <div
className="image-bubble image-loaded"
role="button"
tabIndex={0}
aria-label="查看图片"
onClick={handleClick}
onKeyDown={handlePreviewKeyDown}
>
<img <img
src={imageUrl} src={imageUrl}
alt="图片" alt="图片"
@@ -1,496 +0,0 @@
// Legacy fallback: SETTINGS-01 moved the default entry to features/settings.
// Keep this panel intact until its database-key, image-key, AI and API sections are migrated.
import React, { useEffect, useState } from 'react'
import { isWindows } from '../utils/runtime-environment'
interface SelfInfo {
wxid: string
nickname: string
avatar?: string
accountRoot: string
}
interface AppSettings {
dbRoot: string
apiEnabled: boolean
apiHost: string
apiPort: number
imageKeyRoot: string
imageXorKey: string
imageAesKey: string
}
interface ApiState {
running: boolean
host: string
port: number
error?: string
}
interface AiModelConfig {
apiKey: string
baseURL: string
model: string
}
interface SettingsPanelProps {
open: boolean
selfInfo: SelfInfo | null
dbReady: boolean
dbKey: string
aiModelConfig: AiModelConfig
onClose: () => void
onDbKeyChange: (key: string) => void
onAiModelConfigChange: (config: AiModelConfig) => void
onSaveAiModelConfig: () => void
onDbRootChanged: () => void
}
const AI_MODEL_OPTIONS = [
{ value: 'deepseek-chat', label: 'DeepSeek Chat' },
{ value: 'gpt-4o', label: 'GPT-4o' },
{ value: 'gpt-4o-mini', label: 'GPT-4o Mini' },
{ value: 'gpt-4-turbo', label: 'GPT-4 Turbo' },
{ value: 'claude-3-5-sonnet-20240620', label: 'Claude 3.5 Sonnet' },
{ value: 'moonshot-v1-8k', label: 'Moonshot V1' }
]
export const SettingsPanel: React.FC<SettingsPanelProps> = ({
open,
selfInfo,
dbReady,
dbKey,
aiModelConfig,
onClose,
onDbKeyChange,
onAiModelConfigChange,
onSaveAiModelConfig,
onDbRootChanged
}) => {
const dbRootPlaceholder = isWindows
? 'C:\\Users\\你\\Documents\\WeChat Files'
: '~/Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files'
const [settings, setSettings] = useState<AppSettings | null>(null)
const [settingsPath, setSettingsPath] = useState('')
const [apiState, setApiState] = useState<ApiState | null>(null)
const [testStatus, setTestStatus] = useState<{
kind: 'idle' | 'ok' | 'fail'
message: string
wxid?: string
accountRoot?: string
}>({ kind: 'idle', message: '' })
const [reopenStatus, setReopenStatus] = useState<string>('')
const [imageKeyStatus, setImageKeyStatus] = useState<{
kind: 'idle' | 'ok' | 'fail'
message: string
}>({ kind: 'idle', message: '' })
const [busy, setBusy] = useState(false)
useEffect(() => {
if (!open) return
void refresh()
}, [open])
useEffect(() => {
if (!open) return
return window.api.onImageKeyStatus(({ message }) => {
setImageKeyStatus({ kind: 'idle', message })
})
}, [open])
async function refresh(): Promise<void> {
const [{ settings, settingsPath }, api] = await Promise.all([
window.api.getSettings(),
window.api.apiStatus()
])
setSettings(settings)
setSettingsPath(settingsPath)
setApiState(api)
}
if (!open) return null
async function handleSave(patch: Partial<AppSettings>): Promise<void> {
if (!settings) return
setBusy(true)
const next = await window.api.setSettings(patch)
setSettings(next.settings)
setBusy(false)
}
async function handleTest(): Promise<void> {
setTestStatus({ kind: 'idle', message: '测试中...' })
setBusy(true)
try {
const result = await window.api.testConnection(dbKey, settings?.dbRoot)
if (result.success) {
setTestStatus({
kind: 'ok',
message: '连接成功',
wxid: result.wxid,
accountRoot: result.accountRoot
})
if (result.accountRoot && settings && result.accountRoot !== settings.dbRoot) {
const next = await window.api.setSettings({ dbRoot: result.accountRoot })
setSettings(next.settings)
}
} else {
setTestStatus({ kind: 'fail', message: result.error || '连接失败' })
}
} catch (error) {
setTestStatus({
kind: 'fail',
message: error instanceof Error ? error.message : String(error)
})
} finally {
setBusy(false)
}
}
async function handleReopen(): Promise<void> {
if (!settings) return
setBusy(true)
setReopenStatus('重新初始化中...')
try {
const result = await window.api.reopenWithRoot(settings.dbRoot)
if (result.success) {
setReopenStatus(`已重新打开:${result.info?.wxid || '未知'}`)
onDbRootChanged()
} else {
setReopenStatus(result.error || '重新打开失败')
}
} catch (error) {
setReopenStatus(error instanceof Error ? error.message : String(error))
} finally {
setBusy(false)
}
}
async function handleApiToggle(enabled: boolean): Promise<void> {
setBusy(true)
await handleSave({ apiEnabled: enabled })
const state = await window.api.apiToggle(enabled)
setApiState(state)
setBusy(false)
}
async function handleApiRestart(): Promise<void> {
if (!settings) return
setBusy(true)
await window.api.apiStop()
const state = await window.api.apiStart(settings.apiHost, settings.apiPort)
setApiState(state)
setBusy(false)
}
async function handleAutoGetImageKey(): Promise<void> {
if (!settings) return
setBusy(true)
setImageKeyStatus({ kind: 'idle', message: '正在扫描微信内存获取图片密钥...' })
try {
const result = await window.api.autoGetImageKey()
if (!result.success || !result.aesKey) {
setImageKeyStatus({ kind: 'fail', message: result.error || '图片密钥获取失败' })
return
}
const imageXorKey =
result.imageXorKey ||
(typeof result.xorKey === 'number'
? `0x${result.xorKey.toString(16).toUpperCase().padStart(2, '0')}`
: settings.imageXorKey)
const imageAesKey = result.imageAesKey || result.aesKey
setSettings(result.settings || { ...settings, imageXorKey, imageAesKey })
setImageKeyStatus({
kind: 'ok',
message: result.verified ? '图片密钥已获取并校验通过' : '图片密钥已获取,未完成模板校验'
})
} catch (error) {
setImageKeyStatus({
kind: 'fail',
message: error instanceof Error ? error.message : String(error)
})
} finally {
setBusy(false)
}
}
return (
<div className="settings-overlay" onClick={onClose}>
<div className="settings-modal" onClick={(e) => e.stopPropagation()}>
<div className="settings-header">
<h2></h2>
<button className="settings-close" onClick={onClose} title="关闭">
×
</button>
</div>
<div className="settings-body">
{/* 自我信息卡片 */}
<section className="settings-section">
<div className="settings-section-title"></div>
{dbReady && selfInfo ? (
<div className="settings-self">
<div className="settings-self-avatar">
{selfInfo.avatar ? (
<img
src={selfInfo.avatar}
alt={selfInfo.nickname}
referrerPolicy="no-referrer"
/>
) : (
(selfInfo.nickname || selfInfo.wxid || '?').charAt(0)
)}
</div>
<div className="settings-self-info">
<div className="settings-self-nickname">{selfInfo.nickname}</div>
<div className="settings-self-wxid">{selfInfo.wxid}</div>
<div className="settings-self-account">{selfInfo.accountRoot}</div>
</div>
</div>
) : (
<div className="settings-self-empty"></div>
)}
</section>
{/* 测试连接 */}
<section className="settings-section">
<div className="settings-section-title"></div>
<div className="settings-row">
<button
className="settings-btn settings-btn-primary"
onClick={handleTest}
disabled={busy || !dbKey}
>
</button>
{testStatus.kind !== 'idle' && (
<span className={`settings-status ${testStatus.kind}`}>
{testStatus.kind === 'ok' ? '✓' : '✗'} {testStatus.message}
{testStatus.wxid ? ` · ${testStatus.wxid}` : ''}
{testStatus.accountRoot ? ` · ${testStatus.accountRoot}` : ''}
</span>
)}
</div>
<div className="settings-hint">
使 + ,
</div>
</section>
{/* 解密密钥 */}
<section className="settings-section">
<div className="settings-section-title"></div>
<div className="settings-row">
<input
type="text"
className="settings-input"
value={dbKey}
onChange={(e) => onDbKeyChange(e.target.value)}
placeholder="64 位 hex 密钥,如 0x..."
spellCheck={false}
/>
</div>
<div className="settings-hint">
safeStorage
</div>
</section>
{/* 图片解密密钥 */}
<section className="settings-section">
<div className="settings-section-title"></div>
<div className="settings-row">
<input
type="text"
className="settings-input"
value={settings?.imageKeyRoot || settings?.dbRoot || ''}
onChange={(e) =>
setSettings(settings ? { ...settings, imageKeyRoot: e.target.value } : null)
}
onBlur={(e) => handleSave({ imageKeyRoot: e.target.value })}
placeholder={dbRootPlaceholder}
spellCheck={false}
/>
</div>
<div className="settings-row">
<input
type="text"
className="settings-input settings-input-quarter"
value={settings?.imageXorKey ?? ''}
onChange={(e) =>
setSettings(settings ? { ...settings, imageXorKey: e.target.value } : null)
}
onBlur={(e) => handleSave({ imageXorKey: e.target.value })}
placeholder="XOR Key,如 0x40"
spellCheck={false}
/>
<input
type="text"
className="settings-input"
value={settings?.imageAesKey ?? ''}
onChange={(e) =>
setSettings(settings ? { ...settings, imageAesKey: e.target.value } : null)
}
onBlur={(e) => handleSave({ imageAesKey: e.target.value })}
placeholder="AES Key16 位字符"
spellCheck={false}
/>
<button className="settings-btn" onClick={handleAutoGetImageKey} disabled={busy}>
</button>
</div>
{imageKeyStatus.message && (
<div className={`settings-status ${imageKeyStatus.kind}`}>
{imageKeyStatus.kind === 'ok' ? '✓ ' : imageKeyStatus.kind === 'fail' ? '✗ ' : ''}
{imageKeyStatus.message}
</div>
)}
<div className="settings-hint">
使Windows
2-3
</div>
</section>
{/* 数据库根目录 */}
<section className="settings-section">
<div className="settings-section-title"></div>
<div className="settings-row">
<input
type="text"
className="settings-input"
value={settings?.dbRoot ?? ''}
onChange={(e) =>
setSettings(settings ? { ...settings, dbRoot: e.target.value } : null)
}
onBlur={(e) => handleSave({ dbRoot: e.target.value })}
placeholder={dbRootPlaceholder}
spellCheck={false}
/>
</div>
<div className="settings-row">
<button className="settings-btn" onClick={handleReopen} disabled={busy || !dbReady}>
</button>
{reopenStatus && <span className="settings-status">{reopenStatus}</span>}
</div>
<div className="settings-hint">
Windows Documents\WeChat FilesmacOS
xwechat_files db_storage/session.db
</div>
</section>
<section className="settings-section">
<div className="settings-section-title">AI </div>
<div className="settings-row">
<select
className="settings-input settings-input-half"
value={aiModelConfig.model}
onChange={(event) =>
onAiModelConfigChange({ ...aiModelConfig, model: event.target.value })
}
>
{AI_MODEL_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<button className="settings-btn" onClick={onSaveAiModelConfig}>
AI
</button>
</div>
<div className="settings-row">
<input
type="text"
className="settings-input"
value={aiModelConfig.baseURL}
onChange={(event) =>
onAiModelConfigChange({ ...aiModelConfig, baseURL: event.target.value })
}
placeholder="https://api.deepseek.com"
spellCheck={false}
/>
</div>
<div className="settings-row">
<input
type="password"
className="settings-input"
value={aiModelConfig.apiKey}
onChange={(event) =>
onAiModelConfigChange({ ...aiModelConfig, apiKey: event.target.value })
}
placeholder="API Key"
spellCheck={false}
/>
</div>
<div className="settings-hint">
沿 localStorage
</div>
</section>
{/* API 服务 */}
<section className="settings-section">
<div className="settings-section-title"> HTTP API</div>
<div className="settings-row">
<label className="settings-toggle">
<input
type="checkbox"
checked={settings?.apiEnabled ?? false}
onChange={(e) => handleApiToggle(e.target.checked)}
disabled={busy}
/>
<span> API (127.0.0.1:6131)</span>
</label>
{apiState && (
<span className={`settings-status ${apiState.running ? 'ok' : 'fail'}`}>
{apiState.running ? '运行中' : '已停止'}
{apiState.error ? ` · ${apiState.error}` : ''}
</span>
)}
</div>
<div className="settings-row">
<input
type="text"
className="settings-input settings-input-half"
value={settings?.apiHost ?? ''}
onChange={(e) =>
setSettings(settings ? { ...settings, apiHost: e.target.value } : null)
}
onBlur={(e) => handleSave({ apiHost: e.target.value })}
placeholder="host"
spellCheck={false}
/>
<input
type="number"
className="settings-input settings-input-quarter"
value={settings?.apiPort ?? 6131}
onChange={(e) =>
setSettings(
settings ? { ...settings, apiPort: Number(e.target.value) || 6131 } : null
)
}
onBlur={(e) => handleSave({ apiPort: Number(e.target.value) || 6131 })}
placeholder="port"
/>
<button className="settings-btn" onClick={handleApiRestart} disabled={busy}>
API
</button>
</div>
<div className="settings-hint">
API Bearer Token Token API Center
Claude / Codex
<br />
<code>docs/skill/tracememo-reader/SKILL.md</code>
</div>
</section>
{/* 配置文件位置 */}
<section className="settings-section">
<div className="settings-section-title"></div>
<div className="settings-row">
<code className="settings-path">{settingsPath}</code>
</div>
</section>
</div>
</div>
</div>
)
}
+10 -33
View File
@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from 'react' import React, { useState } from 'react'
import { Contact } from '../../../../shared/types' import { Contact } from '../../../../shared/types'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../ui'
import { ConversationContentSearch } from './ConversationContentSearch' import { ConversationContentSearch } from './ConversationContentSearch'
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon, SendIcon } from './icons' import { AiIcon, MoreIcon, RefreshIcon, SearchIcon, SendIcon } from './icons'
import { supportsPersonalWechatSend } from '../../utils/runtime-environment' import { supportsPersonalWechatSend } from '../../utils/runtime-environment'
@@ -32,21 +33,10 @@ export function ChatHeader({
onOpenAiSettings onOpenAiSettings
}: ChatHeaderProps): React.ReactElement { }: ChatHeaderProps): React.ReactElement {
const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter)) const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter))
const [moreOpen, setMoreOpen] = useState(false)
const moreRef = useRef<HTMLDivElement>(null)
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话' const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
const typeLabel = isGroupChat ? '群聊' : '联系人' const typeLabel = isGroupChat ? '群聊' : '联系人'
const visibleCount = contentFilter ? filteredCount : loadedCount const visibleCount = contentFilter ? filteredCount : loadedCount
useEffect(() => {
if (!moreOpen) return
const handlePointerDown = (event: PointerEvent): void => {
if (!moreRef.current?.contains(event.target as Node)) setMoreOpen(false)
}
window.addEventListener('pointerdown', handlePointerDown)
return () => window.removeEventListener('pointerdown', handlePointerDown)
}, [moreOpen])
const handleCloseSearch = (): void => { const handleCloseSearch = (): void => {
onContentFilterChange('') onContentFilterChange('')
setSearchOpen(false) setSearchOpen(false)
@@ -91,29 +81,16 @@ export function ChatHeader({
<button type="button" className="chat-icon-button" onClick={onRefresh} title="刷新聊天记录"> <button type="button" className="chat-icon-button" onClick={onRefresh} title="刷新聊天记录">
<RefreshIcon /> <RefreshIcon />
</button> </button>
<div className="chat-menu" ref={moreRef}> <DropdownMenu>
<button <DropdownMenuTrigger asChild>
type="button" <button type="button" className="chat-icon-button" title="更多">
className="chat-icon-button"
onClick={() => setMoreOpen((current) => !current)}
title="更多"
>
<MoreIcon /> <MoreIcon />
</button> </button>
{moreOpen && ( </DropdownMenuTrigger>
<div className="chat-dropdown-menu right"> <DropdownMenuContent align="end">
<button <DropdownMenuItem onSelect={() => onRefreshData?.()}></DropdownMenuItem>
type="button" </DropdownMenuContent>
onClick={() => { </DropdownMenu>
onRefreshData?.()
setMoreOpen(false)
}}
>
</button>
</div>
)}
</div>
<span <span
className="chat-tool-button-wrapper" className="chat-tool-button-wrapper"
title={supportsPersonalWechatSend ? '发送消息' : '仅支持 macOS'} title={supportsPersonalWechatSend ? '发送消息' : '仅支持 macOS'}
@@ -0,0 +1,160 @@
import { useRef, useState } from 'react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
IconButton,
Separator
} from '../ui'
interface ChatImageViewerProps {
imageUrl: string
onClose: () => void
}
export function ChatImageViewer({ imageUrl, onClose }: ChatImageViewerProps): React.ReactElement {
const [scale, setScale] = useState(1)
const [rotation, setRotation] = useState(0)
const [offset, setOffset] = useState({ x: 0, y: 0 })
const dragRef = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(null)
const contentRef = useRef<HTMLDivElement | null>(null)
const restoreFocusRef = useRef<HTMLElement | null>(null)
const closingRef = useRef(false)
const closeViewer = (): void => {
if (closingRef.current) return
closingRef.current = true
const restoreFocus = restoreFocusRef.current
restoreFocusRef.current = null
onClose()
queueMicrotask(() => restoreFocus?.focus())
}
const zoom = (delta: number): void => {
setScale((current) => Math.min(8, Math.max(0.1, Number((current + delta).toFixed(2)))))
}
const reset = (): void => {
setScale(1)
setRotation(0)
setOffset({ x: 0, y: 0 })
}
const handleMouseDown = (event: React.MouseEvent): void => {
event.preventDefault()
dragRef.current = {
x: event.clientX,
y: event.clientY,
offsetX: offset.x,
offsetY: offset.y
}
}
const handleMouseMove = (event: React.MouseEvent): void => {
if (!dragRef.current) return
const drag = dragRef.current
setOffset({
x: drag.offsetX + event.clientX - drag.x,
y: drag.offsetY + event.clientY - drag.y
})
}
const stopDragging = (): void => {
dragRef.current = null
}
return (
<Dialog open onOpenChange={(open) => !open && closeViewer()}>
<DialogContent
ref={contentRef}
className="h-[min(900px,calc(100vh-2rem))] max-w-[min(1280px,calc(100vw-2rem))] grid-rows-[48px_minmax(0,1fr)] gap-0 overflow-hidden rounded-lg bg-muted p-0"
onOpenAutoFocus={(event) => {
restoreFocusRef.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null
event.preventDefault()
contentRef.current?.focus()
}}
>
<DialogHeader className="flex-row items-center space-y-0 border-b border-border bg-surface/90 px-4 pr-12">
<DialogTitle className="mr-2 shrink-0 text-sm font-medium tracking-normal">
</DialogTitle>
<DialogDescription className="sr-only">
</DialogDescription>
<div className="flex min-w-0 items-center gap-1">
<IconButton
label="缩小"
variant="ghost"
className="h-7 w-7 text-lg"
onClick={() => zoom(-0.1)}
>
<span aria-hidden></span>
</IconButton>
<span className="w-12 shrink-0 text-center text-xs tabular-nums text-muted-foreground">
{Math.round(scale * 100)}%
</span>
<IconButton
label="放大"
variant="ghost"
className="h-7 w-7 text-lg"
onClick={() => zoom(0.1)}
>
<span aria-hidden>+</span>
</IconButton>
<Separator orientation="vertical" className="mx-1 h-5" />
<IconButton
label="左旋转"
variant="ghost"
className="h-7 w-7 text-lg"
onClick={() => setRotation((current) => current - 90)}
>
<span aria-hidden></span>
</IconButton>
<IconButton
label="右旋转"
variant="ghost"
className="h-7 w-7 text-lg"
onClick={() => setRotation((current) => current + 90)}
>
<span aria-hidden></span>
</IconButton>
<IconButton
label="重置图片"
tooltip="重置"
variant="ghost"
className="h-7 w-7 text-lg"
onClick={reset}
>
<span aria-hidden></span>
</IconButton>
</div>
</DialogHeader>
<div
className="flex min-h-0 cursor-grab select-none items-center justify-center overflow-hidden overscroll-contain bg-muted p-7 active:cursor-grabbing"
aria-label="图片查看区域"
onWheel={(event) => {
event.preventDefault()
zoom(event.deltaY > 0 ? -0.1 : 0.1)
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={stopDragging}
onMouseLeave={stopDragging}
>
<img
className="pointer-events-none h-auto w-auto max-w-none select-none object-contain shadow-floating transition-transform duration-fast ease-out"
src={imageUrl}
alt="图片预览"
draggable={false}
style={{
transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale}) rotate(${rotation}deg)`
}}
/>
</div>
</DialogContent>
</Dialog>
)
}
+11 -27
View File
@@ -1,4 +1,5 @@
import React, { useEffect, useRef, useState } from 'react' import React from 'react'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../ui'
import { ArrowDownIcon, ExportIcon } from './icons' import { ArrowDownIcon, ExportIcon } from './icons'
export type ExportRange = number | 'all' export type ExportRange = number | 'all'
@@ -18,47 +19,30 @@ const EXPORT_OPTIONS: { label: string; value: ExportRange }[] = [
] ]
export function ExportMenu({ disabled, onExport }: ExportMenuProps): React.ReactElement { export function ExportMenu({ disabled, onExport }: ExportMenuProps): React.ReactElement {
const [open, setOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const handlePointerDown = (event: PointerEvent): void => {
if (!menuRef.current?.contains(event.target as Node)) setOpen(false)
}
window.addEventListener('pointerdown', handlePointerDown)
return () => window.removeEventListener('pointerdown', handlePointerDown)
}, [open])
return ( return (
<div className="chat-menu" ref={menuRef}> <DropdownMenu>
<DropdownMenuTrigger asChild>
<button <button
type="button" type="button"
className="chat-tool-button" className="chat-tool-button"
disabled={disabled} disabled={disabled}
onClick={() => setOpen((current) => !current)}
title={disabled ? '没有可导出的消息' : '导出聊天记录'} title={disabled ? '没有可导出的消息' : '导出聊天记录'}
> >
<ExportIcon /> <ExportIcon />
<span></span> <span></span>
<ArrowDownIcon /> <ArrowDownIcon />
</button> </button>
{open && ( </DropdownMenuTrigger>
<div className="chat-dropdown-menu"> <DropdownMenuContent align="start">
{EXPORT_OPTIONS.map((option, index) => ( {EXPORT_OPTIONS.map((option, index) => (
<button <DropdownMenuItem
key={`${option.label}-${index}`} key={`${option.label}-${index}`}
type="button" onSelect={() => onExport(option.value)}
onClick={() => {
onExport(option.value)
setOpen(false)
}}
> >
{option.label} {option.label}
</button> </DropdownMenuItem>
))} ))}
</div> </DropdownMenuContent>
)} </DropdownMenu>
</div>
) )
} }
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Contact } from '../../../../shared/types' import type { Contact } from '../../../../shared/types'
import type { PersonalWechatSenderStatus } from '../../../../shared/personal-wechat' import type { PersonalWechatSenderStatus } from '../../../../shared/personal-wechat'
import type { TextToSpeechSettings, TextToSpeechVoice } from '../../../../shared/text-to-speech' import type { TextToSpeechSettings, TextToSpeechVoice } from '../../../../shared/text-to-speech'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
type SendMode = 'image' | 'voice' type SendMode = 'image' | 'voice'
type VoiceSource = 'generated' | 'file' type VoiceSource = 'generated' | 'file'
@@ -82,6 +83,8 @@ export function PersonalWechatSendDialog({
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null) const [result, setResult] = useState<{ success: boolean; message: string } | null>(null)
const generatedVoiceRef = useRef<GeneratedVoice | null>(null) const generatedVoiceRef = useRef<GeneratedVoice | null>(null)
const generatedAudioRef = useRef<HTMLAudioElement | null>(null) const generatedAudioRef = useRef<HTMLAudioElement | null>(null)
const restoreFocusRef = useRef<HTMLElement | null>(null)
const closingRef = useRef(false)
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话' const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
const targetId = contact.m_nsUsrName const targetId = contact.m_nsUsrName
const selectedTypeReady = mode === 'voice' ? status?.canSendVoice : status?.canSendImage const selectedTypeReady = mode === 'voice' ? status?.canSendVoice : status?.canSendImage
@@ -122,9 +125,13 @@ export function PersonalWechatSendDialog({
}, []) }, [])
const handleClose = useCallback((): void => { const handleClose = useCallback((): void => {
if (isBusy) return if (isBusy || closingRef.current) return
closingRef.current = true
const restoreFocus = restoreFocusRef.current
restoreFocusRef.current = null
clearGeneratedVoice() clearGeneratedVoice()
onClose() onClose()
queueMicrotask(() => restoreFocus?.focus())
}, [clearGeneratedVoice, isBusy, onClose]) }, [clearGeneratedVoice, isBusy, onClose])
const handleOpenTextToSpeechSettings = (showSupportedVersions = false): void => { const handleOpenTextToSpeechSettings = (showSupportedVersions = false): void => {
@@ -212,14 +219,6 @@ export function PersonalWechatSendDialog({
return () => audio?.pause() return () => audio?.pause()
}, [generatedVoice]) }, [generatedVoice])
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key === 'Escape' && !isBusy) handleClose()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [handleClose, isBusy])
const handleRebind = async (): Promise<void> => { const handleRebind = async (): Promise<void> => {
if (isBusy) return if (isBusy) return
setIsRebinding(true) setIsRebinding(true)
@@ -392,23 +391,27 @@ export function PersonalWechatSendDialog({
] ]
return ( return (
<div className="personal-wechat-send-backdrop" role="presentation" onMouseDown={handleClose}> <Dialog open onOpenChange={(open) => !open && handleClose()}>
<section <DialogContent
className="personal-wechat-send-dialog" className="personal-wechat-send-dialog max-h-[calc(100vh-3rem)] max-w-[620px] gap-4 overflow-y-auto p-[22px]"
role="dialog" onOpenAutoFocus={() => {
aria-modal="true" restoreFocusRef.current =
aria-labelledby="personal-wechat-send-title" document.activeElement instanceof HTMLElement ? document.activeElement : null
onMouseDown={(event) => event.stopPropagation()} }}
onEscapeKeyDown={(event) => isBusy && event.preventDefault()}
onPointerDownOutside={(event) => isBusy && event.preventDefault()}
> >
<header> <DialogHeader className="flex-row items-center justify-between space-y-0 pr-10">
<div> <div>
<span className="personal-wechat-send-kicker"></span> <span className="text-[11px] font-bold tracking-normal text-primary"></span>
<h2 id="personal-wechat-send-title"></h2> <DialogTitle className="mt-0.5 text-[19px] leading-[26px] tracking-normal">
</DialogTitle>
</div> </div>
<button type="button" className="personal-wechat-send-close" onClick={handleClose}> <DialogDescription className="sr-only">
×
</button> </DialogDescription>
</header> </DialogHeader>
<div className="personal-wechat-send-device-note" role="note"> <div className="personal-wechat-send-device-note" role="note">
<span aria-hidden>i</span> <span aria-hidden>i</span>
@@ -676,7 +679,7 @@ export function PersonalWechatSendDialog({
</button> </button>
)} )}
</footer> </footer>
</section> </DialogContent>
</div> </Dialog>
) )
} }
@@ -0,0 +1,441 @@
import React from 'react'
import type { ExportContactType, ExportNameMode } from '../../../../shared/export'
import type { VoiceModelStatus } from '../../../../shared/voice-recognition'
import { Button, Checkbox, Input, RadioGroup, RadioGroupItem } from '../ui'
import type { Contact, ExportFormat, ExportRange, ExportStatus } from './exportTypes'
import { displayName, formatLabels, formatOrder, messageKinds } from './exportUtils'
interface NameOption {
value: ExportNameMode
label: string
}
interface ExportConfigurationPanelProps {
taskCenter: React.ReactNode
selectedContacts: Contact[]
exportAll: boolean
allContactTypes: ExportContactType[]
selectedLabel: string
selectionMode: boolean
exportContactCount: number
format: ExportFormat
range: ExportRange
startDate: string
endDate: string
selectedKinds: ReadonlySet<string>
nameOptions: NameOption[]
nameMode: ExportNameMode
includeMedia: boolean
includeVoiceTranscripts: boolean
includeAvatars: boolean
preferOriginal: boolean
fallbackThumbnail: boolean
keepMissing: boolean
voiceModelStatus: VoiceModelStatus | null
zip: boolean
fileName: string
defaultOutputName: string
selectedTargetPath: string
status: ExportStatus
canStart: boolean
onToggleSelectionMode: () => void
onFormatChange: (format: ExportFormat) => void
onZipChange: (zip: boolean) => void
onRangeChange: (range: ExportRange) => void
onStartDateChange: (value: string) => void
onEndDateChange: (value: string) => void
onToggleKind: (kind: string) => void
onNameModeChange: (mode: ExportNameMode) => void
onIncludeMediaChange: (checked: boolean) => void
onIncludeVoiceTranscriptsChange: (checked: boolean) => void
onIncludeAvatarsChange: (checked: boolean) => void
onPreferOriginalChange: (checked: boolean) => void
onFallbackThumbnailChange: (checked: boolean) => void
onKeepMissingChange: (checked: boolean) => void
onFileNameChange: (value: string) => void
onSelectOutputDirectory: () => void
onReset: () => void
onStart: () => void
}
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'
export function ExportConfigurationPanel({
taskCenter,
selectedContacts,
exportAll,
allContactTypes,
selectedLabel,
selectionMode,
exportContactCount,
format,
range,
startDate,
endDate,
selectedKinds,
nameOptions,
nameMode,
includeMedia,
includeVoiceTranscripts,
includeAvatars,
preferOriginal,
fallbackThumbnail,
keepMissing,
voiceModelStatus,
zip,
fileName,
defaultOutputName,
selectedTargetPath,
status,
canStart,
onToggleSelectionMode,
onFormatChange,
onZipChange,
onRangeChange,
onStartDateChange,
onEndDateChange,
onToggleKind,
onNameModeChange,
onIncludeMediaChange,
onIncludeVoiceTranscriptsChange,
onIncludeAvatarsChange,
onPreferOriginalChange,
onFallbackThumbnailChange,
onKeepMissingChange,
onFileNameChange,
onSelectOutputDirectory,
onReset,
onStart
}: ExportConfigurationPanelProps): React.ReactElement {
const mediaOptionsEnabled = includeMedia && format === 'html'
const voiceTranscriptsEnabled =
mediaOptionsEnabled && selectedKinds.has('voice') && voiceModelStatus?.state === 'ready'
return (
<main className="export-config-panel flex min-h-0 min-w-0 flex-col overflow-hidden bg-background">
<div className="export-config-scroll min-h-0 flex-1 overflow-auto px-7 pb-7 pt-6">
{taskCenter}
<header className="mb-6 flex items-center gap-3.5">
<span className="relative h-[58px] w-[70px] shrink-0" aria-hidden>
{exportAll
? allContactTypes.map((type, index) => (
<span
className={`export-chat-avatar export-all-chat-avatar ${type} absolute grid h-[52px] w-[52px] place-items-center overflow-hidden rounded-lg border-2 border-background text-xs font-bold text-primary-foreground ${
type === 'group' ? 'bg-[#2f7d5c]' : 'bg-[#416a8b]'
}`}
style={{ left: index * 12, top: 3 + index * 5 }}
key={type}
>
{type === 'group' ? '群' : '联'}
</span>
))
: selectedContacts.slice(0, 3).map((contact, index) => (
<span
className="export-chat-avatar absolute grid h-[52px] w-[52px] place-items-center overflow-hidden rounded-lg border-2 border-background bg-primary/10 text-[22px] font-bold text-primary"
style={{ left: index * 12, top: 3 + index * 5 }}
key={contact.md5}
>
{contact.avatar ? (
<img className="h-full w-full object-cover" src={contact.avatar} alt="" />
) : (
displayName(contact).slice(0, 1)
)}
</span>
))}
</span>
<span className="min-w-0 flex-1">
<h1 className="m-0 text-xl font-bold leading-7 tracking-normal text-foreground">
</h1>
<p className="m-0 mt-0.5 overflow-hidden text-ellipsis whitespace-nowrap text-[13px] text-muted-foreground">
{selectedLabel}
</p>
</span>
<Button
variant="outline"
size="sm"
className="text-primary"
disabled={exportAll}
onClick={onToggleSelectionMode}
>
{exportAll ? '已选择全部聊天' : selectionMode ? '完成选择' : '+ 添加聊天'}
</Button>
</header>
<section className={sectionClassName}>
<h3 className={sectionTitleClassName}></h3>
<div className="grid grid-cols-2 gap-2 min-[900px]:grid-cols-4">
{formatOrder.map((value) => {
const active = format === value
return (
<Button
key={value}
variant="outline"
aria-pressed={active}
className={`${
active ? 'active border-2 border-primary bg-primary/10 hover:bg-primary/15' : ''
} h-[78px] min-w-0 flex-col gap-1.5 whitespace-normal px-2 text-foreground`}
disabled={!exportAll && exportContactCount > 1 && value !== 'html'}
onClick={() => onFormatChange(value)}
>
<strong className="text-[13px]">{formatLabels[value].label}</strong>
{formatLabels[value].hint && (
<small className="text-[10px] font-normal text-success">
{formatLabels[value].hint}
</small>
)}
</Button>
)
})}
</div>
<p className={helperClassName}>
{exportAll
? '全部导出固定使用全部时间;每个群聊或联系人都会在自己的目录中生成所选格式的独立档案。'
: selectedContacts.length > 1
? '多聊天合并仅支持 HTML,会保留每条消息所属的聊天。'
: 'CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。'}
</p>
{format === 'html' && (
<>
<RadioGroup
className="mt-2.5 gap-2 rounded-lg border border-border bg-muted px-3 py-2.5 text-xs text-foreground"
value={zip ? 'zip' : 'folder'}
onValueChange={(value) => onZipChange(value === 'zip')}
aria-label="HTML 打包方式"
>
<label className="flex cursor-pointer items-center gap-2" htmlFor="html-folder">
<RadioGroupItem id="html-folder" value="folder" />
HTML
</label>
<label className="flex cursor-pointer items-center gap-2" htmlFor="html-zip">
<RadioGroupItem id="html-zip" value="zip" />
HTML ZIP
</label>
</RadioGroup>
<p className={helperClassName}>
使
</p>
</>
)}
</section>
<section className={sectionClassName}>
<div className="flex items-center justify-between gap-3">
<h3 className={sectionTitleClassName}></h3>
<span className="mb-3 text-xs text-primary">
{status === 'completed' ? '已完成导出' : '消息数量将在开始导出后统计'}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
{(exportAll
? [['all', '全部时间']]
: [
['all', '全部时间'],
['today', '今天'],
['threeDays', '最近 3 天'],
['sevenDays', '最近 7 天'],
['custom', '自定义时间']
]
).map(([value, label]) => (
<Button
key={value}
variant={range === value ? 'default' : 'outline'}
aria-pressed={range === value}
className={range === value ? 'active' : ''}
onClick={() => onRangeChange(value as ExportRange)}
>
{label}
</Button>
))}
</div>
{!exportAll && range === 'custom' && (
<div className="mt-2.5 grid grid-cols-2 gap-2.5 rounded-lg bg-muted p-3">
<label className="grid gap-1.5 text-[11px] text-muted-foreground">
<Input
type="datetime-local"
value={startDate}
onChange={(event) => onStartDateChange(event.target.value)}
/>
</label>
<label className="grid gap-1.5 text-[11px] text-muted-foreground">
<Input
type="datetime-local"
value={endDate}
onChange={(event) => onEndDateChange(event.target.value)}
/>
</label>
</div>
)}
</section>
<section className={sectionClassName}>
<h3 className={sectionTitleClassName}></h3>
<div
className={`${choicePanelClassName} grid grid-cols-2 gap-x-5 gap-y-1 min-[900px]:grid-cols-3`}
>
{messageKinds.map(([value, label]) => (
<label
key={value}
className="flex min-h-7 cursor-pointer items-center gap-2 text-xs text-foreground"
>
<Checkbox
checked={selectedKinds.has(value)}
onCheckedChange={() => onToggleKind(value)}
/>
<span>{label}</span>
</label>
))}
</div>
</section>
<section className={sectionClassName}>
<h3 className={sectionTitleClassName}></h3>
<RadioGroup
className="flex gap-2 rounded-lg bg-muted p-2.5"
value={nameMode}
onValueChange={(value) => onNameModeChange(value as ExportNameMode)}
aria-label="消息显示名称"
>
{nameOptions.map((option) => (
<label
key={option.value}
className="flex min-h-8 flex-1 cursor-pointer items-center gap-2 rounded-md border border-border bg-surface px-2 text-xs text-foreground"
htmlFor={`export-name-${option.value}`}
>
<RadioGroupItem id={`export-name-${option.value}`} value={option.value} />
<span>{option.label}</span>
</label>
))}
</RadioGroup>
</section>
<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">
<span></span>
<Checkbox
checked={includeMedia}
disabled={format !== 'html'}
onCheckedChange={(checked) => onIncludeMediaChange(checked === true)}
/>
</label>
<div
className={`${choicePanelClassName} mt-2 grid gap-0.5 ${mediaOptionsEnabled ? '' : 'opacity-50'}`}
>
{(
[
['prefer-original', '优先导出原图', preferOriginal, onPreferOriginalChange],
[
'fallback-thumbnail',
'原图缺失时使用缩略图',
fallbackThumbnail,
onFallbackThumbnailChange
],
['keep-missing', '媒体缺失时保留占位说明', keepMissing, onKeepMissingChange]
] as const
).map(([id, label, checked, onChange]) => (
<label
key={id}
className="flex min-h-7 cursor-pointer items-center gap-2 text-xs text-foreground"
>
<Checkbox
checked={checked}
disabled={!mediaOptionsEnabled}
onCheckedChange={(nextChecked) => onChange(nextChecked === true)}
/>
<span>{label}</span>
</label>
))}
<label className="flex min-h-7 cursor-pointer items-center gap-2 text-xs text-foreground">
<Checkbox
checked={includeVoiceTranscripts && voiceModelStatus?.state === 'ready'}
disabled={!voiceTranscriptsEnabled}
onCheckedChange={(checked) => onIncludeVoiceTranscriptsChange(checked === true)}
/>
<span></span>
</label>
</div>
<p className={helperClassName}>
HTML CSVJSON Markdown
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{[
'图片解密:已就绪',
'视频资源:可用',
'语音资源:可用',
`语音转文字:${voiceModelStatus?.state === 'ready' ? '已就绪' : '请先在设置中准备模型'}`,
'表情资源:按需解析',
'文件附件:按需复制'
].map((label) => (
<span
key={label}
className="rounded bg-primary/10 px-2 py-1 text-[10px] text-success"
>
{label}
</span>
))}
</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">
<span></span>
<Checkbox
checked={includeAvatars}
onCheckedChange={(checked) => onIncludeAvatarsChange(checked === true)}
/>
</label>
</section>
<section className="mb-0 grid gap-3">
<h3 className="m-0 text-xs font-bold tracking-normal text-foreground"></h3>
<label className="grid gap-1.5 text-[11px] text-muted-foreground">
<Input
value={fileName}
onChange={(event) => onFileNameChange(event.target.value)}
placeholder={defaultOutputName}
/>
</label>
<div className="flex items-center gap-2.5 rounded-lg border border-border bg-surface px-2.5 py-2 text-[11px] text-muted-foreground">
<span></span>
<strong
className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap font-medium text-foreground"
title={selectedTargetPath}
>
{selectedTargetPath}
</strong>
<Button variant="outline" size="sm" onClick={onSelectOutputDirectory}>
</Button>
</div>
{format === 'html' && (
<p className={helperClassName}></p>
)}
</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">
<span
className={`h-2 w-2 shrink-0 rounded-full ${status === 'completed' ? 'bg-primary' : 'bg-success'}`}
aria-hidden
/>
<span>
{status === 'running' ? '正在后台导出' : status === 'completed' ? '导出完成' : '准备就绪'}
</span>
<span className="ml-3 min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-muted-foreground">
{selectedTargetPath}
</span>
<Button variant="ghost" size="sm" onClick={onReset}>
</Button>
<Button className="min-w-[132px]" disabled={!canStart} onClick={onStart}>
{status === 'running' ? '正在导出' : status === 'completed' ? '再次导出' : '开始导出'}
</Button>
</footer>
</main>
)
}
@@ -1,5 +1,6 @@
import React from 'react' import React from 'react'
import type { ExportContactType } from '../../../../shared/export' import type { ExportContactType } from '../../../../shared/export'
import { Button, Checkbox, Input, Tabs, TabsList, TabsTrigger } from '../ui'
import type { Contact, SelfInfo } from './exportTypes' import type { Contact, SelfInfo } from './exportTypes'
import { displayName } from './exportUtils' import { displayName } from './exportUtils'
@@ -55,22 +56,35 @@ export function ExportContactPanel({
(allContactTypes.includes('user') ? userCount : 0) (allContactTypes.includes('user') ? userCount : 0)
return ( return (
<aside className="export-contact-panel"> <aside className="flex min-h-0 min-w-0 flex-col border-r border-border bg-sidebar">
<div className="export-panel-header"> <div className="border-b border-border px-4 pb-3 pt-5">
<div className="export-panel-title-row"> <div className="mb-3.5 flex items-center justify-between gap-2">
<h2></h2> <h2 className="text-[17px] font-bold tracking-normal text-foreground"></h2>
<span className="export-count-badge"> {contacts.length.toLocaleString()} </span> <span className="whitespace-nowrap rounded-md bg-surface px-2 py-1 text-[11px] text-muted-foreground">
{contacts.length.toLocaleString()}
</span>
</div> </div>
<label className="export-search-field"> <label className="relative block">
<span aria-hidden></span> <span
<input className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
aria-hidden
>
</span>
<Input
className="h-[38px] pl-9 text-[13px]"
value={contactFilter} value={contactFilter}
onChange={(event) => onContactFilterChange(event.target.value)} onChange={(event) => onContactFilterChange(event.target.value)}
placeholder="搜索群聊、联系人或 wxid" placeholder="搜索群聊、联系人或 wxid"
aria-label="搜索聊天" aria-label="搜索聊天"
/> />
</label> </label>
<div className="export-filter-tabs" role="tablist" aria-label="聊天类型"> <Tabs
className="mt-3"
value={contactType}
onValueChange={(value) => onContactTypeChange(value as 'all' | 'group' | 'user')}
>
<TabsList className="grid w-full grid-cols-3 bg-muted">
{( {(
[ [
['all', '全部'], ['all', '全部'],
@@ -78,30 +92,36 @@ export function ExportContactPanel({
['user', '联系人'] ['user', '联系人']
] as const ] as const
).map(([value, label]) => ( ).map(([value, label]) => (
<button <TabsTrigger
key={value} key={value}
type="button" className="w-full data-[state=active]:text-primary"
className={contactType === value ? 'active' : ''} value={value}
onClick={() => onContactTypeChange(value)}
> >
{label} {label}
</button> </TabsTrigger>
))} ))}
</div> </TabsList>
<button </Tabs>
type="button" <Button
className={`export-all-button ${exportAll ? 'active' : ''}`} variant="outline"
className={`mt-2.5 h-auto w-full justify-between gap-3 px-2.5 py-2 text-left ${
exportAll ? 'border-primary bg-primary/10 text-primary hover:bg-primary/15' : ''
}`}
aria-pressed={exportAll} aria-pressed={exportAll}
onClick={onExportAll} onClick={onExportAll}
> >
<span> <span className="grid min-w-0 gap-0.5">
<strong></strong> <strong className="text-xs"></strong>
<small></small> <small className="text-[10px] font-normal text-muted-foreground">
</small>
</span> </span>
<b>{(exportAll ? selectedAllCount : contacts.length).toLocaleString()}</b> <b className="text-[11px] text-muted-foreground">
</button> {(exportAll ? selectedAllCount : contacts.length).toLocaleString()}
</b>
</Button>
{exportAll && ( {exportAll && (
<div className="export-all-type-options" aria-label="全部导出范围"> <div className="mt-2 grid grid-cols-2 gap-2" aria-label="全部导出范围">
{( {(
[ [
['group', '群聊'], ['group', '群聊'],
@@ -109,19 +129,22 @@ export function ExportContactPanel({
] as const ] as const
).map(([type, label]) => { ).map(([type, label]) => {
const count = type === 'group' ? groupCount : userCount const count = type === 'group' ? groupCount : userCount
const checked = allContactTypes.includes(type)
return ( return (
<label key={type}> <label
<input className="grid min-w-0 cursor-pointer grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1.5 rounded-md border border-border bg-surface px-2 py-2 text-[11px] text-muted-foreground"
type="checkbox" key={type}
>
<Checkbox
aria-label={`导出全部${label}`} aria-label={`导出全部${label}`}
checked={allContactTypes.includes(type)} checked={checked}
disabled={ disabled={
exportRunning || (allContactTypes.length === 1 && allContactTypes[0] === type) exportRunning || (allContactTypes.length === 1 && allContactTypes[0] === type)
} }
onChange={() => onToggleAllContactType(type)} onCheckedChange={() => onToggleAllContactType(type)}
/> />
<span>{label}</span> <span>{label}</span>
<b>{count.toLocaleString()}</b> <b className="text-[10px] text-muted-foreground">{count.toLocaleString()}</b>
</label> </label>
) )
})} })}
@@ -130,24 +153,24 @@ export function ExportContactPanel({
</div> </div>
{exportAll ? ( {exportAll ? (
<div className="export-all-status"> <div className="border-b border-border bg-primary/10 px-4 py-2 text-[11px] leading-[17px] text-primary">
{allContactTypes.includes('group') ? `全部群聊 ${groupCount}` : ''} {allContactTypes.includes('group') ? `全部群聊 ${groupCount}` : ''}
{allContactTypes.length === 2 ? '和' : ''} {allContactTypes.length === 2 ? '和' : ''}
{allContactTypes.includes('user') ? `全部联系人 ${userCount}` : ''} {allContactTypes.includes('user') ? `全部联系人 ${userCount}` : ''}
</div> </div>
) : selectionMode ? ( ) : selectionMode ? (
<div className="export-multi-select-bar"> <div className="flex items-center justify-between border-b border-border bg-primary/10 px-4 py-2 text-xs text-primary">
<span> <span>
{selectedContactIds.length} / {selectionLimit} {selectedContactIds.length} / {selectionLimit}
</span> </span>
<button type="button" onClick={onCompleteSelection}> <Button className="h-auto p-0 text-xs" variant="link" onClick={onCompleteSelection}>
</button> </Button>
</div> </div>
) : null} ) : null}
<div className="export-contact-list"> <div className="export-contact-list min-h-0 flex-1 overflow-auto py-2">
{filteredContacts.map((contact) => { {filteredContacts.map((contact) => {
const name = displayName(contact) const name = displayName(contact)
const selected = selectedContactIds.includes(contact.md5) const selected = selectedContactIds.includes(contact.md5)
@@ -159,20 +182,37 @@ export function ExportContactPanel({
<button <button
key={contact.md5} key={contact.md5}
type="button" type="button"
className={`export-contact-item ${!exportAll && activeContact?.md5 === contact.md5 ? 'active' : ''} ${visuallySelected ? 'selected' : ''}`} className={`flex w-full items-center gap-2.5 border-0 border-l-[3px] px-4 py-[11px] text-left text-foreground transition-colors hover:bg-surface/60 disabled:cursor-not-allowed disabled:opacity-50 ${
!exportAll && activeContact?.md5 === contact.md5
? 'border-l-primary bg-primary/10'
: 'border-l-transparent bg-transparent'
}`}
onClick={() => onSelectContact(contact)} onClick={() => onSelectContact(contact)}
disabled={atLimit} disabled={atLimit}
aria-pressed={visuallySelected} aria-pressed={visuallySelected}
> >
<span className="export-contact-avatar"> <span className="grid h-[38px] w-[38px] shrink-0 place-items-center overflow-hidden rounded-lg bg-primary/10 font-bold text-primary">
{contact.avatar ? <img src={contact.avatar} alt="" /> : name.slice(0, 1)} {contact.avatar ? (
<img className="h-full w-full object-cover" src={contact.avatar} alt="" />
) : (
name.slice(0, 1)
)}
</span> </span>
<span className="export-contact-copy"> <span className="grid min-w-0 flex-1 gap-0.5">
<strong>{name}</strong> <strong className="overflow-hidden text-ellipsis whitespace-nowrap text-[13px]">
<small>{contact.type === 'group' ? '群聊' : '联系人'}</small> {name}
</strong>
<small className="text-[11px] text-muted-foreground">
{contact.type === 'group' ? '群聊' : '联系人'}
</small>
</span> </span>
{!exportAll && selectionMode && ( {!exportAll && selectionMode && (
<span className={`export-contact-check ${selected ? 'checked' : ''}`} aria-hidden> <span
className={`grid h-[18px] w-[18px] shrink-0 place-items-center rounded border text-xs text-primary-foreground ${
selected ? 'border-primary bg-primary' : 'border-border-strong'
}`}
aria-hidden
>
{selected ? '✓' : ''} {selected ? '✓' : ''}
</span> </span>
)} )}
@@ -181,21 +221,28 @@ export function ExportContactPanel({
})} })}
</div> </div>
<button type="button" className="export-account-summary" onClick={onOpenSettings}> <Button
<span className="export-account-avatar"> type="button"
variant="ghost"
className="h-auto justify-start gap-2 border-t border-border px-4 py-3.5 text-left"
onClick={onOpenSettings}
>
<span className="grid h-[34px] w-[34px] shrink-0 place-items-center overflow-hidden rounded-full bg-primary/10 font-bold text-primary">
{selfInfo?.avatar ? ( {selfInfo?.avatar ? (
<img src={selfInfo.avatar} alt="" /> <img className="h-full w-full object-cover" src={selfInfo.avatar} alt="" />
) : ( ) : (
(selfInfo?.nickname || '我').slice(0, 1) (selfInfo?.nickname || '我').slice(0, 1)
)} )}
</span> </span>
<span> <span className="grid min-w-0 gap-0.5">
<strong>{selfInfo?.nickname || '当前账号'}</strong> <strong className="overflow-hidden text-ellipsis whitespace-nowrap text-[13px] text-foreground">
<small className={dbReady ? 'ready' : ''}> {selfInfo?.nickname || '当前账号'}
</strong>
<small className={`text-[11px] ${dbReady ? 'text-success' : 'text-muted-foreground'}`}>
{dbReady ? '数据库已连接' : '数据库未连接'} {dbReady ? '数据库已连接' : '数据库未连接'}
</small> </small>
</span> </span>
</button> </Button>
</aside> </aside>
) )
} }
@@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import { Button, Progress } from '../ui'
import type { Message } from './exportTypes' import type { Message } from './exportTypes'
import type { ExportJobProgress, ExportStatus, SelfInfo } from './exportTypes' import type { ExportJobProgress, ExportStatus, SelfInfo } from './exportTypes'
import { formatPreviewTime } from './exportUtils' import { formatPreviewTime } from './exportUtils'
@@ -68,12 +69,12 @@ export function ExportPreviewPanel({
: '' : ''
return ( return (
<aside className={`export-preview-panel ${status !== 'idle' ? `status-${status}` : ''}`}> <aside className="flex min-h-0 min-w-0 flex-col overflow-hidden border-l border-border bg-surface max-[1100px]:hidden">
{status === 'idle' && ( {status === 'idle' && (
<> <>
<div className="export-preview-heading"> <div className="flex h-[52px] shrink-0 items-center justify-between border-b border-border px-[18px] text-xs text-foreground">
<strong></strong> <strong className="font-semibold"></strong>
<span> <span className="text-[11px] text-muted-foreground">
{allExport {allExport
? `${selectedCount} 个聊天 · 分目录导出` ? `${selectedCount} 个聊天 · 分目录导出`
: selectedCount > 1 : selectedCount > 1
@@ -81,8 +82,10 @@ export function ExportPreviewPanel({
: '仅预览最近 20 条'} : '仅预览最近 20 条'}
</span> </span>
</div> </div>
<div className="export-message-preview"> <div className="min-h-0 flex-1 overflow-auto px-3.5 py-4">
<div className="export-preview-date">{allExport ? '全量归档' : '最近消息'}</div> <div className="mx-auto mb-[18px] w-fit rounded-full bg-muted px-2.5 py-1 text-[10px] text-muted-foreground">
{allExport ? '全量归档' : '最近消息'}
</div>
{(allExport {(allExport
? [ ? [
{ {
@@ -107,22 +110,44 @@ export function ExportPreviewPanel({
isSender: false isSender: false
} }
] ]
).map((message) => ( ).map((message) => {
const systemMessage = Boolean(
message.contentData?.type === 'system' && message.contentData.pat
)
return (
<div <div
key={`${message.exportConversationId || 'single'}:${message.id}`} key={`${message.exportConversationId || 'single'}:${message.id}`}
className={`export-preview-message ${message.isSender ? 'mine' : ''} ${ className={`mx-auto mb-[15px] flex w-full max-w-[620px] items-start gap-2 ${
message.contentData?.type === 'system' && message.contentData.pat ? 'system' : '' message.isSender ? 'flex-row-reverse' : ''
} ${systemMessage ? 'justify-center' : ''}`}
>
<span
className={`h-[30px] w-[30px] shrink-0 place-items-center overflow-hidden rounded-lg bg-primary/10 text-[11px] font-bold text-primary ${
systemMessage ? 'hidden' : 'grid'
}`} }`}
> >
<span className="export-preview-avatar">
{message.img || (message.isSender && selfInfo?.avatar) ? ( {message.img || (message.isSender && selfInfo?.avatar) ? (
<img src={message.img || selfInfo?.avatar} alt="" /> <img
className="h-full w-full object-cover"
src={message.img || selfInfo?.avatar}
alt=""
/>
) : ( ) : (
(message.isSender ? '我' : message.name || '友').slice(0, 1) (message.isSender ? '我' : message.name || '友').slice(0, 1)
)} )}
</span> </span>
<span className="export-preview-bubble"> <span
<small> className={`export-preview-bubble max-w-[78%] rounded-lg px-2.5 py-2 text-xs leading-[18px] text-foreground shadow-surface ${
systemMessage
? 'max-w-[92%] bg-muted px-2.5 py-1 text-center text-[11px] text-muted-foreground shadow-none'
: message.isSender
? 'bg-[#95ec69]'
: 'bg-surface'
}`}
>
<small
className={`${systemMessage ? 'hidden' : 'mb-1 block'} text-[10px] text-muted-foreground`}
>
{selectedCount > 1 && message.exportConversationName {selectedCount > 1 && message.exportConversationName
? `${message.exportConversationName} · ` ? `${message.exportConversationName} · `
: ''} : ''}
@@ -132,107 +157,106 @@ export function ExportPreviewPanel({
{message.content || `[${message.type}]`} {message.content || `[${message.type}]`}
</span> </span>
</div> </div>
))} )
})}
</div> </div>
<div className="export-preview-stats export-preview-real-stats"> <div className="grid shrink-0 gap-3 border-t border-border p-[18px]">
<span> <span className="flex justify-between text-xs text-muted-foreground">
<strong>{previewItems.length}</strong> <strong>{previewItems.length}</strong>
</span> </span>
<span> <span className="flex justify-between text-xs text-muted-foreground">
<strong>{previewMediaCount}</strong> <strong>{previewMediaCount}</strong>
</span> </span>
<span> <span className="flex justify-between text-xs text-muted-foreground">
<strong> <strong className="font-semibold text-foreground">
{previewBytes < 1024 {previewBytes < 1024
? `${previewBytes} B` ? `${previewBytes} B`
: `${(previewBytes / 1024).toFixed(1)} KB`} : `${(previewBytes / 1024).toFixed(1)} KB`}
</strong> </strong>
</span> </span>
</div> </div>
<div className="export-preview-stats">
<span>
<strong></strong>
</span>
<span>
<strong></strong>
</span>
<span>
<strong></strong>
</span>
</div>
</> </>
)} )}
{status === 'running' && ( {status === 'running' && (
<div className="export-job-state"> <div className="grid h-full content-center gap-3 p-7 text-center">
<h2></h2> <h2 className="text-lg font-bold tracking-normal text-foreground"></h2>
<p></p> <p className="text-xs leading-[18px] text-muted-foreground">
</p>
{currentTargetText && ( {currentTargetText && (
<div className="export-current-target"> <div className="my-1 grid gap-1 rounded-lg border border-border bg-surface p-3">
<span>{progress?.currentTargetType === 'group' ? '群聊' : '联系人'}</span> <span className="text-[10px] text-muted-foreground">
<strong>{currentTargetText}</strong> {progress?.currentTargetType === 'group' ? '群聊' : '联系人'}
</span>
<strong className="overflow-hidden text-ellipsis whitespace-nowrap text-xs text-foreground">
{currentTargetText}
</strong>
</div> </div>
)} )}
<ol> <ol className="my-2 grid list-none gap-2.5 p-0 text-left">
<li className="done"></li> <li className="done text-xs text-success before:mr-2 before:content-['✓']"></li>
{steps.map((step, index) => ( {steps.map((step, index) => (
<li <li
key={step.phase} key={step.phase}
className={ className={`${
index < currentStepIndex ? 'done' : index === currentStepIndex ? 'current' : '' index < currentStepIndex
} ? "done text-success before:content-['✓']"
: index === currentStepIndex
? "current font-semibold text-foreground before:content-['●']"
: "text-muted-foreground before:content-['○']"
} text-xs before:mr-2`}
> >
{step.label} {step.label}
</li> </li>
))} ))}
</ol> </ol>
<div <Progress
className={`export-progress-bar ${indeterminate ? 'indeterminate' : ''}`} className="h-1.5"
role="progressbar" value={percent}
indeterminate={indeterminate}
aria-label="导出进度" aria-label="导出进度"
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={100} aria-valuemax={100}
aria-valuenow={indeterminate ? undefined : percent} aria-valuenow={indeterminate ? undefined : percent}
aria-valuetext={indeterminate ? '正在读取消息' : `${percent}%`} aria-valuetext={indeterminate ? '正在读取消息' : `${percent}%`}
> />
<span style={indeterminate ? undefined : { width: `${percent}%` }} /> <strong className="text-xs font-medium text-muted-foreground">{progressText}</strong>
</div> <Button variant="outline" onClick={() => onCancel(jobId)}>
<strong>{progressText}</strong>
<button type="button" className="export-cancel-button" onClick={() => onCancel(jobId)}>
</button> </Button>
</div> </div>
)} )}
{status === 'completed' && ( {status === 'completed' && (
<div className="export-job-state completed"> <div className="grid h-full content-center gap-3 p-7 text-center">
<div className="export-success-icon"></div> <div className="mx-auto grid h-[58px] w-[58px] place-items-center rounded-full border-[5px] border-primary/10 bg-surface text-3xl text-primary">
<h2></h2>
<p></p> </div>
<div className="export-complete-summary"> <h2 className="text-lg font-bold tracking-normal text-foreground"></h2>
<span> <p className="text-xs text-muted-foreground"></p>
<strong>{progress?.processed.toLocaleString() || '已完成'}</strong> <div className="my-3 grid gap-3 rounded-lg bg-muted p-3.5 text-left">
<span className="flex justify-between text-xs text-muted-foreground">
<strong className="font-semibold text-foreground">
{progress?.processed.toLocaleString() || '已完成'}
</strong>
</span> </span>
<span> <span className="flex justify-between text-xs text-muted-foreground">
<strong></strong> <strong className="font-semibold text-foreground"></strong>
</span> </span>
<span> <span className="flex justify-between text-xs text-muted-foreground">
<strong></strong> <strong className="font-semibold text-foreground"></strong>
</span> </span>
</div> </div>
<button <Button onClick={() => progress?.outputPath && onReveal(progress.outputPath)}>
type="button"
className="export-primary-button"
onClick={() => progress?.outputPath && onReveal(progress.outputPath)}
>
{allExport ? '打开导出目录' : '打开档案'} {allExport ? '打开导出目录' : '打开档案'}
</button> </Button>
<button <Button
type="button" variant="outline"
className="export-open-folder-button"
onClick={() => progress?.outputPath && onReveal(progress.outputPath)} onClick={() => progress?.outputPath && onReveal(progress.outputPath)}
> >
</button> </Button>
</div> </div>
)} )}
</aside> </aside>
@@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import { Button, Progress } from '../ui'
import type { ExportTaskRecord } from './exportTypes' import type { ExportTaskRecord } from './exportTypes'
interface ExportTaskCenterProps { interface ExportTaskCenterProps {
@@ -60,49 +61,60 @@ export function ExportTaskCenter({
return ( return (
<> <>
<button type="button" className="export-task-center-button" onClick={onToggle}> <Button className="mb-3 self-start" size="sm" variant="outline" onClick={onToggle}>
{taskCount > 0 ? ` (${taskCount})` : ''} {taskCount > 0 ? ` (${taskCount})` : ''}
</button> </Button>
{open && ( {open && (
<section className="export-task-center"> <section className="mb-[18px] rounded-lg border border-border bg-surface p-3">
<div className="export-section-heading"> <div className="flex items-center justify-between gap-3">
<h3></h3> <h3 className="text-xs font-bold tracking-normal text-foreground"></h3>
<span>{tasks.length} </span> <span className="text-xs text-primary">{tasks.length} </span>
</div> </div>
{tasks.length === 0 ? ( {tasks.length === 0 ? (
<p></p> <p className="mt-3 text-xs text-muted-foreground"></p>
) : ( ) : (
tasks.map((task) => { tasks.map((task) => {
const detail = taskDetail(task) const detail = taskDetail(task)
return ( return (
<div className="export-task-row" key={task.jobId}> <div
<span> className="grid grid-cols-[minmax(0,1fr)_110px_auto] items-center gap-3 border-t border-border py-2 text-xs text-muted-foreground first:mt-2"
<strong>{task.targetLabel}</strong> key={task.jobId}
<small> >
<span className="grid min-w-0 gap-0.5">
<strong className="overflow-hidden text-ellipsis whitespace-nowrap text-foreground">
{task.targetLabel}
</strong>
<small className="overflow-hidden text-ellipsis whitespace-nowrap">
{task.format.toUpperCase()} · {phaseLabels[task.progress.phase]} {task.format.toUpperCase()} · {phaseLabels[task.progress.phase]}
</small> </small>
{detail && ( {detail && (
<small <small
className={`export-task-detail ${task.status}`} className={`export-task-detail ${task.status} whitespace-normal [overflow-wrap:anywhere] ${
task.status === 'completed'
? 'text-primary'
: task.status === 'failed'
? 'text-destructive'
: ''
}`}
title={task.status === 'failed' ? detail : undefined} title={task.status === 'failed' ? detail : undefined}
> >
{detail} {detail}
</small> </small>
)} )}
</span> </span>
<span className="export-task-progress"> <span className="grid gap-1 text-right text-[10px] text-muted-foreground">
<i style={{ width: `${task.progress.percent ?? 0}%` }} /> <Progress className="h-1.5" value={task.progress.percent ?? 0} />
<b>{task.progress.percent ?? 0}%</b> <b className="font-medium">{task.progress.percent ?? 0}%</b>
</span> </span>
{task.status === 'running' && ( {task.status === 'running' && (
<button type="button" onClick={() => onCancel(task.jobId)}> <Button size="sm" variant="outline" onClick={() => onCancel(task.jobId)}>
</button> </Button>
)} )}
{task.status === 'failed' && ( {task.status === 'failed' && (
<button type="button" onClick={() => void copyTaskLog(task)}> <Button size="sm" variant="outline" onClick={() => void copyTaskLog(task)}>
{copiedJobId === task.jobId ? '已复制' : '复制日志'} {copiedJobId === task.jobId ? '已复制' : '复制日志'}
</button> </Button>
)} )}
</div> </div>
) )
@@ -9,6 +9,7 @@ import type {
ExportTarget ExportTarget
} from '../../../../shared/export' } from '../../../../shared/export'
import { ExportContactPanel } from './ExportContactPanel' import { ExportContactPanel } from './ExportContactPanel'
import { ExportConfigurationPanel } from './ExportConfigurationPanel'
import { ExportPreviewPanel } from './ExportPreviewPanel' import { ExportPreviewPanel } from './ExportPreviewPanel'
import { ExportTaskCenter } from './ExportTaskCenter' import { ExportTaskCenter } from './ExportTaskCenter'
import type { import type {
@@ -19,7 +20,7 @@ import type {
ExportWorkspaceProps, ExportWorkspaceProps,
GroupMemberName GroupMemberName
} from './exportTypes' } from './exportTypes'
import { displayName, formatLabels, formatOrder, messageKinds } from './exportUtils' import { displayName } from './exportUtils'
import type { VoiceModelStatus } from '../../../../shared/voice-recognition' import type { VoiceModelStatus } from '../../../../shared/voice-recognition'
import { resolveMemberName } from '../../../../shared/member-names' import { resolveMemberName } from '../../../../shared/member-names'
@@ -445,6 +446,12 @@ export function ExportWorkspace({
? `${outputDirectory}/${outputName}${format === 'html' ? (zip ? '.zip' : '/') : `.${format === 'markdown' ? 'md' : format}`}` ? `${outputDirectory}/${outputName}${format === 'html' ? (zip ? '.zip' : '/') : `.${format === 'markdown' ? 'md' : format}`}`
: targetPath : targetPath
const selectOutputDirectory = (): void => {
void window.api.selectExportDirectory().then((result) => {
if (!result.canceled && result.path) setOutputDirectory(result.path)
})
}
return ( return (
<div className="export-workspace"> <div className="export-workspace">
<ExportContactPanel <ExportContactPanel
@@ -470,8 +477,8 @@ export function ExportWorkspace({
onOpenSettings={onOpenSettings} onOpenSettings={onOpenSettings}
/> />
<main className="export-config-panel"> <ExportConfigurationPanel
<div className="export-config-scroll"> taskCenter={
<ExportTaskCenter <ExportTaskCenter
open={taskCenterOpen} open={taskCenterOpen}
taskCount={taskCount} taskCount={taskCount}
@@ -479,327 +486,55 @@ export function ExportWorkspace({
onToggle={() => setTaskCenterOpen((open) => !open)} onToggle={() => setTaskCenterOpen((open) => !open)}
onCancel={(taskJobId) => void onCancelExport(taskJobId)} onCancel={(taskJobId) => void onCancelExport(taskJobId)}
/> />
<header className="export-config-header"> }
<span className="export-chat-avatar-stack" aria-hidden> selectedContacts={selectedContacts}
{exportAll exportAll={exportAll}
? allContactTypes.map((type) => ( allContactTypes={allContactTypes}
<span selectedLabel={selectedLabel}
className={`export-chat-avatar export-all-chat-avatar ${type}`} selectionMode={selectionMode}
key={type} exportContactCount={exportContacts.length}
> format={format}
{type === 'group' ? '群' : '联'} range={range}
</span> startDate={startDate}
)) endDate={endDate}
: selectedContacts.slice(0, 3).map((contact) => ( selectedKinds={selectedKinds}
<span className="export-chat-avatar" key={contact.md5}> nameOptions={nameOptions}
{contact.avatar ? ( nameMode={nameMode}
<img src={contact.avatar} alt="" /> includeMedia={includeMedia}
) : ( includeVoiceTranscripts={includeVoiceTranscripts}
displayName(contact).slice(0, 1) includeAvatars={includeAvatars}
)} preferOriginal={preferOriginal}
</span> fallbackThumbnail={fallbackThumbnail}
))} keepMissing={keepMissing}
</span> voiceModelStatus={voiceModelStatus}
<span className="export-config-title"> zip={zip}
<h1></h1> fileName={fileName}
<p>{selectedLabel}</p> defaultOutputName={defaultOutputName}
</span> selectedTargetPath={selectedTargetPath}
<button status={status}
type="button" canStart={Boolean(activeContact && exportContacts.length && status !== 'running')}
className="export-add-chat-button" onToggleSelectionMode={() => {
disabled={exportAll}
onClick={() => {
setExportAll(false) setExportAll(false)
setSelectionMode((current) => !current) setSelectionMode((current) => !current)
}} }}
> onFormatChange={setFormat}
{exportAll ? '已选择全部聊天' : selectionMode ? '完成选择' : '+ 添加聊天'} onZipChange={setZip}
</button> onRangeChange={setRange}
</header> onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
<section className="export-section export-format-top"> onToggleKind={toggleKind}
<h3></h3> onNameModeChange={setNameMode}
<div className="export-format-grid"> onIncludeMediaChange={setIncludeMedia}
{formatOrder.map((value) => ( onIncludeVoiceTranscriptsChange={setIncludeVoiceTranscripts}
<button onIncludeAvatarsChange={setIncludeAvatars}
key={value} onPreferOriginalChange={setPreferOriginal}
type="button" onFallbackThumbnailChange={setFallbackThumbnail}
className={format === value ? 'active' : ''} onKeepMissingChange={setKeepMissing}
disabled={!exportAll && exportContacts.length > 1 && value !== 'html'} onFileNameChange={setFileName}
onClick={() => setFormat(value)} onSelectOutputDirectory={selectOutputDirectory}
> onReset={resetDefaults}
<strong>{formatLabels[value].label}</strong> onStart={() => void handleStart()}
{formatLabels[value].hint && <small>{formatLabels[value].hint}</small>}
</button>
))}
</div>
<p className="export-helper-text">
{exportAll
? '全部导出固定使用全部时间;每个群聊或联系人都会在自己的目录中生成所选格式的独立档案。'
: selectedContacts.length > 1
? '多聊天合并仅支持 HTML,会保留每条消息所属的聊天。'
: 'CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。'}
</p>
{format === 'html' && (
<>
<div className="export-html-options">
<label>
<input
type="radio"
name="html-package-top"
checked={!zip}
onChange={() => setZip(false)}
/>{' '}
HTML
</label>
<label>
<input
type="radio"
name="html-package-top"
checked={zip}
onChange={() => setZip(true)}
/>{' '}
HTML ZIP
</label>
</div>
<p className="export-helper-text">
使
</p>
</>
)}
</section>
<section className="export-section">
<div className="export-section-heading">
<h3></h3>
<span>{status === 'completed' ? '已完成导出' : '消息数量将在开始导出后统计'}</span>
</div>
<div className="export-range-toggle">
<button
type="button"
className={range === 'all' ? 'active' : ''}
onClick={() => setRange('all')}
>
</button>
{!exportAll && (
<>
<button
type="button"
className={range === 'today' ? 'active' : ''}
onClick={() => setRange('today')}
>
</button>
<button
type="button"
className={range === 'threeDays' ? 'active' : ''}
onClick={() => setRange('threeDays')}
>
3
</button>
<button
type="button"
className={range === 'sevenDays' ? 'active' : ''}
onClick={() => setRange('sevenDays')}
>
7
</button>
<button
type="button"
className={range === 'custom' ? 'active' : ''}
onClick={() => setRange('custom')}
>
</button>
</>
)}
</div>
{!exportAll && range === 'custom' && (
<div className="export-date-fields">
<label>
<input
type="datetime-local"
value={startDate}
onChange={(event) => setStartDate(event.target.value)}
/> />
</label>
<label>
<input
type="datetime-local"
value={endDate}
onChange={(event) => setEndDate(event.target.value)}
/>
</label>
</div>
)}
</section>
<section className="export-section">
<h3></h3>
<div className="export-kind-grid">
{messageKinds.map(([value, label]) => (
<label key={value} className="export-check-row">
<input
type="checkbox"
checked={selectedKinds.has(value)}
onChange={() => toggleKind(value)}
/>
<span>{label}</span>
</label>
))}
</div>
</section>
<section className="export-section">
<h3></h3>
<div className="export-name-mode-grid" role="radiogroup" aria-label="消息显示名称">
{nameOptions.map((option) => (
<label key={option.value} className="export-name-mode-option">
<input
type="radio"
name="export-name-mode"
checked={nameMode === option.value}
onChange={() => setNameMode(option.value)}
/>
<span>{option.label}</span>
</label>
))}
</div>
</section>
<section className="export-section">
<h3></h3>
<label className="export-media-master">
<span></span>
<input
type="checkbox"
checked={includeMedia}
disabled={format !== 'html'}
onChange={(event) => setIncludeMedia(event.target.checked)}
/>
</label>
<div
className={`export-media-options ${includeMedia && format === 'html' ? '' : 'disabled'}`}
>
<label className="export-check-row">
<input
type="checkbox"
checked={preferOriginal}
disabled={!includeMedia || format !== 'html'}
onChange={(event) => setPreferOriginal(event.target.checked)}
/>
<span></span>
</label>
<label className="export-check-row">
<input
type="checkbox"
checked={fallbackThumbnail}
disabled={!includeMedia || format !== 'html'}
onChange={(event) => setFallbackThumbnail(event.target.checked)}
/>
<span>使</span>
</label>
<label className="export-check-row">
<input
type="checkbox"
checked={keepMissing}
disabled={!includeMedia || format !== 'html'}
onChange={(event) => setKeepMissing(event.target.checked)}
/>
<span></span>
</label>
<label className="export-check-row">
<input
type="checkbox"
checked={includeVoiceTranscripts && voiceModelStatus?.state === 'ready'}
disabled={
!includeMedia ||
format !== 'html' ||
!selectedKinds.has('voice') ||
voiceModelStatus?.state !== 'ready'
}
onChange={(event) => setIncludeVoiceTranscripts(event.target.checked)}
/>
<span></span>
</label>
</div>
<p className="export-helper-text">
HTML CSVJSON Markdown
</p>
<div className="export-resource-statuses">
<span></span>
<span></span>
<span></span>
<span>
{voiceModelStatus?.state === 'ready' ? '已就绪' : '请先在设置中准备模型'}
</span>
<span></span>
<span></span>
</div>
<p className="export-helper-text"></p>
<label className="export-media-master">
<span></span>
<input
type="checkbox"
checked={includeAvatars}
onChange={(event) => setIncludeAvatars(event.target.checked)}
/>
</label>
</section>
<section className="export-section export-save-section">
<h3></h3>
<label>
<input
value={fileName}
onChange={(event) => setFileName(event.target.value)}
placeholder={defaultOutputName}
/>
</label>
<div className="export-target-path">
<span></span>
<strong>{selectedTargetPath}</strong>
<button type="button" onClick={() => {
void window.api.selectExportDirectory().then((result) => {
if (!result.canceled && result.path) setOutputDirectory(result.path)
})
}}></button>
</div>
{format === 'html' && (
<p className="export-helper-text">
</p>
)}
</section>
</div>
<footer className="export-action-bar">
<span className={`export-ready-dot ${status === 'completed' ? 'completed' : ''}`} />
<span>
{status === 'running'
? '正在后台导出'
: status === 'completed'
? '导出完成'
: '准备就绪'}
</span>
<span className="export-target-summary">{selectedTargetPath}</span>
<button type="button" className="export-reset-button" onClick={resetDefaults}>
</button>
<button
type="button"
className="export-primary-button"
disabled={!activeContact || !exportContacts.length || status === 'running'}
onClick={handleStart}
>
{status === 'running' ? '正在导出' : status === 'completed' ? '再次导出' : '开始导出'}
</button>
</footer>
</main>
<ExportPreviewPanel <ExportPreviewPanel
status={status} status={status}
@@ -1,5 +1,15 @@
import React, { useMemo, useState } from 'react' import React, { useEffect, useMemo, useRef, useState } from 'react'
import { AccountSummary } from '../account/AccountSummary' import { AccountSummary } from '../account/AccountSummary'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button
} from '../ui'
import type { GeneratedReportRecord } from './types' import type { GeneratedReportRecord } from './types'
interface SelfInfo { interface SelfInfo {
@@ -90,6 +100,15 @@ export function ReportHistorySidebar({
const [keyword, setKeyword] = useState('') const [keyword, setKeyword] = useState('')
const [pendingDelete, setPendingDelete] = useState<GeneratedReportRecord | null>(null) const [pendingDelete, setPendingDelete] = useState<GeneratedReportRecord | null>(null)
const [deleteError, setDeleteError] = useState('') const [deleteError, setDeleteError] = useState('')
const [deletePending, setDeletePending] = useState(false)
const deleteTriggerRef = useRef<HTMLButtonElement | null>(null)
const restoreDeleteFocusRef = useRef(false)
useEffect(() => {
if (pendingDelete || !restoreDeleteFocusRef.current) return
restoreDeleteFocusRef.current = false
deleteTriggerRef.current?.focus()
}, [pendingDelete])
const reportGroups = useMemo(() => { const reportGroups = useMemo(() => {
const lower = keyword.trim().toLowerCase() const lower = keyword.trim().toLowerCase()
@@ -108,12 +127,18 @@ export function ReportHistorySidebar({
const confirmDelete = async (): Promise<void> => { const confirmDelete = async (): Promise<void> => {
if (!pendingDelete) return if (!pendingDelete) return
setDeleteError('') setDeleteError('')
setDeletePending(true)
try {
const result = await onDeleteReport(pendingDelete.id) const result = await onDeleteReport(pendingDelete.id)
if (!result.success) { if (!result.success) {
setDeleteError(result.error || '删除日报失败') setDeleteError(result.error || '删除日报失败')
return return
} }
restoreDeleteFocusRef.current = false
setPendingDelete(null) setPendingDelete(null)
} finally {
setDeletePending(false)
}
} }
return ( return (
@@ -179,14 +204,19 @@ export function ReportHistorySidebar({
type="button" type="button"
className="report-history-delete" className="report-history-delete"
title="删除日报" title="删除日报"
aria-label="删除日报"
onClick={(event) => { onClick={(event) => {
event.stopPropagation() event.stopPropagation()
deleteTriggerRef.current = event.currentTarget
restoreDeleteFocusRef.current = true
setPendingDelete(report) setPendingDelete(report)
}} }}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') return if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
deleteTriggerRef.current = event.currentTarget
restoreDeleteFocusRef.current = true
setPendingDelete(report) setPendingDelete(report)
}} }}
> >
@@ -211,23 +241,39 @@ export function ReportHistorySidebar({
onClick={onOpenSettings} onClick={onOpenSettings}
/> />
</div> </div>
{pendingDelete && ( <AlertDialog
<div className="report-delete-confirm" role="dialog" aria-modal="true"> open={Boolean(pendingDelete)}
<div className="report-delete-confirm-card"> onOpenChange={(open) => {
<h2></h2> if (!open && !deletePending) {
<p></p> setPendingDelete(null)
{deleteError && <p className="report-delete-error">{deleteError}</p>} setDeleteError('')
<div> }
<button type="button" onClick={() => setPendingDelete(null)}> }}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
{deleteError && <p className="text-sm text-destructive">{deleteError}</p>}
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button variant="outline" disabled={deletePending}>
</button> </Button>
<button type="button" className="danger" onClick={() => void confirmDelete()}> </AlertDialogCancel>
<Button
</button> variant="destructive"
</div> disabled={deletePending}
</div> onClick={() => void confirmDelete()}
</div> >
)} {deletePending ? '删除中…' : '删除'}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</aside> </aside>
) )
} }
@@ -1,8 +1,17 @@
import React, { useEffect, useRef, useState } from 'react' import React from 'react'
import { import {
SELECTABLE_REPORT_TEMPLATES, SELECTABLE_REPORT_TEMPLATES,
type SelectableReportTemplateId type SelectableReportTemplateId
} from '../../../../shared/report-templates' } from '../../../../shared/report-templates'
import {
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '../ui'
interface ReportToolbarProps { interface ReportToolbarProps {
canCopyImage: boolean canCopyImage: boolean
@@ -37,99 +46,82 @@ export function ReportToolbar({
onShare, onShare,
onSendToGroup onSendToGroup
}: ReportToolbarProps): React.ReactElement { }: ReportToolbarProps): React.ReactElement {
const [moreOpen, setMoreOpen] = useState(false)
const [templateOpen, setTemplateOpen] = useState(false)
const menuRef = useRef<HTMLDivElement>(null)
const templateMenuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!moreOpen && !templateOpen) return
const close = (event: PointerEvent): void => {
if (!menuRef.current?.contains(event.target as Node)) setMoreOpen(false)
if (!templateMenuRef.current?.contains(event.target as Node)) setTemplateOpen(false)
}
window.addEventListener('pointerdown', close)
return () => window.removeEventListener('pointerdown', close)
}, [moreOpen, templateOpen])
return ( return (
<div className="report-viewer-toolbar"> <div className="report-viewer-toolbar">
<div className="report-template-switch-menu" ref={templateMenuRef}> <DropdownMenu>
<button <DropdownMenuTrigger asChild>
type="button" <Button
variant="outline"
size="sm"
disabled={!canSwitchTemplate || isSwitchingTemplate} disabled={!canSwitchTemplate || isSwitchingTemplate}
title={ title={
canSwitchTemplate canSwitchTemplate
? '使用已生成的数据或本地 HTML 更换展示模板,不会重新调用 AI' ? '使用已生成的数据或本地 HTML 更换展示模板,不会重新调用 AI'
: '当前报告缺少可复用数据和 HTML,无法切换模板' : '当前报告缺少可复用数据和 HTML,无法切换模板'
} }
onClick={() => setTemplateOpen((open) => !open)}
> >
{isSwitchingTemplate ? '切换中…' : '切换模板'} {isSwitchingTemplate ? '切换中…' : '切换模板'}
</button> </Button>
{templateOpen && canSwitchTemplate && ( </DropdownMenuTrigger>
<div className="report-template-switch-popover" role="menu" aria-label="切换日报模板"> <DropdownMenuContent className="w-64" align="end" aria-label="切换日报模板">
<p> AI</p> <DropdownMenuLabel> AI</DropdownMenuLabel>
<DropdownMenuSeparator />
{SELECTABLE_REPORT_TEMPLATES.map((template) => ( {SELECTABLE_REPORT_TEMPLATES.map((template) => (
<button <DropdownMenuItem
type="button" className="grid min-h-11 grid-cols-[62px_minmax(0,1fr)_auto] gap-2"
role="menuitem"
className={template.id === currentTemplateId ? 'active' : undefined}
key={template.id} key={template.id}
onClick={() => { onSelect={() => onSwitchTemplate(template.id)}
setTemplateOpen(false)
onSwitchTemplate(template.id)
}}
> >
<span>{template.label}</span> <span className="text-xs text-muted-foreground">{template.label}</span>
<b>{template.name}</b> <strong className="overflow-hidden text-ellipsis whitespace-nowrap text-sm font-semibold">
{template.id === currentTemplateId && <i></i>} {template.name}
</button> </strong>
))} {template.id === currentTemplateId && (
</div> <span className="text-xs text-primary"></span>
)} )}
</div> </DropdownMenuItem>
<button type="button" onClick={onRegenerate}> ))}
</DropdownMenuContent>
</DropdownMenu>
<Button variant="outline" size="sm" onClick={onRegenerate}>
</button> </Button>
<button type="button" disabled={!canCopyImage} onClick={onCopyImage}> <Button variant="outline" size="sm" disabled={!canCopyImage} onClick={onCopyImage}>
</button> </Button>
<span <span
className="report-toolbar-button-hint" className="report-toolbar-button-hint"
title={sendToGroupHint} title={sendToGroupHint}
aria-label={sendToGroupHint} aria-label={sendToGroupHint}
tabIndex={canSendToGroup ? -1 : 0} tabIndex={canSendToGroup ? -1 : 0}
> >
<button type="button" disabled={!canSendToGroup} onClick={() => onSendToGroup?.()}> <Button
variant="outline"
</button> size="sm"
</span> disabled={!canSendToGroup}
<button type="button" className="primary" disabled={!canReveal} onClick={onReveal}> onClick={() => onSendToGroup?.()}
</button>
<button type="button" disabled={!canShare} onClick={onShare}>
</button>
<div className="report-more-menu" ref={menuRef}>
<button type="button" onClick={() => setMoreOpen((open) => !open)}>
</button>
{moreOpen && (
<div className="report-more-popover">
<button
type="button"
disabled={!canReveal}
onClick={() => {
setMoreOpen(false)
onReveal()
}}
> >
</Button>
</span>
<Button size="sm" disabled={!canReveal} onClick={onReveal}>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled={!canShare} onSelect={onShare}>
</DropdownMenuItem>
<DropdownMenuItem disabled={!canReveal} onSelect={onReveal}>
</button> </DropdownMenuItem>
</div> </DropdownMenuContent>
)} </DropdownMenu>
</div>
</div> </div>
) )
} }
@@ -1,6 +1,15 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import type { PublishWechatShareCardResult } from '../../../../shared/wechat-share-card' import type { PublishWechatShareCardResult } from '../../../../shared/wechat-share-card'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Input,
Textarea
} from '../ui'
interface WechatShareCardDialogProps { interface WechatShareCardDialogProps {
pngPath: string pngPath: string
@@ -25,6 +34,14 @@ export function WechatShareCardDialog({
const [error, setError] = useState('') const [error, setError] = useState('')
const [result, setResult] = useState<PublishWechatShareCardResult | null>(null) const [result, setResult] = useState<PublishWechatShareCardResult | null>(null)
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const restoreFocusRef = useRef<HTMLElement | null>(null)
const closeDialog = (): void => {
const restoreFocus = restoreFocusRef.current
restoreFocusRef.current = null
onClose()
queueMicrotask(() => restoreFocus?.focus())
}
useEffect(() => { useEffect(() => {
void window.api.getWechatShareConfig().then((response) => { void window.api.getWechatShareConfig().then((response) => {
@@ -34,19 +51,6 @@ export function WechatShareCardDialog({
}) })
}, []) }, [])
useEffect(() => {
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const closeOnEscape = (event: KeyboardEvent): void => {
if (event.key === 'Escape' && !busy) onClose()
}
window.addEventListener('keydown', closeOnEscape)
return () => {
document.body.style.overflow = previousOverflow
window.removeEventListener('keydown', closeOnEscape)
}
}, [busy, onClose])
const publish = async (): Promise<void> => { const publish = async (): Promise<void> => {
setBusy(true) setBusy(true)
setError('') setError('')
@@ -82,55 +86,57 @@ export function WechatShareCardDialog({
setCopied(true) setCopied(true)
} }
return createPortal( return (
<div className="wechat-share-dialog-backdrop" role="presentation" onMouseDown={onClose}> <Dialog open onOpenChange={(open) => !open && closeDialog()}>
<section <DialogContent
className="wechat-share-dialog" className="max-h-[calc(100vh-3rem)] max-w-[540px] overflow-y-auto p-0"
role="dialog" onOpenAutoFocus={() => {
aria-modal="true" restoreFocusRef.current =
aria-labelledby="wechat-share-title" document.activeElement instanceof HTMLElement ? document.activeElement : null
onMouseDown={(event) => event.stopPropagation()} }}
onEscapeKeyDown={(event) => busy && event.preventDefault()}
> >
<header> <DialogHeader className="border-b border-border px-6 py-5 pr-12">
<div> <DialogTitle className="text-xl"></DialogTitle>
<h2 id="wechat-share-title"></h2> <DialogDescription> 7 </DialogDescription>
<p> 7 </p> </DialogHeader>
</div>
<button type="button" className="wechat-share-dialog-close" onClick={onClose}>
×
</button>
</header>
{result?.qrCodeDataUrl ? ( {result?.qrCodeDataUrl ? (
<div className="wechat-share-success"> <div className="grid justify-items-center gap-3 p-7 text-center">
<img src={result.qrCodeDataUrl} alt="微信分享二维码" /> <img
<h3>使</h3> className="w-[260px] max-w-[80%] rounded-lg border-[10px] border-surface shadow-floating"
<p> ···</p> src={result.qrCodeDataUrl}
alt="微信分享二维码"
/>
<h3 className="text-lg font-semibold">使</h3>
<p className="text-sm leading-relaxed text-muted-foreground">
···
</p>
{result.expiresAt && ( {result.expiresAt && (
<small> {new Date(result.expiresAt).toLocaleString('zh-CN')}</small> <small className="text-muted-foreground">
{new Date(result.expiresAt).toLocaleString('zh-CN')}
</small>
)} )}
<div> <div className="mt-3 flex justify-end gap-2">
<button type="button" onClick={() => void copyLink()}> <Button variant="outline" onClick={() => void copyLink()}>
{copied ? '链接已复制' : '复制分享链接'} {copied ? '链接已复制' : '复制分享链接'}
</button> </Button>
<button type="button" className="primary" onClick={onClose}> <Button onClick={closeDialog}></Button>
</button>
</div> </div>
</div> </div>
) : ( ) : (
<div className="wechat-share-form"> <div className="grid gap-4 px-6 py-5">
<label> <label className="grid gap-2">
<span></span> <span className="text-sm font-medium"></span>
<input <Input
maxLength={64} maxLength={64}
value={title} value={title}
onChange={(event) => setTitle(event.target.value)} onChange={(event) => setTitle(event.target.value)}
/> />
</label> </label>
<label> <label className="grid gap-2">
<span></span> <span className="text-sm font-medium"></span>
<textarea <Textarea
maxLength={120} maxLength={120}
rows={3} rows={3}
value={description} value={description}
@@ -138,29 +144,31 @@ export function WechatShareCardDialog({
/> />
</label> </label>
{configured === true && !editingConfig && ( {configured === true && !editingConfig && (
<div className="wechat-share-service-summary"> <div className="flex min-w-0 items-center justify-between gap-3 rounded-md border border-border bg-background p-3">
<div> <div className="grid min-w-0 gap-1">
<span></span> <span className="text-xs text-muted-foreground"></span>
<b>{serviceUrl}</b> <b className="overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{serviceUrl}
</b>
</div> </div>
<button type="button" onClick={() => setEditingConfig(true)}> <Button variant="outline" size="sm" onClick={() => setEditingConfig(true)}>
</button> </Button>
</div> </div>
)} )}
{(configured === false || editingConfig) && ( {(configured === false || editingConfig) && (
<div className="wechat-share-service-config"> <div className="grid min-w-0 gap-3 rounded-md border border-primary/20 bg-primary/5 p-4">
<h3></h3> <h3 className="text-sm font-semibold text-primary"></h3>
<label> <label className="grid gap-2">
<span></span> <span className="text-sm font-medium"></span>
<input <Input
value={serviceUrl} value={serviceUrl}
onChange={(event) => setServiceUrl(event.target.value)} onChange={(event) => setServiceUrl(event.target.value)}
/> />
</label> </label>
<label> <label className="grid gap-2">
<span></span> <span className="text-sm font-medium"></span>
<input <Input
type="password" type="password"
autoComplete="off" autoComplete="off"
value={uploadToken} value={uploadToken}
@@ -168,20 +176,20 @@ export function WechatShareCardDialog({
placeholder="Cloudflare Worker 的 UPLOAD_TOKEN" placeholder="Cloudflare Worker 的 UPLOAD_TOKEN"
/> />
</label> </label>
<p> AppSecret</p> <p className="text-xs leading-relaxed text-muted-foreground">
AppSecret
</p>
</div> </div>
)} )}
<div className="wechat-share-privacy"> <div className="rounded-md bg-muted p-3 text-xs leading-relaxed text-muted-foreground">
R2 R2
</div> </div>
{error && <p className="report-inline-error">{error}</p>} {error && <p className="text-sm text-destructive">{error}</p>}
<footer> <footer className="flex justify-end gap-2">
<button type="button" onClick={onClose}> <Button variant="outline" onClick={closeDialog}>
</button> </Button>
<button <Button
type="button"
className="primary"
disabled={ disabled={
busy || busy ||
configured === null || configured === null ||
@@ -191,12 +199,11 @@ export function WechatShareCardDialog({
onClick={() => void publish()} onClick={() => void publish()}
> >
{busy ? '正在生成卡片…' : '生成二维码'} {busy ? '正在生成卡片…' : '生成二维码'}
</button> </Button>
</footer> </footer>
</div> </div>
)} )}
</section> </DialogContent>
</div>, </Dialog>
document.body
) )
} }
@@ -0,0 +1,161 @@
import * as React from 'react'
import { Button, Popover, PopoverClose, PopoverContent, PopoverTrigger, Textarea } from '../ui'
export interface AISearchComposerProps {
query: string
sourceLabel: string
rangeLabel: string
history: string[]
historyOpen: boolean
loading: boolean
knowledgeSyncing: boolean
inputRef?: React.Ref<HTMLTextAreaElement>
onQueryChange: (value: string) => void
onHistoryOpenChange: (open: boolean) => void
onRestoreHistory: (query: string) => void
onRemoveHistory: (query: string) => void
onSubmit: () => void
onCancel: () => void
}
export function AISearchComposer({
query,
sourceLabel,
rangeLabel,
history,
historyOpen,
loading,
knowledgeSyncing,
inputRef,
onQueryChange,
onHistoryOpenChange,
onRestoreHistory,
onRemoveHistory,
onSubmit,
onCancel
}: AISearchComposerProps): React.ReactElement {
return (
<form
className="relative border-t border-border bg-surface px-[18px] pb-3.5 pt-3 [@media(max-height:820px)]:pb-2.5 [@media(max-height:820px)]:pt-[9px]"
onSubmit={(event) => {
event.preventDefault()
onSubmit()
}}
>
<div className="flex items-center gap-[7px] text-[10px] leading-[15px] text-muted-foreground">
<span></span>
<strong className="font-bold text-primary">{sourceLabel}</strong>
<span className="text-foreground/70">{rangeLabel}</span>
<Popover open={historyOpen} onOpenChange={onHistoryOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="ml-auto h-6 rounded-full bg-background px-2 text-[10px] text-primary shadow-none"
>
{history.length ? ` · ${history.length}` : ''}
</Button>
</PopoverTrigger>
<PopoverContent
className="max-h-[236px] w-[min(420px,calc(100vw-36px))] overflow-y-auto p-2.5"
aria-label="历史提问"
side="top"
align="end"
sideOffset={8}
collisionPadding={16}
>
<div className="flex items-center justify-between gap-2 px-1 pb-2 text-xs font-semibold">
<span></span>
<PopoverClose asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-base text-muted-foreground"
aria-label="关闭历史提问"
>
<span aria-hidden>×</span>
</Button>
</PopoverClose>
</div>
{history.length ? (
history.map((item) => (
<div className="flex items-center gap-1" key={item}>
<Button
variant="ghost"
className="h-8 min-w-0 flex-1 justify-start overflow-hidden px-2 text-xs font-normal text-muted-foreground"
onClick={() => onRestoreHistory(item)}
title={item}
>
<span className="overflow-hidden text-ellipsis whitespace-nowrap">{item}</span>
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-base text-muted-foreground"
onClick={() => onRemoveHistory(item)}
aria-label={`删除历史问题:${item}`}
title="删除这条历史问题"
>
<span aria-hidden>×</span>
</Button>
</div>
))
) : (
<span className="block px-1 pb-1 pt-2 text-[10px] text-muted-foreground">
</span>
)}
</PopoverContent>
</Popover>
</div>
<div className="mt-[7px] flex items-end gap-2">
<Textarea
ref={inputRef}
value={query}
onChange={(event) => onQueryChange(event.target.value)}
onKeyDown={(event) => {
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return
event.preventDefault()
onSubmit()
}}
className="min-h-[56px] min-w-0 flex-1 resize-none bg-background px-2.5 py-2 text-xs leading-[18px]"
placeholder="例如:技术交流群最近讨论了哪些 Windows 性能问题?"
rows={2}
/>
{loading ? (
<Button
type="button"
variant="outline"
className="border-destructive/50 bg-destructive/10 px-3 text-[11px] text-destructive hover:bg-destructive/15 hover:text-destructive"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
onCancel()
}}
>
<span aria-hidden className="text-base leading-3">
×
</span>
</Button>
) : (
<Button
type="submit"
className="px-3 text-[11px]"
disabled={knowledgeSyncing}
title={knowledgeSyncing ? '知识库同步完成后才能开始分析' : undefined}
>
{knowledgeSyncing ? '同步中,暂不可分析' : '开始分析'}
<span aria-hidden className="text-base leading-3">
</span>
</Button>
)}
</div>
<div className="mt-1.5 flex items-center justify-between gap-2 text-[10px] leading-[15px] text-muted-foreground">
<span>Enter · Shift + Enter </span>
<span>AI 使</span>
</div>
</form>
)
}
@@ -0,0 +1,115 @@
import * as React from 'react'
import { Button, EmptyState } from '../ui'
import type { EvidenceItem } from './searchTypes'
import { formatMessageTime, messageIdentity, messageText, senderName } from './searchUtils'
export interface AISearchEvidencePanelProps {
evidence: EvidenceItem[]
collectionCount: number
selectedEvidence: number
evidenceFlash: { index: number; nonce: number }
senderNames: Record<string, string>
hasMoreEvidence: boolean
onFocusEvidence: (index: number) => void
onJumpToEvidence: (index: number) => void
onLoadMoreEvidence: () => void
setEvidenceCardRef: (index: number, node: HTMLElement | null) => void
}
export function AISearchEvidencePanel({
evidence,
collectionCount,
selectedEvidence,
evidenceFlash,
senderNames,
hasMoreEvidence,
onFocusEvidence,
onJumpToEvidence,
onLoadMoreEvidence,
setEvidenceCardRef
}: AISearchEvidencePanelProps): React.ReactElement {
return (
<aside className="min-h-0 min-w-0 overflow-y-auto border-l border-border bg-surface-muted px-3.5 py-4 [@media(max-height:820px)]:pt-3">
<div className="mb-3.5 flex items-start justify-between gap-2.5">
<div className="flex flex-col gap-0.5">
<span className="text-[10px] leading-[15px] text-muted-foreground"></span>
<strong className="text-sm font-bold leading-5 text-foreground"></strong>
</div>
{collectionCount > 0 && (
<span className="whitespace-nowrap rounded-md bg-accent px-1.5 py-0.5 text-[10px] font-bold leading-4 text-accent-foreground">
{evidence.length}/{collectionCount}
</span>
)}
</div>
{evidence.length ? (
evidence.map((item, index) => {
const selected = selectedEvidence === index
const flashing = evidenceFlash.index === index
const evidenceLabel = item.evidenceId || `E${index + 1}`
const evidenceSender = senderName(item.message, item.contact, senderNames)
return (
<article
key={`${messageIdentity(item.message)}-${index}-${flashing ? evidenceFlash.nonce : 0}`}
ref={(node) => setEvidenceCardRef(index, node)}
className={`group relative mb-2 block w-full rounded-md border bg-surface-elevated p-3 text-left text-foreground shadow-surface transition-colors duration-fast ease-tm-standard has-[:hover]:border-primary ${selected ? 'border-primary bg-accent' : 'border-border'} ${flashing ? 'focus-flash ai-search-evidence-focus-flash' : ''}`}
style={{ animationDelay: `${Math.min(index, 7) * 45}ms` }}
>
<button
type="button"
className="absolute inset-0 z-0 cursor-pointer rounded-md border-0 bg-transparent p-0 hover:bg-accent/70 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
aria-label={`选择证据 ${evidenceLabel}${evidenceSender}`}
onClick={() => onFocusEvidence(index)}
>
<span className="sr-only"></span>
</button>
<div className="pointer-events-none relative z-[1]">
<span className="flex justify-between gap-2">
<strong className="overflow-hidden text-ellipsis whitespace-nowrap text-[11px] font-bold">
{evidenceLabel} · {evidenceSender}
</strong>
<time className="shrink-0 text-[9px] text-muted-foreground">
{formatMessageTime(item.message)}
</time>
</span>
<span className="mt-0.5 block text-[10px] leading-[15px] text-primary">
{item.contact.m_nsNickName}
</span>
{item.sourceKind === 'voice' && (
<span className="block text-[11px] font-semibold text-primary"></span>
)}
<span className="mt-[7px] block overflow-hidden text-[11px] leading-[17px] text-muted-foreground [display:-webkit-box] [-webkit-box-orient:vertical] [-webkit-line-clamp:3]">
{messageText(item.message)}
</span>
<Button
variant="link"
size="sm"
className="pointer-events-auto relative z-[2] mt-1.5 h-auto p-0 text-[10px] font-bold"
onClick={() => onJumpToEvidence(index)}
>
</Button>
</div>
</article>
)
})
) : (
<EmptyState
className="min-h-[260px] border-0 bg-transparent px-6 py-8"
icon={<span className="text-[34px] leading-none opacity-60"></span>}
title="等待检索结果"
description="分析完成后,这里会显示支持结论的原始消息。"
/>
)}
{hasMoreEvidence && (
<Button
variant="outline"
size="sm"
className="mb-3 mt-1 w-full text-[11px] font-bold text-primary"
onClick={onLoadMoreEvidence}
>
</Button>
)}
</aside>
)
}
@@ -1,5 +1,4 @@
import React, { useMemo, useRef, useState } from 'react' import React, { useMemo, useRef, useState } from 'react'
import * as Popover from '@radix-ui/react-popover'
import { aiSearchIntentLabel, aiSearchRangeStart } from '../../../../shared/ai-search' import { aiSearchIntentLabel, aiSearchRangeStart } from '../../../../shared/ai-search'
import type { AiSearchProgressEvent, AiSearchTimeRange } from '../../../../shared/ai-search' import type { AiSearchProgressEvent, AiSearchTimeRange } from '../../../../shared/ai-search'
@@ -10,14 +9,7 @@ import type {
SearchStage, SearchStage,
SearchTrace SearchTrace
} from './searchTypes' } from './searchTypes'
import { import { RANGE_LABELS, createSearchRequestContext } from './searchUtils'
RANGE_LABELS,
buildSearchCacheKey,
formatMessageTime,
messageIdentity,
messageText,
senderName
} from './searchUtils'
import { markdownToPlainText, renderMarkdown } from './searchMarkdown' import { markdownToPlainText, renderMarkdown } from './searchMarkdown'
import { import {
contactLabel, contactLabel,
@@ -27,17 +19,17 @@ import {
formatSearchTraceOverview, formatSearchTraceOverview,
knowledgeStateLabel knowledgeStateLabel
} from './searchFormatters' } from './searchFormatters'
import { import { mapPipelineResultToRendererResult } from './searchMappers'
mapEvidenceSenderNames, import { createSearchResultResetState, resolveSearchResultViewTransition } from './searchState'
mapPipelineEvidence,
mapSearchResultToTrace
} from './searchMappers'
import { createSearchResultResetState } from './searchState'
import { useSearchHistory } from './hooks/useSearchHistory' import { useSearchHistory } from './hooks/useSearchHistory'
import { useKnowledgeStatus } from './hooks/useKnowledgeStatus' import { useKnowledgeStatus } from './hooks/useKnowledgeStatus'
import { useExternalProviderConsent } from './hooks/useExternalProviderConsent' import { useExternalProviderConsent } from './hooks/useExternalProviderConsent'
import { EVIDENCE_PAGE_SIZE, useEvidenceCollection } from './hooks/useEvidenceCollection' import { EVIDENCE_PAGE_SIZE, useEvidenceCollection } from './hooks/useEvidenceCollection'
import { useAiSearchRun } from './hooks/useAiSearchRun' import { useAiSearchRun } from './hooks/useAiSearchRun'
import { ensureAiSearchDataConsent } from './services/aiSearchProviderConsent'
import { ExternalProviderConsentDialog } from './ExternalProviderConsentDialog'
import { AISearchComposer } from './AISearchComposer'
import { AISearchEvidencePanel } from './AISearchEvidencePanel'
export function AISearchWorkspace({ export function AISearchWorkspace({
contacts, contacts,
@@ -200,24 +192,6 @@ export function AISearchWorkspace({
const modelLabel = aiModelConfig.configured const modelLabel = aiModelConfig.configured
? `${aiModelConfig.providerName} · ${aiModelConfig.modelName}` ? `${aiModelConfig.providerName} · ${aiModelConfig.modelName}`
: '尚未配置 AI 模型' : '尚未配置 AI 模型'
const ensureAiSearchDataConsent = async (requestId: string): Promise<boolean> => {
const status = await window.api.getAiSearchProviderStatus()
if (!status.configured || !status.requiresConsent) return true
if (!status.providerId || !status.recipient) throw new Error('当前 AI 服务信息不完整')
const confirmed = await requestExternalProviderConsent(
status.providerName || '当前 AI 服务',
status.recipient
)
if (!confirmed) return false
const authorized = await window.api.authorizeAiSearchExternalProvider({
requestId,
providerId: status.providerId,
recipient: status.recipient
})
if (!authorized.success) throw new Error(authorized.error || '无法确认本次数据发送授权')
return true
}
const cancelAnalysis = async (): Promise<void> => { const cancelAnalysis = async (): Promise<void> => {
clearExternalProviderConsent() clearExternalProviderConsent()
const requestId = searchRunRequestId const requestId = searchRunRequestId
@@ -247,7 +221,20 @@ export function AISearchWorkspace({
onNotice('知识库正在同步,请等待同步完成后再开始分析') onNotice('知识库正在同步,请等待同步完成后再开始分析')
return return
} }
const normalizedQuery = query.trim() const {
normalizedQuery,
effectiveRange,
effectiveTimeRangeOverride,
conversationId,
cacheKey
} = createSearchRequestContext({
query,
scope,
range,
timeRangeOverride,
activeContactMd5: activeContact?.md5,
retry
})
if (!normalizedQuery) { if (!normalizedQuery) {
setAnalysisError('先输入一个想了解的问题') setAnalysisError('先输入一个想了解的问题')
setStage('insufficient') setStage('insufficient')
@@ -258,14 +245,6 @@ export function AISearchWorkspace({
setStage('insufficient') setStage('insufficient')
return return
} }
const effectiveRange = retry?.range || range
const effectiveTimeRangeOverride = retry?.timeRangeOverride || timeRangeOverride
const cacheKey = buildSearchCacheKey(
scope,
scope === 'conversation' ? activeContact?.md5 || '' : '',
effectiveRange,
normalizedQuery
)
try { try {
const cached = consumeCacheBypass() ? null : readCachedResult(cacheKey) const cached = consumeCacheBypass() ? null : readCachedResult(cacheKey)
if (cached) { if (cached) {
@@ -281,7 +260,13 @@ export function AISearchWorkspace({
} }
const requestId = createRequestId() const requestId = createRequestId()
try { try {
if (!(await ensureAiSearchDataConsent(requestId))) { if (
!(await ensureAiSearchDataConsent({
requestId,
api: window.api,
requestExternalProviderConsent
}))
) {
onNotice('已取消本次 AI Search,未执行检索,也未向远程 AI 服务发送聊天内容') onNotice('已取消本次 AI Search,未执行检索,也未向远程 AI 服务发送聊天内容')
return return
} }
@@ -300,7 +285,7 @@ export function AISearchWorkspace({
text: normalizedQuery, text: normalizedQuery,
scope, scope,
range: effectiveRange, range: effectiveRange,
conversationId: scope === 'conversation' ? activeContact?.md5 : undefined, conversationId,
timeRangeOverride: effectiveTimeRangeOverride timeRangeOverride: effectiveTimeRangeOverride
}) })
if (outcome.kind === 'stale') return if (outcome.kind === 'stale') return
@@ -323,47 +308,28 @@ export function AISearchWorkspace({
elapsedMs: searchResult.elapsedMs, elapsedMs: searchResult.elapsedMs,
errorStage: searchResult.errorStage errorStage: searchResult.errorStage
}) })
const evidenceItems = mapPipelineEvidence(searchResult.evidence, allContacts) const mappedResult = mapPipelineResultToRendererResult(searchResult, allContacts)
const collectionItems = mapPipelineEvidence( setSearchTrace(mappedResult.searchTrace)
searchResult.evidenceCollection || searchResult.evidence, setEvidenceResult(mappedResult.evidence, mappedResult.evidenceCollection)
allContacts setSenderNames(mappedResult.senderNames)
) setMessageCount(mappedResult.messageCount)
setSearchTrace(mapSearchResultToTrace(searchResult, evidenceItems.length)) const viewTransition = resolveSearchResultViewTransition(searchResult, effectiveRange)
setEvidenceResult(evidenceItems, collectionItems) if (viewTransition.stage !== 'result') {
const nextSenderNames = mapEvidenceSenderNames(evidenceItems) setAnalysisError(viewTransition.analysisError)
setSenderNames(nextSenderNames) setStage(viewTransition.stage)
setMessageCount(searchResult.knowledge.totalMessages)
if (searchResult.status === 'no_evidence') {
setAnalysisError(`${RANGE_LABELS[effectiveRange]}内没有找到与问题相关的聊天消息。`)
setStage('insufficient')
return return
} }
if (searchResult.status === 'retrieval_incomplete') { if (!viewTransition.answer) throw new Error('搜索任务未返回回答')
setAnalysisError(searchResult.error || '当前检索未完整覆盖聊天记录,未生成总结。')
setStage('partial')
return
}
if (searchResult.status === 'failed') {
setAnalysisError(searchResult.error || '本地搜索暂时无法完成')
setStage('insufficient')
return
}
if (searchResult.status === 'ai_failed') {
setAnalysisError(searchResult.error || '证据已找到,但 AI 暂时无法生成回答')
setStage('partial')
return
}
if (!searchResult.answer) throw new Error('搜索任务未返回回答')
setResultQuery(normalizedQuery) setResultQuery(normalizedQuery)
setAnswer(searchResult.answer) setAnswer(viewTransition.answer)
rememberQuery(normalizedQuery) rememberQuery(normalizedQuery)
persistSearchResult({ persistSearchResult({
key: cacheKey, key: cacheKey,
answer: searchResult.answer, answer: viewTransition.answer,
evidence: evidenceItems, evidence: mappedResult.evidence,
evidenceCollection: collectionItems, evidenceCollection: mappedResult.evidenceCollection,
senderNames: nextSenderNames, senderNames: mappedResult.senderNames,
messageCount: searchResult.knowledge.totalMessages messageCount: mappedResult.messageCount
}) })
setStage('result') setStage('result')
} catch (error) { } catch (error) {
@@ -1038,212 +1004,41 @@ export function AISearchWorkspace({
{stage === 'partial' && renderPartial()} {stage === 'partial' && renderPartial()}
{stage === 'insufficient' && renderInsufficient()} {stage === 'insufficient' && renderInsufficient()}
</div> </div>
<form className="ai-search-composer" onSubmit={(event) => void runAnalysis(event)}> <AISearchComposer
<div className="ai-search-composer-meta"> query={query}
<span></span> sourceLabel={sourceLabel}
<strong>{sourceLabel}</strong> rangeLabel={RANGE_LABELS[range]}
<em>{RANGE_LABELS[range]}</em> history={history}
<Popover.Root open={historyOpen} onOpenChange={setHistoryOpen}> historyOpen={historyOpen}
<Popover.Trigger asChild> loading={stage === 'loading'}
<button type="button" className="ai-search-history-trigger"> knowledgeSyncing={knowledgeSyncing}
{history.length ? ` · ${history.length}` : ''} inputRef={composerRef}
</button> onQueryChange={setQuery}
</Popover.Trigger> onHistoryOpenChange={setHistoryOpen}
<Popover.Portal> onRestoreHistory={restoreHistoryQuery}
<Popover.Content onRemoveHistory={removeHistoryQuery}
className="ai-search-history-popover" onSubmit={() => void runAnalysis()}
aria-label="历史提问" onCancel={() => void cancelAnalysis()}
side="top"
align="end"
sideOffset={8}
collisionPadding={16}
>
<div className="ai-search-history-popover-heading">
<strong></strong>
<Popover.Close asChild>
<button type="button" aria-label="关闭历史提问">
×
</button>
</Popover.Close>
</div>
{history.length ? (
history.map((item) => (
<div className="ai-search-history-popover-item" key={item}>
<button
type="button"
onClick={() => restoreHistoryQuery(item)}
title={item}
>
{item}
</button>
<button
type="button"
onClick={() => removeHistoryQuery(item)}
aria-label={`删除历史问题:${item}`}
title="删除这条历史问题"
>
×
</button>
</div>
))
) : (
<span className="ai-search-history-empty"></span>
)}
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</div>
<div className="ai-search-composer-row">
<textarea
ref={composerRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) {
return
}
event.preventDefault()
void runAnalysis()
}}
placeholder="例如:技术交流群最近讨论了哪些 Windows 性能问题?"
rows={2}
/> />
{stage === 'loading' ? (
<button
type="button"
className="cancel"
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void cancelAnalysis()
}}
>
<span>×</span>
</button>
) : (
<button
type="submit"
className="primary"
disabled={knowledgeSyncing}
title={knowledgeSyncing ? '知识库同步完成后才能开始分析' : undefined}
>
{knowledgeSyncing ? '同步中,暂不可分析' : '开始分析'}
<span></span>
</button>
)}
</div>
<div className="ai-search-composer-foot">
<span>Enter · Shift + Enter </span>
<span>AI 使</span>
</div>
</form>
</main> </main>
<aside className="ai-search-evidence-panel"> <AISearchEvidencePanel
<div className="ai-search-panel-heading"> evidence={visibleEvidence}
<div> collectionCount={evidenceCollection.length}
<span></span> selectedEvidence={selectedEvidence}
<strong></strong> evidenceFlash={evidenceFlash}
senderNames={senderNames}
hasMoreEvidence={hasMoreEvidence}
onFocusEvidence={focusEvidence}
onJumpToEvidence={jumpToEvidence}
onLoadMoreEvidence={loadMoreEvidence}
setEvidenceCardRef={setEvidenceCardRef}
/>
</div> </div>
{evidenceCollection.length > 0 && ( <ExternalProviderConsentDialog
<span className="ai-search-count-badge"> consent={externalProviderConsent}
{visibleEvidence.length}/{evidenceCollection.length} onCancel={() => settleExternalProviderConsent(false)}
</span> onConfirm={() => settleExternalProviderConsent(true)}
)} />
</div>
{visibleEvidence.length ? (
visibleEvidence.map((item, index) => (
<article
key={`${messageIdentity(item.message)}-${index}-${evidenceFlash.index === index ? evidenceFlash.nonce : 0}`}
ref={(node) => {
setEvidenceCardRef(index, node)
}}
className={`ai-search-evidence-card ${selectedEvidence === index ? 'active' : ''} ${evidenceFlash.index === index ? 'focus-flash' : ''}`}
style={{ animationDelay: `${Math.min(index, 7) * 45}ms` }}
onClick={() => {
focusEvidence(index)
}}
>
<span className="ai-search-evidence-card-top">
<strong>
{item.evidenceId || `E${index + 1}`} ·{' '}
{senderName(item.message, item.contact, senderNames)}
</strong>
<time>{formatMessageTime(item.message)}</time>
</span>
<span className="ai-search-evidence-conversation">{item.contact.m_nsNickName}</span>
{item.sourceKind === 'voice' && (
<span className="ai-search-evidence-source-kind"></span>
)}
<span className="ai-search-evidence-text">{messageText(item.message)}</span>
<button
type="button"
className="ai-search-evidence-link"
onClick={(event) => {
event.stopPropagation()
jumpToEvidence(index)
}}
>
</button>
</article>
))
) : (
<div className="ai-search-evidence-empty">
<div></div>
<strong></strong>
<span></span>
</div>
)}
{hasMoreEvidence && (
<button
type="button"
className="ai-search-evidence-load-more"
onClick={loadMoreEvidence}
>
</button>
)}
</aside>
</div>
{externalProviderConsent && (
<div
className="ai-search-consent-backdrop"
role="presentation"
onMouseDown={() => settleExternalProviderConsent(false)}
>
<section
className="ai-search-consent-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="ai-search-consent-title"
onMouseDown={(event) => event.stopPropagation()}
>
<span className="ai-search-kicker">AI SEARCH</span>
<h2 id="ai-search-consent-title"></h2>
<p>
<strong>{externalProviderConsent.providerName}</strong>
{externalProviderConsent.recipient}
8 Evidence
</p>
<p className="ai-search-consent-note">
/ ID
</p>
<div className="ai-search-consent-actions">
<button type="button" onClick={() => settleExternalProviderConsent(false)}>
</button>
<button
type="button"
className="primary"
onClick={() => settleExternalProviderConsent(true)}
>
</button>
</div>
</section>
</div>
)}
</div> </div>
) )
} }
@@ -0,0 +1,73 @@
import * as React from 'react'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '../ui'
export interface ExternalProviderConsent {
providerName: string
recipient: string
}
export interface ExternalProviderConsentDialogProps {
consent: ExternalProviderConsent | null
onConfirm: () => void
onCancel: () => void
}
export function ExternalProviderConsentDialog({
consent,
onConfirm,
onCancel
}: ExternalProviderConsentDialogProps): React.ReactElement {
const open = Boolean(consent)
const restoreFocusRef = React.useRef<HTMLElement | null>(null)
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen && open) onCancel()
}}
>
<DialogContent
className="max-w-[460px] gap-0 p-5"
onOpenAutoFocus={() => {
restoreFocusRef.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null
}}
onCloseAutoFocus={(event) => {
event.preventDefault()
restoreFocusRef.current?.focus()
restoreFocusRef.current = null
}}
>
<DialogHeader className="gap-0">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
AI SEARCH
</span>
<DialogTitle className="mt-1.5 text-lg"></DialogTitle>
<DialogDescription className="mt-3 text-[13px] leading-[21px]">
<strong className="font-semibold text-foreground">{consent?.providerName}</strong>{' '}
{consent?.recipient} 8
Evidence
</DialogDescription>
</DialogHeader>
<p className="mt-2.5 rounded-md bg-primary/10 p-2.5 text-[13px] leading-[21px] text-foreground">
/ ID
</p>
<DialogFooter className="mt-[18px] gap-2">
<Button variant="outline" onClick={onCancel}>
</Button>
<Button onClick={onConfirm}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -27,6 +27,9 @@ export function useExternalProviderConsent(): {
recipient: string recipient: string
): Promise<boolean> => ): Promise<boolean> =>
new Promise((resolve) => { new Promise((resolve) => {
const previousResolve = externalConsentResolverRef.current
externalConsentResolverRef.current = null
previousResolve?.(false)
externalConsentResolverRef.current = resolve externalConsentResolverRef.current = resolve
setExternalProviderConsent({ providerName, recipient }) setExternalProviderConsent({ providerName, recipient })
}) })
@@ -70,6 +70,33 @@ export const mapSearchResultToTrace = (
voiceCoverage: result.knowledge.voiceCoverage voiceCoverage: result.knowledge.voiceCoverage
}) })
export interface PipelineRendererResult {
evidence: EvidenceItem[]
evidenceCollection: EvidenceItem[]
searchTrace: SearchTrace
senderNames: Record<string, string>
messageCount: number
}
export const mapPipelineResultToRendererResult = (
result: AiSearchPipelineResult,
contacts: Contact[]
): PipelineRendererResult => {
const evidence = mapPipelineEvidence(result.evidence, contacts)
const evidenceCollection = mapPipelineEvidence(
result.evidenceCollection || result.evidence,
contacts
)
return {
evidence,
evidenceCollection,
searchTrace: mapSearchResultToTrace(result, evidence.length),
senderNames: mapEvidenceSenderNames(evidence),
messageCount: result.knowledge.totalMessages
}
}
export const mapCacheRecordToResult = ( export const mapCacheRecordToResult = (
cached: AISearchCacheRecord, cached: AISearchCacheRecord,
queryValue: string, queryValue: string,
@@ -1,5 +1,12 @@
import type { AiSearchAgentRun } from '../../../../shared/ai-search' import type { AiSearchAgentRun, AiSearchPipelineResult } from '../../../../shared/ai-search'
import type { EvidenceItem, SearchProgressByStage, SearchTrace } from './searchTypes' import { RANGE_LABELS } from './searchUtils'
import type {
EvidenceItem,
SearchProgressByStage,
SearchRange,
SearchStage,
SearchTrace
} from './searchTypes'
export interface SearchResultResetState { export interface SearchResultResetState {
analysisError: string analysisError: string
@@ -28,3 +35,50 @@ export const createSearchResultResetState = (): SearchResultResetState => ({
agentTrace: [], agentTrace: [],
searchDetailsOpen: false searchDetailsOpen: false
}) })
export type SearchResultViewTransition =
| {
stage: Extract<SearchStage, 'partial' | 'insufficient'>
analysisError: string
}
| {
stage: Extract<SearchStage, 'result'>
analysisError: ''
answer?: string
}
export const resolveSearchResultViewTransition = (
result: AiSearchPipelineResult,
range: SearchRange
): SearchResultViewTransition => {
if (result.status === 'no_evidence') {
return {
stage: 'insufficient',
analysisError: `${RANGE_LABELS[range]}内没有找到与问题相关的聊天消息。`
}
}
if (result.status === 'retrieval_incomplete') {
return {
stage: 'partial',
analysisError: result.error || '当前检索未完整覆盖聊天记录,未生成总结。'
}
}
if (result.status === 'failed') {
return {
stage: 'insufficient',
analysisError: result.error || '本地搜索暂时无法完成'
}
}
if (result.status === 'ai_failed') {
return {
stage: 'partial',
analysisError: result.error || '证据已找到,但 AI 暂时无法生成回答'
}
}
return {
stage: 'result',
analysisError: '',
answer: result.answer
}
}
@@ -1,5 +1,14 @@
import type { Contact, Message } from '../../../../shared/types' import type { Contact, Message } from '../../../../shared/types'
import type { AISearchCacheRecord, EvidenceItem, GroupMemberName, SearchIntent, SearchQueryPlan, SearchRange, SearchScope } from './searchTypes' import type { AiSearchTimeRange } from '../../../../shared/ai-search'
import type {
AISearchCacheRecord,
EvidenceItem,
GroupMemberName,
SearchIntent,
SearchQueryPlan,
SearchRange,
SearchScope
} from './searchTypes'
export const RANGE_LABELS: Record<SearchRange, string> = { export const RANGE_LABELS: Record<SearchRange, string> = {
today: '今天', today: '今天',
@@ -77,7 +86,8 @@ const SEARCH_STOP_WORDS = new Set([
export const messageText = (message: Message): string => export const messageText = (message: Message): string =>
String(message.content || '').trim() || `[${message.type || '消息'}]` String(message.content || '').trim() || `[${message.type || '消息'}]`
export const normalizeSearchText = (value: string): string => value.toLowerCase().replace(/\s+/g, '') export const normalizeSearchText = (value: string): string =>
value.toLowerCase().replace(/\s+/g, '')
export const includesSearchAlias = (query: string, alias: string): boolean => { export const includesSearchAlias = (query: string, alias: string): boolean => {
const normalizedAlias = normalizeSearchText(alias.trim()) const normalizedAlias = normalizeSearchText(alias.trim())
@@ -215,7 +225,7 @@ export const formatMessageDate = (dateKey: string): string => {
return `${month}/${day}` return `${month}/${day}`
} }
export const selectEvenly = <T,>(items: T[], count: number): T[] => { export const selectEvenly = <T>(items: T[], count: number): T[] => {
if (count >= items.length) return items if (count >= items.length) return items
if (count <= 0) return [] if (count <= 0) return []
if (count === 1) return [items[items.length - 1]] if (count === 1) return [items[items.length - 1]]
@@ -262,7 +272,11 @@ const looksLikeUserId = (value: string): boolean =>
export const formatMemberName = (member: GroupMemberName): string => export const formatMemberName = (member: GroupMemberName): string =>
member.groupNickname || member.wechatNickname || member.nickname || member.remark || member.wxid member.groupNickname || member.wechatNickname || member.nickname || member.remark || member.wxid
export const senderName = (message: Message, contact: Contact, names: Record<string, string>): string => { export const senderName = (
message: Message,
contact: Contact,
names: Record<string, string>
): string => {
const identifiers = [message.senderId, message.from, message.name].filter( const identifiers = [message.senderId, message.from, message.name].filter(
(value): value is string => Boolean(value?.trim()) (value): value is string => Boolean(value?.trim())
) )
@@ -304,6 +318,48 @@ export const buildSearchCacheKey = (
query: string query: string
): string => JSON.stringify([scope, contactMd5, range, query.trim().toLowerCase()]) ): string => JSON.stringify([scope, contactMd5, range, query.trim().toLowerCase()])
export type CreateSearchRequestContextInput = {
query: string
scope: SearchScope
range: SearchRange
timeRangeOverride?: AiSearchTimeRange
activeContactMd5?: string
retry?: {
range: SearchRange
timeRangeOverride?: AiSearchTimeRange
}
}
export type SearchRequestContext = {
normalizedQuery: string
effectiveRange: SearchRange
effectiveTimeRangeOverride?: AiSearchTimeRange
conversationId?: string
cacheKey: string
}
export const createSearchRequestContext = ({
query,
scope,
range,
timeRangeOverride,
activeContactMd5,
retry
}: CreateSearchRequestContextInput): SearchRequestContext => {
const normalizedQuery = query.trim()
const effectiveRange = retry?.range || range
const effectiveTimeRangeOverride = retry?.timeRangeOverride || timeRangeOverride
const conversationId = scope === 'conversation' ? activeContactMd5 : undefined
return {
normalizedQuery,
effectiveRange,
effectiveTimeRangeOverride,
conversationId,
cacheKey: buildSearchCacheKey(scope, conversationId || '', effectiveRange, normalizedQuery)
}
}
export const parseSearchCacheKey = ( export const parseSearchCacheKey = (
key: string key: string
): { scope: SearchScope; contactMd5: string; range: SearchRange; query: string } | null => { ): { scope: SearchScope; contactMd5: string; range: SearchRange; query: string } | null => {
@@ -0,0 +1,50 @@
import type {
AiSearchExternalAuthorizationResult,
AiSearchProviderStatus
} from '../../../../../shared/ai-provider'
export type AiSearchProviderConsentApi = {
getAiSearchProviderStatus: () => Promise<AiSearchProviderStatus>
authorizeAiSearchExternalProvider: (request: {
requestId: string
providerId: string
recipient: string
}) => Promise<AiSearchExternalAuthorizationResult>
}
export type RequestExternalProviderConsent = (
providerName: string,
recipient: string
) => Promise<boolean>
/**
* Renderer adapter for the existing AI Search provider-consent IPC flow.
* Provider identity and authorization policy remain owned by main/services.
*/
export async function ensureAiSearchDataConsent({
requestId,
api,
requestExternalProviderConsent
}: {
requestId: string
api: AiSearchProviderConsentApi
requestExternalProviderConsent: RequestExternalProviderConsent
}): Promise<boolean> {
const status = await api.getAiSearchProviderStatus()
if (!status.configured || !status.requiresConsent) return true
if (!status.providerId || !status.recipient) throw new Error('当前 AI 服务信息不完整')
const confirmed = await requestExternalProviderConsent(
status.providerName || '当前 AI 服务',
status.recipient
)
if (!confirmed) return false
const authorized = await api.authorizeAiSearchExternalProvider({
requestId,
providerId: status.providerId,
recipient: status.recipient
})
if (!authorized.success) throw new Error(authorized.error || '无法确认本次数据发送授权')
return true
}
+1 -1
View File
@@ -3,7 +3,7 @@ import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '../../lib/cn' import { cn } from '../../lib/cn'
const buttonVariants = cva( const buttonVariants = cva(
'inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors duration-fast ease-tm-standard focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', 'inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md border-0 bg-transparent text-sm font-medium transition-colors duration-fast ease-tm-standard focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
{ {
variants: { variants: {
variant: { variant: {
+20 -8
View File
@@ -2,21 +2,33 @@ import * as React from 'react'
import * as ProgressPrimitive from '@radix-ui/react-progress' import * as ProgressPrimitive from '@radix-ui/react-progress'
import { cn } from '../../lib/cn' import { cn } from '../../lib/cn'
const Progress = React.forwardRef< interface ProgressProps extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
React.ElementRef<typeof ProgressPrimitive.Root>, indeterminate?: boolean
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> }
>(({ className, value, ...props }, ref) => (
const Progress = React.forwardRef<React.ElementRef<typeof ProgressPrimitive.Root>, ProgressProps>(
({ className, value, indeterminate = false, ...props }, ref) => (
<ProgressPrimitive.Root <ProgressPrimitive.Root
ref={ref} ref={ref}
className={cn('relative h-2 w-full overflow-hidden rounded-full bg-muted', className)} value={indeterminate ? null : value}
className={cn(
'relative h-2 w-full overflow-hidden rounded-full bg-muted',
indeterminate && 'indeterminate',
className
)}
{...props} {...props}
> >
<ProgressPrimitive.Indicator <ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-transform duration-normal ease-tm-standard" data-slot="progress-indicator"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }} className={cn(
'h-full w-full flex-1 bg-primary transition-transform duration-normal ease-tm-standard',
indeterminate && 'w-1/3 animate-pulse'
)}
style={{ transform: indeterminate ? undefined : `translateX(-${100 - (value || 0)}%)` }}
/> />
</ProgressPrimitive.Root> </ProgressPrimitive.Root>
)) )
)
Progress.displayName = ProgressPrimitive.Root.displayName Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress } export { Progress }
+1 -1
View File
@@ -26,7 +26,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
'inline-flex items-center justify-center rounded-sm px-3 py-1.5 text-xs font-medium 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-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',
className className
)} )}
{...props} {...props}
@@ -1,4 +1,12 @@
import { useMemo, useState, type ReactElement } from 'react' import { useMemo, useRef, useState, type ReactElement } from 'react'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '../../../components/ui'
export function SkillPreviewDialog({ export function SkillPreviewDialog({
content, content,
@@ -11,48 +19,71 @@ export function SkillPreviewDialog({
}): ReactElement { }): ReactElement {
const [raw, setRaw] = useState(false) const [raw, setRaw] = useState(false)
const lines = useMemo(() => content.split('\n'), [content]) const lines = useMemo(() => content.split('\n'), [content])
const restoreFocusRef = useRef<HTMLElement | null>(null)
const closingRef = useRef(false)
const closeDialog = (): void => {
if (closingRef.current) return
closingRef.current = true
const restoreFocus = restoreFocusRef.current
restoreFocusRef.current = null
onClose()
queueMicrotask(() => restoreFocus?.focus())
}
return ( return (
<div <Dialog open onOpenChange={(open) => !open && closeDialog()}>
className="api-markdown-overlay" <DialogContent
role="dialog" className="h-[min(720px,calc(100vh-2rem))] max-w-[820px] grid-rows-[auto_minmax(0,1fr)] gap-0 overflow-hidden p-0"
aria-modal="true" onOpenAutoFocus={() => {
aria-label="TraceMemo Reader Skill 预览" restoreFocusRef.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null
}}
> >
<div> <DialogHeader className="flex-row items-center justify-between space-y-0 border-b border-border px-6 py-4 pr-14">
<header> <div className="flex min-w-0 items-baseline gap-2">
<div> <DialogTitle className="truncate tracking-normal">
<strong>TraceMemo Reader</strong> TraceMemo Reader Skill
<span>{version || 'v1.0'}</span> </DialogTitle>
<span className="shrink-0 text-xs text-muted-foreground">{version || 'v1.0'}</span>
</div> </div>
<div> <DialogDescription className="sr-only">
<button type="button" onClick={() => setRaw((current) => !current)}> TraceMemo Reader Skill
</DialogDescription>
<Button variant="outline" size="sm" onClick={() => setRaw((current) => !current)}>
{raw ? '渲染预览' : '原始文本'} {raw ? '渲染预览' : '原始文本'}
</button> </Button>
<button type="button" onClick={onClose}> </DialogHeader>
</button>
</div>
</header>
{raw ? ( {raw ? (
<pre>{content}</pre> <pre className="m-0 min-h-0 overflow-auto whitespace-pre-wrap break-words bg-muted/40 px-6 py-5 font-mono text-xs leading-5 text-foreground">
{content}
</pre>
) : ( ) : (
<article className="skill-markdown-preview"> <article className="min-h-0 overflow-auto px-6 py-5 text-sm leading-6 text-foreground [overflow-wrap:anywhere]">
{lines.map((line, index) => {lines.map((line, index) =>
line.startsWith('# ') ? ( line.startsWith('# ') ? (
<h1 key={index}>{line.slice(2)}</h1> <h1 className="mb-4 text-xl font-semibold tracking-normal" key={index}>
{line.slice(2)}
</h1>
) : line.startsWith('## ') ? ( ) : line.startsWith('## ') ? (
<h2 key={index}>{line.slice(3)}</h2> <h2 className="mb-2 mt-5 text-base font-semibold tracking-normal" key={index}>
{line.slice(3)}
</h2>
) : line.startsWith('- ') ? ( ) : line.startsWith('- ') ? (
<li key={index}>{line.slice(2)}</li> <li className="ml-5 list-disc" key={index}>
{line.slice(2)}
</li>
) : line.startsWith('```') ? null : line ? ( ) : line.startsWith('```') ? null : line ? (
<p key={index}>{line}</p> <p className="my-1.5" key={index}>
{line}
</p>
) : ( ) : (
<br key={index} /> <br key={index} />
) )
)} )}
</article> </article>
)} )}
</div> </DialogContent>
</div> </Dialog>
) )
} }
@@ -5,6 +5,14 @@ import type {
TextToSpeechVoice TextToSpeechVoice
} from '../../../../../shared/text-to-speech' } from '../../../../../shared/text-to-speech'
import type { PersonalWechatRuntimeStatus } from '../../../../../shared/personal-wechat-runtime' import type { PersonalWechatRuntimeStatus } from '../../../../../shared/personal-wechat-runtime'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '../../../components/ui'
import { isMac, isWindows } from '../../../utils/runtime-environment' import { isMac, isWindows } from '../../../utils/runtime-environment'
const VOICE_PAGE_SIZE = 24 const VOICE_PAGE_SIZE = 24
@@ -198,6 +206,7 @@ export function TextToSpeechPage({
const [showWechatVersions, setShowWechatVersions] = useState(false) const [showWechatVersions, setShowWechatVersions] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const audioRef = useRef<HTMLAudioElement | null>(null) const audioRef = useRef<HTMLAudioElement | null>(null)
const wechatVersionsTriggerRef = useRef<HTMLButtonElement | null>(null)
const personalWechatRuntimeSupported = isMac && Boolean(runtimeStatus?.supported) const personalWechatRuntimeSupported = isMac && Boolean(runtimeStatus?.supported)
const loadVoices = useCallback( const loadVoices = useCallback(
@@ -488,6 +497,7 @@ export function TextToSpeechPage({
: '当前系统安全存储不可用,请从应用环境中提供 API Key' : '当前系统安全存储不可用,请从应用环境中提供 API Key'
return ( return (
<Dialog open={showWechatVersions} onOpenChange={setShowWechatVersions}>
<div className="settings-page text-to-speech-page"> <div className="settings-page text-to-speech-page">
<header className="settings-page-header"> <header className="settings-page-header">
<div> <div>
@@ -552,13 +562,17 @@ export function TextToSpeechPage({
<li> PID </li> <li> PID </li>
</ul> </ul>
{personalWechatRuntimeSupported ? ( {personalWechatRuntimeSupported ? (
<button <Button
type="button" className="mt-2"
className="tts-supported-versions-button" variant="outline"
onClick={() => setShowWechatVersions(true)} size="sm"
onClick={(event) => {
wechatVersionsTriggerRef.current = event.currentTarget
setShowWechatVersions(true)
}}
> >
</button> </Button>
) : null} ) : null}
</div> </div>
</div> </div>
@@ -634,7 +648,13 @@ export function TextToSpeechPage({
</button> </button>
{personalWechatRuntimeSupported ? ( {personalWechatRuntimeSupported ? (
<button type="button" onClick={() => setShowWechatVersions(true)}> <button
type="button"
onClick={(event) => {
wechatVersionsTriggerRef.current = event.currentTarget
setShowWechatVersions(true)
}}
>
</button> </button>
) : null} ) : null}
@@ -883,49 +903,40 @@ export function TextToSpeechPage({
{error ? <p className="tts-settings-error">{error}</p> : null} {error ? <p className="tts-settings-error">{error}</p> : null}
</div> </div>
</div> </div>
{showWechatVersions ? (
<div
className="tts-version-modal-backdrop"
role="presentation"
onMouseDown={() => setShowWechatVersions(false)}
>
<section
className="tts-version-modal"
role="dialog"
aria-modal="true"
aria-labelledby="tts-version-modal-title"
onMouseDown={(event) => event.stopPropagation()}
>
<header>
<div>
<h2 id="tts-version-modal-title"></h2>
</div> </div>
<div className="tts-version-modal-actions"> <DialogContent
<a href={WECHAT_VERSION_DOWNLOAD_URL} target="_blank" rel="noreferrer"> className="max-h-[calc(100vh-3rem)] max-w-[620px] overflow-y-auto"
onCloseAutoFocus={(event) => {
const trigger = wechatVersionsTriggerRef.current
if (!trigger) return
event.preventDefault()
trigger.focus()
wechatVersionsTriggerRef.current = null
}}
>
<DialogHeader className="pr-8">
<DialogTitle className="text-lg"></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<a
className="w-fit rounded-md border border-primary/30 bg-primary/10 px-3 py-2 text-xs font-semibold text-primary hover:border-primary"
href={WECHAT_VERSION_DOWNLOAD_URL}
target="_blank"
rel="noreferrer"
>
</a> </a>
<button <div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
type="button"
className="tts-version-modal-close"
onClick={() => setShowWechatVersions(false)}
aria-label="关闭"
>
×
</button>
</div>
</header>
<p className="tts-version-modal-intro"></p>
<div className="tts-version-grid">
{BUNDLED_WECHAT_VERSIONS.map((version) => ( {BUNDLED_WECHAT_VERSIONS.map((version) => (
<span key={version}>{version}</span> <span
className="rounded-md border border-border bg-background px-2 py-2 text-center text-xs text-muted-foreground"
key={version}
>
{version}
</span>
))} ))}
</div> </div>
</section> </DialogContent>
</div> </Dialog>
) : null}
</div>
) )
} }
-67
View File
@@ -717,70 +717,3 @@
color: var(--wxex-text-secondary); color: var(--wxex-text-secondary);
font: 12px/18px var(--wxex-font); font: 12px/18px var(--wxex-font);
} }
.api-markdown-overlay {
position: absolute;
inset: 0;
z-index: 4;
display: flex;
align-items: stretch;
justify-content: center;
padding: 20px;
background: rgba(32, 39, 36, 0.24);
}
.api-markdown-overlay > div {
display: flex;
width: min(820px, 100%);
flex-direction: column;
min-height: 0;
padding: 14px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-lg);
background: #fff;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.16);
}
.api-markdown-overlay header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.api-markdown-overlay header > div {
display: flex;
align-items: center;
gap: 8px;
}
.api-markdown-overlay header span {
color: var(--wxex-text-muted);
font: 12px/18px var(--wxex-font);
}
.api-markdown-overlay 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;
}
.api-markdown-overlay pre,
.skill-markdown-preview {
min-height: 0;
overflow: auto;
margin: 14px 0 0;
white-space: pre-wrap;
color: var(--wxex-text-primary);
font: 12px/19px var(--wxex-font);
}
.skill-markdown-preview h1 {
font-size: 20px;
}
.skill-markdown-preview h2 {
margin-top: 18px;
font-size: 16px;
}
.skill-markdown-preview p {
margin: 6px 0;
}
.skill-markdown-preview li {
margin-left: 18px;
}
-198
View File
@@ -193,32 +193,7 @@
background: #5a60c6; background: #5a60c6;
} }
.personal-wechat-send-backdrop {
position: fixed;
z-index: 90;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
background: rgba(18, 29, 25, 0.42);
backdrop-filter: blur(3px);
-webkit-app-region: no-drag;
}
.personal-wechat-send-dialog { .personal-wechat-send-dialog {
box-sizing: border-box;
width: min(100%, 620px);
max-height: calc(100vh - 48px);
overflow-y: auto;
display: grid;
gap: 16px;
border: 1px solid var(--wxex-border);
border-radius: 16px;
background: var(--wxex-bg-elevated);
box-shadow: 0 24px 70px rgba(18, 31, 26, 0.24);
padding: 22px;
> header,
> footer { > footer {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -226,13 +201,6 @@
gap: 12px; gap: 12px;
} }
h2 {
margin: 2px 0 0;
color: var(--wxex-text-primary);
font-size: 19px;
line-height: 26px;
}
> footer { > footer {
justify-content: flex-end; justify-content: flex-end;
@@ -264,30 +232,6 @@
} }
} }
.personal-wechat-send-kicker {
color: var(--wxex-brand);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
}
.personal-wechat-send-close {
width: 32px;
height: 32px;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--wxex-text-muted);
cursor: pointer;
font-size: 24px;
line-height: 28px;
&:hover {
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
}
.personal-wechat-send-device-note { .personal-wechat-send-device-note {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
@@ -872,49 +816,6 @@
stroke-linejoin: round; stroke-linejoin: round;
} }
.chat-menu {
position: relative;
}
.chat-dropdown-menu {
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 40;
min-width: 150px;
overflow: hidden;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
box-shadow: var(--wxex-shadow-popover);
padding: 4px;
&.right {
right: 0;
left: auto;
}
button {
box-sizing: border-box;
width: 100%;
display: block;
border: 0;
border-radius: var(--wxex-radius-sm);
background: transparent;
color: var(--wxex-text-primary);
cursor: pointer;
font: 13px/18px var(--wxex-font);
padding: 7px 9px;
text-align: left;
white-space: nowrap;
&:hover {
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
}
}
}
.chat-content-search { .chat-content-search {
box-sizing: border-box; box-sizing: border-box;
width: 280px; width: 280px;
@@ -2654,105 +2555,6 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.image-viewer-overlay {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
background: rgba(16, 24, 28, 0.28);
backdrop-filter: blur(1px);
overscroll-behavior: contain;
}
.image-viewer-window {
width: min(1280px, 96vw);
height: min(900px, 92vh);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.14);
border-radius: 8px;
background: #eaf0f1;
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.22);
}
.image-viewer-titlebar {
height: 40px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px 0 16px;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
background: rgba(255, 255, 255, 0.78);
color: #333;
font-size: 14px;
button {
width: 28px;
height: 28px;
border: 0;
border-radius: 50%;
background: transparent;
color: #555;
cursor: pointer;
font-size: 18px;
line-height: 1;
&:hover {
background: rgba(0, 0, 0, 0.08);
}
}
}
.image-viewer-tools {
display: flex;
align-items: center;
gap: 12px;
}
.image-viewer-title {
margin-right: 8px;
font-weight: 500;
}
.image-viewer-zoom {
min-width: 44px;
color: #666;
text-align: center;
}
.image-viewer-divider {
width: 1px;
height: 18px;
background: rgba(0, 0, 0, 0.12);
}
.image-viewer-stage {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 28px;
overflow: hidden;
overscroll-behavior: contain;
cursor: grab;
user-select: none;
img {
max-width: none;
max-height: none;
width: auto;
height: auto;
object-fit: contain;
box-shadow: 0 3px 16px rgba(0, 0, 0, 0.12);
transform-origin: center center;
transition: transform 0.08s ease-out;
user-select: none;
pointer-events: none;
}
}
.database-account-list { .database-account-list {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -258,10 +258,6 @@
overflow: visible; overflow: visible;
} }
.export-config-scroll > .export-section:not(.export-format-top):has(.export-format-grid) {
display: none;
}
.conversation-section-virtual-content { .conversation-section-virtual-content {
position: relative; position: relative;
width: 100%; width: 100%;
File diff suppressed because it is too large Load Diff
@@ -171,65 +171,6 @@
border-top: 1px solid var(--wxex-border); border-top: 1px solid var(--wxex-border);
} }
.report-delete-confirm {
position: fixed;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
background: rgba(32, 39, 36, 0.28);
}
.report-delete-confirm-card {
display: grid;
width: min(360px, calc(100vw - 48px));
gap: 12px;
padding: 18px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-lg);
background: var(--wxex-bg-main);
box-shadow: var(--wxex-shadow-popover);
}
.report-delete-confirm-card h2 {
margin: 0;
color: var(--wxex-text-primary);
font: 700 18px/24px var(--wxex-font);
}
.report-delete-confirm-card p {
margin: 0;
color: var(--wxex-text-secondary);
font: 13px/20px var(--wxex-font);
}
.report-delete-confirm-card > div {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.report-delete-confirm-card button {
min-height: 32px;
padding: 0 14px;
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-delete-confirm-card button.danger {
border-color: #a64242;
background: #a64242;
color: #fff;
}
.report-delete-error {
color: #a64242 !important;
}
.report-viewer { .report-viewer {
display: flex; display: flex;
min-width: 0; min-width: 0;
@@ -274,7 +215,6 @@
.report-viewer-toolbar button, .report-viewer-toolbar button,
.report-zoom-bar button, .report-zoom-bar button,
.report-more-popover button,
.report-settings-section button { .report-settings-section button {
min-height: 32px; min-height: 32px;
padding: 0 12px; padding: 0 12px;
@@ -338,86 +278,6 @@
transform: translateY(0); transform: translateY(0);
} }
.report-more-menu {
position: relative;
}
.report-template-switch-menu {
position: relative;
}
.report-template-switch-popover {
position: absolute;
top: 38px;
right: 0;
z-index: 9;
width: 260px;
padding: 8px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
box-shadow: var(--wxex-shadow-popover);
}
.report-template-switch-popover p {
margin: 0 4px 6px;
color: var(--wxex-text-muted);
font: 12px/18px var(--wxex-font);
}
.report-template-switch-popover button {
display: grid;
width: 100%;
min-height: 42px;
grid-template-columns: 62px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 6px 8px;
border: 0;
border-radius: 6px;
background: transparent;
text-align: left;
}
.report-template-switch-popover button:hover,
.report-template-switch-popover button.active {
background: var(--wxex-ai-soft);
}
.report-template-switch-popover button span,
.report-template-switch-popover button i {
color: var(--wxex-text-muted);
font: 11px/16px var(--wxex-font);
font-style: normal;
}
.report-template-switch-popover button b {
overflow: hidden;
color: var(--wxex-text-primary);
font: 600 13px/18px var(--wxex-font);
text-overflow: ellipsis;
white-space: nowrap;
}
.report-more-popover {
position: absolute;
top: 38px;
right: 0;
z-index: 8;
min-width: 128px;
padding: 6px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
box-shadow: var(--wxex-shadow-popover);
}
.report-more-popover button {
width: 100%;
border: 0;
text-align: left;
}
.report-viewer-status { .report-viewer-status {
flex: 0 0 auto; flex: 0 0 auto;
padding: 8px 22px; padding: 8px 22px;
@@ -1226,198 +1086,3 @@
color: var(--wxex-text-muted); color: var(--wxex-text-muted);
font: 11px/17px var(--wxex-font); font: 11px/17px var(--wxex-font);
} }
.wechat-share-dialog-backdrop {
position: fixed;
inset: 0;
z-index: 10000;
display: grid;
place-items: center;
padding: 24px;
background: rgba(20, 31, 27, 0.46);
backdrop-filter: blur(8px);
}
.wechat-share-dialog {
width: min(540px, calc(100vw - 48px));
max-height: calc(100vh - 48px);
overflow: auto;
border: 1px solid rgba(95, 129, 116, 0.2);
border-radius: 22px;
background: #fff;
box-shadow: 0 28px 90px rgba(14, 32, 25, 0.24);
color: #17241f;
font-family: var(--wxex-font);
}
.wechat-share-dialog,
.wechat-share-dialog * {
box-sizing: border-box;
}
.wechat-share-dialog > header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 24px 26px 18px;
border-bottom: 1px solid #e8eeeb;
}
.wechat-share-dialog h2,
.wechat-share-dialog h3,
.wechat-share-dialog p {
margin: 0;
}
.wechat-share-dialog header h2 {
color: #17241f;
font-size: 20px;
}
.wechat-share-dialog header p {
margin-top: 6px;
color: #718078;
font-size: 13px;
}
.wechat-share-dialog-close {
width: 32px;
height: 32px;
border: 0;
border-radius: 50%;
background: #f1f5f3;
color: #5f6d67;
font-size: 22px;
line-height: 1;
}
.wechat-share-form {
display: grid;
gap: 18px;
padding: 24px 26px 26px;
}
.wechat-share-form label {
display: grid;
gap: 8px;
}
.wechat-share-form label > span {
color: #33463e;
font-size: 13px;
font-weight: 650;
}
.wechat-share-form input,
.wechat-share-form textarea {
min-width: 0;
max-width: 100%;
width: 100%;
border: 1px solid #cfdbd6;
border-radius: 11px;
background: #fbfdfc;
padding: 11px 13px;
color: #1f2c27;
font: inherit;
outline: none;
}
.wechat-share-form input:focus,
.wechat-share-form textarea:focus {
border-color: #32a978;
box-shadow: 0 0 0 3px rgba(50, 169, 120, 0.12);
}
.wechat-share-service-config {
min-width: 0;
display: grid;
gap: 14px;
padding: 16px;
border: 1px solid #d6e8df;
border-radius: 14px;
background: #f2faf6;
}
.wechat-share-service-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-width: 0;
padding: 13px 15px;
border: 1px solid #dce6e1;
border-radius: 12px;
background: #f7faf8;
}
.wechat-share-service-summary > div {
display: grid;
min-width: 0;
gap: 3px;
}
.wechat-share-service-summary span {
color: #728078;
font-size: 11px;
}
.wechat-share-service-summary b {
overflow: hidden;
color: #2c4138;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.wechat-share-service-summary button {
flex: 0 0 auto;
min-height: 30px;
padding: 0 11px;
}
.wechat-share-service-config h3 {
color: #23523f;
font-size: 14px;
}
.wechat-share-service-config p,
.wechat-share-privacy {
color: #68776f;
font-size: 12px;
line-height: 1.6;
}
.wechat-share-privacy {
padding: 12px 14px;
border-radius: 10px;
background: #f5f7f6;
}
.wechat-share-form > footer,
.wechat-share-success > div {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.wechat-share-form button,
.wechat-share-success button {
min-height: 38px;
padding: 0 17px;
border: 1px solid #ced9d4;
border-radius: 10px;
background: #fff;
color: #34463f;
}
.wechat-share-form button.primary,
.wechat-share-success button.primary {
border-color: #15945e;
background: #15945e;
color: #fff;
}
.wechat-share-form button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.wechat-share-success {
display: grid;
justify-items: center;
gap: 11px;
padding: 28px;
text-align: center;
}
.wechat-share-success > img {
width: 260px;
max-width: 80%;
border: 10px solid #fff;
border-radius: 14px;
box-shadow: 0 10px 35px rgba(27, 59, 46, 0.12);
}
.wechat-share-success h3 {
color: #203029;
font-size: 18px;
}
.wechat-share-success p,
.wechat-share-success small {
color: #6c7b74;
line-height: 1.6;
}
.wechat-share-success > div {
margin-top: 12px;
}
+5 -479
View File
@@ -88,7 +88,6 @@
.ai-search-model-status button, .ai-search-model-status button,
.ai-search-result-actions button, .ai-search-result-actions button,
.ai-search-composer button,
.ai-search-insufficient button { .ai-search-insufficient button {
border: 1px solid var(--wxex-border); border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm); border-radius: var(--wxex-radius-sm);
@@ -109,8 +108,7 @@
); );
} }
.ai-search-scope-panel, .ai-search-scope-panel {
.ai-search-evidence-panel {
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
@@ -125,38 +123,6 @@
border-right: 1px solid var(--wxex-border); border-right: 1px solid var(--wxex-border);
} }
.ai-search-evidence-panel {
padding: 16px 14px;
border-left: 1px solid var(--wxex-border);
}
.ai-search-panel-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin-bottom: 14px;
div {
display: flex;
flex-direction: column;
gap: 2px;
}
span:first-child {
color: var(--wxex-text-muted);
font-size: 10px;
line-height: 15px;
}
strong {
color: var(--wxex-text-primary);
font-size: 14px;
font-weight: 700;
line-height: 20px;
}
}
.ai-search-header-actions { .ai-search-header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -403,8 +369,7 @@
font-weight: 700; font-weight: 700;
} }
.ai-search-local-badge, .ai-search-local-badge {
.ai-search-count-badge {
border-radius: var(--wxex-radius-sm); border-radius: var(--wxex-radius-sm);
background: var(--wxex-ai-soft); background: var(--wxex-ai-soft);
color: var(--wxex-ai); color: var(--wxex-ai);
@@ -629,61 +594,6 @@
} }
} }
.ai-search-history {
margin-top: 18px;
button {
min-width: 0;
padding: 5px 0;
overflow: hidden;
border: 0;
border-radius: var(--wxex-radius-sm);
background: transparent;
color: var(--wxex-text-secondary);
cursor: pointer;
font: inherit;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 10px;
&:hover {
color: var(--wxex-brand);
}
}
}
.ai-search-history-item {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
> button:first-child {
flex: 1;
}
}
.ai-search-history-delete {
width: 20px;
height: 20px;
flex: 0 0 auto;
padding: 0 !important;
border: 0;
border-radius: var(--wxex-radius-sm);
background: transparent;
color: var(--wxex-text-muted);
cursor: pointer;
font-size: 16px !important;
line-height: 18px;
text-align: center !important;
&:hover {
background: var(--wxex-brand-soft);
color: var(--wxex-danger, #b34d46) !important;
}
}
.ai-search-main { .ai-search-main {
display: flex; display: flex;
min-width: 0; min-width: 0;
@@ -1274,226 +1184,7 @@
font-weight: 700; font-weight: 700;
} }
.ai-search-composer { .ai-search-evidence-focus-flash {
position: relative;
padding: 12px 18px 14px;
border-top: 1px solid var(--wxex-border);
background: var(--wxex-bg-elevated);
}
.ai-search-composer-meta,
.ai-search-composer-foot {
display: flex;
align-items: center;
gap: 7px;
color: var(--wxex-text-muted);
font-size: 10px;
line-height: 15px;
}
.ai-search-composer-meta {
strong {
color: var(--wxex-brand);
font-weight: 700;
}
em {
font-style: normal;
color: var(--wxex-text-secondary);
}
}
.ai-search-history-trigger {
margin-left: auto;
padding: 3px 7px !important;
border: 1px solid var(--wxex-border) !important;
border-radius: 999px !important;
background: var(--wxex-bg-main) !important;
color: var(--wxex-brand) !important;
cursor: pointer;
font-size: 10px !important;
}
.ai-search-history-popover {
z-index: 30;
width: min(420px, calc(100% - 36px));
max-height: 236px;
overflow: auto;
padding: 9px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
box-shadow: 0 10px 28px rgba(31, 52, 45, 0.16);
transform-origin: var(--radix-popover-content-transform-origin);
animation: ai-search-history-popover-in 150ms ease-out both;
}
@keyframes ai-search-history-popover-in {
from {
opacity: 0;
transform: translateY(5px) scale(0.985);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.ai-search-history-popover-heading,
.ai-search-history-popover-item {
display: flex;
align-items: center;
gap: 7px;
}
.ai-search-history-popover-heading {
justify-content: space-between;
padding: 2px 3px 7px;
color: var(--wxex-text-primary);
font-size: 11px;
}
.ai-search-history-popover-heading button,
.ai-search-history-popover-item > button:last-child {
width: 22px;
height: 22px;
flex: 0 0 auto;
padding: 0 !important;
border: 0 !important;
background: transparent !important;
color: var(--wxex-text-muted) !important;
cursor: pointer;
font-size: 16px !important;
line-height: 20px;
}
.ai-search-history-popover-item > button:first-child {
flex: 1;
min-width: 0;
padding: 7px 6px !important;
overflow: hidden;
border: 0 !important;
border-radius: var(--wxex-radius-sm) !important;
background: transparent !important;
color: var(--wxex-text-secondary) !important;
cursor: pointer;
font: inherit;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-search-history-popover-item > button:hover {
background: var(--wxex-brand-soft) !important;
color: var(--wxex-brand) !important;
}
.ai-search-history-empty {
display: block;
padding: 8px 4px 3px;
color: var(--wxex-text-muted);
font-size: 10px;
}
.ai-search-composer-row {
display: flex;
align-items: flex-end;
gap: 8px;
margin-top: 7px;
textarea {
width: 100%;
min-width: 0;
resize: none;
padding: 9px 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
outline: 0;
background: var(--wxex-bg-main);
color: var(--wxex-text-primary);
font: inherit;
font-size: 12px;
line-height: 18px;
&:focus {
border-color: var(--wxex-brand);
}
}
button {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 13px;
white-space: nowrap;
font-size: 11px;
span {
font-size: 16px;
line-height: 12px;
}
&.primary {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
color: #fff;
}
&.cancel {
border-color: color-mix(in srgb, var(--wxex-danger) 48%, var(--wxex-border));
background: color-mix(in srgb, var(--wxex-danger) 9%, var(--wxex-bg-elevated));
color: var(--wxex-danger);
cursor: pointer;
}
&.cancel:hover {
background: color-mix(in srgb, var(--wxex-danger) 15%, var(--wxex-bg-elevated));
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
.ai-search-composer-foot {
justify-content: space-between;
margin-top: 6px;
}
.ai-search-evidence-card {
display: block;
width: 100%;
margin-bottom: 8px;
padding: 11px;
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: inherit;
text-align: left;
animation: ai-search-evidence-in 240ms ease-out both;
}
@keyframes ai-search-evidence-in {
from {
opacity: 0;
transform: translateX(7px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.ai-search-evidence-card:hover,
.ai-search-evidence-card.active {
border-color: var(--wxex-brand);
background: #f4fbf8;
}
.ai-search-evidence-card.focus-flash {
animation: ai-search-evidence-flash 0.72s ease-in-out 2; animation: ai-search-evidence-flash 0.72s ease-in-out 2;
} }
@@ -1509,76 +1200,12 @@
} }
} }
.ai-search-consent-backdrop {
position: fixed;
z-index: 100;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
background: rgba(24, 35, 30, 0.26);
}
.ai-search-consent-dialog {
width: min(460px, 100%);
padding: 20px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
box-shadow: 0 14px 38px rgba(24, 35, 30, 0.2);
h2 {
margin: 5px 0 12px;
font-size: 18px;
}
p {
margin: 0;
color: var(--wxex-text-secondary);
font-size: 13px;
line-height: 21px;
}
}
.ai-search-consent-note {
margin-top: 10px !important;
padding: 10px;
border-radius: var(--wxex-radius-sm);
background: var(--wxex-brand-soft);
color: var(--wxex-text-primary) !important;
}
.ai-search-consent-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 18px;
button {
min-height: 34px;
padding: 0 13px;
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;
}
.primary {
border-color: var(--wxex-brand);
background: var(--wxex-brand);
color: #fff;
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.ai-search-spinner, .ai-search-spinner,
.ai-search-result, .ai-search-result,
.ai-search-summary-block, .ai-search-summary-block,
.ai-search-answer > *, .ai-search-answer > *,
.ai-search-evidence-card, .ai-search-evidence-focus-flash,
.ai-search-history-popover,
.ai-search-pipeline-step.active .ai-search-pipeline-mark, .ai-search-pipeline-step.active .ai-search-pipeline-mark,
.ai-search-sync-progress-track span { .ai-search-sync-progress-track span {
animation: none !important; animation: none !important;
@@ -1586,106 +1213,11 @@
} }
} }
.ai-search-evidence-card-top,
.ai-search-evidence-conversation,
.ai-search-evidence-source-kind,
.ai-search-evidence-text,
.ai-search-evidence-link {
display: block;
}
.ai-search-evidence-source-kind,
.ai-search-voice-coverage-warning { .ai-search-voice-coverage-warning {
color: var(--wxex-brand); color: var(--wxex-brand);
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
} }
.ai-search-evidence-card-top {
display: flex;
justify-content: space-between;
gap: 8px;
}
.ai-search-evidence-card-top strong {
overflow: hidden;
font-size: 11px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.ai-search-evidence-card time {
flex: 0 0 auto;
color: var(--wxex-text-muted);
font-size: 9px;
}
.ai-search-evidence-conversation {
margin-top: 2px;
color: var(--wxex-brand);
font-size: 10px;
line-height: 15px;
}
.ai-search-evidence-text {
display: -webkit-box;
margin-top: 7px;
overflow: hidden;
color: var(--wxex-text-secondary);
font-size: 11px;
line-height: 17px;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.ai-search-evidence-link {
margin-top: 7px;
color: var(--wxex-ai);
font-size: 10px;
font-weight: 700;
}
.ai-search-evidence-load-more {
display: block;
width: 100%;
margin: 4px 0 12px;
padding: 8px 10px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-elevated);
color: var(--wxex-ai);
cursor: pointer;
font-size: 11px;
font-weight: 700;
}
.ai-search-evidence-load-more:hover {
border-color: var(--wxex-brand);
background: #f4fbf8;
}
.ai-search-evidence-empty {
display: flex;
min-height: 260px;
align-items: center;
justify-content: center;
padding: 24px;
flex-direction: column;
color: var(--wxex-text-muted);
text-align: center;
}
.ai-search-evidence-empty div {
margin-bottom: 10px;
font-size: 34px;
opacity: 0.6;
}
.ai-search-evidence-empty strong {
color: var(--wxex-text-secondary);
font-size: 12px;
}
.ai-search-evidence-empty span {
max-width: 190px;
margin-top: 6px;
font-size: 10px;
line-height: 16px;
}
.ai-search-muted { .ai-search-muted {
color: var(--wxex-text-muted); color: var(--wxex-text-muted);
font-size: 10px; font-size: 10px;
@@ -1714,8 +1246,7 @@
} }
@media (max-height: 820px) { @media (max-height: 820px) {
.ai-search-scope-panel, .ai-search-scope-panel {
.ai-search-evidence-panel {
padding-top: 12px; padding-top: 12px;
} }
@@ -1734,11 +1265,6 @@
padding-top: 20px; padding-top: 20px;
padding-bottom: 24px; padding-bottom: 24px;
} }
.ai-search-composer {
padding-top: 9px;
padding-bottom: 10px;
}
} }
.ai-search-header-actions { .ai-search-header-actions {
@@ -133,130 +133,6 @@
font-weight: 800; font-weight: 800;
} }
.tts-supported-versions-button {
margin-top: 10px;
border: 1px solid rgba(190, 96, 60, 0.3);
border-radius: 999px;
background: var(--wxex-bg-elevated);
color: #9c4a32;
cursor: pointer;
padding: 6px 11px;
font: 11px/17px var(--wxex-font);
}
.tts-version-modal-backdrop {
position: fixed;
z-index: 110;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
background: rgba(18, 29, 25, 0.46);
backdrop-filter: blur(3px);
-webkit-app-region: no-drag;
}
.tts-version-modal {
box-sizing: border-box;
width: min(100%, 620px);
max-height: calc(100vh - 48px);
overflow-y: auto;
display: grid;
gap: 16px;
border: 1px solid var(--wxex-border);
border-radius: 16px;
background: var(--wxex-bg-elevated);
box-shadow: 0 24px 70px rgba(18, 31, 26, 0.24);
padding: 22px;
> header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
> div {
display: flex;
align-items: center;
gap: 9px;
}
h2 {
margin: 0;
color: var(--wxex-text-primary);
font-size: 18px;
}
}
}
.tts-version-modal-actions {
display: flex;
align-items: center;
gap: 10px;
> a {
border: 1px solid rgba(36, 122, 99, 0.28);
border-radius: 999px;
background: var(--wxex-brand-soft);
color: var(--wxex-brand);
padding: 6px 10px;
font-size: 10px;
font-weight: 650;
text-decoration: none;
&:hover {
border-color: var(--wxex-brand);
}
}
}
.tts-version-modal-close {
width: 30px;
height: 30px;
flex: 0 0 auto;
border: 0;
border-radius: 50%;
background: var(--wxex-bg-main);
color: var(--wxex-text-secondary);
cursor: pointer;
font: 22px/28px var(--wxex-font);
}
.tts-version-modal-intro {
margin: 0;
color: var(--wxex-text-secondary);
font-size: 11px;
line-height: 19px;
code {
display: inline;
margin: 0 4px;
border-radius: 4px;
background: var(--wxex-bg-main);
color: var(--wxex-brand);
padding: 2px 5px;
font-size: 10px;
word-break: break-all;
}
}
.tts-version-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 7px;
span {
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-sm);
background: var(--wxex-bg-main);
color: var(--wxex-text-secondary);
padding: 8px 9px;
font: 11px/17px var(--wxex-font);
text-align: center;
}
}
.tts-runtime-card { .tts-runtime-card {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
@@ -340,26 +216,6 @@
} }
} }
.tts-version-modal-warning {
display: grid;
gap: 6px;
border-radius: var(--wxex-radius-md);
background: rgba(190, 96, 60, 0.08);
padding: 12px 13px;
strong {
color: #9c4a32;
font-size: 12px;
}
p {
margin: 0;
color: var(--wxex-text-secondary);
font-size: 10px;
line-height: 17px;
}
}
.tts-api-card { .tts-api-card {
display: grid; display: grid;
gap: 18px; gap: 18px;
@@ -880,10 +736,6 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.tts-version-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.tts-runtime-card { .tts-runtime-card {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -891,13 +743,4 @@
.tts-runtime-actions { .tts-runtime-actions {
justify-content: flex-start; justify-content: flex-start;
} }
.tts-version-modal > header {
align-items: stretch;
flex-direction: column;
}
.tts-version-modal-actions {
justify-content: space-between;
}
} }
+1 -1
View File
@@ -10,7 +10,7 @@
background: var(--wxex-bg-main); background: var(--wxex-bg-main);
} }
/* SETTINGS-01: formal settings workspace. The legacy SettingsPanel remains only as a fallback source. */ /* SETTINGS-01: formal settings workspace. */
.settings-workspace { .settings-workspace {
display: flex; display: flex;
width: 100%; width: 100%;
-531
View File
@@ -137,537 +137,6 @@
transition: width 0.18s ease-out; transition: width 0.18s ease-out;
} }
/* 首次连接成功后的下一步引导 */
.first-use-welcome-overlay {
position: fixed;
inset: 0;
z-index: 1200;
display: grid;
place-items: center;
padding: 24px;
background: rgba(16, 27, 23, 0.42);
backdrop-filter: blur(4px);
animation: settings-fade-in 0.16s ease-out;
}
.first-use-welcome {
position: relative;
width: min(520px, 100%);
padding: 34px;
border: 1px solid rgba(203, 222, 214, 0.9);
border-radius: 18px;
background: #fff;
box-shadow: 0 24px 70px rgba(16, 35, 28, 0.24);
animation: settings-pop-in 0.18s ease-out;
}
.first-use-welcome-close {
position: absolute;
top: 14px;
right: 16px;
width: 30px;
height: 30px;
border: 0;
border-radius: 50%;
color: #7c8882;
background: transparent;
cursor: pointer;
font-size: 23px;
line-height: 1;
}
.first-use-welcome-close:hover {
color: #26342d;
background: #f0f4f1;
}
.first-use-welcome-mark {
width: 42px;
height: 42px;
display: grid;
place-items: center;
margin-bottom: 18px;
border-radius: 13px;
color: #fff;
background: linear-gradient(135deg, #176b57, #42a27f);
box-shadow: 0 7px 16px rgba(23, 107, 87, 0.22);
font-size: 22px;
}
.first-use-welcome-eyebrow {
margin: 0 0 5px;
color: #247a63;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
}
.first-use-welcome h2 {
margin: 0;
color: #1f2c26;
font-size: 25px;
line-height: 34px;
letter-spacing: -0.025em;
}
.first-use-welcome-lead {
margin: 8px 0 24px;
color: #66756d;
font-size: 13px;
line-height: 21px;
}
.first-use-welcome-actions {
display: grid;
gap: 9px;
}
.first-use-welcome-feature {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
width: 100%;
padding: 17px 16px;
border: 1px solid #247a63;
border-radius: 12px;
color: #fff;
background: linear-gradient(135deg, #247a63, #176b57);
cursor: pointer;
text-align: left;
transition:
border-color 0.15s ease,
background 0.15s ease,
transform 0.15s ease;
&:hover {
border-color: #00604c;
background: linear-gradient(135deg, #176b57, #00604c);
transform: translateY(-1px);
}
}
.first-use-welcome-feature-icon {
width: 34px;
height: 34px;
flex: 0 0 34px;
display: grid;
place-items: center;
border-radius: 10px;
color: #176b57;
background: #fff;
font-size: 18px;
}
.first-use-welcome-feature-copy {
min-width: 0;
flex: 1;
}
.first-use-welcome-feature-arrow {
flex: 0 0 auto;
font-size: 11px;
font-weight: 650;
white-space: nowrap;
}
.first-use-welcome-feature-copy strong,
.first-use-welcome-feature-copy small {
display: block;
}
.first-use-welcome-feature-copy strong {
font-size: 14px;
line-height: 20px;
}
.first-use-welcome-feature-copy small {
margin-top: 2px;
color: rgba(255, 255, 255, 0.76);
font-size: 11px;
line-height: 16px;
}
.first-use-welcome-secondary-actions {
display: flex;
gap: 22px;
margin: 16px 0 22px;
}
.first-use-welcome-secondary-actions button {
padding: 0;
border: 0;
color: #53645b;
background: transparent;
cursor: pointer;
font-size: 12px;
}
.first-use-welcome-secondary-actions button:hover {
color: #247a63;
text-decoration: underline;
}
.first-use-welcome-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 23px;
padding-top: 16px;
border-top: 1px solid #edf1ef;
span,
button,
a {
color: #68766f;
background: transparent;
font-size: 11px;
text-decoration: none;
}
button {
padding: 0;
border: 0;
cursor: pointer;
}
button:hover,
a:hover {
color: #247a63;
text-decoration: underline;
}
}
@media (max-width: 640px), (max-height: 560px) {
.first-use-welcome-overlay {
display: block;
overflow: auto;
padding: 18px;
}
.first-use-welcome {
width: auto;
margin: 0 auto;
padding: 26px 22px 22px;
}
.first-use-welcome h2 {
font-size: 22px;
line-height: 29px;
}
.first-use-welcome-feature {
align-items: flex-start;
flex-wrap: wrap;
gap: 10px;
}
.first-use-welcome-feature-arrow {
width: 100%;
margin-left: 44px;
}
.first-use-welcome-footer {
align-items: flex-start;
flex-direction: column;
gap: 9px;
}
}
/* 设置面板 */
.settings-overlay {
position: fixed;
inset: 0;
background: rgba(15, 21, 26, 0.45);
backdrop-filter: blur(2px);
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
animation: settings-fade-in 0.16s ease-out;
}
@keyframes settings-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.settings-modal {
width: min(560px, 92vw);
max-height: 84vh;
background: #fff;
border-radius: 12px;
box-shadow: 0 24px 60px rgba(15, 21, 26, 0.28);
display: flex;
flex-direction: column;
overflow: hidden;
animation: settings-pop-in 0.18s ease-out;
}
@keyframes settings-pop-in {
from {
transform: translateY(8px) scale(0.98);
opacity: 0;
}
to {
transform: translateY(0) scale(1);
opacity: 1;
}
}
.settings-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 22px;
border-bottom: 1px solid #ececec;
h2 {
margin: 0;
font-size: 17px;
font-weight: 600;
color: #1f2429;
}
}
.settings-close {
background: transparent;
border: 0;
color: #7d858a;
font-size: 26px;
line-height: 1;
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
transition: background-color 0.15s ease;
&:hover {
background: #f0f2f4;
color: #1f2429;
}
}
.settings-body {
padding: 12px 22px 22px;
overflow-y: auto;
}
.settings-section {
margin-top: 14px;
padding: 14px 16px;
border-radius: 10px;
background: #fafbfc;
border: 1px solid #ececec;
}
.settings-section:first-child {
margin-top: 6px;
}
.settings-section-title {
font-size: 12px;
font-weight: 600;
color: #6f767c;
letter-spacing: 0.5px;
text-transform: uppercase;
margin-bottom: 10px;
}
.settings-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-top: 8px;
}
.settings-row:first-child {
margin-top: 0;
}
.settings-input {
flex: 1;
min-width: 0;
padding: 8px 11px;
border: 1px solid #d4d9dc;
border-radius: 6px;
background: #fff;
font-size: 13px;
color: #1f2429;
font-family: 'SF Mono', Menlo, Consolas, monospace;
outline: none;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.settings-input:focus {
border-color: #07c160;
box-shadow: 0 0 0 3px rgba(7, 193, 96, 0.12);
}
.settings-input-half {
flex: 0 1 140px;
}
.settings-input-quarter {
flex: 0 1 90px;
}
.settings-btn {
padding: 7px 14px;
border: 1px solid #d4d9dc;
border-radius: 6px;
background: #fff;
color: #30383d;
cursor: pointer;
font-size: 13px;
transition:
border-color 0.15s ease,
background-color 0.15s ease,
color 0.15s ease;
&:hover:not(:disabled) {
border-color: #07c160;
color: #078f49;
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
}
.settings-btn-primary {
background: #07c160;
color: #fff;
border-color: #07c160;
&:hover:not(:disabled) {
background: #06ad56;
color: #fff;
border-color: #06ad56;
}
}
.settings-hint {
margin-top: 10px;
font-size: 11px;
color: #8a9298;
line-height: 1.6;
}
.settings-hint code {
background: #eef0f2;
padding: 1px 5px;
border-radius: 3px;
font-size: 10.5px;
}
.settings-status {
font-size: 12px;
color: #59636a;
}
.settings-status.ok {
color: #078f49;
}
.settings-status.fail {
color: #c73737;
}
.settings-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
color: #30383d;
input {
width: 16px;
height: 16px;
accent-color: #07c160;
cursor: pointer;
}
}
.settings-self {
display: flex;
align-items: center;
gap: 12px;
}
.settings-self-avatar {
width: 56px;
height: 56px;
border-radius: 10px;
background: #07c160;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
font-weight: 500;
overflow: hidden;
flex-shrink: 0;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.settings-self-info {
flex: 1;
min-width: 0;
}
.settings-self-nickname {
font-size: 15px;
font-weight: 600;
color: #1f2429;
}
.settings-self-wxid {
font-size: 12px;
color: #6f767c;
margin-top: 2px;
font-family: 'SF Mono', Menlo, Consolas, monospace;
}
.settings-self-account {
font-size: 11px;
color: #8a9298;
margin-top: 2px;
word-break: break-all;
}
.settings-self-empty {
font-size: 13px;
color: #8a9298;
}
.settings-path {
display: inline-block;
font-family: 'SF Mono', Menlo, Consolas, monospace;
font-size: 11px;
background: #eef0f2;
padding: 3px 7px;
border-radius: 4px;
color: #30383d;
word-break: break-all;
max-width: 100%;
}
.app-toast { .app-toast {
position: fixed; position: fixed;
top: 18px; top: 18px;
-4
View File
@@ -230,9 +230,7 @@
.search-workspace, .search-workspace,
.ai-search-scope-panel, .ai-search-scope-panel,
.ai-search-main, .ai-search-main,
.ai-search-evidence-panel,
.export-config-panel, .export-config-panel,
.export-preview-panel,
.api-main, .api-main,
.api-runtime-panel { .api-runtime-panel {
background: var(--wxex-bg-main); background: var(--wxex-bg-main);
@@ -240,9 +238,7 @@
} }
.ai-search-scope-panel, .ai-search-scope-panel,
.ai-search-evidence-panel,
.export-config-panel, .export-config-panel,
.export-preview-panel,
.api-runtime-panel { .api-runtime-panel {
border-color: var(--wxex-border); border-color: var(--wxex-border);
} }
@@ -0,0 +1,78 @@
import { fireEvent, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import {
AISearchComposer,
type AISearchComposerProps
} from '../../src/renderer/src/components/search/AISearchComposer'
const renderComposer = (
overrides: Partial<AISearchComposerProps> = {}
): { props: AISearchComposerProps } => {
const props: AISearchComposerProps = {
query: '当前问题',
sourceLabel: '所有聊天记录',
rangeLabel: '近 30 天',
history: ['历史问题一'],
historyOpen: false,
loading: false,
knowledgeSyncing: false,
onQueryChange: vi.fn(),
onHistoryOpenChange: vi.fn(),
onRestoreHistory: vi.fn(),
onRemoveHistory: vi.fn(),
onSubmit: vi.fn(),
onCancel: vi.fn(),
...overrides
}
render(<AISearchComposer {...props} />)
return { props }
}
describe('AISearchComposer', () => {
it('submits with the form or Enter while preserving Shift+Enter and IME composition', async () => {
const user = userEvent.setup()
const { props } = renderComposer()
const textbox = screen.getByRole('textbox')
await user.click(screen.getByRole('button', { name: /开始分析/ }))
expect(props.onSubmit).toHaveBeenCalledOnce()
fireEvent.keyDown(textbox, { key: 'Enter' })
expect(props.onSubmit).toHaveBeenCalledTimes(2)
fireEvent.keyDown(textbox, { key: 'Enter', shiftKey: true })
fireEvent.keyDown(textbox, { key: 'Enter', isComposing: true })
expect(props.onSubmit).toHaveBeenCalledTimes(2)
})
it('updates the controlled query and routes loading cancellation', async () => {
const user = userEvent.setup()
const { props } = renderComposer({ loading: true })
await user.type(screen.getByRole('textbox'), '新')
expect(props.onQueryChange).toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: /取消分析/ }))
expect(props.onCancel).toHaveBeenCalledOnce()
expect(props.onSubmit).not.toHaveBeenCalled()
})
it('routes history restore, delete, and close interactions', async () => {
const user = userEvent.setup()
const { props } = renderComposer({ historyOpen: true })
await user.click(screen.getByRole('button', { name: '历史问题一' }))
expect(props.onRestoreHistory).toHaveBeenCalledWith('历史问题一')
await user.click(screen.getByRole('button', { name: '删除历史问题:历史问题一' }))
expect(props.onRemoveHistory).toHaveBeenCalledWith('历史问题一')
await user.click(screen.getByRole('button', { name: '关闭历史提问' }))
expect(props.onHistoryOpenChange).toHaveBeenCalledWith(false)
})
it('disables submission while Knowledge is syncing', () => {
renderComposer({ knowledgeSyncing: true })
expect(screen.getByRole('button', { name: /同步中,暂不可分析/ })).toBeDisabled()
})
})
@@ -0,0 +1,70 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { AISearchEvidencePanel } from '../../src/renderer/src/components/search/AISearchEvidencePanel'
import type { EvidenceItem } from '../../src/renderer/src/components/search/searchTypes'
import { makeCacheRecord, makePipelineEvidence } from './support/ai-search-fixtures'
const evidence = makeCacheRecord({
query: '证据测试',
evidence: [makePipelineEvidence(1)]
}).evidence as EvidenceItem[]
const renderPanel = (
overrides: Partial<React.ComponentProps<typeof AISearchEvidencePanel>> = {}
): React.ComponentProps<typeof AISearchEvidencePanel> => {
const props: React.ComponentProps<typeof AISearchEvidencePanel> = {
evidence,
collectionCount: 2,
selectedEvidence: 0,
evidenceFlash: { index: -1, nonce: 0 },
senderNames: { 'sender-1': '发送者 1' },
hasMoreEvidence: true,
onFocusEvidence: vi.fn(),
onJumpToEvidence: vi.fn(),
onLoadMoreEvidence: vi.fn(),
setEvidenceCardRef: vi.fn(),
...overrides
}
render(<AISearchEvidencePanel {...props} />)
return props
}
describe('AISearchEvidencePanel', () => {
it('renders stable Evidence labels and routes focus, jump, and load-more callbacks', async () => {
const user = userEvent.setup()
const props = renderPanel()
expect(screen.getByText('1/2 条样本')).toBeVisible()
expect(screen.getByText(/E1 · 发送者 1/)).toBeVisible()
expect(screen.getByText('证据 1')).toBeVisible()
await user.click(screen.getByRole('button', { name: '选择证据 E1,发送者 1' }))
expect(props.onFocusEvidence).toHaveBeenCalledWith(0)
await user.click(screen.getByRole('button', { name: '跳转到原聊天 ↗' }))
expect(props.onJumpToEvidence).toHaveBeenCalledWith(0)
await user.click(screen.getByRole('button', { name: '加载更多证据' }))
expect(props.onLoadMoreEvidence).toHaveBeenCalledOnce()
})
it('supports keyboard focus selection and the focus-flash state', async () => {
const user = userEvent.setup()
const props = renderPanel({ evidenceFlash: { index: 0, nonce: 1 } })
const card = screen.getByText(/E1 · 发送者 1/).closest('article')
const selection = screen.getByRole('button', { name: '选择证据 E1,发送者 1' })
expect(card).toHaveClass('ai-search-evidence-focus-flash')
selection.focus()
await user.keyboard('{Enter}')
expect(props.onFocusEvidence).toHaveBeenCalledWith(0)
})
it('uses the shared EmptyState when no Evidence is visible', () => {
renderPanel({ evidence: [], collectionCount: 0, hasMoreEvidence: false })
expect(screen.getByText('等待检索结果')).toBeVisible()
expect(screen.queryByRole('button', { name: '加载更多证据' })).not.toBeInTheDocument()
})
})
@@ -132,4 +132,26 @@ describe('useExternalProviderConsent', () => {
expect(resolved).toHaveBeenCalledOnce() expect(resolved).toHaveBeenCalledOnce()
expect(resolved).toHaveBeenCalledWith(true) expect(resolved).toHaveBeenCalledWith(true)
}) })
it.each([true, false])(
'settles the previous pending request when a new request replaces it, then resolves %s',
async (secondDecision) => {
const { result } = renderHook(() => useExternalProviderConsent())
let first!: Promise<boolean>
let second!: Promise<boolean>
act(() => {
first = result.current.requestExternalProviderConsent('First', 'first-recipient')
second = result.current.requestExternalProviderConsent('Second', 'second-recipient')
})
await expect(first).resolves.toBe(false)
expect(result.current.externalProviderConsent).toEqual({
providerName: 'Second',
recipient: 'second-recipient'
})
act(() => result.current.settleExternalProviderConsent(secondDecision))
await expect(second).resolves.toBe(secondDecision)
}
)
}) })
@@ -4,8 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AISearchWorkspace } from '../../src/renderer/src/components/search/AISearchWorkspace' import { AISearchWorkspace } from '../../src/renderer/src/components/search/AISearchWorkspace'
import { import {
SEARCH_CACHE_KEY, SEARCH_CACHE_KEY,
SEARCH_HISTORY_KEY, SEARCH_HISTORY_KEY
buildSearchCacheKey
} from '../../src/renderer/src/components/search/searchUtils' } from '../../src/renderer/src/components/search/searchUtils'
import { import {
aiSearchContact, aiSearchContact,
@@ -45,12 +44,11 @@ const readyKnowledgeStatus = {
shmBytes: 32 shmBytes: 32
} }
let knowledgeListener: ((status: typeof readyKnowledgeStatus) => void) | undefined
let progressListener: ((progress: Record<string, unknown>) => void) | undefined let progressListener: ((progress: Record<string, unknown>) => void) | undefined
let knowledgeUnsubscribe: ReturnType<typeof vi.fn> let knowledgeUnsubscribe: ReturnType<typeof vi.fn>
let progressUnsubscribe: ReturnType<typeof vi.fn> let progressUnsubscribe: ReturnType<typeof vi.fn>
const makeProps = (overrides: Record<string, unknown> = {}) => ({ const makeProps = (overrides: Record<string, unknown> = {}): Record<string, unknown> => ({
contacts: [aiSearchContact, aiSearchGroup], contacts: [aiSearchContact, aiSearchGroup],
selectedContact: aiSearchContact, selectedContact: aiSearchContact,
dbReady: true, dbReady: true,
@@ -68,17 +66,17 @@ const makeProps = (overrides: Record<string, unknown> = {}) => ({
...overrides ...overrides
}) })
const renderWorkspace = (overrides: Record<string, unknown> = {}) => const renderWorkspace = (overrides: Record<string, unknown> = {}): ReturnType<typeof render> =>
render(<AISearchWorkspace {...(makeProps(overrides) as never)} />) render(<AISearchWorkspace {...(makeProps(overrides) as never)} />)
const submitQuery = async (query = '测试搜索问题') => { const submitQuery = async (query = '测试搜索问题'): Promise<ReturnType<typeof userEvent.setup>> => {
const user = userEvent.setup() const user = userEvent.setup()
await user.type(screen.getByRole('textbox'), query) await user.type(screen.getByRole('textbox'), query)
await user.click(screen.getByRole('button', { name: /开始分析/ })) await user.click(screen.getByRole('button', { name: /开始分析/ }))
return user return user
} }
const emitProgress = async (progress: Record<string, unknown>) => { const emitProgress = async (progress: Record<string, unknown>): Promise<void> => {
await act(async () => { await act(async () => {
progressListener?.(progress) progressListener?.(progress)
}) })
@@ -88,7 +86,6 @@ beforeEach(() => {
localStorage.clear() localStorage.clear()
sessionStorage.clear() sessionStorage.clear()
vi.clearAllMocks() vi.clearAllMocks()
knowledgeListener = undefined
progressListener = undefined progressListener = undefined
knowledgeUnsubscribe = vi.fn() knowledgeUnsubscribe = vi.fn()
progressUnsubscribe = vi.fn() progressUnsubscribe = vi.fn()
@@ -97,10 +94,7 @@ beforeEach(() => {
api.getSettings.mockResolvedValue({ settings: { debugEnabled: false } }) api.getSettings.mockResolvedValue({ settings: { debugEnabled: false } })
api.getAppLogPath.mockResolvedValue('') api.getAppLogPath.mockResolvedValue('')
api.getKnowledgeStatus.mockResolvedValue(readyKnowledgeStatus) api.getKnowledgeStatus.mockResolvedValue(readyKnowledgeStatus)
api.onKnowledgeStatus.mockImplementation((listener: typeof knowledgeListener) => { api.onKnowledgeStatus.mockImplementation(() => knowledgeUnsubscribe)
knowledgeListener = listener
return knowledgeUnsubscribe
})
api.onAiSearchProgress.mockImplementation((listener: typeof progressListener) => { api.onAiSearchProgress.mockImplementation((listener: typeof progressListener) => {
progressListener = listener progressListener = listener
return progressUnsubscribe return progressUnsubscribe
@@ -193,6 +187,55 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
expect(screen.queryByText('测试搜索答案')).not.toBeInTheDocument() expect(screen.queryByText('测试搜索答案')).not.toBeInTheDocument()
}) })
it.each([
['no_evidence', '近 30 天内没有找到与问题相关的聊天消息。'],
['retrieval_incomplete', '证据已就绪'],
['failed', '本地检索失败'],
['ai_failed', '证据已找到,但 AI 暂时无法生成回答']
] as const)('does not persist History or Cache for %s', async (status, expectedText) => {
api.runAiSearch.mockResolvedValue(
makeSearchResult({
status,
error: status === 'no_evidence' ? '应被忽略' : expectedText,
evidence: [makePipelineEvidence(1)]
})
)
renderWorkspace()
await submitQuery(`${status} 问题`)
expect(await screen.findByText(expectedText)).toBeInTheDocument()
expect(localStorage.getItem(SEARCH_HISTORY_KEY)).toBeNull()
expect(localStorage.getItem(SEARCH_CACHE_KEY)).toBeNull()
expect(sessionStorage.length).toBe(0)
})
it('applies common Evidence state before rejecting a completed result without an answer', async () => {
api.runAiSearch.mockResolvedValue({
...makeSearchResult({ evidence: [makePipelineEvidence(1)] }),
answer: undefined
})
renderWorkspace()
await submitQuery('缺少回答')
expect(await screen.findByText('搜索任务未返回回答')).toBeInTheDocument()
expect(screen.getByText('E1 · 发送者 1')).toBeInTheDocument()
expect(localStorage.getItem(SEARCH_HISTORY_KEY)).toBeNull()
expect(localStorage.getItem(SEARCH_CACHE_KEY)).toBeNull()
})
it('keeps an explicitly empty Evidence Collection separate from Final Evidence', async () => {
api.runAiSearch.mockResolvedValue(
makeSearchResult({ evidence: [makePipelineEvidence(1)], evidenceCollection: [] })
)
renderWorkspace()
await submitQuery('空浏览集合')
expect(await screen.findByText('测试搜索答案')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'E1' })).toBeInTheDocument()
expect(screen.queryByText('E1 · 发送者 1')).not.toBeInTheDocument()
expect(screen.getByText('等待检索结果')).toBeInTheDocument()
})
it('handles an IPC rejection from the Search Worker as an insufficient result', async () => { it('handles an IPC rejection from the Search Worker as an insufficient result', async () => {
api.runAiSearch.mockRejectedValue(new Error('Worker IPC 连接断开')) api.runAiSearch.mockRejectedValue(new Error('Worker IPC 连接断开'))
renderWorkspace() renderWorkspace()
@@ -218,7 +261,9 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
}) })
it('loads more Evidence from the current collection without calling runAiSearch again', async () => { it('loads more Evidence from the current collection without calling runAiSearch again', async () => {
const evidenceCollection = Array.from({ length: 9 }, (_, index) => makePipelineEvidence(index + 1)) const evidenceCollection = Array.from({ length: 9 }, (_, index) =>
makePipelineEvidence(index + 1)
)
api.runAiSearch.mockResolvedValue( api.runAiSearch.mockResolvedValue(
makeSearchResult({ evidence: evidenceCollection.slice(0, 8), evidenceCollection }) makeSearchResult({ evidence: evidenceCollection.slice(0, 8), evidenceCollection })
) )
@@ -263,7 +308,12 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
await waitFor(() => expect(api.runAiSearch).toHaveBeenCalledTimes(2)) await waitFor(() => expect(api.runAiSearch).toHaveBeenCalledTimes(2))
expect(screen.queryByText('E1 · 发送者 1')).not.toBeInTheDocument() expect(screen.queryByText('E1 · 发送者 1')).not.toBeInTheDocument()
resolveSecond?.(makeSearchResult({ requestId: api.runAiSearch.mock.calls[1][0].requestId, evidence: [secondEvidence] })) resolveSecond?.(
makeSearchResult({
requestId: api.runAiSearch.mock.calls[1][0].requestId,
evidence: [secondEvidence]
})
)
expect(await screen.findByText('E2 · 发送者 2')).toBeInTheDocument() expect(await screen.findByText('E2 · 发送者 2')).toBeInTheDocument()
}) })
@@ -352,6 +402,32 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
expect(screen.queryByRole('dialog', { name: '确认发送本次搜索资料' })).not.toBeInTheDocument() expect(screen.queryByRole('dialog', { name: '确认发送本次搜索资料' })).not.toBeInTheDocument()
}) })
it('settles duplicate consent submissions without starting duplicate provider flows', async () => {
api.getAiSearchProviderStatus.mockResolvedValue({
configured: true,
requiresConsent: true,
providerId: 'remote-provider',
providerName: 'Remote Provider',
recipient: 'remote@example.test'
})
api.runAiSearch.mockResolvedValue(makeSearchResult())
renderWorkspace()
const user = userEvent.setup()
await user.type(screen.getByRole('textbox'), '重复授权问题')
const form = screen.getByRole('textbox').closest('form') as HTMLFormElement
fireEvent.submit(form)
await screen.findByRole('dialog', { name: '确认发送本次搜索资料' })
fireEvent.submit(form)
await waitFor(() => expect(api.getAiSearchProviderStatus).toHaveBeenCalledTimes(2))
await screen.findByRole('dialog', { name: '确认发送本次搜索资料' })
await user.click(screen.getByRole('button', { name: '继续并发送' }))
await screen.findByText('测试搜索答案')
expect(api.authorizeAiSearchExternalProvider).toHaveBeenCalledOnce()
expect(api.runAiSearch).toHaveBeenCalledOnce()
})
it('does not start Search when the user rejects consent', async () => { it('does not start Search when the user rejects consent', async () => {
api.getAiSearchProviderStatus.mockResolvedValue({ api.getAiSearchProviderStatus.mockResolvedValue({
configured: true, configured: true,
@@ -429,9 +505,7 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
const query = '没有证据的缓存' const query = '没有证据的缓存'
localStorage.setItem( localStorage.setItem(
SEARCH_CACHE_KEY, SEARCH_CACHE_KEY,
JSON.stringify([ JSON.stringify([makeCacheRecord({ query, answer: '只有摘要的缓存', evidence: [] })])
makeCacheRecord({ query, answer: '只有摘要的缓存', evidence: [] })
])
) )
renderWorkspace() renderWorkspace()
await submitQuery(query) await submitQuery(query)
@@ -443,8 +517,12 @@ describe('AISearchWorkspace regression coverage before decomposition', () => {
it('does not reuse the previous successful result for a new query', async () => { it('does not reuse the previous successful result for a new query', async () => {
api.runAiSearch api.runAiSearch
.mockResolvedValueOnce(makeSearchResult({ answer: '第一轮答案', evidence: [makePipelineEvidence(1)] })) .mockResolvedValueOnce(
.mockResolvedValueOnce(makeSearchResult({ answer: '第轮答案', evidence: [makePipelineEvidence(2)] })) makeSearchResult({ answer: '第轮答案', evidence: [makePipelineEvidence(1)] })
)
.mockResolvedValueOnce(
makeSearchResult({ answer: '第二轮答案', evidence: [makePipelineEvidence(2)] })
)
renderWorkspace() renderWorkspace()
const user = await submitQuery('第一轮问题') const user = await submitQuery('第一轮问题')
await screen.findByText('第一轮答案') await screen.findByText('第一轮答案')
@@ -0,0 +1,73 @@
import { useState } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { ChatImageViewer } from '../../src/renderer/src/components/chat/ChatImageViewer'
import { TooltipProvider } from '../../src/renderer/src/components/ui'
const imageUrl =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
function Harness({ onClose = vi.fn() }: { onClose?: () => void }): React.ReactElement {
const [open, setOpen] = useState(false)
return (
<TooltipProvider>
<button type="button" onClick={() => setOpen(true)}>
</button>
{open && (
<ChatImageViewer
imageUrl={imageUrl}
onClose={() => {
onClose()
setOpen(false)
}}
/>
)}
</TooltipProvider>
)
}
describe('ChatImageViewer', () => {
it('zooms, rotates, drags, and resets the image', async () => {
const user = userEvent.setup()
render(<Harness />)
await user.click(screen.getByRole('button', { name: '查看图片' }))
const image = screen.getByAltText('图片预览')
const stage = screen.getByLabelText('图片查看区域')
expect(screen.getByText('100%')).toBeVisible()
await user.click(screen.getByRole('button', { name: '放大' }))
expect(screen.getByText('110%')).toBeVisible()
await user.click(screen.getByRole('button', { name: '右旋转' }))
expect(image).toHaveStyle({ transform: 'translate(0px, 0px) scale(1.1) rotate(90deg)' })
fireEvent.mouseDown(stage, { clientX: 10, clientY: 15 })
fireEvent.mouseMove(stage, { clientX: 35, clientY: 45 })
fireEvent.mouseUp(stage)
expect(image).toHaveStyle({ transform: 'translate(25px, 30px) scale(1.1) rotate(90deg)' })
await user.click(screen.getByRole('button', { name: '重置图片' }))
expect(screen.getByText('100%')).toBeVisible()
expect(image).toHaveStyle({ transform: 'translate(0px, 0px) scale(1) rotate(0deg)' })
})
it('closes with Escape or the overlay and restores focus', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(<Harness onClose={onClose} />)
const opener = screen.getByRole('button', { name: '查看图片' })
await user.click(opener)
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog', { name: '图片查看' })).not.toBeInTheDocument()
await waitFor(() => expect(opener).toHaveFocus())
await user.click(opener)
const dialog = screen.getByRole('dialog', { name: '图片查看' })
await user.click(dialog.previousElementSibling as HTMLElement)
expect(screen.queryByRole('dialog', { name: '图片查看' })).not.toBeInTheDocument()
await waitFor(() => expect(opener).toHaveFocus())
expect(onClose).toHaveBeenCalledTimes(2)
})
})
+60
View File
@@ -0,0 +1,60 @@
import { render, screen } 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 { ExportMenu } from '../../src/renderer/src/components/chat/ExportMenu'
import type { Contact } from '../../src/shared/types'
const contact: Contact = {
md5: 'group-md5',
m_nsUsrName: 'group@chatroom',
m_nsNickName: '测试群',
type: 'group'
}
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} />)
const trigger = screen.getByRole('button', { name: '导出' })
await user.click(trigger)
await user.click(screen.getByRole('menuitem', { name: '导出近 7 天' }))
expect(onExport).toHaveBeenCalledWith(7)
expect(screen.queryByRole('menuitem', { name: '导出近 7 天' })).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
})
it('closes the chat More menu with Escape and invokes refresh data once', async () => {
const user = userEvent.setup()
const onRefreshData = vi.fn()
render(
<ChatHeader
contact={contact}
isGroupChat
loadedCount={3}
filteredCount={3}
contentFilter=""
isAiLoading={false}
onContentFilterChange={vi.fn()}
onRefresh={vi.fn()}
onRefreshData={onRefreshData}
onTestSend={vi.fn()}
onOpenAiSettings={vi.fn()}
/>
)
const trigger = screen.getByRole('button', { name: '更多' })
await user.click(trigger)
expect(screen.getByRole('menuitem', { name: '刷新数据' })).toBeVisible()
await user.keyboard('{Escape}')
expect(screen.queryByRole('menuitem', { name: '刷新数据' })).not.toBeInTheDocument()
expect(trigger).toHaveFocus()
await user.click(trigger)
await user.click(screen.getByRole('menuitem', { name: '刷新数据' }))
expect(onRefreshData).toHaveBeenCalledOnce()
})
})
@@ -69,7 +69,9 @@ describe('export progress panel', () => {
const progressbar = screen.getByRole('progressbar', { name: '导出进度' }) const progressbar = screen.getByRole('progressbar', { name: '导出进度' })
expect(progressbar).toHaveAttribute('aria-valuenow', '31') expect(progressbar).toHaveAttribute('aria-valuenow', '31')
expect(progressbar.querySelector('span')).toHaveStyle({ width: '31%' }) expect(progressbar.querySelector('[data-slot="progress-indicator"]')).toHaveStyle({
transform: 'translateX(-69%)'
})
}) })
it('shows the current conversation and overall position for all export', () => { it('shows the current conversation and overall position for all export', () => {
+20
View File
@@ -31,6 +31,7 @@ describe('ExportWorkspace multi-chat selection', () => {
onExportProgress: vi.fn(() => vi.fn()), onExportProgress: vi.fn(() => vi.fn()),
getVoiceModelStatus: vi.fn().mockRejectedValue(new Error('fixture model unavailable')), getVoiceModelStatus: vi.fn().mockRejectedValue(new Error('fixture model unavailable')),
getGroupSnapshot: vi.fn(async () => ({ members: [] })), getGroupSnapshot: vi.fn(async () => ({ members: [] })),
selectExportDirectory: vi.fn(async () => ({ canceled: false, path: '/fixture/export' })),
cancelExport: vi.fn(async () => ({ success: true })), cancelExport: vi.fn(async () => ({ success: true })),
revealExport: vi.fn(async () => ({ success: true })) revealExport: vi.fn(async () => ({ success: true }))
} }
@@ -197,6 +198,25 @@ describe('ExportWorkspace multi-chat selection', () => {
expect(document.querySelector('.export-all-chat-avatar.user')).not.toBeInTheDocument() expect(document.querySelector('.export-all-chat-avatar.user')).not.toBeInTheDocument()
expect(loadPreviewMessages).not.toHaveBeenCalled() expect(loadPreviewMessages).not.toHaveBeenCalled()
}) })
it('keeps custom dates and output directory selection in the workspace request', async () => {
const onStartExport = vi.fn(async () => ({ success: false }))
renderWorkspace(onStartExport)
await userEvent.click(screen.getByRole('button', { name: '自定义时间' }))
await userEvent.type(screen.getByLabelText('开始时间'), '2026-08-01T09:30')
await userEvent.type(screen.getByLabelText('结束时间'), '2026-08-02T18:45')
await userEvent.click(screen.getByRole('button', { name: '选择位置' }))
expect(await screen.findByText('/fixture/export/聊天 A_聊天档案.csv')).toBeVisible()
await userEvent.click(screen.getByRole('button', { name: '开始导出' }))
await waitFor(() => expect(onStartExport).toHaveBeenCalledOnce())
expect(onStartExport.mock.calls[0][0]).toMatchObject({
outputDirectory: '/fixture/export',
startTime: Math.floor(new Date('2026-08-01T09:30').getTime() / 1000),
endTime: Math.floor(new Date('2026-08-02T18:45').getTime() / 1000)
})
})
}) })
describe('ExportTaskCenter details', () => { describe('ExportTaskCenter details', () => {
@@ -0,0 +1,84 @@
import { useState } from 'react'
import userEvent from '@testing-library/user-event'
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import {
ExternalProviderConsentDialog,
type ExternalProviderConsent
} from '../../src/renderer/src/components/search/ExternalProviderConsentDialog'
const consent: ExternalProviderConsent = {
providerName: 'Remote Provider',
recipient: 'remote@example.test'
}
describe('ExternalProviderConsentDialog', () => {
it('renders provider details and confirms the controlled action', async () => {
const user = userEvent.setup()
const onConfirm = vi.fn()
render(
<ExternalProviderConsentDialog consent={consent} onConfirm={onConfirm} onCancel={vi.fn()} />
)
expect(screen.getByRole('dialog', { name: '确认发送本次搜索资料' })).toBeVisible()
expect(screen.getByText('Remote Provider')).toBeVisible()
expect(screen.getByRole('dialog')).toHaveTextContent('remote@example.test')
await user.click(screen.getByRole('button', { name: '继续并发送' }))
expect(onConfirm).toHaveBeenCalledOnce()
})
it('treats the cancel button, Escape, and outside interaction as cancellation', async () => {
const user = userEvent.setup()
const onCancel = vi.fn()
const { rerender } = render(
<ExternalProviderConsentDialog consent={consent} onConfirm={vi.fn()} onCancel={onCancel} />
)
await user.click(screen.getByRole('button', { name: '取消' }))
expect(onCancel).toHaveBeenCalledOnce()
rerender(
<ExternalProviderConsentDialog consent={consent} onConfirm={vi.fn()} onCancel={onCancel} />
)
await user.keyboard('{Escape}')
expect(onCancel).toHaveBeenCalledTimes(2)
rerender(
<ExternalProviderConsentDialog consent={consent} onConfirm={vi.fn()} onCancel={onCancel} />
)
const overlay = screen.getByRole('dialog').previousElementSibling as HTMLElement
await user.click(overlay)
expect(onCancel).toHaveBeenCalledTimes(3)
})
it('restores focus to the opener after the dialog is cancelled', async () => {
const user = userEvent.setup()
function Harness(): React.ReactElement {
const [open, setOpen] = useState(false)
return (
<>
<button type="button" onClick={() => setOpen(true)}>
</button>
<ExternalProviderConsentDialog
consent={open ? consent : null}
onConfirm={() => setOpen(false)}
onCancel={() => setOpen(false)}
/>
</>
)
}
render(<Harness />)
const opener = screen.getByRole('button', { name: '打开授权确认' })
await user.click(opener)
expect(screen.getByRole('dialog', { name: '确认发送本次搜索资料' })).toBeVisible()
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog', { name: '确认发送本次搜索资料' })).not.toBeInTheDocument()
expect(opener).toHaveFocus()
})
})
@@ -0,0 +1,70 @@
import { useState } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { FirstUseWelcome } from '../../src/renderer/src/components/FirstUseWelcome'
describe('FirstUseWelcome', () => {
it('closes with Escape or the overlay and restores focus to the opener', async () => {
const user = userEvent.setup()
function Harness(): React.ReactElement {
const [open, setOpen] = useState(false)
return (
<>
<button type="button" onClick={() => setOpen(true)}>
</button>
{open && (
<FirstUseWelcome
onDismiss={() => setOpen(false)}
onOpenSearch={vi.fn()}
onOpenReport={vi.fn()}
onOpenAISettings={vi.fn()}
/>
)}
</>
)
}
render(<Harness />)
const opener = screen.getByRole('button', { name: '打开新手引导' })
await user.click(opener)
expect(screen.getByRole('dialog', { name: '开始探索你的微信' })).toBeVisible()
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog', { name: '开始探索你的微信' })).not.toBeInTheDocument()
await waitFor(() => expect(opener).toHaveFocus())
await user.click(opener)
const dialog = screen.getByRole('dialog', { name: '开始探索你的微信' })
await user.click(dialog.previousElementSibling as HTMLElement)
expect(screen.queryByRole('dialog', { name: '开始探索你的微信' })).not.toBeInTheDocument()
await waitFor(() => expect(opener).toHaveFocus())
})
it('keeps the feature callbacks and guide link intact', async () => {
const user = userEvent.setup()
const onOpenSearch = vi.fn()
const onOpenReport = vi.fn()
const onOpenAISettings = vi.fn()
render(
<FirstUseWelcome
onDismiss={vi.fn()}
onOpenSearch={onOpenSearch}
onOpenReport={onOpenReport}
onOpenAISettings={onOpenAISettings}
/>
)
await user.click(screen.getByRole('button', { name: /试试 AI 群聊日报/ }))
expect(onOpenReport).toHaveBeenCalledOnce()
await user.click(screen.getByRole('button', { name: '问问你的微信' }))
expect(onOpenSearch).toHaveBeenCalledOnce()
await user.click(screen.getByRole('button', { name: '配置 AI 模型' }))
expect(onOpenAISettings).toHaveBeenCalledOnce()
expect(screen.getByRole('link', { name: '查看完整使用教程' })).toHaveAttribute(
'href',
'https://github.com/Wxw-Gu/WechatExplorer/blob/main/docs/user-guide/getting-started.md'
)
})
})
+7
View File
@@ -40,6 +40,13 @@ describe('ImageBubble', () => {
await userEvent.click(image) await userEvent.click(image)
await waitFor(() => expect(onImageClick).toHaveBeenCalledWith(original)) await waitFor(() => expect(onImageClick).toHaveBeenCalledWith(original))
expect(requestImage.mock.calls[1][3]).toMatchObject({ force: true }) expect(requestImage.mock.calls[1][3]).toMatchObject({ force: true })
onImageClick.mockClear()
requestImage.mockResolvedValueOnce({ data: original, isThumbnail: false })
const trigger = screen.getByRole('button', { name: '查看图片' })
trigger.focus()
await userEvent.keyboard('{Enter}')
await waitFor(() => expect(onImageClick).toHaveBeenCalledWith(original))
}) })
it('shows an explicit error and allows retry', async () => { it('shows an explicit error and allows retry', async () => {
@@ -1,4 +1,6 @@
import { useState } from 'react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PersonalWechatSendDialog } from '../../src/renderer/src/components/chat/PersonalWechatSendDialog' import { PersonalWechatSendDialog } from '../../src/renderer/src/components/chat/PersonalWechatSendDialog'
@@ -47,6 +49,27 @@ const readyStatus = {
message: '个人微信已绑定' message: '个人微信已绑定'
} }
function Harness({ onClose = vi.fn() }: { onClose?: () => void }): React.ReactElement {
const [open, setOpen] = useState(false)
return (
<>
<button type="button" onClick={() => setOpen(true)}>
</button>
{open && (
<PersonalWechatSendDialog
contact={contact}
isGroupChat
onClose={() => {
onClose()
setOpen(false)
}}
/>
)}
</>
)
}
describe('PersonalWechatSendDialog', () => { describe('PersonalWechatSendDialog', () => {
beforeEach(() => { beforeEach(() => {
getStatus.mockReset().mockResolvedValue(readyStatus) getStatus.mockReset().mockResolvedValue(readyStatus)
@@ -208,4 +231,43 @@ describe('PersonalWechatSendDialog', () => {
expect(await screen.findByText('已绑定,等待消息初始化')).toBeInTheDocument() expect(await screen.findByText('已绑定,等待消息初始化')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '测试发送图片到群聊' })).toBeDisabled() expect(screen.getByRole('button', { name: '测试发送图片到群聊' })).toBeDisabled()
}) })
it('closes with Escape or the overlay and restores focus to the opener', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(<Harness onClose={onClose} />)
const opener = screen.getByRole('button', { name: '打开测试发送' })
await user.click(opener)
await screen.findByText('技术交流群')
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog', { name: '个人微信测试发送' })).not.toBeInTheDocument()
await waitFor(() => expect(opener).toHaveFocus())
await user.click(opener)
const dialog = await screen.findByRole('dialog', { name: '个人微信测试发送' })
await user.click(dialog.previousElementSibling as HTMLElement)
expect(screen.queryByRole('dialog', { name: '个人微信测试发送' })).not.toBeInTheDocument()
await waitFor(() => expect(opener).toHaveFocus())
expect(onClose).toHaveBeenCalledTimes(2)
})
it('keeps the dialog open while a message is being sent', async () => {
const user = userEvent.setup()
sendMessage.mockImplementation(() => new Promise(() => undefined))
render(<Harness />)
await user.click(screen.getByRole('button', { name: '打开测试发送' }))
await screen.findByText('技术交流群')
await user.click(screen.getByRole('button', { name: '选择图片' }))
await user.click(screen.getByRole('button', { name: '测试发送图片到群聊' }))
expect(screen.getByRole('button', { name: '正在发送…' })).toBeDisabled()
await user.keyboard('{Escape}')
expect(screen.getByRole('dialog', { name: '个人微信测试发送' })).toBeVisible()
await user.click(
screen.getByRole('dialog', { name: '个人微信测试发送' }).previousElementSibling as HTMLElement
)
expect(screen.getByRole('dialog', { name: '个人微信测试发送' })).toBeVisible()
})
}) })
+140 -6
View File
@@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ReportGroupMemberSelector } from '../../src/renderer/src/components/reports/ReportGroupMemberSelector' import { ReportGroupMemberSelector } from '../../src/renderer/src/components/reports/ReportGroupMemberSelector'
import { ReportTaskStatusPanel } from '../../src/renderer/src/components/reports/ReportTaskStatusPanel' import { ReportTaskStatusPanel } from '../../src/renderer/src/components/reports/ReportTaskStatusPanel'
@@ -6,6 +7,7 @@ import { ReportTemplateSelector } from '../../src/renderer/src/components/report
import { ReportViewer } from '../../src/renderer/src/components/reports/ReportViewer' import { ReportViewer } from '../../src/renderer/src/components/reports/ReportViewer'
import { ReportInfoPanel } from '../../src/renderer/src/components/reports/ReportInfoPanel' import { ReportInfoPanel } from '../../src/renderer/src/components/reports/ReportInfoPanel'
import { ReportToolbar } from '../../src/renderer/src/components/reports/ReportToolbar' 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 { ModelSummary } from '../../src/renderer/src/components/reports/ModelSummary'
import type { Contact } from '../../src/shared/types' import type { Contact } from '../../src/shared/types'
import type { GeneratedReportRecord } from '../../src/shared/report-history' import type { GeneratedReportRecord } from '../../src/shared/report-history'
@@ -434,7 +436,8 @@ describe('daily report controls', () => {
globalThis.ResizeObserver = originalResizeObserver globalThis.ResizeObserver = originalResizeObserver
}) })
it('keeps the file action inside More and labels both AI model roles', () => { it('keeps the file action inside More and labels both AI model roles', async () => {
const user = userEvent.setup()
render( render(
<> <>
<ReportToolbar <ReportToolbar
@@ -469,13 +472,53 @@ describe('daily report controls', () => {
</> </>
) )
expect(screen.getByRole('button', { name: '生成微信卡片' })).toBeVisible() expect(screen.queryByRole('menuitem', { name: '生成微信卡片' })).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '更多' })) await user.click(screen.getByRole('button', { name: '更多' }))
expect(screen.getByRole('button', { name: '打开文件夹' })).toBeVisible() expect(screen.getByRole('menuitem', { name: '生成微信卡片' })).toBeVisible()
expect(screen.getByRole('menuitem', { name: '打开文件夹' })).toBeVisible()
expect(screen.getByText('文字模型')).toBeVisible() expect(screen.getByText('文字模型')).toBeVisible()
expect(screen.getByText('DeepSeek Chat')).toBeVisible() expect(screen.getByText('DeepSeek Chat')).toBeVisible()
expect(screen.getByText('图片模型')).toBeVisible() expect(screen.getByText('图片模型')).toBeVisible()
expect(screen.getByText('gpt-5.6-sol')).toBeVisible() expect(screen.getByText('gpt-5.6-sol')).toBeVisible()
await user.keyboard('{Escape}')
expect(screen.queryByRole('menuitem', { name: '生成微信卡片' })).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: '更多' })).toHaveFocus()
})
it('keeps unavailable toolbar actions disabled inside More', async () => {
const user = userEvent.setup()
const onReveal = vi.fn()
const onShare = vi.fn()
render(
<ReportToolbar
canCopyImage={false}
canReveal={false}
canShare={false}
canSwitchTemplate={false}
isSwitchingTemplate={false}
onSwitchTemplate={vi.fn()}
onRegenerate={vi.fn()}
onCopyImage={vi.fn()}
onReveal={onReveal}
onShare={onShare}
/>
)
expect(screen.getByRole('button', { name: '切换模板' })).toBeDisabled()
expect(screen.getByRole('button', { name: '复制图片' })).toBeDisabled()
expect(screen.getByRole('button', { name: '打开报告' })).toBeDisabled()
await user.click(screen.getByRole('button', { name: '更多' }))
const shareItem = screen.getByRole('menuitem', { name: '生成微信卡片' })
const revealItem = screen.getByRole('menuitem', { name: '打开文件夹' })
expect(shareItem).toHaveAttribute('data-disabled')
expect(revealItem).toHaveAttribute('data-disabled')
await user.click(shareItem)
await user.click(revealItem)
expect(onShare).not.toHaveBeenCalled()
expect(onReveal).not.toHaveBeenCalled()
}) })
it('opens the current group send dialog with the report PNG preselected', async () => { it('opens the current group send dialog with the report PNG preselected', async () => {
@@ -550,6 +593,7 @@ describe('daily report controls', () => {
}) })
it('switches templates from the top toolbar using the saved report snapshot', async () => { it('switches templates from the top toolbar using the saved report snapshot', async () => {
const user = userEvent.setup()
const onSwitchTemplate = vi.fn(async () => ({ success: true })) const onSwitchTemplate = vi.fn(async () => ({ success: true }))
const report: GeneratedReportRecord = { const report: GeneratedReportRecord = {
id: 'report-switch', id: 'report-switch',
@@ -579,10 +623,10 @@ describe('daily report controls', () => {
/> />
) )
fireEvent.click(screen.getByRole('button', { name: '切换模板' })) await user.click(screen.getByRole('button', { name: '切换模板' }))
expect(screen.getByText('仅重新排版,不调用 AI')).toBeVisible() expect(screen.getByText('仅重新排版,不调用 AI')).toBeVisible()
expect(screen.getByRole('menuitem', { name: /默认模板经典日报/ })).toBeVisible() expect(screen.getByRole('menuitem', { name: /默认模板经典日报/ })).toBeVisible()
fireEvent.click(screen.getByRole('menuitem', { name: /Mobile 03AI Command Center/ })) await user.click(screen.getByRole('menuitem', { name: /Mobile 03AI Command Center/ }))
await waitFor(() => expect(onSwitchTemplate).toHaveBeenCalledWith(report, 'mobile-dashboard')) await waitFor(() => expect(onSwitchTemplate).toHaveBeenCalledWith(report, 'mobile-dashboard'))
rerender( rerender(
@@ -640,6 +684,96 @@ describe('daily report controls', () => {
expect(screen.getByText('wxid-one')).toBeVisible() expect(screen.getByText('wxid-one')).toBeVisible()
}) })
it('cancels report deletion and restores focus to the delete trigger', async () => {
const user = userEvent.setup()
const report: GeneratedReportRecord = {
id: 'report-delete-cancel',
contactId: 'group-md5',
contactName: '测试群',
dateRange: '今天',
messageCount: 10,
generatedAt: '2026-08-17T10:00:00.000Z',
reportDate: '2026-08-17',
htmlStatus: 'ready',
pngStatus: 'ready'
}
render(
<ReportHistorySidebar
reports={[report]}
selectedReportId={report.id}
selfInfo={null}
dbReady
onSelectReport={vi.fn()}
onCreateReport={vi.fn()}
onDeleteReport={vi.fn(async () => ({ success: true }))}
onOpenSettings={vi.fn()}
/>
)
const deleteTrigger = screen.getByRole('button', { name: '删除日报' })
await user.click(deleteTrigger)
expect(screen.getByRole('alertdialog', { name: '删除日报?' })).toBeVisible()
await user.click(screen.getByRole('button', { name: '取消' }))
expect(screen.queryByRole('alertdialog', { name: '删除日报?' })).not.toBeInTheDocument()
expect(deleteTrigger).toHaveFocus()
})
it('prevents duplicate report deletion, keeps failures open, and closes after success', async () => {
const user = userEvent.setup()
const report: GeneratedReportRecord = {
id: 'report-delete-result',
contactId: 'group-md5',
contactName: '测试群',
dateRange: '今天',
messageCount: 10,
generatedAt: '2026-08-17T10:00:00.000Z',
reportDate: '2026-08-17',
htmlStatus: 'ready',
pngStatus: 'ready'
}
let settleDelete: ((result: { success: boolean; error?: string }) => void) | undefined
const onDeleteReport = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<{ success: boolean; error?: string }>((resolve) => {
settleDelete = resolve
})
)
.mockResolvedValueOnce({ success: true })
render(
<ReportHistorySidebar
reports={[report]}
selectedReportId={report.id}
selfInfo={null}
dbReady
onSelectReport={vi.fn()}
onCreateReport={vi.fn()}
onDeleteReport={onDeleteReport}
onOpenSettings={vi.fn()}
/>
)
await user.click(screen.getByRole('button', { name: '删除日报' }))
await user.click(screen.getByRole('button', { name: '删除', exact: true }))
const pendingButton = screen.getByRole('button', { name: '删除中…' })
expect(pendingButton).toBeDisabled()
await user.click(pendingButton)
expect(onDeleteReport).toHaveBeenCalledTimes(1)
settleDelete?.({ success: false, error: '文件正在使用' })
expect(await screen.findByText('文件正在使用')).toBeVisible()
expect(screen.getByRole('alertdialog', { name: '删除日报?' })).toBeVisible()
await user.click(screen.getByRole('button', { name: '删除', exact: true }))
await waitFor(() => expect(onDeleteReport).toHaveBeenCalledTimes(2))
await waitFor(() =>
expect(screen.queryByRole('alertdialog', { name: '删除日报?' })).not.toBeInTheDocument()
)
})
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', () => {
const onChange = vi.fn() const onChange = vi.fn()
render(<ReportTemplateSelector value="v1" onChange={onChange} />) render(<ReportTemplateSelector value="v1" onChange={onChange} />)
@@ -0,0 +1,76 @@
import { useState } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { SkillPreviewDialog } from '../../src/renderer/src/features/api-center/components/SkillPreviewDialog'
const content = `# TraceMemo Reader
##
-
访`
function Harness({ onClose = vi.fn() }: { onClose?: () => void }): React.ReactElement {
const [open, setOpen] = useState(false)
return (
<>
<button type="button" onClick={() => setOpen(true)}>
Skill
</button>
{open && (
<SkillPreviewDialog
content={content}
version="v1.2"
onClose={() => {
onClose()
setOpen(false)
}}
/>
)}
</>
)
}
describe('SkillPreviewDialog', () => {
it('renders the line-based preview and switches between rendered and raw content', async () => {
const user = userEvent.setup()
render(<Harness />)
await user.click(screen.getByRole('button', { name: '预览 Skill' }))
const dialog = screen.getByRole('dialog', { name: 'TraceMemo Reader Skill 预览' })
expect(dialog).toBeVisible()
expect(screen.getByText('v1.2')).toBeVisible()
expect(screen.getByRole('heading', { name: '能力' })).toBeVisible()
expect(screen.getByText('读取本地聊天记录')).toBeVisible()
expect(screen.queryByText(content)).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '原始文本' }))
expect(dialog.querySelector('pre')?.textContent).toBe(content)
await user.click(screen.getByRole('button', { name: '渲染预览' }))
expect(screen.getByRole('heading', { name: '能力' })).toBeVisible()
})
it('closes with Escape or the overlay and restores focus to the opener', async () => {
const user = userEvent.setup()
const onClose = vi.fn()
render(<Harness onClose={onClose} />)
const opener = screen.getByRole('button', { name: '预览 Skill' })
await user.click(opener)
await user.keyboard('{Escape}')
expect(
screen.queryByRole('dialog', { name: 'TraceMemo Reader Skill 预览' })
).not.toBeInTheDocument()
await waitFor(() => expect(opener).toHaveFocus())
await user.click(opener)
const dialog = screen.getByRole('dialog', { name: 'TraceMemo Reader Skill 预览' })
await user.click(dialog.previousElementSibling as HTMLElement)
expect(
screen.queryByRole('dialog', { name: 'TraceMemo Reader Skill 预览' })
).not.toBeInTheDocument()
await waitFor(() => expect(opener).toHaveFocus())
expect(onClose).toHaveBeenCalledTimes(2)
})
})
@@ -1,7 +1,15 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { TextToSpeechPage } from '../../src/renderer/src/features/settings/pages/TextToSpeechPage' import { TextToSpeechPage } from '../../src/renderer/src/features/settings/pages/TextToSpeechPage'
vi.mock('../../src/renderer/src/utils/runtime-environment', () => ({
isMac: true,
isWindows: false,
runtimePlatform: 'darwin',
supportsPersonalWechatSend: true
}))
const getSettings = vi.fn() const getSettings = vi.fn()
const saveSettings = vi.fn() const saveSettings = vi.fn()
const listVoices = vi.fn() const listVoices = vi.fn()
@@ -45,6 +53,7 @@ const response = {
describe('TextToSpeechPage', () => { describe('TextToSpeechPage', () => {
beforeEach(() => { beforeEach(() => {
sessionStorage.clear()
getSettings.mockReset().mockResolvedValue(response) getSettings.mockReset().mockResolvedValue(response)
listVoices.mockReset().mockResolvedValue({ listVoices.mockReset().mockResolvedValue({
success: true, success: true,
@@ -126,4 +135,40 @@ describe('TextToSpeechPage', () => {
fireEvent.click(screen.getByRole('button', { name: '前往 api.fish.audio 获取 Key' })) fireEvent.click(screen.getByRole('button', { name: '前往 api.fish.audio 获取 Key' }))
await waitFor(() => expect(openApiKeys).toHaveBeenCalledTimes(1)) await waitFor(() => expect(openApiKeys).toHaveBeenCalledTimes(1))
}) })
it('opens supported versions from both triggers and restores focus after closing', async () => {
const user = userEvent.setup()
render(<TextToSpeechPage onNotice={vi.fn()} />)
const guideTrigger = await screen.findByRole('button', { name: '查看支持版本' })
await user.click(guideTrigger)
expect(screen.getByRole('dialog', { name: '支持的微信版本' })).toBeVisible()
expect(screen.getByText('请安装下列完整版本之一。')).toBeVisible()
expect(screen.getByRole('link', { name: /下载微信历史版本/ })).toHaveAttribute(
'href',
'https://github.com/zsbai/wechat-versions/releases'
)
expect(screen.getByText('4.1.6.12')).toBeVisible()
expect(screen.getByText('4.1.11.53')).toBeVisible()
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog', { name: '支持的微信版本' })).not.toBeInTheDocument()
expect(guideTrigger).toHaveFocus()
const runtimeTrigger = screen.getByRole('button', { name: '支持版本' })
await user.click(runtimeTrigger)
const dialog = screen.getByRole('dialog', { name: '支持的微信版本' })
expect(dialog).toBeVisible()
await user.click(dialog.previousElementSibling as HTMLElement)
expect(screen.queryByRole('dialog', { name: '支持的微信版本' })).not.toBeInTheDocument()
expect(runtimeTrigger).toHaveFocus()
})
it('keeps the session handoff that opens supported versions automatically', async () => {
sessionStorage.setItem('wxe:show-supported-wechat-versions', '1')
render(<TextToSpeechPage onNotice={vi.fn()} />)
expect(await screen.findByRole('dialog', { name: '支持的微信版本' })).toBeVisible()
expect(sessionStorage.getItem('wxe:show-supported-wechat-versions')).toBeNull()
})
}) })
@@ -0,0 +1,116 @@
import { useState } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { WechatShareCardDialog } from '../../src/renderer/src/components/reports/WechatShareCardDialog'
const getConfig = vi.fn()
const saveConfig = vi.fn()
const publishCard = vi.fn()
function Harness(): React.ReactElement {
const [open, setOpen] = useState(false)
return (
<>
<button type="button" onClick={() => setOpen(true)}>
</button>
{open && (
<WechatShareCardDialog
pngPath="/tmp/report.png"
initialTitle="测试群日报"
initialDescription="日报摘要"
onClose={() => setOpen(false)}
/>
)}
</>
)
}
describe('WechatShareCardDialog', () => {
beforeEach(() => {
getConfig.mockReset().mockResolvedValue({
success: true,
configured: true,
serviceUrl: 'https://share.example.test'
})
saveConfig.mockReset().mockResolvedValue({ success: true })
publishCard.mockReset()
Object.defineProperty(window, 'api', {
configurable: true,
value: {
getWechatShareConfig: getConfig,
saveWechatShareConfig: saveConfig,
publishWechatShareCard: publishCard
}
})
})
it('closes with Escape or the overlay and restores focus to the opener', async () => {
const user = userEvent.setup()
render(<Harness />)
const opener = screen.getByRole('button', { name: '打开分享卡片' })
await user.click(opener)
expect(screen.getByRole('dialog', { name: '生成微信分享卡片' })).toBeVisible()
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog', { name: '生成微信分享卡片' })).not.toBeInTheDocument()
await waitFor(() => expect(opener).toHaveFocus())
await user.click(opener)
const dialog = screen.getByRole('dialog', { name: '生成微信分享卡片' })
await user.click(dialog.previousElementSibling as HTMLElement)
expect(screen.queryByRole('dialog', { name: '生成微信分享卡片' })).not.toBeInTheDocument()
await waitFor(() => expect(opener).toHaveFocus())
})
it('keeps the dialog open when Escape is pressed while publishing', async () => {
const user = userEvent.setup()
publishCard.mockImplementation(() => new Promise(() => undefined))
render(<Harness />)
await user.click(screen.getByRole('button', { name: '打开分享卡片' }))
await waitFor(() => expect(getConfig).toHaveBeenCalledOnce())
await user.click(screen.getByRole('button', { name: '生成二维码' }))
expect(screen.getByRole('button', { name: '正在生成卡片…' })).toBeDisabled()
await user.keyboard('{Escape}')
expect(screen.getByRole('dialog', { name: '生成微信分享卡片' })).toBeVisible()
})
it('saves first-time configuration, publishes the card, and copies its link', async () => {
const user = userEvent.setup()
const writeText = vi.spyOn(navigator.clipboard, 'writeText')
getConfig.mockResolvedValue({ success: true, configured: false })
publishCard.mockResolvedValue({
success: true,
shareUrl: 'https://share.example.test/card-1',
qrCodeDataUrl: 'data:image/png;base64,fixture',
expiresAt: '2026-08-26T08:00:00.000Z'
})
render(<Harness />)
await user.click(screen.getByRole('button', { name: '打开分享卡片' }))
const uploadToken = await screen.findByPlaceholderText('Cloudflare Worker 的 UPLOAD_TOKEN')
await user.clear(uploadToken)
await user.type(uploadToken, 'a'.repeat(24))
await user.click(screen.getByRole('button', { name: '生成二维码' }))
await waitFor(() =>
expect(saveConfig).toHaveBeenCalledWith({
serviceUrl: 'https://share.example.com',
uploadToken: 'a'.repeat(24)
})
)
expect(publishCard).toHaveBeenCalledWith({
pngPath: '/tmp/report.png',
title: '测试群日报',
description: '日报摘要',
expiresInDays: 7
})
expect(await screen.findByAltText('微信分享二维码')).toBeVisible()
await user.click(screen.getByRole('button', { name: '复制分享链接' }))
await waitFor(() => expect(writeText).toHaveBeenCalledWith('https://share.example.test/card-1'))
expect(await screen.findByRole('button', { name: '链接已复制' })).toBeVisible()
})
})
+215 -10
View File
@@ -4,6 +4,13 @@ import { tmpdir } from 'os'
import { resolve } from 'path' import { resolve } from 'path'
import { launchTestApp } from './support/electron' import { launchTestApp } from './support/electron'
async function dismissFirstUseWelcome(page: import('@playwright/test').Page): Promise<void> {
const welcome = page.getByRole('dialog', { name: '开始探索你的微信' })
await expect(welcome).toBeVisible()
await welcome.getByRole('button', { name: '关闭' }).click()
await expect(welcome).toHaveCount(0)
}
test('APP-01 first launch renders a usable connection screen without uncaught errors', async () => { test('APP-01 first launch renders a usable connection screen without uncaught errors', async () => {
const fixture = await launchTestApp({ mode: 'disconnected' }) const fixture = await launchTestApp({ mode: 'disconnected' })
const pageErrors: Error[] = [] const pageErrors: Error[] = []
@@ -29,6 +36,7 @@ test('KEY-01 KEY-02 invalid key remains recoverable and valid key enters the app
await expect(keyInput).toBeVisible() await expect(keyInput).toBeVisible()
await keyInput.fill('a'.repeat(64)) await keyInput.fill('a'.repeat(64))
await fixture.page.getByRole('button', { name: '连接数据库' }).click() await fixture.page.getByRole('button', { name: '连接数据库' }).click()
await dismissFirstUseWelcome(fixture.page)
await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible() await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible()
} finally { } finally {
await fixture.close() await fixture.close()
@@ -67,8 +75,13 @@ test('P2-01 P2-02 guided connection exposes safe diagnostics and completes all s
await fixture.page.getByRole('button', { name: '检查完成,继续' }).click() await fixture.page.getByRole('button', { name: '检查完成,继续' }).click()
await fixture.page.getByRole('button', { name: '我已准备好' }).click() await fixture.page.getByRole('button', { name: '我已准备好' }).click()
await fixture.page.getByRole('button', { name: '开始准备连接组件' }).click() await fixture.page.getByRole('button', { name: '开始准备连接组件' }).click()
await expect(fixture.page.getByRole('button', { name: '微信已登录,验证连接' })).toBeEnabled() const verifyConnection = fixture.page.getByRole('button', {
await fixture.page.getByRole('button', { name: '微信已登录,验证连接' }).click() name: '验证连接',
exact: true
})
await expect(verifyConnection).toBeEnabled()
await verifyConnection.click()
await dismissFirstUseWelcome(fixture.page)
await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible() await expect(fixture.page.getByRole('navigation', { name: '一级导航' })).toBeVisible()
} finally { } finally {
await fixture.close() await fixture.close()
@@ -114,9 +127,130 @@ test('NAV-01 NAV-02 every top-level page is unique and switchable', async () =>
} }
}) })
test('API-01 masks, reveals, and confirms rotation of the local API token', async () => { test('CHAT-01 archive More menu is keyboard-safe and keeps the page usable', async () => {
const fixture = await launchTestApp() const fixture = await launchTestApp()
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try { try {
await fixture.setWindowContentSize({ width: 1000, height: 650 })
await fixture.page.getByText('产品测试群', { exact: true }).click()
const moreButton = fixture.page.getByRole('button', { name: '更多' })
await moreButton.click()
await expect(fixture.page.getByRole('menuitem', { name: '刷新数据' })).toBeVisible()
await fixture.page.keyboard.press('Escape')
await expect(fixture.page.getByRole('menuitem', { name: '刷新数据' })).toHaveCount(0)
await expect(moreButton).toBeFocused()
await moreButton.click()
await fixture.page.getByRole('menuitem', { name: '刷新数据' }).click()
await expect(fixture.page.getByRole('heading', { name: '产品测试群' })).toBeVisible()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
} finally {
await fixture.close()
}
})
test('CHAT-02 personal WeChat send dialog is keyboard-safe and fits the viewport', async () => {
test.skip(process.platform !== 'darwin', 'Personal WeChat sending is currently macOS-only')
const fixture = await launchTestApp()
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 1000, height: 650 })
await fixture.page.getByText('产品测试群', { exact: true }).click()
const trigger = fixture.page.getByRole('button', { name: '发送消息' })
await trigger.click()
const dialog = fixture.page.getByRole('dialog', { name: '个人微信测试发送' })
await expect(dialog).toBeVisible()
await expect(
dialog.locator('.personal-wechat-send-status strong').filter({ hasText: '个人微信已绑定' })
).toBeVisible()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(await dialog.evaluate((element) => element.scrollWidth <= element.clientWidth)).toBe(
true
)
const bounds = await dialog.boundingBox()
expect(bounds).not.toBeNull()
expect(bounds!.y).toBeGreaterThanOrEqual(0)
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(650)
expect(pageErrors).toEqual([])
await fixture.page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0)
await expect(trigger).toBeFocused()
} finally {
await fixture.close()
}
})
test('GUIDE-01 first-use welcome is keyboard-safe and fits the viewport', async () => {
const fixture = await launchTestApp()
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 1000, height: 650 })
const guideButton = fixture.page.getByRole('button', { name: '新手引导' })
await guideButton.click()
const dialog = fixture.page.getByRole('dialog', { name: '开始探索你的微信' })
await expect(dialog).toBeVisible()
await expect(dialog.getByRole('button', { name: /试试 AI 群聊日报/ })).toBeVisible()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
await fixture.page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0)
await expect(guideButton).toBeFocused()
} finally {
await fixture.close()
}
})
test('SETTINGS-01 supported WeChat versions dialog is keyboard-safe and fits the viewport', async () => {
test.skip(process.platform !== 'darwin', 'The personal WeChat runtime is currently macOS-only')
const fixture = await launchTestApp()
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 1000, height: 650 })
await fixture.page
.getByRole('navigation', { name: '一级导航' })
.getByRole('button', { name: '设置' })
.click()
await fixture.page.getByRole('button', { name: '文字转语音' }).click()
const trigger = fixture.page.getByRole('button', { name: '查看支持版本' })
await expect(trigger).toBeVisible()
await trigger.click()
const dialog = fixture.page.getByRole('dialog', { name: '支持的微信版本' })
await expect(dialog).toBeVisible()
await expect(dialog.getByText('4.1.6.12')).toBeVisible()
await expect(dialog.getByText('4.1.11.53')).toBeVisible()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
await fixture.page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0)
await expect(trigger).toBeFocused()
} finally {
await fixture.close()
}
})
test('API-01 manages the local API token and previews the Reader Skill safely', async () => {
const fixture = await launchTestApp()
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 1000, height: 650 })
await fixture.page.getByRole('button', { name: 'API' }).click() await fixture.page.getByRole('button', { name: 'API' }).click()
await expect(fixture.page.getByText('API Token', { exact: true })).toBeVisible() await expect(fixture.page.getByText('API Token', { exact: true })).toBeVisible()
await expect(fixture.page.getByText('••••••••••••••••')).toBeVisible() await expect(fixture.page.getByText('••••••••••••••••')).toBeVisible()
@@ -128,6 +262,30 @@ test('API-01 masks, reveals, and confirms rotation of the local API token', asyn
fixture.page.once('dialog', (dialog) => dialog.accept()) fixture.page.once('dialog', (dialog) => dialog.accept())
await fixture.page.getByRole('button', { name: '重新生成 Token' }).click() await fixture.page.getByRole('button', { name: '重新生成 Token' }).click()
await expect(fixture.page.getByText('Token 已生成')).toBeVisible() await expect(fixture.page.getByText('Token 已生成')).toBeVisible()
const previewTrigger = fixture.page
.locator('#api-reader-skill')
.getByRole('button', { name: '预览 Skill' })
await expect(previewTrigger).toBeEnabled()
await previewTrigger.click()
const previewDialog = fixture.page.getByRole('dialog', {
name: 'TraceMemo Reader Skill 预览'
})
await expect(previewDialog).toBeVisible()
await expect(previewDialog.getByRole('heading', { name: '能力' })).toBeVisible()
await previewDialog.getByRole('button', { name: '原始文本' }).click()
await expect(previewDialog.getByText(/# TraceMemo Reader/)).toBeVisible()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(
await previewDialog.evaluate((element) => element.scrollWidth <= element.clientWidth)
).toBe(true)
expect(pageErrors).toEqual([])
await fixture.page.keyboard.press('Escape')
await expect(previewDialog).toHaveCount(0)
await expect(previewTrigger).toBeFocused()
} finally { } finally {
await fixture.close() await fixture.close()
} }
@@ -135,7 +293,10 @@ test('API-01 masks, reveals, and confirms rotation of the local API token', asyn
test('EXPORT-01 multi-chat selection stays local to export and forces HTML', async () => { test('EXPORT-01 multi-chat selection stays local to export and forces HTML', async () => {
const fixture = await launchTestApp() const fixture = await launchTestApp()
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try { try {
await fixture.setWindowContentSize({ width: 1000, height: 650 })
const navigation = fixture.page.getByRole('navigation', { name: '一级导航' }) const navigation = fixture.page.getByRole('navigation', { name: '一级导航' })
await fixture.page.getByRole('button', { name: '联系人 (1)' }).click() await fixture.page.getByRole('button', { name: '联系人 (1)' }).click()
await fixture.page.getByText('文件传输助手', { exact: true }).click() await fixture.page.getByText('文件传输助手', { exact: true }).click()
@@ -157,6 +318,10 @@ test('EXPORT-01 multi-chat selection stays local to export and forces HTML', asy
await expect( await expect(
fixture.page.locator('.export-preview-bubble').filter({ hasText: '这是一条脱敏测试消息' }) fixture.page.locator('.export-preview-bubble').filter({ hasText: '这是一条脱敏测试消息' })
).toHaveCount(1) ).toHaveCount(1)
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
await navigation.getByRole('button', { name: '档案' }).click() await navigation.getByRole('button', { name: '档案' }).click()
await expect(fixture.page.getByText('转发多条内容', { exact: true })).toBeVisible() await expect(fixture.page.getByText('转发多条内容', { exact: true })).toBeVisible()
@@ -165,6 +330,26 @@ test('EXPORT-01 multi-chat selection stays local to export and forces HTML', asy
} }
}) })
test('LAYOUT-01 core workspaces fit a narrow desktop viewport without page errors', async () => {
const fixture = await launchTestApp()
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
const navigation = fixture.page.getByRole('navigation', { name: '一级导航' })
for (const pageName of ['档案', '问问微信', '日报', '导出', '设置']) {
await navigation.getByRole('button', { name: pageName }).click()
await expect(fixture.page.locator('main.app-shell-main')).not.toBeEmpty()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
}
expect(pageErrors).toEqual([])
} finally {
await fixture.close()
}
})
test('ARCH-01 ARCH-02 folded chats and supported message types are represented explicitly', async () => { test('ARCH-01 ARCH-02 folded chats and supported message types are represented explicitly', async () => {
const fixture = await launchTestApp() const fixture = await launchTestApp()
try { try {
@@ -173,9 +358,19 @@ test('ARCH-01 ARCH-02 folded chats and supported message types are represented e
await expect(fixture.page.getByText('这是一条脱敏测试消息', { exact: true })).toBeVisible() await expect(fixture.page.getByText('这是一条脱敏测试消息', { exact: true })).toBeVisible()
await expect(fixture.page.getByText('暂不支持此消息', { exact: true })).toBeVisible() await expect(fixture.page.getByText('暂不支持此消息', { exact: true })).toBeVisible()
await expect(fixture.page.getByAltText('图片')).toBeVisible() await expect(fixture.page.getByAltText('图片')).toBeVisible()
await fixture.page.locator('.image-bubble.image-loaded').click() const imageTrigger = fixture.page.getByRole('button', { name: '查看图片' })
await expect(fixture.page.getByText('图片查看', { exact: true })).toBeVisible() await imageTrigger.click()
await fixture.page.locator('.image-viewer-overlay').click({ position: { x: 5, y: 5 } }) const imageDialog = fixture.page.getByRole('dialog', { name: '图片查看' })
await expect(imageDialog).toBeVisible()
await imageDialog.getByRole('button', { name: '放大' }).click()
await expect(imageDialog.getByText('110%')).toBeVisible()
await imageDialog.getByRole('button', { name: '右旋转' }).click()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
await fixture.page.keyboard.press('Escape')
await expect(imageDialog).toHaveCount(0)
await expect(imageTrigger).toBeFocused()
await fixture.page.getByRole('button', { name: '折叠群聊 (1)' }).click() await fixture.page.getByRole('button', { name: '折叠群聊 (1)' }).click()
await expect(fixture.page.getByText('折叠群聊样本', { exact: true })).toBeVisible() await expect(fixture.page.getByText('折叠群聊样本', { exact: true })).toBeVisible()
@@ -291,11 +486,21 @@ test('REPORT-01 REPORT-02 generates a fixed report with non-empty local assets',
await expect(fixture.page.getByText('固定响应模型')).toBeVisible() await expect(fixture.page.getByText('固定响应模型')).toBeVisible()
await expect(fixture.page.getByText('图片模型')).toBeVisible() await expect(fixture.page.getByText('图片模型')).toBeVisible()
await expect(fixture.page.getByText('固定图片识别模型')).toBeVisible() await expect(fixture.page.getByText('固定图片识别模型')).toBeVisible()
await expect(fixture.page.getByRole('button', { name: '生成微信卡片' })).toHaveCount(0) await expect(fixture.page.getByRole('menuitem', { name: '生成微信卡片' })).toHaveCount(0)
await fixture.page.getByRole('button', { name: '更多' }).click() const moreButton = fixture.page.getByRole('button', { name: '更多' })
await expect(fixture.page.getByRole('button', { name: '生成微信卡片' })).toBeVisible() await moreButton.click()
await fixture.page.getByRole('menuitem', { name: '生成微信卡片' }).click()
const shareDialog = fixture.page.getByRole('dialog', { name: '生成微信分享卡片' })
await expect(shareDialog).toBeVisible()
await expect(shareDialog.locator('input').first()).toHaveValue(/产品测试群日报/)
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
await fixture.page.keyboard.press('Escape')
await expect(shareDialog).toHaveCount(0)
await expect(moreButton).toBeFocused()
await fixture.page.setViewportSize({ width: 1024, height: 760 }) await fixture.setWindowContentSize({ width: 1024, height: 760 })
const reportTitle = fixture.page.getByRole('heading', { name: '产品测试群 群聊日报' }) const reportTitle = fixture.page.getByRole('heading', { name: '产品测试群 群聊日报' })
await expect(reportTitle).toBeVisible() await expect(reportTitle).toBeVisible()
expect((await reportTitle.boundingBox())?.width || 0).toBeGreaterThan(170) expect((await reportTitle.boundingBox())?.width || 0).toBeGreaterThan(170)
+78 -4
View File
@@ -12,9 +12,11 @@ app.setPath('logs', path.join(userData, 'logs'))
app.commandLine.appendSwitch('disable-gpu') app.commandLine.appendSwitch('disable-gpu')
const VALID_KEY = 'a'.repeat(64) const VALID_KEY = 'a'.repeat(64)
const imageData = const imageData = `data:image/png;base64,${fs.readFileSync(path.join(root, 'resources/icon.png')).toString('base64')}`
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9ZQmcAAAAASUVORK5CYII='
const voiceData = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA=' const voiceData = 'UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA='
const configuredNow = Number(process.env.WXE_E2E_NOW_MS)
const fixtureNowMs =
Number.isFinite(configuredNow) && configuredNow > 0 ? configuredNow : Date.now()
const formatFixtureDateTime = (timestampSeconds) => { const formatFixtureDateTime = (timestampSeconds) => {
const date = new Date(timestampSeconds * 1000) const date = new Date(timestampSeconds * 1000)
@@ -24,7 +26,7 @@ const formatFixtureDateTime = (timestampSeconds) => {
const allFixtureMessages = Object.values(fixture.messages).flat() const allFixtureMessages = Object.values(fixture.messages).flat()
const latestFixtureTime = Math.max(...allFixtureMessages.map((message) => message.createTime || 0)) const latestFixtureTime = Math.max(...allFixtureMessages.map((message) => message.createTime || 0))
const fixtureTimeOffset = Math.floor(Date.now() / 1000) - 3600 - latestFixtureTime const fixtureTimeOffset = Math.floor(fixtureNowMs / 1000) - 3600 - latestFixtureTime
for (const message of allFixtureMessages) { for (const message of allFixtureMessages) {
message.createTime = (message.createTime || latestFixtureTime) + fixtureTimeOffset message.createTime = (message.createTime || latestFixtureTime) + fixtureTimeOffset
message.datetime = formatFixtureDateTime(message.createTime) message.datetime = formatFixtureDateTime(message.createTime)
@@ -206,7 +208,7 @@ let settings = {
debugEnabled: false, debugEnabled: false,
autoLogin: connected, autoLogin: connected,
autoLoginPreferenceSet: true, autoLoginPreferenceSet: true,
appearanceTheme: 'light', appearanceTheme: process.env.WXE_E2E_APPEARANCE_THEME === 'dark' ? 'dark' : 'light',
compactMode: false, compactMode: false,
showStartupProgress: false, showStartupProgress: false,
imageXorKey: '0x40', imageXorKey: '0x40',
@@ -241,6 +243,65 @@ handle('settings:set', (patch) => {
settings = { ...settings, ...patch } settings = { ...settings, ...patch }
return { settings, settingsPath: path.join(userData, 'settings.json') } return { settings, settingsPath: path.join(userData, 'settings.json') }
}) })
handle('tts:getSettings', () => ({
success: true,
settings: {
provider: 'fish-audio',
hasApiKey: false,
hasStoredApiKey: false,
hasEnvironmentApiKey: false,
keySource: 'missing',
encryptionAvailable: true,
selectedVoiceId: '',
outputFormat: 'mp3',
model: 's2.1-pro-free',
phase: 'ready'
},
voices: []
}))
handle('wechat-personal:getRuntimeStatus', () => ({
version: 'v0.0.18',
state: 'ready',
downloadedBytes: 100,
totalBytes: 100,
progress: 1,
platform: 'darwin',
architecture: 'arm64',
supported: true,
removable: true
}))
handle('wechat-personal:getStatus', () => ({
state: 'online',
platform: 'darwin',
arch: 'arm64',
sipDisabled: true,
wechatRunning: true,
wechatPid: 4668,
boundWechatPid: 4668,
oneBotPid: 5401,
endpoint: '127.0.0.1:58080',
endpointReady: true,
wechatVersion: '4.1.11.53',
runtimeReady: true,
attachReady: true,
baseAddress: '0x114ef8000',
baseAddressReady: true,
textHookInstalled: true,
textHookReady: true,
imageHookInstalled: true,
imageHookReady: true,
messageListenerReady: true,
canSend: true,
canSendText: true,
canSendImage: true,
canSendVoice: true,
message: '个人微信已绑定'
}))
handle('wechat-share:getConfig', () => ({
success: true,
configured: true,
serviceUrl: 'https://share.example.test'
}))
handle('key:getSavedDbKey', () => ({ handle('key:getSavedDbKey', () => ({
success: true, success: true,
key: savedKey || undefined, key: savedKey || undefined,
@@ -579,6 +640,19 @@ handle('api:rotateToken', () => ({
maskedToken: '••••••••••••••••' maskedToken: '••••••••••••••••'
})) }))
handle('api:copyCurl', () => ({ success: true })) handle('api:copyCurl', () => ({ success: true }))
handle('api:skillStatus', () => ({
available: true,
version: 'v1.2',
filePath: '/fixture/tracememo-reader/SKILL.md',
directoryPath: '/fixture/tracememo-reader',
source: 'development',
githubUrl: 'https://example.test/tracememo-reader'
}))
handle('api:readSkill', () => ({
success: true,
content:
'# TraceMemo Reader\n\n## 能力\n- 读取本地聊天记录\n- 导出群聊日报\n\n仅在用户授权后访问。'
}))
handle('api:start', () => ({ running: true, host: settings.apiHost, port: settings.apiPort })) handle('api:start', () => ({ running: true, host: settings.apiHost, port: settings.apiPort }))
handle('api:stop', () => ({ running: false, host: settings.apiHost, port: settings.apiPort })) handle('api:stop', () => ({ running: false, host: settings.apiHost, port: settings.apiPort }))
handle('api:toggle', (enabled) => ({ handle('api:toggle', (enabled) => ({
+18 -1
View File
@@ -10,6 +10,7 @@ export interface TestApplication {
app: ElectronApplication app: ElectronApplication
page: Page page: Page
userData: string userData: string
setWindowContentSize: (size: { width: number; height: number }) => Promise<void>
close: () => Promise<void> close: () => Promise<void>
} }
@@ -20,6 +21,8 @@ export async function launchTestApp(
largeContacts?: number largeContacts?: number
corruptCache?: boolean corruptCache?: boolean
aiFailure?: string aiFailure?: string
now?: number
appearanceTheme?: 'light' | 'dark'
} = {} } = {}
): Promise<TestApplication> { ): Promise<TestApplication> {
const ownsDirectory = !options.userData const ownsDirectory = !options.userData
@@ -39,15 +42,29 @@ export async function launchTestApp(
WXE_E2E_MODE: options.mode || 'connected', WXE_E2E_MODE: options.mode || 'connected',
WXE_E2E_LARGE_CONTACTS: String(options.largeContacts || 0), WXE_E2E_LARGE_CONTACTS: String(options.largeContacts || 0),
WXE_E2E_CORRUPT_CACHE: options.corruptCache ? '1' : '0', WXE_E2E_CORRUPT_CACHE: options.corruptCache ? '1' : '0',
WXE_E2E_AI_FAILURE: options.aiFailure || '' WXE_E2E_AI_FAILURE: options.aiFailure || '',
WXE_E2E_NOW_MS: options.now ? String(options.now) : '',
WXE_E2E_APPEARANCE_THEME: options.appearanceTheme || 'light'
} }
}) })
const page = await app.firstWindow() const page = await app.firstWindow()
await page.waitForLoadState('domcontentloaded') await page.waitForLoadState('domcontentloaded')
const setWindowContentSize = async (size: { width: number; height: number }): Promise<void> => {
await app.evaluate(({ BrowserWindow }, nextSize) => {
const [window] = BrowserWindow.getAllWindows()
if (!window) throw new Error('E2E BrowserWindow is unavailable')
window.setContentSize(nextSize.width, nextSize.height)
}, size)
await page.waitForFunction(
(nextSize) => window.innerWidth === nextSize.width && window.innerHeight === nextSize.height,
size
)
}
return { return {
app, app,
page, page,
userData, userData,
setWindowContentSize,
close: async () => { close: async () => {
if (!page.isClosed() && closeDelayMs > 0) await page.waitForTimeout(closeDelayMs) if (!page.isClosed() && closeDelayMs > 0) await page.waitForTimeout(closeDelayMs)
await app.close().catch(() => undefined) await app.close().catch(() => undefined)
+208 -3
View File
@@ -5,6 +5,14 @@ import { launchTestApp } from './support/electron'
const baselineDirectory = resolve(`tests/e2e/__screenshots__/${process.platform}/visual.spec.ts`) const baselineDirectory = resolve(`tests/e2e/__screenshots__/${process.platform}/visual.spec.ts`)
const visualViewport = { width: 1000, height: 650 } const visualViewport = { width: 1000, height: 650 }
const visualNow = Date.parse('2026-08-19T14:46:40+08:00')
async function clearScreenshotFocus(page: import('@playwright/test').Page): Promise<void> {
await page.evaluate(() => {
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
})
}
test.skip( test.skip(
!existsSync(baselineDirectory) && process.env.WXE_UPDATE_VISUAL_BASELINES !== '1', !existsSync(baselineDirectory) && process.env.WXE_UPDATE_VISUAL_BASELINES !== '1',
`No reviewed ${process.platform} visual baseline is committed yet` `No reviewed ${process.platform} visual baseline is committed yet`
@@ -13,8 +21,9 @@ test.skip(
test('NAV-01 login page visual @visual', async () => { test('NAV-01 login page visual @visual', async () => {
const fixture = await launchTestApp({ mode: 'disconnected' }) const fixture = await launchTestApp({ mode: 'disconnected' })
try { try {
await fixture.page.setViewportSize(visualViewport) await fixture.setWindowContentSize(visualViewport)
await expect(fixture.page.getByRole('heading', { name: 'TraceMemo(迹忆)' })).toBeVisible() await expect(fixture.page.getByRole('heading', { name: 'TraceMemo(迹忆)' })).toBeVisible()
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot('login-page.png', { await expect(fixture.page).toHaveScreenshot('login-page.png', {
animations: 'disabled', animations: 'disabled',
caret: 'hide' caret: 'hide'
@@ -25,11 +34,12 @@ test('NAV-01 login page visual @visual', async () => {
}) })
test('ARCH-01 archive page visual @visual', async () => { test('ARCH-01 archive page visual @visual', async () => {
const fixture = await launchTestApp() const fixture = await launchTestApp({ now: visualNow })
try { try {
await fixture.page.setViewportSize(visualViewport) await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByText('产品测试群', { exact: true }).click() await fixture.page.getByText('产品测试群', { exact: true }).click()
await expect(fixture.page.getByText('这是一条脱敏测试消息', { exact: true })).toBeVisible() await expect(fixture.page.getByText('这是一条脱敏测试消息', { exact: true })).toBeVisible()
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot('archive-page.png', { await expect(fixture.page).toHaveScreenshot('archive-page.png', {
animations: 'disabled', animations: 'disabled',
caret: 'hide' caret: 'hide'
@@ -38,3 +48,198 @@ test('ARCH-01 archive page visual @visual', async () => {
await fixture.close() await fixture.close()
} }
}) })
test('ASK-01 AI Search idle 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: '问问微信' }).click()
await expect(fixture.page.getByRole('heading', { name: '问问你的微信' })).toBeVisible()
await expect(fixture.page.getByPlaceholder(/例如:技术交流群/)).toBeVisible()
await expect(fixture.page.getByRole('main', { name: '问问微信' })).not.toBeEmpty()
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.png', {
animations: 'disabled',
caret: 'hide'
})
} finally {
await fixture.close()
}
})
test('ASK-03 AI Search result 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: '问问微信' }).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.getByRole('button', { name: /选择证据 E1/ })).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('ai-search-result-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[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: 'API' }).click()
await fixture.page
.locator('#api-reader-skill')
.getByRole('button', { name: '预览 Skill' })
.click()
await expect(
fixture.page.getByRole('dialog', { name: 'TraceMemo Reader Skill 预览' })
).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-skill-preview.png', {
animations: 'disabled',
caret: 'hide'
})
} finally {
await fixture.close()
}
})
test('CHAT-02 personal WeChat send dialog visual @visual', async () => {
test.skip(process.platform !== 'darwin', 'Personal WeChat sending is currently macOS-only')
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()
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', {
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[] = []
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()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot('chat-image-viewer.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[] = []
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.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('export-workspace-idle.png', {
animations: 'disabled',
caret: 'hide'
})
} finally {
await fixture.close()
}
})
test('THEME-01 archive 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.getByText('产品测试群', { exact: true }).click()
await expect(fixture.page.getByText('这是一条脱敏测试消息', { exact: true })).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('archive-page-dark.png', {
animations: 'disabled',
caret: 'hide'
})
} finally {
await fixture.close()
}
})
test('THEME-02 export workspace 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('export-workspace-dark.png', {
animations: 'disabled',
caret: 'hide'
})
} finally {
await fixture.close()
}
})
@@ -0,0 +1,102 @@
import { describe, expect, it, vi } from 'vitest'
import { ensureAiSearchDataConsent } from '../../src/renderer/src/components/search/services/aiSearchProviderConsent'
const makeApi = (): {
getAiSearchProviderStatus: ReturnType<typeof vi.fn>
authorizeAiSearchExternalProvider: ReturnType<typeof vi.fn>
} => ({
getAiSearchProviderStatus: vi.fn(),
authorizeAiSearchExternalProvider: vi.fn()
})
describe('ensureAiSearchDataConsent', () => {
it('bypasses consent when the configured provider does not require it', async () => {
const api = makeApi()
api.getAiSearchProviderStatus.mockResolvedValue({ configured: true, requiresConsent: false })
const requestConsent = vi.fn()
await expect(
ensureAiSearchDataConsent({
requestId: 'req-1',
api,
requestExternalProviderConsent: requestConsent
})
).resolves.toBe(true)
expect(requestConsent).not.toHaveBeenCalled()
expect(api.authorizeAiSearchExternalProvider).not.toHaveBeenCalled()
})
it('preserves provider fields through consent and authorization', async () => {
const api = makeApi()
api.getAiSearchProviderStatus.mockResolvedValue({
configured: true,
requiresConsent: true,
providerId: 'provider-1',
providerName: 'Remote Provider',
recipient: 'remote@example.test'
})
api.authorizeAiSearchExternalProvider.mockResolvedValue({ success: true })
const requestConsent = vi.fn().mockResolvedValue(true)
await expect(
ensureAiSearchDataConsent({
requestId: 'req-1',
api,
requestExternalProviderConsent: requestConsent
})
).resolves.toBe(true)
expect(requestConsent).toHaveBeenCalledWith('Remote Provider', 'remote@example.test')
expect(api.authorizeAiSearchExternalProvider).toHaveBeenCalledWith({
requestId: 'req-1',
providerId: 'provider-1',
recipient: 'remote@example.test'
})
})
it('does not authorize when the user rejects consent', async () => {
const api = makeApi()
api.getAiSearchProviderStatus.mockResolvedValue({
configured: true,
requiresConsent: true,
providerId: 'provider-1',
recipient: 'remote@example.test'
})
const requestConsent = vi.fn().mockResolvedValue(false)
await expect(
ensureAiSearchDataConsent({
requestId: 'req-1',
api,
requestExternalProviderConsent: requestConsent
})
).resolves.toBe(false)
expect(api.authorizeAiSearchExternalProvider).not.toHaveBeenCalled()
})
it('preserves incomplete-status and authorization errors', async () => {
const api = makeApi()
api.getAiSearchProviderStatus.mockResolvedValue({ configured: true, requiresConsent: true })
await expect(
ensureAiSearchDataConsent({
requestId: 'req-1',
api,
requestExternalProviderConsent: vi.fn()
})
).rejects.toThrow('当前 AI 服务信息不完整')
api.getAiSearchProviderStatus.mockResolvedValue({
configured: true,
requiresConsent: true,
providerId: 'provider-1',
recipient: 'remote@example.test'
})
api.authorizeAiSearchExternalProvider.mockResolvedValue({ success: false, error: '授权失败' })
await expect(
ensureAiSearchDataConsent({
requestId: 'req-1',
api,
requestExternalProviderConsent: vi.fn().mockResolvedValue(true)
})
).rejects.toThrow('授权失败')
})
})
@@ -1,5 +1,9 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { AiSearchFinalEvidence, AiSearchPipelineResult } from '../../src/shared/ai-search' import type {
AiSearchFinalEvidence,
AiSearchPipelineResult,
AiSearchTimeRange
} from '../../src/shared/ai-search'
import type { KnowledgeRuntimeStatus } from '../../src/shared/knowledge' import type { KnowledgeRuntimeStatus } from '../../src/shared/knowledge'
import type { Contact } from '../../src/shared/types' import type { Contact } from '../../src/shared/types'
import { import {
@@ -17,9 +21,14 @@ import {
mapEvidenceSenderNames, mapEvidenceSenderNames,
mapPipelineEvidence, mapPipelineEvidence,
mapPipelineEvidenceItem, mapPipelineEvidenceItem,
mapPipelineResultToRendererResult,
mapSearchResultToTrace mapSearchResultToTrace
} from '../../src/renderer/src/components/search/searchMappers' } from '../../src/renderer/src/components/search/searchMappers'
import { createSearchResultResetState } from '../../src/renderer/src/components/search/searchState' import {
createSearchResultResetState,
resolveSearchResultViewTransition
} from '../../src/renderer/src/components/search/searchState'
import { createSearchRequestContext } from '../../src/renderer/src/components/search/searchUtils'
import type { import type {
AISearchCacheRecord, AISearchCacheRecord,
EvidenceItem, EvidenceItem,
@@ -89,6 +98,113 @@ const makeTrace = (overrides: Partial<SearchTrace> = {}): SearchTrace => ({
...overrides ...overrides
}) })
describe('AI Search request context', () => {
it('trims only the submitted query while keeping cache query normalization unchanged', () => {
const context = createSearchRequestContext({
query: ' Mixed Case 问题 ',
scope: 'global',
range: '30d'
})
expect(context.normalizedQuery).toBe('Mixed Case 问题')
expect(context.cacheKey).toBe(JSON.stringify(['global', '', '30d', 'mixed case 问题']))
})
it.each(['global', 'groups', 'contacts'] as const)(
'does not attach a conversation to %s scope',
(scope) => {
const context = createSearchRequestContext({
query: '范围问题',
scope,
range: '7d',
activeContactMd5: 'ignored-contact'
})
expect(context.conversationId).toBeUndefined()
expect(context.cacheKey).toBe(JSON.stringify([scope, '', '7d', '范围问题']))
}
)
it('uses the active contact for conversation request and cache identity', () => {
const context = createSearchRequestContext({
query: '会话问题',
scope: 'conversation',
range: 'today',
activeContactMd5: aiSearchContact.md5
})
expect(context.conversationId).toBe(aiSearchContact.md5)
expect(context.cacheKey).toBe(
JSON.stringify(['conversation', aiSearchContact.md5, 'today', '会话问题'])
)
})
it('keeps a missing conversation undefined while using the empty cache slot', () => {
const context = createSearchRequestContext({
query: '未选择会话',
scope: 'conversation',
range: 'all'
})
expect(context.conversationId).toBeUndefined()
expect(context.cacheKey).toBe(JSON.stringify(['conversation', '', 'all', '未选择会话']))
})
it('uses retry range and retry time override when both are provided', () => {
const currentOverride: AiSearchTimeRange = {
label: '近 30 天',
reason: '当前选择',
source: 'user_selected'
}
const retryOverride: AiSearchTimeRange = {
label: '全部历史',
reason: '用户主动扩大到全部历史',
source: 'user_retry'
}
const context = createSearchRequestContext({
query: '重试问题',
scope: 'global',
range: '30d',
timeRangeOverride: currentOverride,
retry: { range: 'all', timeRangeOverride: retryOverride }
})
expect(context.effectiveRange).toBe('all')
expect(context.effectiveTimeRangeOverride).toBe(retryOverride)
expect(context.cacheKey).toBe(JSON.stringify(['global', '', 'all', '重试问题']))
})
it('falls back to the current time override when retry does not provide one', () => {
const currentOverride: AiSearchTimeRange = {
startTime: 123,
label: '近 30 天',
reason: '用户在界面选择的时间范围',
source: 'user_selected'
}
const context = createSearchRequestContext({
query: '保留当前时间范围',
scope: 'global',
range: '7d',
timeRangeOverride: currentOverride,
retry: { range: '30d', timeRangeOverride: undefined }
})
expect(context.effectiveRange).toBe('30d')
expect(context.effectiveTimeRangeOverride).toBe(currentOverride)
})
it('keeps the current range and undefined override when no retry exists', () => {
const context = createSearchRequestContext({
query: '普通问题',
scope: 'global',
range: '7d'
})
expect(context.effectiveRange).toBe('7d')
expect(context.effectiveTimeRangeOverride).toBeUndefined()
})
})
describe('AI Search workspace pure formatters', () => { describe('AI Search workspace pure formatters', () => {
it('formats byte counts exactly as the workspace did', () => { it('formats byte counts exactly as the workspace did', () => {
expect(formatBytes(0)).toBe('0 B') expect(formatBytes(0)).toBe('0 B')
@@ -211,6 +327,47 @@ describe('AI Search pipeline evidence mapping', () => {
}) })
describe('AI Search trace and cache mapping', () => { describe('AI Search trace and cache mapping', () => {
it('maps a pipeline result into the common Renderer state without mixing collection senders', () => {
const finalEvidence = makeFinalEvidence(1, { senderId: 'final-sender', sender: '总结发送者' })
const collectionEvidence = makeFinalEvidence(2, {
senderId: 'collection-sender',
sender: '浏览发送者'
})
const result = makeSearchResult({
evidence: [finalEvidence],
evidenceCollection: [finalEvidence, collectionEvidence]
})
const mapped = mapPipelineResultToRendererResult(result, [aiSearchContact])
expect(mapped.evidence.map((item) => item.evidenceId)).toEqual(['E1'])
expect(mapped.evidenceCollection.map((item) => item.evidenceId)).toEqual(['E1', 'E2'])
expect(mapped.senderNames).toEqual({ 'final-sender': '总结发送者' })
expect(mapped.messageCount).toBe(result.knowledge.totalMessages)
expect(mapped.searchTrace).toEqual(mapSearchResultToTrace(result, 1))
})
it('falls back to Final Evidence only when the runtime collection is missing', () => {
const evidence = [makeFinalEvidence(1)]
const result = makeSearchResult({ evidence })
;(
result as AiSearchPipelineResult & { evidenceCollection?: AiSearchFinalEvidence[] }
).evidenceCollection = undefined
const mapped = mapPipelineResultToRendererResult(result, [aiSearchContact])
expect(mapped.evidenceCollection).toEqual(mapped.evidence)
})
it('preserves an explicitly empty Evidence Collection without falling back', () => {
const result = makeSearchResult({ evidence: [makeFinalEvidence(1)], evidenceCollection: [] })
const mapped = mapPipelineResultToRendererResult(result, [aiSearchContact])
expect(mapped.evidence).toHaveLength(1)
expect(mapped.evidenceCollection).toEqual([])
})
it('maps pipeline trace fields and preserves the original default values', () => { it('maps pipeline trace fields and preserves the original default values', () => {
const result: AiSearchPipelineResult = makeSearchResult() const result: AiSearchPipelineResult = makeSearchResult()
@@ -392,6 +549,64 @@ describe('AI Search trace and cache mapping', () => {
}) })
}) })
describe('AI Search result view transition', () => {
it('maps no Evidence to the range-specific insufficient message and ignores pipeline error', () => {
const result = makeSearchResult({ status: 'no_evidence', error: '不应使用的错误' })
expect(resolveSearchResultViewTransition(result, '7d')).toEqual({
stage: 'insufficient',
analysisError: '近 7 天内没有找到与问题相关的聊天消息。'
})
})
it.each([
['retrieval_incomplete', 'partial', '当前检索未完整覆盖聊天记录,未生成总结。'],
['failed', 'insufficient', '本地搜索暂时无法完成'],
['ai_failed', 'partial', '证据已找到,但 AI 暂时无法生成回答']
] as const)('maps %s to its existing fallback presentation', (status, stage, analysisError) => {
expect(resolveSearchResultViewTransition(makeSearchResult({ status }), '30d')).toEqual({
stage,
analysisError
})
})
it.each([
['retrieval_incomplete', 'partial'],
['failed', 'insufficient'],
['ai_failed', 'partial']
] as const)('preserves the pipeline error for %s', (status, stage) => {
expect(
resolveSearchResultViewTransition(
makeSearchResult({ status, error: '主进程返回的错误' }),
'30d'
)
).toEqual({
stage,
analysisError: '主进程返回的错误'
})
})
it('keeps a completed answer for the Workspace success path', () => {
expect(
resolveSearchResultViewTransition(makeSearchResult({ answer: '完整回答' }), '30d')
).toEqual({
stage: 'result',
analysisError: '',
answer: '完整回答'
})
})
it('leaves a missing completed answer for the existing Workspace guard', () => {
const result = { ...makeSearchResult(), answer: undefined }
expect(resolveSearchResultViewTransition(result, '30d')).toEqual({
stage: 'result',
analysisError: '',
answer: undefined
})
})
})
describe('AI Search result reset state', () => { describe('AI Search result reset state', () => {
it('returns every existing search result reset value', () => { it('returns every existing search result reset value', () => {
expect(createSearchResultResetState()).toEqual({ expect(createSearchResultResetState()).toEqual({