mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-21 21:47:00 +08:00
feat: 完成 UI 基础层与核心页面迁移
This commit is contained in:
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 Key,16 位字符"
|
|
||||||
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 Files,macOS
|
|
||||||
通常是 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 导出中生效,CSV、JSON 和 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 导出中生效,CSV、JSON 和 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
|
||||||
|
}
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user