This commit is contained in:
cc committed 2026-06-03 19:13:07 +08:00
1 parent f3b0cf5d3a
commit 54016abac5
382 files changed
+2 -188190

No files matched your search

-73
View File
@@ -1,73 +0,0 @@
import React, { memo, useEffect, useState, useRef } from 'react'
interface AnimatedStreamingTextProps {
text: string
className?: string
loading?: boolean
}
export const AnimatedStreamingText = memo(({ text, className, loading }: AnimatedStreamingTextProps) => {
const [displayedSegments, setDisplayedSegments] = useState<string[]>([])
const prevTextRef = useRef('')
useEffect(() => {
const currentText = (text || '').trim()
const prevText = prevTextRef.current
if (currentText === prevText) return
if (!currentText.startsWith(prevText) && prevText !== '') {
// 如果不是追加而是全新的文本(比如重新识别),则重置
setDisplayedSegments([currentText])
prevTextRef.current = currentText
return
}
const newPart = currentText.slice(prevText.length)
if (newPart) {
// 将新部分作为单独的段加入,以触发动画
setDisplayedSegments(prev => [...prev, newPart])
}
prevTextRef.current = currentText
}, [text])
// 处理 loading 状态的显示
if (loading && !text) {
return <span className={className}>转写中<span className="dot-flashing">...</span></span>
}
return (
<span className={className}>
{displayedSegments.map((segment, index) => (
<span key={index} className="fade-in-text">
{segment}
</span>
))}
<style>{`
.fade-in-text {
animation: premiumFadeIn 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
opacity: 0;
display: inline-block;
filter: blur(4px);
}
@keyframes premiumFadeIn {
from {
opacity: 0;
transform: translateY(4px) scale(0.98);
filter: blur(4px);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
filter: blur(0);
}
}
.dot-flashing {
animation: blink 1s infinite;
}
@keyframes blink { 50% { opacity: 0; } }
`}</style>
</span>
)
})
AnimatedStreamingText.displayName = 'AnimatedStreamingText'
-104
View File
@@ -1,104 +0,0 @@
.avatar-component {
position: relative;
display: inline-block;
overflow: hidden;
background-color: var(--bg-tertiary, #f5f5f5);
flex-shrink: 0;
border-radius: 4px;
/* Default radius */
&.circle {
border-radius: 50%;
}
&.rounded {
border-radius: 6px;
}
/* Image styling */
img.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity 0.3s ease-in-out;
border-radius: inherit;
&.loaded {
opacity: 1;
}
&.instant {
transition: none !important;
opacity: 1 !important;
}
}
/* Placeholder/Letter styling */
.avatar-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 500;
color: var(--text-secondary, #666);
background-color: var(--bg-tertiary, #e0e0e0);
font-size: 1.2em;
text-transform: uppercase;
user-select: none;
border-radius: inherit;
}
.avatar-loading {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-tertiary, #999);
background-color: var(--bg-tertiary, #e0e0e0);
border-radius: inherit;
.avatar-loading-icon {
animation: avatar-spin 0.9s linear infinite;
}
}
/* Loading Skeleton */
.avatar-skeleton {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(90deg,
var(--bg-tertiary, #f0f0f0) 25%,
var(--bg-secondary, #e0e0e0) 50%,
var(--bg-tertiary, #f0f0f0) 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
z-index: 1;
border-radius: inherit;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@keyframes avatar-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
}
-170
View File
@@ -1,170 +0,0 @@
import React, { useState, useEffect, useRef, useMemo } from 'react'
import { Loader2, User } from 'lucide-react'
import { avatarLoadQueue } from '../utils/AvatarLoadQueue'
import './Avatar.scss'
// 全局缓存已成功加载过的头像 URL,用于控制后续是否显示动画
const loadedAvatarCache = new Set<string>()
const MAX_LOADED_AVATAR_CACHE_SIZE = 3000
const rememberLoadedAvatar = (src: string): void => {
if (!src) return
if (loadedAvatarCache.has(src)) {
loadedAvatarCache.delete(src)
}
loadedAvatarCache.add(src)
while (loadedAvatarCache.size > MAX_LOADED_AVATAR_CACHE_SIZE) {
const oldest = loadedAvatarCache.values().next().value as string | undefined
if (!oldest) break
loadedAvatarCache.delete(oldest)
}
}
interface AvatarProps {
src?: string
name?: string
size?: number | string
shape?: 'circle' | 'square' | 'rounded'
className?: string
lazy?: boolean
loading?: boolean
onClick?: () => void
}
export const Avatar = React.memo(function Avatar({
src,
name,
size = 48,
shape = 'rounded',
className = '',
lazy = true,
loading = false,
onClick
}: AvatarProps) {
// 如果 URL 已在缓存中,则直接标记为已加载,不显示骨架屏和淡入动画
const isCached = useMemo(() => src ? loadedAvatarCache.has(src) : false, [src])
const isFailed = useMemo(() => src ? avatarLoadQueue.hasFailed(src) : false, [src])
const [imageLoaded, setImageLoaded] = useState(isCached)
const [imageError, setImageError] = useState(isFailed)
const [shouldLoad, setShouldLoad] = useState(!lazy || isCached)
const [isInQueue, setIsInQueue] = useState(false)
const imgRef = useRef<HTMLImageElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const getAvatarLetter = (): string => {
if (!name) return '?'
const chars = [...name]
return chars[0] || '?'
}
// Intersection Observer for lazy loading
useEffect(() => {
if (!lazy || shouldLoad || isInQueue || !src || !containerRef.current || isCached || imageError || isFailed) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !isInQueue) {
setIsInQueue(true)
avatarLoadQueue.enqueue(src).then(() => {
setImageError(false)
setShouldLoad(true)
}).catch(() => {
setImageError(true)
setShouldLoad(false)
}).finally(() => {
setIsInQueue(false)
})
observer.disconnect()
}
})
},
{ rootMargin: '100px' }
)
observer.observe(containerRef.current)
return () => observer.disconnect()
}, [src, lazy, shouldLoad, isInQueue, isCached, imageError, isFailed])
// Reset state when src changes
useEffect(() => {
const cached = src ? loadedAvatarCache.has(src) : false
const failed = src ? avatarLoadQueue.hasFailed(src) : false
setImageLoaded(cached)
setImageError(failed)
if (failed) {
setShouldLoad(false)
setIsInQueue(false)
} else if (lazy && !cached) {
setShouldLoad(false)
setIsInQueue(false)
} else {
setShouldLoad(true)
}
}, [src, lazy])
// Check if image is already cached/loaded
useEffect(() => {
if (shouldLoad && imgRef.current?.complete && imgRef.current?.naturalWidth > 0) {
setImageLoaded(true)
}
}, [src, shouldLoad])
const style = {
width: typeof size === 'number' ? `${size}px` : size,
height: typeof size === 'number' ? `${size}px` : size,
}
const hasValidUrl = !!src && !imageError && shouldLoad
const shouldShowLoadingPlaceholder = loading && !hasValidUrl && !imageError
return (
<div
ref={containerRef}
className={`avatar-component ${shape} ${className}`}
style={style}
onClick={onClick}
>
{hasValidUrl ? (
<>
{!imageLoaded && <div className="avatar-skeleton" />}
<img
ref={imgRef}
src={src}
alt={name || 'avatar'}
className={`avatar-image ${imageLoaded ? 'loaded' : ''} ${isCached ? 'instant' : ''}`}
onLoad={() => {
if (src) {
avatarLoadQueue.clearFailed(src)
rememberLoadedAvatar(src)
}
setImageLoaded(true)
setImageError(false)
}}
onError={() => {
if (src) {
avatarLoadQueue.markFailed(src)
loadedAvatarCache.delete(src)
}
setImageLoaded(false)
setImageError(true)
setShouldLoad(false)
}}
loading={lazy ? "lazy" : "eager"}
referrerPolicy="no-referrer"
/>
</>
) : shouldShowLoadingPlaceholder ? (
<div className="avatar-loading">
<Loader2 size="50%" className="avatar-loading-icon" />
</div>
) : (
<div className="avatar-placeholder">
{name ? <span className="avatar-letter">{getAvatarLetter()}</span> : <User size="50%" />}
</div>
)}
</div>
)
})
-133
View File
@@ -1,133 +0,0 @@
import React, { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { Loader2, X, Image as ImageIcon, Clock, CheckCircle, XCircle } from 'lucide-react'
import { useBatchImageDecryptStore } from '../stores/batchImageDecryptStore'
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
import '../styles/batchTranscribe.scss'
export const BatchImageDecryptGlobal: React.FC = () => {
const {
isBatchDecrypting,
progress,
showToast,
showResultToast,
result,
sessionName,
startTime,
setShowToast,
setShowResultToast
} = useBatchImageDecryptStore()
const voiceToastOccupied = useBatchTranscribeStore(
state => state.isBatchTranscribing && state.showToast
)
const [eta, setEta] = useState('')
useEffect(() => {
if (!isBatchDecrypting || !startTime || progress.current === 0) {
setEta('')
return
}
const timer = setInterval(() => {
const elapsed = Date.now() - startTime
if (elapsed <= 0) return
const rate = progress.current / elapsed
const remain = progress.total - progress.current
if (remain <= 0 || rate <= 0) {
setEta('')
return
}
const seconds = Math.ceil((remain / rate) / 1000)
if (seconds < 60) {
setEta(`${seconds}秒`)
} else {
const m = Math.floor(seconds / 60)
const s = seconds % 60
setEta(`${m}分${s}秒`)
}
}, 1000)
return () => clearInterval(timer)
}, [isBatchDecrypting, progress.current, progress.total, startTime])
useEffect(() => {
if (!showResultToast) return
const timer = window.setTimeout(() => setShowResultToast(false), 6000)
return () => window.clearTimeout(timer)
}, [showResultToast, setShowResultToast])
const toastBottom = useMemo(() => (voiceToastOccupied ? 148 : 24), [voiceToastOccupied])
return (
<>
{showToast && isBatchDecrypting && createPortal(
<div className="batch-progress-toast" style={{ bottom: toastBottom }}>
<div className="batch-progress-toast-header">
<div className="batch-progress-toast-title">
<Loader2 size={14} className="spin" />
<span>批量解密图片{sessionName ? `(${sessionName})` : ''}</span>
</div>
<button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化">
<X size={14} />
</button>
</div>
<div className="batch-progress-toast-body">
<div className="progress-info-row">
<div className="progress-text">
<span>{progress.current} / {progress.total}</span>
<span className="progress-percent">
{progress.total > 0 ? Math.round((progress.current / progress.total) * 100) : 0}%
</span>
</div>
{eta && (
<div className="progress-eta">
<Clock size={12} />
<span>剩余 {eta}</span>
</div>
)}
</div>
<div className="progress-bar">
<div
className="progress-fill"
style={{
width: `${progress.total > 0 ? (progress.current / progress.total) * 100 : 0}%`
}}
/>
</div>
</div>
</div>,
document.body
)}
{showResultToast && createPortal(
<div className="batch-progress-toast batch-inline-result-toast" style={{ bottom: toastBottom }}>
<div className="batch-progress-toast-header">
<div className="batch-progress-toast-title">
<ImageIcon size={14} />
<span>图片批量解密完成</span>
</div>
<button className="batch-progress-toast-close" onClick={() => setShowResultToast(false)} title="关闭">
<X size={14} />
</button>
</div>
<div className="batch-progress-toast-body">
<div className="batch-inline-result-summary">
<div className="batch-inline-result-item success">
<CheckCircle size={14} />
<span>成功 {result.success}</span>
</div>
<div className={`batch-inline-result-item ${result.fail > 0 ? 'fail' : 'muted'}`}>
<XCircle size={14} />
<span>失败 {result.fail}</span>
</div>
</div>
</div>
</div>,
document.body
)}
</>
)
}
-148
View File
@@ -1,148 +0,0 @@
import React, { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Loader2, X, CheckCircle, XCircle, AlertCircle, Clock, Mic } from 'lucide-react'
import { useBatchTranscribeStore } from '../stores/batchTranscribeStore'
import '../styles/batchTranscribe.scss'
/**
* 全局批量转写进度浮窗 + 结果弹窗
* 挂载在 App 层,切换页面时不会消失
*/
export const BatchTranscribeGlobal: React.FC = () => {
const {
isBatchTranscribing,
progress,
showToast,
showResult,
result,
sessionName,
startTime,
taskType,
setShowToast,
setShowResult
} = useBatchTranscribeStore()
const [eta, setEta] = useState<string>('')
// 计算剩余时间
useEffect(() => {
if (!isBatchTranscribing || !startTime || progress.current === 0) {
setEta('')
return
}
const timer = setInterval(() => {
const now = Date.now()
const elapsed = now - startTime
const rate = progress.current / elapsed // ms per item
const remainingItems = progress.total - progress.current
if (remainingItems <= 0) {
setEta('')
return
}
const remainingTimeMs = remainingItems / rate
const remainingSeconds = Math.ceil(remainingTimeMs / 1000)
if (remainingSeconds < 60) {
setEta(`${remainingSeconds}秒`)
} else {
const minutes = Math.floor(remainingSeconds / 60)
const seconds = remainingSeconds % 60
setEta(`${minutes}分${seconds}秒`)
}
}, 1000)
return () => clearInterval(timer)
}, [isBatchTranscribing, startTime, progress.current, progress.total])
return (
<>
{/* 批量转写进度浮窗(非阻塞) */}
{showToast && isBatchTranscribing && createPortal(
<div className="batch-progress-toast">
<div className="batch-progress-toast-header">
<div className="batch-progress-toast-title">
<Loader2 size={14} className="spin" />
<span>{taskType === 'decrypt' ? '批量解密语音中' : '批量转写中'}{sessionName ? `(${sessionName})` : ''}</span>
</div>
<button className="batch-progress-toast-close" onClick={() => setShowToast(false)} title="最小化">
<X size={14} />
</button>
</div>
<div className="batch-progress-toast-body">
<div className="progress-info-row">
<div className="progress-text">
<span>{progress.current} / {progress.total}</span>
<span className="progress-percent">
{progress.total > 0
? Math.round((progress.current / progress.total) * 100)
: 0}%
</span>
</div>
{eta && (
<div className="progress-eta">
<Clock size={12} />
<span>剩余 {eta}</span>
</div>
)}
</div>
<div className="progress-bar">
<div
className="progress-fill"
style={{
width: `${progress.total > 0
? (progress.current / progress.total) * 100
: 0}%`
}}
/>
</div>
</div>
</div>,
document.body
)}
{/* 批量转写结果对话框 */}
{showResult && createPortal(
<div className="batch-modal-overlay" onClick={() => setShowResult(false)}>
<div className="batch-modal-content batch-result-modal" onClick={(e) => e.stopPropagation()}>
<div className="batch-modal-header">
{taskType === 'decrypt' ? <Mic size={20} /> : <CheckCircle size={20} />}
<h3>{taskType === 'decrypt' ? '语音解密完成' : '转写完成'}</h3>
</div>
<div className="batch-modal-body">
<div className="result-summary">
<div className="result-item success">
<CheckCircle size={18} />
<span className="label">成功:</span>
<span className="value">{result.success} 条</span>
</div>
{result.fail > 0 && (
<div className="result-item fail">
<XCircle size={18} />
<span className="label">失败:</span>
<span className="value">{result.fail} 条</span>
</div>
)}
</div>
{result.fail > 0 && (
<div className="result-tip">
<AlertCircle size={16} />
<span>{taskType === 'decrypt' ? '部分语音解密失败,可能是语音未缓存或文件损坏' : '部分语音转写失败,可能是语音文件损坏或网络问题'}</span>
</div>
)}
</div>
<div className="batch-modal-footer">
<button className="btn-primary" onClick={() => setShowResult(false)}>
确定
</button>
</div>
</div>
</div>,
document.body
)}
</>
)
}
-139
View File
@@ -1,139 +0,0 @@
.chat-analysis-header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 32px;
padding: 4px 0;
background: transparent;
border: none;
flex-shrink: 0;
}
.chat-analysis-back {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px 4px 4px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
font-size: 13px;
font-weight: 500;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
.chat-analysis-breadcrumb {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--text-tertiary);
.chat-analysis-breadcrumb-separator {
opacity: 0.5;
font-size: 12px;
}
}
.chat-analysis-dropdown {
position: relative;
}
.chat-analysis-current-trigger {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: background 0.15s ease, color 0.15s ease;
.current {
color: var(--text-primary);
}
svg {
transition: transform 0.15s ease;
}
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.open svg {
transform: rotate(180deg);
}
}
.chat-analysis-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 120px;
padding: 4px;
background: var(--bg-secondary-solid, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 10px;
box-shadow: var(--shadow-md);
z-index: 20;
}
.chat-analysis-menu-item {
width: 100%;
display: block;
padding: 8px 12px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-primary);
text-align: left;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background 0.15s ease;
&:hover {
background: var(--bg-hover);
}
}
.chat-analysis-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
margin-left: auto;
flex-wrap: wrap;
}
@media (max-width: 768px) {
.chat-analysis-header {
align-items: flex-start;
flex-wrap: wrap;
}
.chat-analysis-breadcrumb {
flex-wrap: wrap;
row-gap: 4px;
}
.chat-analysis-actions {
width: 100%;
justify-content: flex-start;
}
}
-105
View File
@@ -1,105 +0,0 @@
import { ChevronDown, ChevronLeft } from 'lucide-react'
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { useNavigate } from 'react-router-dom'
import './ChatAnalysisHeader.scss'
export type ChatAnalysisMode = 'private' | 'group'
interface ChatAnalysisHeaderProps {
currentMode: ChatAnalysisMode
actions?: ReactNode
}
const MODE_CONFIG: Record<ChatAnalysisMode, { label: string; path: string }> = {
private: {
label: '私聊分析',
path: '/analytics/private'
},
group: {
label: '群聊分析',
path: '/analytics/group'
}
}
function ChatAnalysisHeader({ currentMode, actions }: ChatAnalysisHeaderProps) {
const navigate = useNavigate()
const currentLabel = MODE_CONFIG[currentMode].label
const [menuOpen, setMenuOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement | null>(null)
const alternateMode = useMemo(
() => (currentMode === 'private' ? 'group' : 'private'),
[currentMode]
)
useEffect(() => {
if (!menuOpen) return
const handleClickOutside = (event: MouseEvent) => {
if (!dropdownRef.current?.contains(event.target as Node)) {
setMenuOpen(false)
}
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setMenuOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('keydown', handleEscape)
}
}, [menuOpen])
return (
<div className="chat-analysis-header">
<div className="chat-analysis-breadcrumb">
<button
type="button"
className="chat-analysis-back"
onClick={() => navigate('/analytics')}
>
<ChevronLeft size={16} />
<span>聊天分析</span>
</button>
<span className="chat-analysis-breadcrumb-separator">/</span>
<div className="chat-analysis-dropdown" ref={dropdownRef}>
<button
type="button"
className={`chat-analysis-current-trigger ${menuOpen ? 'open' : ''}`}
aria-haspopup="menu"
aria-expanded={menuOpen}
onClick={() => setMenuOpen((prev) => !prev)}
>
<span className="current">{currentLabel}</span>
<ChevronDown size={14} />
</button>
{menuOpen && (
<div className="chat-analysis-menu" role="menu" aria-label="切换聊天分析类型">
<button
type="button"
role="menuitem"
className="chat-analysis-menu-item"
onClick={() => {
setMenuOpen(false)
navigate(MODE_CONFIG[alternateMode].path)
}}
>
{MODE_CONFIG[alternateMode].label}
</button>
</div>
)}
</div>
</div>
{actions ? <div className="chat-analysis-actions">{actions}</div> : null}
</div>
)
}
export default ChatAnalysisHeader
-123
View File
@@ -1,123 +0,0 @@
.confirm-dialog-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
animation: fadeIn 0.2s ease-out;
.confirm-dialog {
width: 480px;
background: var(--bg-primary);
border-radius: 20px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
position: relative;
animation: slideUp 0.2s ease-out;
overflow: hidden;
.close-btn {
position: absolute;
top: 16px;
right: 16px;
background: rgba(0, 0, 0, 0.05);
border: none;
color: var(--text-secondary);
cursor: pointer;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
&:hover {
background: rgba(0, 0, 0, 0.1);
color: var(--text-primary);
}
}
.dialog-title {
padding: 40px 40px 16px;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.dialog-content {
padding: 0 40px 24px;
p {
font-size: 15px;
color: var(--text-primary);
line-height: 1.6;
margin: 0 0 16px 0;
&:last-child {
margin-bottom: 0;
}
}
}
.dialog-actions {
padding: 0 40px 40px;
display: flex;
justify-content: flex-end;
gap: 12px;
button {
padding: 12px 24px;
border-radius: 12px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
border: none;
&.btn-cancel {
background: var(--bg-tertiary);
color: var(--text-secondary);
&:hover {
background: var(--bg-hover);
}
}
&.btn-confirm {
background: var(--primary);
color: var(--on-primary);
&:hover {
background: var(--primary-hover);
}
&:active {
transform: scale(0.98);
}
}
}
}
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
-32
View File
@@ -1,32 +0,0 @@
import { X } from 'lucide-react'
import './ConfirmDialog.scss'
interface ConfirmDialogProps {
open: boolean
title?: string
message: string
onConfirm: () => void
onCancel: () => void
}
export default function ConfirmDialog({ open, title, message, onConfirm, onCancel }: ConfirmDialogProps) {
if (!open) return null
return (
<div className="confirm-dialog-overlay" onClick={onCancel}>
<div className="confirm-dialog" onClick={e => e.stopPropagation()}>
<button className="close-btn" onClick={onCancel}>
<X size={20} />
</button>
{title && <div className="dialog-title">{title}</div>}
<div className="dialog-content">
<p style={{ whiteSpace: 'pre-line' }}>{message}</p>
</div>
<div className="dialog-actions">
<button className="btn-cancel" onClick={onCancel}>取消</button>
<button className="btn-confirm" onClick={onConfirm}>开始获取</button>
</div>
</div>
</div>
)
}
-292
View File
@@ -1,292 +0,0 @@
.date-range-picker {
position: relative;
-webkit-app-region: no-drag;
.picker-trigger {
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
height: 32px;
background: var(--bg-tertiary);
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
}
svg {
color: var(--text-tertiary);
flex-shrink: 0;
}
.picker-text {
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
}
.clear-btn {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
padding: 0;
border: none;
background: var(--bg-hover);
border-radius: 50%;
cursor: pointer;
color: var(--text-tertiary);
margin-left: 4px;
&:hover {
background: var(--border-color);
color: var(--text-primary);
}
}
}
.picker-dropdown {
position: absolute;
top: calc(100% + 8px);
right: 0;
background: var(--bg-secondary-solid, var(--bg-primary, var(--card-bg)));
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
backdrop-filter: none;
-webkit-backdrop-filter: none;
border: 1px solid var(--border-color);
z-index: 1000;
display: flex;
overflow: hidden;
animation: dropdownFadeIn 0.15s ease-out;
}
@keyframes dropdownFadeIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.quick-options {
display: flex;
flex-direction: column;
padding: 12px;
border-right: 1px solid var(--border-color);
min-width: 100px;
.quick-option {
padding: 8px 12px;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
text-align: left;
transition: all 0.15s;
white-space: nowrap;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
.calendar-section {
padding: 16px;
min-width: 280px;
}
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
background: var(--bg-tertiary);
border-radius: 6px;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
.month-year {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
&.clickable {
cursor: pointer;
border-radius: 6px;
padding: 2px 8px;
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--primary);
}
}
}
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: auto repeat(6, 32px);
gap: 2px;
}
.weekday-header {
text-align: center;
font-size: 12px;
color: var(--text-tertiary);
padding: 8px 0;
font-weight: 500;
}
.calendar-day {
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
color: var(--text-tertiary);
border-radius: 8px;
cursor: default;
position: relative;
&.valid {
color: var(--text-primary);
cursor: pointer;
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
}
}
&.today {
font-weight: 600;
color: var(--primary);
}
&.in-range {
background: var(--primary-light);
border-radius: 0;
}
&.start {
background: var(--primary);
color: #fff;
border-radius: 8px 0 0 8px;
&.end {
border-radius: 8px;
}
}
&.end {
background: var(--primary);
color: #fff;
border-radius: 0 8px 8px 0;
}
}
.selection-hint {
text-align: center;
font-size: 12px;
color: var(--text-tertiary);
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-color);
}
.year-month-picker {
padding: 4px 0;
.year-selector {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.year-label {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
background: var(--bg-tertiary);
border-radius: 6px;
cursor: pointer;
color: var(--text-secondary);
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
.month-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
.month-btn {
padding: 8px 0;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.active {
background: var(--primary);
color: #fff;
}
}
}
}
}
-255
View File
@@ -1,255 +0,0 @@
import { useState, useRef, useEffect } from 'react'
import { Calendar, ChevronLeft, ChevronRight, X } from 'lucide-react'
import './DateRangePicker.scss'
interface DateRangePickerProps {
startDate: string
endDate: string
onStartDateChange: (date: string) => void
onEndDateChange: (date: string) => void
onRangeComplete?: () => void
}
const MONTH_NAMES = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']
const WEEKDAY_NAMES = ['日', '一', '二', '三', '四', '五', '六']
// 快捷选项
const QUICK_OPTIONS = [
{ label: '最近7天', days: 7 },
{ label: '最近30天', days: 30 },
{ label: '最近90天', days: 90 },
{ label: '最近一年', days: 365 },
{ label: '全部时间', days: 0 },
]
function DateRangePicker({ startDate, endDate, onStartDateChange, onEndDateChange, onRangeComplete }: DateRangePickerProps) {
const [isOpen, setIsOpen] = useState(false)
const [currentMonth, setCurrentMonth] = useState(new Date())
const [selectingStart, setSelectingStart] = useState(true)
const [showYearMonthPicker, setShowYearMonthPicker] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const [internalStart, setInternalStart] = useState(startDate)
const [internalEnd, setInternalEnd] = useState(endDate)
useEffect(() => {
setInternalStart(startDate)
setInternalEnd(endDate)
}, [startDate, endDate])
useEffect(() => {
if (isOpen) {
setSelectingStart(true)
}
}, [isOpen])
// 点击外部关闭
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isOpen])
const formatDisplayDate = (dateStr: string) => {
if (!dateStr) return ''
const date = new Date(dateStr)
return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`
}
const getDisplayText = () => {
if (!startDate && !endDate) return '选择时间范围'
if (startDate && endDate) return `${formatDisplayDate(startDate)} - ${formatDisplayDate(endDate)}`
if (startDate) return `${formatDisplayDate(startDate)} - ?`
return `? - ${formatDisplayDate(endDate)}`
}
const handleQuickOption = (days: number) => {
if (days === 0) {
onStartDateChange('')
onEndDateChange('')
} else {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - days)
const startStr = `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, '0')}-${String(start.getDate()).padStart(2, '0')}`
const endStr = `${end.getFullYear()}-${String(end.getMonth() + 1).padStart(2, '0')}-${String(end.getDate()).padStart(2, '0')}`
onStartDateChange(startStr)
onEndDateChange(endStr)
}
setIsOpen(false)
setTimeout(() => onRangeComplete?.(), 0)
}
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation()
onStartDateChange('')
onEndDateChange('')
}
const getDaysInMonth = (date: Date) => {
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
}
const getFirstDayOfMonth = (date: Date) => {
return new Date(date.getFullYear(), date.getMonth(), 1).getDay()
}
const handleDateClick = (day: number) => {
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
if (selectingStart) {
setInternalStart(dateStr)
if (internalEnd && dateStr > internalEnd) {
setInternalEnd('')
}
setSelectingStart(false)
} else {
let finalStart = internalStart
let finalEnd = dateStr
if (dateStr < internalStart) {
finalStart = dateStr
finalEnd = internalStart
}
setInternalStart(finalStart)
setInternalEnd(finalEnd)
setSelectingStart(true)
setIsOpen(false)
onStartDateChange(finalStart)
onEndDateChange(finalEnd)
setTimeout(() => onRangeComplete?.(), 0)
}
}
const isInRange = (day: number) => {
if (!internalStart || !internalEnd) return false
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
return dateStr >= internalStart && dateStr <= internalEnd
}
const isStartDate = (day: number) => {
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
return dateStr === internalStart
}
const isEndDate = (day: number) => {
const dateStr = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
return dateStr === internalEnd
}
const isToday = (day: number) => {
const today = new Date()
return currentMonth.getFullYear() === today.getFullYear() &&
currentMonth.getMonth() === today.getMonth() &&
day === today.getDate()
}
const renderCalendar = () => {
const daysInMonth = getDaysInMonth(currentMonth)
const firstDay = getFirstDayOfMonth(currentMonth)
const days: (number | null)[] = []
for (let i = 0; i < firstDay; i++) {
days.push(null)
}
for (let i = 1; i <= daysInMonth; i++) {
days.push(i)
}
return (
<div className="calendar-grid">
{WEEKDAY_NAMES.map(name => (
<div key={name} className="weekday-header">{name}</div>
))}
{days.map((day, index) => (
<div
key={index}
className={`calendar-day ${day ? 'valid' : ''} ${day && isInRange(day) ? 'in-range' : ''} ${day && isStartDate(day) ? 'start' : ''} ${day && isEndDate(day) ? 'end' : ''} ${day && isToday(day) ? 'today' : ''}`}
onClick={() => day && handleDateClick(day)}
>
{day}
</div>
))}
</div>
)
}
return (
<div className="date-range-picker" ref={containerRef}>
<button className="picker-trigger" onClick={() => setIsOpen(!isOpen)}>
<Calendar size={14} />
<span className="picker-text">{getDisplayText()}</span>
{(startDate || endDate) && (
<button className="clear-btn" onClick={handleClear}>
<X size={12} />
</button>
)}
</button>
{isOpen && (
<div className="picker-dropdown">
<div className="quick-options">
{QUICK_OPTIONS.map(opt => (
<button key={opt.label} className="quick-option" onClick={() => handleQuickOption(opt.days)}>
{opt.label}
</button>
))}
</div>
<div className="calendar-section">
<div className="calendar-header">
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1))}>
<ChevronLeft size={16} />
</button>
<span className="month-year clickable" onClick={() => setShowYearMonthPicker(!showYearMonthPicker)}>
{currentMonth.getFullYear()}年 {MONTH_NAMES[currentMonth.getMonth()]}
</span>
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1))}>
<ChevronRight size={16} />
</button>
</div>
{showYearMonthPicker ? (
<div className="year-month-picker">
<div className="year-selector">
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() - 1, currentMonth.getMonth()))}>
<ChevronLeft size={14} />
</button>
<span className="year-label">{currentMonth.getFullYear()}年</span>
<button className="nav-btn" onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear() + 1, currentMonth.getMonth()))}>
<ChevronRight size={14} />
</button>
</div>
<div className="month-grid">
{MONTH_NAMES.map((name, i) => (
<button
key={i}
className={`month-btn ${i === currentMonth.getMonth() ? 'active' : ''}`}
onClick={() => {
setCurrentMonth(new Date(currentMonth.getFullYear(), i))
setShowYearMonthPicker(false)
}}
>{name}</button>
))}
</div>
</div>
) : renderCalendar()}
<div className="selection-hint">
{selectingStart ? '请选择开始日期' : '请选择结束日期'}
</div>
</div>
</div>
)}
</div>
)
}
export default DateRangePicker
-41
View File
@@ -1,41 +0,0 @@
import { Component, ReactNode } from 'react'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error?: Error
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: any) {
console.error('ErrorBoundary caught:', error, errorInfo)
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div style={{ padding: '20px', textAlign: 'center', color: '#999' }}>
<p>消息渲染出错</p>
<p style={{ fontSize: '12px', marginTop: '8px' }}>
{this.state.error?.message || '未知错误'}
</p>
</div>
)
}
return this.props.children
}
}
@@ -1,502 +0,0 @@
.export-date-range-dialog-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
z-index: 9200;
}
.export-date-range-dialog {
width: min(480px, calc(100vw - 32px));
max-height: calc(100vh - 80px);
border-radius: 16px;
border: 1px solid var(--border-color);
background: var(--bg-secondary-solid, var(--bg-primary));
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.16);
overflow: hidden;
}
.export-date-range-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
h4 {
margin: 0;
font-size: 14px;
color: var(--text-primary);
}
}
.export-date-range-dialog-content {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
display: flex;
flex-direction: column;
gap: 10px;
padding-right: 2px;
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
}
.export-date-range-dialog-close-btn {
border: 1px solid var(--border-color);
background: var(--bg-secondary);
border-radius: 8px;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--text-secondary);
}
.export-date-range-preset-list {
display: flex;
flex-wrap: nowrap;
gap: 4px;
overflow-x: auto;
padding-bottom: 2px;
&::-webkit-scrollbar {
height: 4px;
}
}
.export-date-range-preset-item {
flex: 0 0 auto;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
color: var(--text-primary);
min-height: 30px;
padding: 0 8px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
font-size: 11px;
cursor: pointer;
white-space: nowrap;
&.active {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
color: var(--primary);
}
}
.export-date-range-mode-banner {
border-radius: 10px;
padding: 7px 10px;
font-size: 11px;
line-height: 1.4;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-secondary);
&.range {
border-color: rgba(var(--primary-rgb), 0.4);
background: rgba(var(--primary-rgb), 0.1);
color: var(--primary);
}
}
.export-date-range-boundary-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.export-date-range-boundary-card {
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-secondary);
padding: 8px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease;
&.active {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18);
}
.boundary-label {
font-size: 11px;
color: var(--text-secondary);
}
}
.export-date-range-selection-hint {
font-size: 11px;
color: var(--text-secondary);
padding: 0 2px;
}
.export-date-range-calendar-panel {
border: 1px solid var(--border-color);
border-radius: 12px;
background: linear-gradient(180deg, rgba(var(--primary-rgb), 0.04), transparent 28%), var(--bg-secondary);
padding: 10px;
&.single {
width: 100%;
}
}
.export-date-range-calendar-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
}
.export-date-range-calendar-date-label {
display: flex;
flex-direction: column;
gap: 3px;
span {
font-size: 11px;
color: var(--text-secondary);
}
strong {
font-size: 13px;
color: var(--text-primary);
}
}
.export-date-range-date-input {
width: 100%;
min-width: 0;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
height: 30px;
padding: 0 9px;
font-size: 12px;
&:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18);
}
&.invalid {
border-color: #e84d4d;
box-shadow: 0 0 0 1px rgba(232, 77, 77, 0.2);
}
}
.export-date-range-time-select {
position: relative;
width: 100%;
&.open .export-date-range-time-trigger {
border-color: var(--primary);
box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18);
color: var(--primary);
}
}
.export-date-range-time-trigger {
width: 100%;
min-width: 0;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
height: 30px;
padding: 0 9px;
font-size: 12px;
font-family: inherit;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
transition: border-color 0.15s ease, box-shadow 0.15s ease, color 0.15s ease;
&:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 1px rgba(var(--primary-rgb), 0.18);
}
}
.export-date-range-time-trigger-value {
flex: 1;
min-width: 0;
text-align: left;
}
.export-date-range-time-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 24;
border: 1px solid var(--border-color);
border-radius: 12px;
background: color-mix(in srgb, var(--bg-primary) 88%, var(--bg-secondary));
box-shadow: var(--shadow-md);
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.export-date-range-time-dropdown-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
span {
font-size: 11px;
color: var(--text-secondary);
}
strong {
font-size: 13px;
color: var(--text-primary);
}
}
.export-date-range-time-quick-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.export-date-range-time-quick-item,
.export-date-range-time-option {
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--text-primary);
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
&:hover {
background: var(--bg-tertiary);
}
&.active {
border-color: rgba(var(--primary-rgb), 0.28);
background: rgba(var(--primary-rgb), 0.12);
color: var(--primary);
}
}
.export-date-range-time-quick-item {
min-width: 52px;
height: 28px;
padding: 0 10px;
font-size: 11px;
}
.export-date-range-time-columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.export-date-range-time-column {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.export-date-range-time-column-label {
font-size: 11px;
color: var(--text-secondary);
}
.export-date-range-time-column-list {
max-height: 168px;
overflow-y: auto;
padding-right: 2px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
}
.export-date-range-time-option {
min-height: 28px;
padding: 0 8px;
font-size: 11px;
}
.export-date-range-calendar-nav {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--text-primary);
button {
width: 28px;
height: 28px;
border-radius: 8px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
cursor: pointer;
padding: 0;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
&:disabled {
cursor: not-allowed;
opacity: 0.45;
}
}
}
.export-date-range-calendar-weekdays {
margin-top: 10px;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
span {
text-align: center;
font-size: 10px;
color: var(--text-tertiary);
}
}
.export-date-range-calendar-days {
margin-top: 6px;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
}
.export-date-range-calendar-day {
border: 1px solid transparent;
border-radius: 10px;
min-height: 34px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 12px;
cursor: pointer;
padding: 0;
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease, transform 0.15s ease;
&:hover {
border-color: rgba(var(--primary-rgb), 0.28);
transform: translateY(-1px);
}
&:disabled:hover {
border-color: transparent;
transform: none;
}
&.outside {
color: var(--text-quaternary);
opacity: 0.72;
}
&.disabled {
cursor: not-allowed;
opacity: 0.35;
transform: none;
border-color: transparent;
}
&.in-range {
background: rgba(var(--primary-rgb), 0.1);
color: var(--primary);
}
&.range-start,
&.range-end {
border-color: var(--primary);
background: var(--primary);
color: #fff;
font-weight: 600;
opacity: 1;
}
&.active-boundary {
box-shadow: 0 0 0 2px rgba(var(--primary-rgb), 0.22);
}
}
.export-date-range-dialog-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
flex-shrink: 0;
}
.export-date-range-dialog-btn {
border-radius: 8px;
padding: 7px 12px;
font-size: 12px;
font-weight: 600;
border: 1px solid var(--border-color);
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
&.primary {
border-color: var(--primary);
background: var(--primary);
color: #fff;
&:hover {
background: var(--primary-hover);
}
}
&.secondary {
background: var(--bg-secondary);
color: var(--text-primary);
&:hover {
border-color: var(--primary);
color: var(--primary);
}
}
}
@media (max-width: 640px) {
.export-date-range-boundary-row {
grid-template-columns: 1fr;
}
}
@@ -1,762 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Check, ChevronDown, ChevronLeft, ChevronRight, X } from 'lucide-react'
import {
EXPORT_DATE_RANGE_PRESETS,
WEEKDAY_SHORT_LABELS,
addMonths,
buildCalendarCells,
cloneExportDateRangeSelection,
createDateRangeByPreset,
createDefaultDateRange,
formatCalendarMonthTitle,
isSameDay,
parseDateInputValue,
startOfDay,
endOfDay,
toMonthStart,
type ExportDateRangePreset,
type ExportDateRangeSelection
} from '../../utils/exportDateRange'
import './ExportDateRangeDialog.scss'
interface ExportDateRangeDialogProps {
open: boolean
value: ExportDateRangeSelection
title?: string
minDate?: Date | null
maxDate?: Date | null
onClose: () => void
onConfirm: (value: ExportDateRangeSelection) => void
}
type ActiveBoundary = 'start' | 'end'
interface ExportDateRangeDialogDraft extends ExportDateRangeSelection {
panelMonth: Date
}
const HOUR_OPTIONS = Array.from({ length: 24 }, (_, index) => `${index}`.padStart(2, '0'))
const MINUTE_OPTIONS = Array.from({ length: 60 }, (_, index) => `${index}`.padStart(2, '0'))
const QUICK_TIME_OPTIONS = ['00:00', '08:00', '12:00', '18:00', '23:59']
const resolveBounds = (minDate?: Date | null, maxDate?: Date | null): { minDate: Date; maxDate: Date } | null => {
if (!(minDate instanceof Date) || Number.isNaN(minDate.getTime())) return null
if (!(maxDate instanceof Date) || Number.isNaN(maxDate.getTime())) return null
const normalizedMin = startOfDay(minDate)
const normalizedMax = endOfDay(maxDate)
if (normalizedMin.getTime() > normalizedMax.getTime()) return null
return {
minDate: normalizedMin,
maxDate: normalizedMax
}
}
const clampSelectionToBounds = (
value: ExportDateRangeSelection,
minDate?: Date | null,
maxDate?: Date | null
): ExportDateRangeSelection => {
const bounds = resolveBounds(minDate, maxDate)
if (!bounds) return cloneExportDateRangeSelection(value)
// For custom selections, only ensure end >= start, preserve time precision
if (value.preset === 'custom' && !value.useAllTime) {
const { start, end } = value.dateRange
if (end.getTime() < start.getTime()) {
return {
...value,
dateRange: { start, end: start }
}
}
return cloneExportDateRangeSelection(value)
}
// For useAllTime, use bounds directly
if (value.useAllTime) {
return {
preset: value.preset,
useAllTime: true,
dateRange: {
start: bounds.minDate,
end: bounds.maxDate
}
}
}
// For preset selections (not custom), clamp dates to bounds and use default times
const nextStart = new Date(Math.min(Math.max(value.dateRange.start.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
const nextEndCandidate = new Date(Math.min(Math.max(value.dateRange.end.getTime(), bounds.minDate.getTime()), bounds.maxDate.getTime()))
const nextEnd = nextEndCandidate.getTime() < nextStart.getTime() ? nextStart : nextEndCandidate
// Set default times: start at 00:00:00, end at 23:59:59
nextStart.setHours(0, 0, 0, 0)
nextEnd.setHours(23, 59, 59, 999)
return {
preset: value.preset,
useAllTime: false,
dateRange: {
start: nextStart,
end: nextEnd
}
}
}
const buildDialogDraft = (
value: ExportDateRangeSelection,
minDate?: Date | null,
maxDate?: Date | null
): ExportDateRangeDialogDraft => {
const nextValue = clampSelectionToBounds(value, minDate, maxDate)
return {
...nextValue,
panelMonth: toMonthStart(nextValue.dateRange.start)
}
}
export function ExportDateRangeDialog({
open,
value,
title = '时间范围设置',
minDate,
maxDate,
onClose,
onConfirm
}: ExportDateRangeDialogProps) {
// Helper: Format date only (YYYY-MM-DD) for the date input field
const formatDateOnly = (date: Date): string => {
const y = date.getFullYear()
const m = `${date.getMonth() + 1}`.padStart(2, '0')
const d = `${date.getDate()}`.padStart(2, '0')
return `${y}-${m}-${d}`
}
// Helper: Format time only (HH:mm) for the time input field
const formatTimeOnly = (date: Date): string => {
const h = `${date.getHours()}`.padStart(2, '0')
const m = `${date.getMinutes()}`.padStart(2, '0')
return `${h}:${m}`
}
const [draft, setDraft] = useState<ExportDateRangeDialogDraft>(() => buildDialogDraft(value, minDate, maxDate))
const [activeBoundary, setActiveBoundary] = useState<ActiveBoundary>('start')
const [dateInput, setDateInput] = useState({
start: formatDateOnly(value.dateRange.start),
end: formatDateOnly(value.dateRange.end)
})
const [dateInputError, setDateInputError] = useState({ start: false, end: false })
// Default times: start at 00:00, end at 23:59
const [timeInput, setTimeInput] = useState({
start: '00:00',
end: '23:59'
})
const [openTimeDropdown, setOpenTimeDropdown] = useState<ActiveBoundary | null>(null)
const startTimeSelectRef = useRef<HTMLDivElement>(null)
const endTimeSelectRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const nextDraft = buildDialogDraft(value, minDate, maxDate)
setDraft(nextDraft)
setActiveBoundary('start')
setDateInput({
start: formatDateOnly(nextDraft.dateRange.start),
end: formatDateOnly(nextDraft.dateRange.end)
})
// For preset-based selections (not custom), use default times 00:00 and 23:59
// For custom selections, preserve the time from value.dateRange
if (nextDraft.useAllTime || nextDraft.preset !== 'custom') {
setTimeInput({
start: '00:00',
end: '23:59'
})
} else {
setTimeInput({
start: formatTimeOnly(nextDraft.dateRange.start),
end: formatTimeOnly(nextDraft.dateRange.end)
})
}
setOpenTimeDropdown(null)
setDateInputError({ start: false, end: false })
}, [maxDate, minDate, open, value])
useEffect(() => {
if (!open) return
setDateInput({
start: formatDateOnly(draft.dateRange.start),
end: formatDateOnly(draft.dateRange.end)
})
// Don't sync timeInput here - it's controlled by the time picker
setDateInputError({ start: false, end: false })
}, [draft.dateRange.end.getTime(), draft.dateRange.start.getTime(), open])
useEffect(() => {
if (!openTimeDropdown) return
const handlePointerDown = (event: MouseEvent) => {
const target = event.target as Node
const activeContainer = openTimeDropdown === 'start'
? startTimeSelectRef.current
: endTimeSelectRef.current
if (!activeContainer?.contains(target)) {
setOpenTimeDropdown(null)
}
}
const handleEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpenTimeDropdown(null)
}
}
document.addEventListener('mousedown', handlePointerDown)
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('mousedown', handlePointerDown)
document.removeEventListener('keydown', handleEscape)
}
}, [openTimeDropdown])
const bounds = useMemo(() => resolveBounds(minDate, maxDate), [maxDate, minDate])
const clampStartDate = useCallback((targetDate: Date) => {
if (!bounds) return targetDate
const min = bounds.minDate
const max = bounds.maxDate
if (targetDate.getTime() < min.getTime()) return min
if (targetDate.getTime() > max.getTime()) return max
return targetDate
}, [bounds])
const clampEndDate = useCallback((targetDate: Date) => {
if (!bounds) return targetDate
const min = bounds.minDate
const max = bounds.maxDate
if (targetDate.getTime() < min.getTime()) return min
if (targetDate.getTime() > max.getTime()) return max
return targetDate
}, [bounds])
const setRangeStart = useCallback((targetDate: Date) => {
const start = clampStartDate(targetDate)
setDraft(prev => {
return {
...prev,
preset: 'custom',
useAllTime: false,
dateRange: {
start,
end: prev.dateRange.end
},
panelMonth: toMonthStart(start)
}
})
}, [clampStartDate])
const setRangeEnd = useCallback((targetDate: Date) => {
const end = clampEndDate(targetDate)
setDraft(prev => {
const nextStart = prev.useAllTime ? clampStartDate(targetDate) : prev.dateRange.start
return {
...prev,
preset: 'custom',
useAllTime: false,
dateRange: {
start: nextStart,
end: end
},
panelMonth: toMonthStart(targetDate)
}
})
}, [clampEndDate, clampStartDate])
const applyPreset = useCallback((preset: Exclude<ExportDateRangePreset, 'custom'>) => {
if (preset === 'all') {
const previewRange = bounds
? { start: bounds.minDate, end: bounds.maxDate }
: createDefaultDateRange()
setTimeInput({
start: '00:00',
end: '23:59'
})
setOpenTimeDropdown(null)
setDraft(prev => ({
...prev,
preset,
useAllTime: true,
dateRange: previewRange,
panelMonth: toMonthStart(previewRange.start)
}))
setActiveBoundary('start')
return
}
const range = clampSelectionToBounds({
preset,
useAllTime: false,
dateRange: createDateRangeByPreset(preset)
}, minDate, maxDate).dateRange
setTimeInput({
start: '00:00',
end: '23:59'
})
setOpenTimeDropdown(null)
setDraft(prev => ({
...prev,
preset,
useAllTime: false,
dateRange: range,
panelMonth: toMonthStart(range.start)
}))
setActiveBoundary('start')
}, [bounds, maxDate, minDate])
const parseTimeValue = (timeStr: string): { hours: number; minutes: number } | null => {
const matched = /^(\d{1,2}):(\d{2})$/.exec(timeStr.trim())
if (!matched) return null
const hours = Number(matched[1])
const minutes = Number(matched[2])
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) return null
return { hours, minutes }
}
const updateBoundaryTime = useCallback((boundary: ActiveBoundary, timeStr: string) => {
setTimeInput(prev => ({ ...prev, [boundary]: timeStr }))
const parsedTime = parseTimeValue(timeStr)
if (!parsedTime) return
setDraft(prev => {
const dateObj = boundary === 'start' ? prev.dateRange.start : prev.dateRange.end
const newDate = new Date(dateObj)
newDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0)
return {
...prev,
preset: 'custom',
useAllTime: false,
dateRange: {
...prev.dateRange,
[boundary]: newDate
}
}
})
}, [])
const toggleTimeDropdown = useCallback((boundary: ActiveBoundary) => {
setActiveBoundary(boundary)
setOpenTimeDropdown(prev => (prev === boundary ? null : boundary))
}, [])
const handleTimeColumnSelect = useCallback((boundary: ActiveBoundary, field: 'hour' | 'minute', value: string) => {
const parsedCurrent = parseTimeValue(timeInput[boundary]) ?? {
hours: boundary === 'start' ? 0 : 23,
minutes: boundary === 'start' ? 0 : 59
}
const nextHours = field === 'hour' ? Number(value) : parsedCurrent.hours
const nextMinutes = field === 'minute' ? Number(value) : parsedCurrent.minutes
updateBoundaryTime(boundary, `${`${nextHours}`.padStart(2, '0')}:${`${nextMinutes}`.padStart(2, '0')}`)
}, [timeInput, updateBoundaryTime])
const renderTimeDropdown = (boundary: ActiveBoundary) => {
const currentTime = timeInput[boundary]
const parsedCurrent = parseTimeValue(currentTime) ?? {
hours: boundary === 'start' ? 0 : 23,
minutes: boundary === 'start' ? 0 : 59
}
return (
<div className="export-date-range-time-dropdown" onClick={(event) => event.stopPropagation()}>
<div className="export-date-range-time-dropdown-header">
<span>{boundary === 'start' ? '开始时间' : '结束时间'}</span>
<strong>{currentTime}</strong>
</div>
<div className="export-date-range-time-quick-list">
{QUICK_TIME_OPTIONS.map(option => (
<button
key={`${boundary}-${option}`}
type="button"
className={`export-date-range-time-quick-item ${currentTime === option ? 'active' : ''}`}
onClick={() => updateBoundaryTime(boundary, option)}
>
{option}
</button>
))}
</div>
<div className="export-date-range-time-columns">
<div className="export-date-range-time-column">
<span className="export-date-range-time-column-label">小时</span>
<div className="export-date-range-time-column-list">
{HOUR_OPTIONS.map(option => (
<button
key={`${boundary}-hour-${option}`}
type="button"
className={`export-date-range-time-option ${parsedCurrent.hours === Number(option) ? 'active' : ''}`}
onClick={() => handleTimeColumnSelect(boundary, 'hour', option)}
>
{option}
</button>
))}
</div>
</div>
<div className="export-date-range-time-column">
<span className="export-date-range-time-column-label">分钟</span>
<div className="export-date-range-time-column-list">
{MINUTE_OPTIONS.map(option => (
<button
key={`${boundary}-minute-${option}`}
type="button"
className={`export-date-range-time-option ${parsedCurrent.minutes === Number(option) ? 'active' : ''}`}
onClick={() => handleTimeColumnSelect(boundary, 'minute', option)}
>
{option}
</button>
))}
</div>
</div>
</div>
</div>
)
}
// Check if date input string contains time (YYYY-MM-DD HH:mm format)
const dateInputHasTime = (dateStr: string): boolean => /^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}$/.test(dateStr.trim())
const commitStartFromInput = useCallback(() => {
const parsedDate = parseDateInputValue(dateInput.start)
if (!parsedDate) {
setDateInputError(prev => ({ ...prev, start: true }))
return
}
// Only apply time picker value if date input doesn't contain time
if (!dateInputHasTime(dateInput.start)) {
const parsedTime = parseTimeValue(timeInput.start)
if (parsedTime) {
parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0)
}
}
setDateInputError(prev => ({ ...prev, start: false }))
setRangeStart(parsedDate)
}, [dateInput.start, timeInput.start, setRangeStart])
const commitEndFromInput = useCallback(() => {
const parsedDate = parseDateInputValue(dateInput.end)
if (!parsedDate) {
setDateInputError(prev => ({ ...prev, end: true }))
return
}
// Only apply time picker value if date input doesn't contain time
if (!dateInputHasTime(dateInput.end)) {
const parsedTime = parseTimeValue(timeInput.end)
if (parsedTime) {
parsedDate.setHours(parsedTime.hours, parsedTime.minutes, 0, 0)
}
}
setDateInputError(prev => ({ ...prev, end: false }))
setRangeEnd(parsedDate)
}, [dateInput.end, timeInput.end, setRangeEnd])
const shiftPanelMonth = useCallback((delta: number) => {
setDraft(prev => ({
...prev,
panelMonth: addMonths(prev.panelMonth, delta)
}))
}, [])
const handleCalendarSelect = useCallback((targetDate: Date) => {
// Use time from timeInput state (which is updated by the time picker)
const parseTime = (timeStr: string): { hours: number; minutes: number } => {
const matched = /^(\d{1,2}):(\d{2})$/.exec(timeStr.trim())
if (!matched) return { hours: 0, minutes: 0 }
return { hours: Number(matched[1]), minutes: Number(matched[2]) }
}
if (activeBoundary === 'start') {
const newStart = new Date(targetDate)
const time = parseTime(timeInput.start)
newStart.setHours(time.hours, time.minutes, 0, 0)
setRangeStart(newStart)
setActiveBoundary('end')
setOpenTimeDropdown(null)
return
}
const pickedStart = startOfDay(targetDate)
const start = draft.useAllTime ? startOfDay(targetDate) : draft.dateRange.start
const nextStart = pickedStart <= start ? pickedStart : start
const newEnd = new Date(targetDate)
const time = parseTime(timeInput.end)
// If selecting same day or going backwards, use 23:59:59, otherwise use the time from timeInput
if (pickedStart <= start) {
newEnd.setHours(23, 59, 59, 999)
setTimeInput(prev => ({ ...prev, end: '23:59' }))
} else {
newEnd.setHours(time.hours, time.minutes, 59, 999)
}
setDraft(prev => ({
...prev,
preset: 'custom',
useAllTime: false,
dateRange: {
start: nextStart,
end: newEnd
},
panelMonth: toMonthStart(targetDate)
}))
setActiveBoundary('start')
setOpenTimeDropdown(null)
}, [activeBoundary, draft.dateRange.start, draft.useAllTime, timeInput.end, timeInput.start, setRangeStart])
const isRangeModeActive = !draft.useAllTime
const modeText = isRangeModeActive
? '当前导出模式:按时间范围导出'
: '当前导出模式:全部时间导出,选择下方日期会切换为自定义时间范围'
const isPresetActive = useCallback((preset: ExportDateRangePreset): boolean => {
if (preset === 'all') return draft.useAllTime
return !draft.useAllTime && draft.preset === preset
}, [draft])
const calendarCells = useMemo(() => buildCalendarCells(draft.panelMonth), [draft.panelMonth])
const minPanelMonth = bounds ? toMonthStart(bounds.minDate) : null
const maxPanelMonth = bounds ? toMonthStart(bounds.maxDate) : null
const canShiftPrev = !minPanelMonth || draft.panelMonth.getTime() > minPanelMonth.getTime()
const canShiftNext = !maxPanelMonth || draft.panelMonth.getTime() < maxPanelMonth.getTime()
const isStartSelected = useCallback((date: Date) => (
!draft.useAllTime && isSameDay(date, draft.dateRange.start)
), [draft])
const isEndSelected = useCallback((date: Date) => (
!draft.useAllTime && isSameDay(date, draft.dateRange.end)
), [draft])
const isDateInRange = useCallback((date: Date) => (
!draft.useAllTime &&
startOfDay(date).getTime() >= startOfDay(draft.dateRange.start).getTime() &&
startOfDay(date).getTime() <= startOfDay(draft.dateRange.end).getTime()
), [draft])
const isDateSelectable = useCallback((date: Date) => {
if (!bounds) return true
const target = startOfDay(date).getTime()
return target >= startOfDay(bounds.minDate).getTime() && target <= startOfDay(bounds.maxDate).getTime()
}, [bounds])
const hintText = draft.useAllTime
? '选择开始或结束日期后,会自动切换为自定义时间范围'
: (activeBoundary === 'start' ? '下一次点击将设置开始日期' : '下一次点击将设置结束日期')
if (!open) return null
return createPortal(
<div
className="export-date-range-dialog-overlay"
onClick={(event) => {
event.stopPropagation()
onClose()
}}
> <div className="export-date-range-dialog" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
<div className="export-date-range-dialog-header">
<h4>{title}</h4>
<button
type="button"
className="export-date-range-dialog-close-btn"
onClick={onClose}
aria-label="关闭时间范围设置"
>
<X size={14} />
</button>
</div>
<div className="export-date-range-dialog-content">
<div className="export-date-range-preset-list">
{EXPORT_DATE_RANGE_PRESETS.map((preset) => {
const active = isPresetActive(preset.value)
return (
<button
key={preset.value}
type="button"
className={`export-date-range-preset-item ${active ? 'active' : ''}`}
onClick={() => applyPreset(preset.value)}
>
<span>{preset.label}</span>
{active && <Check size={14} />}
</button>
)
})}
</div>
<div className={`export-date-range-mode-banner ${isRangeModeActive ? 'range' : 'all'}`}>
{modeText}
</div>
<div className="export-date-range-boundary-row">
<div
className={`export-date-range-boundary-card ${activeBoundary === 'start' ? 'active' : ''}`}
onClick={() => setActiveBoundary('start')}
>
<span className="boundary-label">开始</span>
<input
type="text"
className={`export-date-range-date-input ${dateInputError.start ? 'invalid' : ''}`}
value={dateInput.start}
placeholder="YYYY-MM-DD"
onChange={(event) => {
const nextValue = event.target.value
setDateInput(prev => ({ ...prev, start: nextValue }))
if (dateInputError.start) {
setDateInputError(prev => ({ ...prev, start: false }))
}
}}
onFocus={() => setActiveBoundary('start')}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
event.preventDefault()
commitStartFromInput()
}}
onBlur={commitStartFromInput}
/>
<div
className={`export-date-range-time-select ${openTimeDropdown === 'start' ? 'open' : ''}`}
ref={startTimeSelectRef}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
className="export-date-range-time-trigger"
onClick={() => toggleTimeDropdown('start')}
aria-haspopup="dialog"
aria-expanded={openTimeDropdown === 'start'}
>
<span className="export-date-range-time-trigger-value">{timeInput.start}</span>
<ChevronDown size={14} />
</button>
{openTimeDropdown === 'start' && renderTimeDropdown('start')}
</div>
</div>
<div
className={`export-date-range-boundary-card ${activeBoundary === 'end' ? 'active' : ''}`}
onClick={() => setActiveBoundary('end')}
>
<span className="boundary-label">结束</span>
<input
type="text"
className={`export-date-range-date-input ${dateInputError.end ? 'invalid' : ''}`}
value={dateInput.end}
placeholder="YYYY-MM-DD"
onChange={(event) => {
const nextValue = event.target.value
setDateInput(prev => ({ ...prev, end: nextValue }))
if (dateInputError.end) {
setDateInputError(prev => ({ ...prev, end: false }))
}
}}
onFocus={() => setActiveBoundary('end')}
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => {
if (event.key !== 'Enter') return
event.preventDefault()
commitEndFromInput()
}}
onBlur={commitEndFromInput}
/>
<div
className={`export-date-range-time-select ${openTimeDropdown === 'end' ? 'open' : ''}`}
ref={endTimeSelectRef}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
className="export-date-range-time-trigger"
onClick={() => toggleTimeDropdown('end')}
aria-haspopup="dialog"
aria-expanded={openTimeDropdown === 'end'}
>
<span className="export-date-range-time-trigger-value">{timeInput.end}</span>
<ChevronDown size={14} />
</button>
{openTimeDropdown === 'end' && renderTimeDropdown('end')}
</div>
</div>
</div>
<div className="export-date-range-selection-hint">{hintText}</div>
<section className="export-date-range-calendar-panel single">
<div className="export-date-range-calendar-panel-header">
<div className="export-date-range-calendar-date-label">
<span>选择日期范围</span>
<strong>{formatCalendarMonthTitle(draft.panelMonth)}</strong>
</div>
<div className="export-date-range-calendar-nav">
<button type="button" onClick={() => shiftPanelMonth(-1)} aria-label="上个月" disabled={!canShiftPrev}>
<ChevronLeft size={14} />
</button>
<button type="button" onClick={() => shiftPanelMonth(1)} aria-label="下个月" disabled={!canShiftNext}>
<ChevronRight size={14} />
</button>
</div>
</div>
<div className="export-date-range-calendar-weekdays">
{WEEKDAY_SHORT_LABELS.map(label => (
<span key={`weekday-${label}`}>{label}</span>
))}
</div>
<div className="export-date-range-calendar-days">
{calendarCells.map((cell) => {
const startSelected = isStartSelected(cell.date)
const endSelected = isEndSelected(cell.date)
const inRange = isDateInRange(cell.date)
const selectable = isDateSelectable(cell.date)
return (
<button
key={cell.date.getTime()}
type="button"
disabled={!selectable}
className={[
'export-date-range-calendar-day',
cell.inCurrentMonth ? '' : 'outside',
selectable ? '' : 'disabled',
inRange ? 'in-range' : '',
startSelected ? 'range-start' : '',
endSelected ? 'range-end' : '',
activeBoundary === 'start' && startSelected ? 'active-boundary' : '',
activeBoundary === 'end' && endSelected ? 'active-boundary' : ''
].filter(Boolean).join(' ')}
onClick={() => handleCalendarSelect(cell.date)}
>
{cell.date.getDate()}
</button>
)
})}
</div>
</section>
</div>
<div className="export-date-range-dialog-actions">
<button type="button" className="export-date-range-dialog-btn secondary" onClick={onClose}>
取消
</button>
<button
type="button"
className="export-date-range-dialog-btn primary"
onClick={() => {
// Validate: end time should not be earlier than start time
if (draft.dateRange.end.getTime() < draft.dateRange.start.getTime()) {
setDateInputError({ start: true, end: true })
return
}
onConfirm(cloneExportDateRangeSelection(draft))
}}
>
确认
</button>
</div>
</div>
</div>,
document.body
)
}
@@ -1,685 +0,0 @@
.export-defaults-settings-form {
.form-group {
margin-bottom: 20px;
&:last-child {
margin-bottom: 0;
}
}
label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 2px;
}
.form-hint {
display: block;
font-size: 12px;
color: var(--text-tertiary);
margin-bottom: 8px;
}
.select-field {
position: relative;
margin-bottom: 10px;
}
.select-trigger {
width: 100%;
padding: 10px 16px;
border: 1px solid var(--border-color);
border-radius: 9999px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--text-tertiary);
}
&.open {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
}
}
.select-value {
flex: 1;
min-width: 0;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.select-dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 6px;
box-shadow: var(--shadow-md);
z-index: 120;
max-height: 320px;
overflow-y: auto;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
}
.select-option {
width: 100%;
text-align: left;
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: none;
border-radius: 10px;
background: transparent;
cursor: pointer;
transition: all 0.15s;
color: var(--text-primary);
font-size: 14px;
&:hover {
background: var(--bg-tertiary);
}
&.active {
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
}
}
.option-label {
font-weight: 500;
}
.option-desc {
font-size: 12px;
color: var(--text-tertiary);
}
.format-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(156px, 1fr));
gap: 6px;
width: 100%;
margin-bottom: 10px;
}
.format-card {
width: 100%;
min-height: 0;
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 8px 10px;
text-align: left;
background: var(--bg-primary);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
transition: border-color 0.2s ease, background 0.2s ease;
&:hover {
border-color: var(--text-tertiary);
}
&.active {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
}
}
.format-label {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.35;
}
.format-desc {
margin-top: 1px;
font-size: 11px;
color: var(--text-tertiary);
line-height: 1.35;
}
.select-option.active .option-desc {
color: var(--primary);
}
.settings-time-range-field {
margin-bottom: 10px;
}
.settings-time-range-trigger {
width: 100%;
padding: 10px 16px;
border: 1px solid var(--border-color);
border-radius: 9999px;
font-size: 14px;
background: var(--bg-primary);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: rgba(var(--primary-rgb), 0.45);
color: var(--primary);
}
&.open {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 15%, transparent);
}
}
.settings-time-range-value {
flex: 1;
min-width: 0;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.settings-time-range-arrow {
color: var(--text-tertiary);
font-weight: 700;
line-height: 1;
}
.log-toggle-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
padding: 10px 14px;
border: 1px solid var(--border-color);
border-radius: 14px;
background: var(--bg-primary);
}
.media-default-grid {
width: 100%;
display: flex;
align-items: center;
flex-wrap: nowrap;
gap: 12px;
margin-bottom: 10px;
label {
display: inline-flex;
align-items: center;
gap: 6px;
position: relative;
margin-bottom: 0;
font-size: 13px;
line-height: 1;
font-weight: 500;
color: var(--text-primary);
cursor: pointer;
white-space: nowrap;
transition: border-color 0.16s ease, background 0.16s ease;
}
input[type='checkbox'] {
margin: 0;
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
&:checked + .media-default-check {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 88%, #fff);
}
&:checked + .media-default-check::after {
opacity: 1;
transform: rotate(-45deg) scale(1);
}
&:focus-visible + .media-default-check {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
}
}
.media-default-check {
width: 14px;
height: 14px;
flex: 0 0 14px;
border: 1px solid color-mix(in srgb, var(--border-color) 82%, var(--text-tertiary));
border-radius: 4px;
background: var(--bg-primary);
display: inline-flex;
align-items: center;
justify-content: center;
transition: border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease;
&::after {
content: '';
width: 7px;
height: 4px;
border-left: 2px solid #fff;
border-bottom: 2px solid #fff;
opacity: 0;
transform: rotate(-45deg) scale(0.72);
transform-origin: center;
transition: opacity 0.16s ease, transform 0.16s ease;
}
}
}
.log-status {
font-size: 13px;
color: var(--text-secondary);
}
.concurrency-inline-options {
width: 100%;
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 6px;
margin-bottom: 10px;
}
.concurrency-option {
border: 1px solid var(--border-color);
border-radius: 10px;
min-height: 38px;
padding: 0;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: border-color 0.2s ease, background 0.2s ease, color 0.2s ease;
&:hover {
border-color: var(--text-tertiary);
}
&.active {
border-color: var(--primary);
background: rgba(var(--primary-rgb), 0.08);
color: var(--primary);
}
}
.switch {
position: relative;
display: inline-flex;
width: 48px;
height: 28px;
cursor: pointer;
flex-shrink: 0;
}
.switch-input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
&:checked + .switch-slider {
background: var(--primary);
}
&:checked + .switch-slider::before {
transform: translateX(20px);
}
&:focus + .switch-slider {
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
}
}
.switch-slider {
position: absolute;
inset: 0;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 999px;
transition: all 0.2s ease;
&::before {
content: '';
position: absolute;
width: 20px;
height: 20px;
left: 3px;
top: 3px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.18);
transition: transform 0.2s ease;
}
}
&.layout-split {
.form-group {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 18px;
align-items: center;
padding: 14px 0;
margin-bottom: 0;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 70%, transparent);
}
.form-group:last-child {
border-bottom: none;
padding-bottom: 0;
}
.form-group:first-child {
padding-top: 0;
}
.form-copy {
min-width: 0;
}
.form-control {
min-width: 0;
display: flex;
justify-content: flex-end;
}
.form-hint {
margin-bottom: 0;
line-height: 1.5;
}
.select-field,
.settings-time-range-field {
width: 100%;
max-width: 360px;
margin-bottom: 0;
}
.log-toggle-line {
width: 100%;
max-width: 360px;
margin-bottom: 0;
}
.media-default-grid {
max-width: 360px;
margin-bottom: 0;
}
.concurrency-inline-options {
max-width: 360px;
margin-bottom: 0;
}
.format-setting-group {
grid-template-columns: 1fr;
gap: 10px;
align-items: stretch;
}
.format-setting-group .form-control {
justify-content: flex-start;
}
.format-grid {
max-width: none;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin-bottom: 0;
}
}
}
@media (max-width: 1024px) {
.export-defaults-settings-form.layout-split {
.media-setting-group {
grid-template-columns: 1fr;
gap: 10px;
align-items: stretch;
}
.media-setting-group .form-control {
justify-content: flex-start;
}
.media-default-grid {
max-width: none;
flex-wrap: wrap;
}
}
}
@media (max-width: 760px) {
.export-defaults-settings-form.layout-split {
.form-group {
grid-template-columns: 1fr;
gap: 10px;
}
.form-control {
justify-content: flex-start;
}
.select-field,
.settings-time-range-field,
.log-toggle-line,
.media-default-grid,
.concurrency-inline-options,
.format-grid {
max-width: none;
}
.media-default-grid {
flex-wrap: wrap;
}
.format-grid {
grid-template-columns: repeat(auto-fit, minmax(156px, 1fr));
}
}
}
// UI rebuild polish for the modal variant used by ExportPage.
.export-defaults-settings-form.layout-split {
display: grid;
gap: 10px;
.form-group {
grid-template-columns: minmax(176px, 0.82fr) minmax(0, 1.18fr);
gap: 12px;
align-items: start;
padding: 12px;
border: none;
border-radius: 12px;
background: color-mix(in srgb, var(--bg-secondary) 82%, var(--bg-primary));
}
.form-group:first-child,
.form-group:last-child {
padding: 12px;
}
.form-copy {
padding-top: 2px;
}
label {
margin-bottom: 3px;
line-height: 1.35;
}
.form-hint {
line-height: 1.45;
}
.form-control {
width: 100%;
min-width: 0;
justify-content: stretch;
}
.select-field,
.settings-time-range-field,
.log-toggle-line,
.media-default-grid,
.concurrency-inline-options {
max-width: none;
width: 100%;
}
.select-trigger,
.settings-time-range-trigger {
border-radius: 12px;
background: var(--bg-primary);
min-height: 42px;
padding: 9px 12px;
}
.log-toggle-line {
border-radius: 12px;
background: var(--bg-primary);
min-height: 42px;
padding: 8px 12px;
}
.concurrency-inline-options {
grid-template-columns: repeat(6, minmax(38px, 1fr));
gap: 6px;
}
.concurrency-option {
min-width: 0;
min-height: 36px;
border-radius: 8px;
}
.format-setting-group {
grid-template-columns: 1fr;
gap: 10px;
}
.format-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
width: 100%;
}
.format-card {
min-height: 68px;
padding: 10px 12px;
border-radius: 8px;
background: var(--bg-primary);
}
.format-label,
.format-desc {
max-width: 100%;
overflow-wrap: anywhere;
}
.media-default-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(78px, 1fr));
gap: 8px;
label {
min-height: 34px;
padding: 7px 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
&:hover {
border-color: color-mix(in srgb, var(--primary) 38%, var(--border-color));
background: color-mix(in srgb, var(--text-tertiary) 4%, var(--bg-primary));
}
}
}
}
.select-dropdown-floating {
background: color-mix(in srgb, var(--bg-primary) 85%, var(--bg-secondary));
border: 1px solid var(--border-color);
border-radius: 12px;
padding: 6px;
box-shadow: var(--shadow-md);
overflow-y: auto;
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
.select-option {
width: 100%;
text-align: left;
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border: none;
border-radius: 10px;
background: transparent;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
color: var(--text-primary);
font-size: 14px;
&:hover {
background: var(--bg-tertiary);
}
&.active {
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
}
}
.option-label {
font-weight: 500;
}
.option-desc {
font-size: 12px;
color: var(--text-tertiary);
}
.select-option.active .option-desc {
color: var(--primary);
}
}
@media (max-width: 980px) {
.export-defaults-settings-form.layout-split {
.form-group {
grid-template-columns: 1fr;
gap: 10px;
}
.format-grid {
grid-template-columns: repeat(auto-fit, minmax(156px, 1fr));
}
}
}
@@ -1,583 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { createPortal } from 'react-dom'
import { ChevronDown } from 'lucide-react'
import * as configService from '../../services/config'
import { ExportDateRangeDialog } from './ExportDateRangeDialog'
import {
createDefaultExportDateRangeSelection,
getExportDateRangeLabel,
resolveExportDateRangeConfig,
serializeExportDateRangeConfig,
type ExportDateRangeSelection
} from '../../utils/exportDateRange'
import './ExportDefaultsSettingsForm.scss'
export interface ExportDefaultsSettingsPatch {
format?: string
avatars?: boolean
dateRange?: ExportDateRangeSelection
fileNamingMode?: configService.ExportFileNamingMode
media?: configService.ExportDefaultMediaConfig
voiceAsText?: boolean
excelCompactColumns?: boolean
concurrency?: number
}
interface ExportDefaultsSettingsFormProps {
onNotify?: (text: string, success: boolean) => void
onDefaultsChanged?: (patch: ExportDefaultsSettingsPatch) => void
layout?: 'stacked' | 'split'
}
const exportFormatOptions = [
{ value: 'excel', label: 'Excel', desc: '电子表格,适合统计分析' },
{ value: 'json', label: 'JSON', desc: '详细格式,包含完整消息信息' },
{ value: 'html', label: 'HTML', desc: '网页格式,可直接浏览' },
{ value: 'txt', label: 'TXT', desc: '纯文本,通用格式' },
{ value: 'arkme-json', label: 'Arkme JSON', desc: '紧凑 JSON,支持 sender 去重与关系统计' },
{ value: 'chatlab', label: 'ChatLab', desc: '标准格式,支持其他软件导入' },
{ value: 'chatlab-jsonl', label: 'ChatLab JSONL', desc: '流式格式,适合大量消息' },
{ value: 'weclone', label: 'WeClone CSV', desc: 'WeClone 兼容字段格式(CSV)' },
{ value: 'sql', label: 'PostgreSQL', desc: '数据库脚本,便于导入到数据库' }
] as const
const exportExcelColumnOptions = [
{ value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' },
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
] as const
const exportFileNamingModeOptions: Array<{ value: configService.ExportFileNamingMode; label: string; desc: string }> = [
{ value: 'classic', label: '简洁模式', desc: '示例:私聊_张三(兼容旧版)' },
{ value: 'date-range', label: '时间范围模式', desc: '示例:私聊_张三_20250101-20250331(推荐)' }
]
const exportConcurrencyOptions = [1, 2, 3, 4, 5, 6] as const
const getOptionLabel = (options: ReadonlyArray<{ value: string; label: string }>, value: string) => {
return options.find((option) => option.value === value)?.label ?? value
}
interface SelectDropdownPlacement {
left: number
width: number
maxHeight: number
top?: number
bottom?: number
}
const resolveSelectDropdownPlacement = (anchor: HTMLElement | null): SelectDropdownPlacement | null => {
if (!anchor || typeof window === 'undefined') return null
const rect = anchor.getBoundingClientRect()
const viewportWidth = window.innerWidth || document.documentElement.clientWidth
const viewportHeight = window.innerHeight || document.documentElement.clientHeight
const viewportMargin = 12
const dropdownGap = 6
const minDropdownHeight = 128
const availableWidth = Math.max(160, viewportWidth - viewportMargin * 2)
const width = Math.min(Math.max(rect.width, 220), availableWidth)
const left = Math.max(viewportMargin, Math.min(rect.left, viewportWidth - width - viewportMargin))
const spaceBelow = Math.max(0, viewportHeight - rect.bottom - viewportMargin - dropdownGap)
const spaceAbove = Math.max(0, rect.top - viewportMargin - dropdownGap)
const shouldOpenAbove = spaceBelow < minDropdownHeight && spaceAbove > spaceBelow
const availableHeight = shouldOpenAbove ? spaceAbove : spaceBelow
const maxHeight = Math.max(96, Math.min(320, availableHeight))
return shouldOpenAbove
? { left, width, maxHeight, bottom: viewportHeight - rect.top + dropdownGap }
: { left, width, maxHeight, top: rect.bottom + dropdownGap }
}
const getSelectDropdownStyle = (placement: SelectDropdownPlacement): CSSProperties => ({
position: 'fixed',
top: placement.top,
bottom: placement.bottom,
left: placement.left,
right: 'auto',
width: placement.width,
maxHeight: placement.maxHeight,
zIndex: 9300
})
export function ExportDefaultsSettingsForm({
onNotify,
onDefaultsChanged,
layout = 'stacked'
}: ExportDefaultsSettingsFormProps) {
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
const [showExportFileNamingModeSelect, setShowExportFileNamingModeSelect] = useState(false)
const [isExportDateRangeDialogOpen, setIsExportDateRangeDialogOpen] = useState(false)
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
const exportFileNamingModeDropdownRef = useRef<HTMLDivElement>(null)
const exportExcelColumnsMenuRef = useRef<HTMLDivElement>(null)
const exportFileNamingModeMenuRef = useRef<HTMLDivElement>(null)
const [exportExcelColumnsPlacement, setExportExcelColumnsPlacement] = useState<SelectDropdownPlacement | null>(null)
const [exportFileNamingModePlacement, setExportFileNamingModePlacement] = useState<SelectDropdownPlacement | null>(null)
const [exportDefaultFormat, setExportDefaultFormat] = useState('excel')
const [exportDefaultAvatars, setExportDefaultAvatars] = useState(true)
const [exportDefaultDateRange, setExportDefaultDateRange] = useState<ExportDateRangeSelection>(() => createDefaultExportDateRangeSelection())
const [exportDefaultFileNamingMode, setExportDefaultFileNamingMode] = useState<configService.ExportFileNamingMode>('classic')
const [exportDefaultMedia, setExportDefaultMedia] = useState<configService.ExportDefaultMediaConfig>({
images: true,
videos: true,
voices: true,
emojis: true,
files: true
})
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(false)
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
const [exportDefaultConcurrency, setExportDefaultConcurrency] = useState(2)
useEffect(() => {
let cancelled = false
void (async () => {
const [savedFormat, savedAvatars, savedDateRange, savedFileNamingMode, savedMedia, savedVoiceAsText, savedExcelCompactColumns, savedConcurrency] = await Promise.all([
configService.getExportDefaultFormat(),
configService.getExportDefaultAvatars(),
configService.getExportDefaultDateRange(),
configService.getExportDefaultFileNamingMode(),
configService.getExportDefaultMedia(),
configService.getExportDefaultVoiceAsText(),
configService.getExportDefaultExcelCompactColumns(),
configService.getExportDefaultConcurrency()
])
if (cancelled) return
setExportDefaultFormat(savedFormat || 'excel')
setExportDefaultAvatars(savedAvatars ?? true)
setExportDefaultDateRange(resolveExportDateRangeConfig(savedDateRange))
setExportDefaultFileNamingMode(savedFileNamingMode ?? 'classic')
setExportDefaultMedia(savedMedia ?? {
images: true,
videos: true,
voices: true,
emojis: true,
files: true
})
setExportDefaultVoiceAsText(savedVoiceAsText ?? false)
setExportDefaultExcelCompactColumns(savedExcelCompactColumns ?? true)
setExportDefaultConcurrency(savedConcurrency ?? 2)
})()
return () => {
cancelled = true
}
}, [])
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as Node
if (
showExportExcelColumnsSelect &&
exportExcelColumnsDropdownRef.current &&
!exportExcelColumnsDropdownRef.current.contains(target) &&
!exportExcelColumnsMenuRef.current?.contains(target)
) {
setShowExportExcelColumnsSelect(false)
}
if (
showExportFileNamingModeSelect &&
exportFileNamingModeDropdownRef.current &&
!exportFileNamingModeDropdownRef.current.contains(target) &&
!exportFileNamingModeMenuRef.current?.contains(target)
) {
setShowExportFileNamingModeSelect(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showExportExcelColumnsSelect, showExportFileNamingModeSelect])
const updateSelectDropdownPlacements = useCallback(() => {
if (showExportExcelColumnsSelect) {
setExportExcelColumnsPlacement(resolveSelectDropdownPlacement(exportExcelColumnsDropdownRef.current))
}
if (showExportFileNamingModeSelect) {
setExportFileNamingModePlacement(resolveSelectDropdownPlacement(exportFileNamingModeDropdownRef.current))
}
}, [showExportExcelColumnsSelect, showExportFileNamingModeSelect])
useEffect(() => {
if (!showExportExcelColumnsSelect) setExportExcelColumnsPlacement(null)
if (!showExportFileNamingModeSelect) setExportFileNamingModePlacement(null)
if (!showExportExcelColumnsSelect && !showExportFileNamingModeSelect) return
updateSelectDropdownPlacements()
window.addEventListener('resize', updateSelectDropdownPlacements)
document.addEventListener('scroll', updateSelectDropdownPlacements, true)
return () => {
window.removeEventListener('resize', updateSelectDropdownPlacements)
document.removeEventListener('scroll', updateSelectDropdownPlacements, true)
}
}, [showExportExcelColumnsSelect, showExportFileNamingModeSelect, updateSelectDropdownPlacements])
const exportExcelColumnsValue = exportDefaultExcelCompactColumns ? 'compact' : 'full'
const exportDateRangeLabel = useMemo(() => getExportDateRangeLabel(exportDefaultDateRange), [exportDefaultDateRange])
const exportExcelColumnsLabel = useMemo(() => getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue), [exportExcelColumnsValue])
const exportFileNamingModeLabel = useMemo(() => getOptionLabel(exportFileNamingModeOptions, exportDefaultFileNamingMode), [exportDefaultFileNamingMode])
const notify = (text: string, success = true) => {
onNotify?.(text, success)
}
const fileNamingModeDropdown = showExportFileNamingModeSelect && exportFileNamingModePlacement
? createPortal(
<div
ref={exportFileNamingModeMenuRef}
className="select-dropdown select-dropdown-floating"
role="listbox"
style={getSelectDropdownStyle(exportFileNamingModePlacement)}
onClick={(event) => event.stopPropagation()}
>
{exportFileNamingModeOptions.map((option) => (
<button
key={option.value}
type="button"
role="option"
aria-selected={exportDefaultFileNamingMode === option.value}
className={`select-option ${exportDefaultFileNamingMode === option.value ? 'active' : ''}`}
onClick={async () => {
setExportDefaultFileNamingMode(option.value)
await configService.setExportDefaultFileNamingMode(option.value)
onDefaultsChanged?.({ fileNamingMode: option.value })
notify('已更新导出文件命名方式', true)
setShowExportFileNamingModeSelect(false)
}}
>
<span className="option-label">{option.label}</span>
<span className="option-desc">{option.desc}</span>
</button>
))}
</div>,
document.body
)
: null
const excelColumnsDropdown = showExportExcelColumnsSelect && exportExcelColumnsPlacement
? createPortal(
<div
ref={exportExcelColumnsMenuRef}
className="select-dropdown select-dropdown-floating"
role="listbox"
style={getSelectDropdownStyle(exportExcelColumnsPlacement)}
onClick={(event) => event.stopPropagation()}
>
{exportExcelColumnOptions.map((option) => (
<button
key={option.value}
type="button"
role="option"
aria-selected={exportExcelColumnsValue === option.value}
className={`select-option ${exportExcelColumnsValue === option.value ? 'active' : ''}`}
onClick={async () => {
const compact = option.value === 'compact'
setExportDefaultExcelCompactColumns(compact)
await configService.setExportDefaultExcelCompactColumns(compact)
onDefaultsChanged?.({ excelCompactColumns: compact })
notify(compact ? '已启用精简列' : '已启用完整列', true)
setShowExportExcelColumnsSelect(false)
}}
>
<span className="option-label">{option.label}</span>
<span className="option-desc">{option.desc}</span>
</button>
))}
</div>,
document.body
)
: null
return (
<div className={`export-defaults-settings-form ${layout === 'split' ? 'layout-split' : 'layout-stacked'}`}>
<div className="form-group">
<div className="form-copy">
<label>导出并发数</label>
<span className="form-hint">导出多个会话时的最大并发(1~6)</span>
</div>
<div className="form-control">
<div className="concurrency-inline-options" role="radiogroup" aria-label="导出并发数">
{exportConcurrencyOptions.map((option) => (
<button
key={option}
type="button"
className={`concurrency-option ${exportDefaultConcurrency === option ? 'active' : ''}`}
aria-pressed={exportDefaultConcurrency === option}
onClick={async () => {
setExportDefaultConcurrency(option)
await configService.setExportDefaultConcurrency(option)
onDefaultsChanged?.({ concurrency: option })
notify(`已将导出并发数设为 ${option}`, true)
}}
>
{option}
</button>
))}
</div>
</div>
</div>
<div className="form-group format-setting-group">
<div className="form-copy">
<label>聊天消息默认导出格式</label>
<span className="form-hint">导出页面默认选中的格式</span>
</div>
<div className="form-control">
<div className="format-grid">
{exportFormatOptions.map((option) => (
<button
key={option.value}
type="button"
className={`format-card ${exportDefaultFormat === option.value ? 'active' : ''}`}
onClick={async () => {
setExportDefaultFormat(option.value)
await configService.setExportDefaultFormat(option.value)
onDefaultsChanged?.({ format: option.value })
notify('已更新导出格式默认值', true)
}}
>
<span className="format-label">{option.label}</span>
<span className="format-desc">{option.desc}</span>
</button>
))}
</div>
</div>
</div>
<div className="form-group">
<div className="form-copy">
<label>聊天消息导出带头像</label>
<span className="form-hint">开启后导出的聊天消息对应的文件中会带头像信息。</span>
</div>
<div className="form-control">
<div className="log-toggle-line">
<span className="log-status">{exportDefaultAvatars ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="shared-export-default-avatars">
<input
id="shared-export-default-avatars"
className="switch-input"
type="checkbox"
checked={exportDefaultAvatars}
onChange={async (e) => {
const enabled = e.target.checked
setExportDefaultAvatars(enabled)
await configService.setExportDefaultAvatars(enabled)
onDefaultsChanged?.({ avatars: enabled })
notify(enabled ? '已开启聊天消息导出带头像' : '已关闭聊天消息导出带头像', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
</div>
<div className="form-group">
<div className="form-copy">
<label>默认导出时间范围</label>
<span className="form-hint">控制导出页面的默认时间选择</span>
</div>
<div className="form-control">
<div className="settings-time-range-field">
<button
type="button"
className={`settings-time-range-trigger ${isExportDateRangeDialogOpen ? 'open' : ''}`}
onClick={() => {
setShowExportExcelColumnsSelect(false)
setShowExportFileNamingModeSelect(false)
setIsExportDateRangeDialogOpen(true)
}}
>
<span className="settings-time-range-value">{exportDateRangeLabel}</span>
<span className="settings-time-range-arrow">&gt;</span>
</button>
</div>
</div>
</div>
<ExportDateRangeDialog
open={isExportDateRangeDialogOpen}
value={exportDefaultDateRange}
onClose={() => setIsExportDateRangeDialogOpen(false)}
onConfirm={async (nextSelection) => {
setExportDefaultDateRange(nextSelection)
await configService.setExportDefaultDateRange(serializeExportDateRangeConfig(nextSelection))
onDefaultsChanged?.({ dateRange: nextSelection })
notify('已更新默认导出时间范围', true)
setIsExportDateRangeDialogOpen(false)
}}
/>
<div className="form-group">
<div className="form-copy">
<label>导出文件命名方式</label>
<span className="form-hint">控制导出文件名是否包含时间范围</span>
</div>
<div className="form-control">
<div className="select-field" ref={exportFileNamingModeDropdownRef}>
<button
type="button"
className={`select-trigger ${showExportFileNamingModeSelect ? 'open' : ''}`}
aria-haspopup="listbox"
aria-expanded={showExportFileNamingModeSelect}
onClick={() => {
setShowExportFileNamingModeSelect(!showExportFileNamingModeSelect)
setShowExportExcelColumnsSelect(false)
setIsExportDateRangeDialogOpen(false)
}}
>
<span className="select-value">{exportFileNamingModeLabel}</span>
<ChevronDown size={16} />
</button>
{fileNamingModeDropdown}
</div>
</div>
</div>
<div className="form-group">
<div className="form-copy">
<label>Excel 列显示</label>
<span className="form-hint">控制 Excel 导出的列字段</span>
</div>
<div className="form-control">
<div className="select-field" ref={exportExcelColumnsDropdownRef}>
<button
type="button"
className={`select-trigger ${showExportExcelColumnsSelect ? 'open' : ''}`}
aria-haspopup="listbox"
aria-expanded={showExportExcelColumnsSelect}
onClick={() => {
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
setShowExportFileNamingModeSelect(false)
setIsExportDateRangeDialogOpen(false)
}}
>
<span className="select-value">{exportExcelColumnsLabel}</span>
<ChevronDown size={16} />
</button>
{excelColumnsDropdown}
</div>
</div>
</div>
<div className="form-group media-setting-group">
<div className="form-copy">
<label>默认导出媒体内容</label>
<span className="form-hint">控制图片、视频、语音、表情包、文件的默认导出开关</span>
</div>
<div className="form-control">
<div className="media-default-grid">
<label>
<input
type="checkbox"
checked={exportDefaultMedia.images}
onChange={async (e) => {
const next = { ...exportDefaultMedia, images: e.target.checked }
setExportDefaultMedia(next)
await configService.setExportDefaultMedia(next)
onDefaultsChanged?.({ media: next })
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出图片`, true)
}}
/>
<span className="media-default-check" aria-hidden="true" />
<span>图片</span>
</label>
<label>
<input
type="checkbox"
checked={exportDefaultMedia.voices}
onChange={async (e) => {
const next = { ...exportDefaultMedia, voices: e.target.checked }
setExportDefaultMedia(next)
await configService.setExportDefaultMedia(next)
onDefaultsChanged?.({ media: next })
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出语音`, true)
}}
/>
<span className="media-default-check" aria-hidden="true" />
<span>语音</span>
</label>
<label>
<input
type="checkbox"
checked={exportDefaultMedia.videos}
onChange={async (e) => {
const next = { ...exportDefaultMedia, videos: e.target.checked }
setExportDefaultMedia(next)
await configService.setExportDefaultMedia(next)
onDefaultsChanged?.({ media: next })
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出视频`, true)
}}
/>
<span className="media-default-check" aria-hidden="true" />
<span>视频</span>
</label>
<label>
<input
type="checkbox"
checked={exportDefaultMedia.emojis}
onChange={async (e) => {
const next = { ...exportDefaultMedia, emojis: e.target.checked }
setExportDefaultMedia(next)
await configService.setExportDefaultMedia(next)
onDefaultsChanged?.({ media: next })
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出表情包`, true)
}}
/>
<span className="media-default-check" aria-hidden="true" />
<span>表情包</span>
</label>
<label>
<input
type="checkbox"
checked={exportDefaultMedia.files}
onChange={async (e) => {
const next = { ...exportDefaultMedia, files: e.target.checked }
setExportDefaultMedia(next)
await configService.setExportDefaultMedia(next)
onDefaultsChanged?.({ media: next })
notify(`已${e.target.checked ? '开启' : '关闭'}默认导出文件`, true)
}}
/>
<span className="media-default-check" aria-hidden="true" />
<span>文件</span>
</label>
</div>
</div>
</div>
<div className="form-group">
<div className="form-copy">
<label>默认语音转文字</label>
<span className="form-hint">导出时默认将语音转写为文字</span>
</div>
<div className="form-control">
<div className="log-toggle-line">
<span className="log-status">{exportDefaultVoiceAsText ? '已开启' : '已关闭'}</span>
<label className="switch" htmlFor="shared-export-default-voice-as-text">
<input
id="shared-export-default-voice-as-text"
className="switch-input"
type="checkbox"
checked={exportDefaultVoiceAsText}
onChange={async (e) => {
const enabled = e.target.checked
setExportDefaultVoiceAsText(enabled)
await configService.setExportDefaultVoiceAsText(enabled)
onDefaultsChanged?.({ voiceAsText: enabled })
notify(enabled ? '已开启默认语音转文字' : '已关闭默认语音转文字', true)
}}
/>
<span className="switch-slider" />
</label>
</div>
</div>
</div>
</div>
)
}
-284
View File
@@ -1,284 +0,0 @@
import { useEffect, useRef } from 'react'
import { useChatStore } from '../stores/chatStore'
import type { ChatSession, Message } from '../types/models'
import { useNavigate } from 'react-router-dom'
export function GlobalSessionMonitor() {
const navigate = useNavigate()
const {
sessions,
setSessions,
currentSessionId,
appendMessages,
messages
} = useChatStore()
const sessionsRef = useRef(sessions)
// 保持 ref 同步
useEffect(() => {
sessionsRef.current = sessions
}, [sessions])
// 去重辅助函数:获取消息 key
const getMessageKey = (msg: Message) => {
if (msg.messageKey) return msg.messageKey
return `fallback:${msg._db_path || ''}:${msg.serverId || 0}:${msg.createTime}:${msg.sortSeq || 0}:${msg.localId || 0}:${msg.senderUsername || ''}:${msg.localType || 0}`
}
// 处理数据库变更
useEffect(() => {
const handleDbChange = (_event: any, data: { type: string; json: string }) => {
try {
const payload = JSON.parse(data.json)
const tableName = payload.table
// 只关注 Session 表
if (tableName === 'Session' || tableName === 'session') {
refreshSessions()
}
} catch (e) {
console.error('解析数据库变更失败:', e)
}
}
if (window.electronAPI.chat.onWcdbChange) {
const removeListener = window.electronAPI.chat.onWcdbChange(handleDbChange)
return () => {
removeListener()
}
}
return () => { }
}, [])
const refreshSessions = async () => {
try {
const result = await window.electronAPI.chat.getSessions()
if (result.success && result.sessions && Array.isArray(result.sessions)) {
const newSessions = result.sessions as ChatSession[]
const oldSessions = sessionsRef.current
// 1. 检测变更并通知
checkForNewMessages(oldSessions, newSessions)
// 2. 更新 store
setSessions(newSessions)
// 3. 如果在活跃会话中,增量刷新消息
const currentId = useChatStore.getState().currentSessionId
if (currentId) {
const currentSessionNew = newSessions.find(s => s.username === currentId)
const currentSessionOld = oldSessions.find(s => s.username === currentId)
if (currentSessionNew && (!currentSessionOld || currentSessionNew.lastTimestamp > currentSessionOld.lastTimestamp)) {
void handleActiveSessionRefresh(currentId)
}
}
}
} catch (e) {
console.error('全局会话刷新失败:', e)
}
}
const checkForNewMessages = async (oldSessions: ChatSession[], newSessions: ChatSession[]) => {
if (!oldSessions || oldSessions.length === 0) {
console.log('[NotificationFilter] Skipping check on initial load (empty baseline)')
return
}
const oldMap = new Map(oldSessions.map(s => [s.username, s]))
for (const newSession of newSessions) {
const oldSession = oldMap.get(newSession.username)
// 条件: 新会话或时间戳更新
const isCurrentSession = newSession.username === useChatStore.getState().currentSessionId
if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) {
// 这是新消息事件
// 免打扰、折叠群、折叠入口不弹通知
if (newSession.isMuted || newSession.isFolded) continue
if (newSession.username.toLowerCase().includes('placeholder_foldgroup')) continue
// 1. 群聊过滤自己发送的消息
if (newSession.username.includes('@chatroom')) {
// 如果是自己发的消息,不弹通知
// 注意:lastMsgSender 需要后端支持返回
// 使用宽松比较以处理 wxid_ 前缀差异
if (newSession.lastMsgSender && newSession.selfWxid) {
const sender = newSession.lastMsgSender.replace(/^wxid_/, '');
const self = newSession.selfWxid.replace(/^wxid_/, '');
// 使用主进程日志打印,方便用户查看
const debugInfo = {
type: 'NotificationFilter',
username: newSession.username,
lastMsgSender: newSession.lastMsgSender,
selfWxid: newSession.selfWxid,
senderClean: sender,
selfClean: self,
match: sender === self
};
if (window.electronAPI.log?.debug) {
window.electronAPI.log.debug(debugInfo);
} else {
console.log('[NotificationFilter]', debugInfo);
}
if (sender === self) {
if (window.electronAPI.log?.debug) {
window.electronAPI.log.debug('[NotificationFilter] Filtered own message');
} else {
console.log('[NotificationFilter] Filtered own message');
}
continue;
}
} else {
const missingInfo = {
type: 'NotificationFilter Missing info',
lastMsgSender: newSession.lastMsgSender,
selfWxid: newSession.selfWxid
};
if (window.electronAPI.log?.debug) {
window.electronAPI.log.debug(missingInfo);
} else {
console.log('[NotificationFilter] Missing info:', missingInfo);
}
}
}
// 新增:如果未读数量没有增加,说明可能是自己在其他设备回复(或者已读),不弹通知
const oldUnread = oldSession ? oldSession.unreadCount : 0
const newUnread = newSession.unreadCount
if (newUnread <= oldUnread) {
// 仅仅是状态同步(如自己在手机上发消息 or 已读),跳过通知
continue
}
let title = newSession.displayName || newSession.username
let avatarUrl = newSession.avatarUrl
let content = newSession.summary || '[新消息]'
if (newSession.username.includes('@chatroom')) {
// 1. 群聊过滤自己发送的消息
// 辅助函数:清理 wxid 后缀 (如 _8602)
const cleanWxid = (id: string) => {
if (!id) return '';
const trimmed = id.trim();
// 仅移除末尾的 _xxxx (4位字母数字)
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/);
return suffixMatch ? suffixMatch[1] : trimmed;
}
if (newSession.lastMsgSender && newSession.selfWxid) {
const senderClean = cleanWxid(newSession.lastMsgSender);
const selfClean = cleanWxid(newSession.selfWxid);
const match = senderClean === selfClean;
if (match) {
continue;
}
}
// 2. 群聊显示发送者名字 (放在内容中: "Name: Message")
// 标题保持为群聊名称 (title 变量)
if (newSession.lastSenderDisplayName) {
content = `${newSession.lastSenderDisplayName}: ${content}`
}
}
// 修复 "Random User" 的逻辑 (缺少具体信息)
// 如果标题看起来像 wxid 或没有头像,尝试获取信息
const needsEnrichment = !newSession.displayName || !newSession.avatarUrl || newSession.displayName === newSession.username
if (needsEnrichment && newSession.username) {
try {
// 尝试丰富或获取联系人详情
const contact = await window.electronAPI.chat.getContact(newSession.username)
if (contact) {
if (contact.remark || contact.nickName) {
title = contact.remark || contact.nickName
}
const avatarResult = await window.electronAPI.chat.getContactAvatar(newSession.username)
if (avatarResult?.avatarUrl) {
avatarUrl = avatarResult.avatarUrl
}
} else {
// 如果不在缓存/数据库中
const enrichResult = await window.electronAPI.chat.enrichSessionsContactInfo([newSession.username])
if (enrichResult.success && enrichResult.contacts) {
const enrichedContact = enrichResult.contacts[newSession.username]
if (enrichedContact) {
if (enrichedContact.displayName) {
title = enrichedContact.displayName
}
if (enrichedContact.avatarUrl) {
avatarUrl = enrichedContact.avatarUrl
}
}
}
// 如果仍然没有有效名称,再尝试一次获取
if (title === newSession.username || title.startsWith('wxid_')) {
const retried = await window.electronAPI.chat.getContact(newSession.username)
if (retried) {
title = retried.remark || retried.nickName || title
const retriedAvatar = await window.electronAPI.chat.getContactAvatar(newSession.username)
if (retriedAvatar?.avatarUrl) {
avatarUrl = retriedAvatar.avatarUrl
}
}
}
}
} catch (e) {
console.warn('获取通知的联系人信息失败', e)
}
}
// 最终检查:如果标题仍是 wxid 格式,则跳过通知(避免显示乱跳用户)
// 群聊例外,因为群聊 username 包含 @chatroom
const isGroupChat = newSession.username.includes('@chatroom')
const isWxidTitle = title.startsWith('wxid_') && title === newSession.username
if (isWxidTitle && !isGroupChat) {
console.warn('[NotificationFilter] 跳过无法识别的用户通知:', newSession.username)
continue
}
// 调用 IPC 以显示独立窗口通知
window.electronAPI.notification?.show({
title: title,
content: content,
avatarUrl: avatarUrl,
sessionId: newSession.username
})
// 我们不再为 Toast 设置本地状态
}
}
}
const handleActiveSessionRefresh = async (sessionId: string) => {
// 从 ChatPage 复制/调整的逻辑,以保持集中
const state = useChatStore.getState()
const msgs = state.messages || []
const lastMsg = msgs[msgs.length - 1]
const minTime = lastMsg?.createTime || 0
try {
const result = await (window.electronAPI.chat as any).getNewMessages(sessionId, minTime)
if (result.success && result.messages && result.messages.length > 0) {
const latestMessages = useChatStore.getState().messages || []
const existingKeys = new Set(latestMessages.map(getMessageKey))
const newMessages = result.messages.filter((msg: Message) => !existingKeys.has(getMessageKey(msg)))
if (newMessages.length > 0) {
appendMessages(newMessages, false)
}
}
} catch (e) {
console.warn('后台活跃会话刷新失败:', e)
}
}
// 此组件不再渲染 UI
return null
}
-90
View File
@@ -1,90 +0,0 @@
.image-preview-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
user-select: none;
}
.preview-image {
max-width: 90vw;
max-height: 90vh;
object-fit: contain;
transition: transform 0.15s ease-out;
&.dragging {
transition: none;
}
}
.preview-content {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: fit-content;
height: fit-content;
}
.image-preview-close {
position: absolute;
bottom: 40px;
left: 50%;
transform: translateX(-50%);
width: 48px;
height: 48px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.3);
background: rgba(0, 0, 0, 0.7);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
backdrop-filter: blur(10px);
&:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.5);
transform: translateX(-50%) scale(1.1);
}
}
.live-photo-btn {
position: absolute;
top: 15px;
right: 15px;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 16px;
background: rgba(0, 0, 0, 0.5);
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.2);
cursor: pointer;
backdrop-filter: blur(10px);
transition: all 0.2s;
z-index: 10000;
&:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.4);
transform: translateY(-2px);
}
&.active {
background: var(--accent-color, #007aff);
border-color: transparent;
box-shadow: 0 4px 12px rgba(0, 122, 255, 0.3);
}
span {
font-size: 14px;
font-weight: 500;
}
}
-149
View File
@@ -1,149 +0,0 @@
import React, { useState, useRef, useCallback, useEffect } from 'react'
import { X } from 'lucide-react'
import { LivePhotoIcon } from './LivePhotoIcon'
import { createPortal } from 'react-dom'
import './ImagePreview.scss'
interface ImagePreviewProps {
src: string
isVideo?: boolean
liveVideoPath?: string
onClose: () => void
}
export const ImagePreview: React.FC<ImagePreviewProps> = ({ src, isVideo, liveVideoPath, onClose }) => {
const [scale, setScale] = useState(1)
const [position, setPosition] = useState({ x: 0, y: 0 })
const [isDragging, setIsDragging] = useState(false)
const [showLive, setShowLive] = useState(false)
const dragStart = useRef({ x: 0, y: 0 })
const positionStart = useRef({ x: 0, y: 0 })
const containerRef = useRef<HTMLDivElement>(null)
// 滚轮缩放
const handleWheel = useCallback((e: React.WheelEvent) => {
if (showLive) return // 播放实况时禁止缩放? 或者支持缩放? 暂定禁止以简化
e.preventDefault()
const delta = e.deltaY > 0 ? 0.9 : 1.1
setScale(prev => Math.min(Math.max(prev * delta, 0.5), 5))
}, [showLive])
// 开始拖动
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (showLive || scale <= 1) return
e.preventDefault()
setIsDragging(true)
dragStart.current = { x: e.clientX, y: e.clientY }
positionStart.current = { ...position }
}, [scale, position, showLive])
// 拖动中
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isDragging) return
const dx = e.clientX - dragStart.current.x
const dy = e.clientY - dragStart.current.y
setPosition({
x: positionStart.current.x + dx,
y: positionStart.current.y + dy
})
}, [isDragging])
// 结束拖动
const handleMouseUp = useCallback(() => {
setIsDragging(false)
}, [])
// 双击重置
const handleDoubleClick = useCallback(() => {
setScale(1)
setPosition({ x: 0, y: 0 })
}, [])
// 点击背景关闭
const handleOverlayClick = useCallback((e: React.MouseEvent) => {
if (e.target === containerRef.current) {
onClose()
}
}, [onClose])
// ESC 关闭
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onClose])
return createPortal(
<div
ref={containerRef}
className="image-preview-overlay"
onClick={handleOverlayClick}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div
className="preview-content"
style={{
position: 'relative',
transform: `translate(${position.x}px, ${position.y}px)`,
width: 'fit-content',
height: 'fit-content'
}}
onClick={(e) => e.stopPropagation()}
>
{(isVideo || showLive) ? (
<video
src={showLive ? liveVideoPath : src}
controls={!showLive}
autoPlay
loop={showLive}
className="preview-image"
style={{
transform: `scale(${scale})`,
maxHeight: '90vh',
maxWidth: '90vw'
}}
/>
) : (
<img
src={src}
alt="图片预览"
className={`preview-image ${isDragging ? 'dragging' : ''}`}
style={{
transform: `scale(${scale})`,
maxHeight: '90vh',
maxWidth: '90vw',
cursor: scale > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default'
}}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onDoubleClick={handleDoubleClick}
draggable={false}
/>
)}
{liveVideoPath && !isVideo && (
<button
className={`live-photo-btn ${showLive ? 'active' : ''}`}
onClick={(e) => {
e.stopPropagation()
setShowLive(!showLive)
}}
title={showLive ? "显示照片" : "播放实况"}
>
<LivePhotoIcon size={20} />
<span>实况</span>
</button>
)}
</div>
<button className="image-preview-close" onClick={onClose}>
<X size={20} />
</button>
</div>,
document.body
)
}
-404
View File
@@ -1,404 +0,0 @@
.jump-date-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
animation: fadeIn 0.2s ease-out;
}
.jump-date-modal {
background: var(--card-bg);
width: 340px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
overflow: hidden;
animation: modalSlideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.jump-date-header {
padding: 18px 20px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-color);
.title-area {
display: flex;
align-items: center;
gap: 10px;
color: var(--text-primary);
svg {
color: var(--primary);
}
h3 {
font-size: 16px;
font-weight: 600;
margin: 0;
}
}
.close-btn {
background: none;
border: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 4px;
border-radius: 6px;
display: flex;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
.calendar-view {
padding: 20px;
.calendar-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
.current-month {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
border: none;
background: transparent;
&.clickable {
cursor: pointer;
border-radius: 6px;
padding: 2px 8px;
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--primary);
}
}
}
.nav-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
}
}
}
.year-month-picker {
padding: 4px 0;
.year-selector {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
.year-label {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
}
.nav-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
border-color: var(--primary);
color: var(--primary);
}
}
}
.month-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
.month-btn {
padding: 10px 0;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.active {
background: var(--primary);
color: #fff;
}
}
}
.year-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
.year-btn {
padding: 10px 0;
border: none;
background: transparent;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--text-secondary);
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.active {
background: var(--primary);
color: #fff;
}
}
}
}
}
.calendar-grid {
position: relative;
&.loading {
.weekdays,
.days {
pointer-events: none;
}
}
.calendar-loading {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-secondary);
font-size: 13px;
.spin {
color: var(--primary);
animation: spin 1s linear infinite;
}
}
.weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: 8px;
.weekday {
text-align: center;
font-size: 12px;
font-weight: 500;
color: var(--text-tertiary);
padding: 4px 0;
}
}
.days {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: repeat(6, 36px);
gap: 4px;
.day-cell {
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: var(--text-primary);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
position: relative;
&.empty {
cursor: default;
}
&:not(.empty):not(.no-message):hover {
background: var(--bg-hover);
}
&.selected {
background: var(--primary);
color: #fff;
font-weight: 600;
}
&.today:not(.selected) {
color: var(--primary);
font-weight: 600;
background: var(--primary-light);
}
// 无消息的日期 - 灰显且不可点击
&.no-message {
opacity: 0.3;
cursor: default;
pointer-events: none;
}
// 有消息的日期指示器小圆点
.message-dot {
position: absolute;
bottom: 3px;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--primary);
}
&.selected .message-dot {
background: rgba(255, 255, 255, 0.7);
}
}
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.quick-options {
display: flex;
gap: 8px;
padding: 0 20px 16px;
button {
flex: 1;
padding: 8px;
font-size: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover);
color: var(--primary);
border-color: var(--primary);
}
}
}
.dialog-footer {
padding: 16px 20px;
display: flex;
gap: 12px;
background: var(--bg-secondary);
button {
flex: 1;
padding: 10px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.cancel-btn {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
.confirm-btn {
background: var(--primary);
border: none;
color: #fff;
&:hover {
background: var(--primary-hover);
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modalSlideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
-306
View File
@@ -1,306 +0,0 @@
import React, { useState } from 'react'
import { X, ChevronLeft, ChevronRight, Calendar as CalendarIcon, Loader2 } from 'lucide-react'
import './JumpToDateDialog.scss'
interface JumpToDateDialogProps {
isOpen: boolean
onClose: () => void
onSelect: (date: Date) => void
currentDate?: Date
/** 有消息的日期集合,格式为 YYYY-MM-DD */
messageDates?: Set<string>
/** 是否正在加载消息日期 */
loadingDates?: boolean
}
const JumpToDateDialog: React.FC<JumpToDateDialogProps> = ({
isOpen,
onClose,
onSelect,
currentDate = new Date(),
messageDates,
loadingDates = false
}) => {
type CalendarViewMode = 'day' | 'month' | 'year'
const getYearPageStart = (year: number): number => Math.floor(year / 12) * 12
const isValidDate = (d: any) => d instanceof Date && !isNaN(d.getTime())
const [calendarDate, setCalendarDate] = useState(isValidDate(currentDate) ? new Date(currentDate) : new Date())
const [selectedDate, setSelectedDate] = useState(new Date(currentDate))
const [viewMode, setViewMode] = useState<CalendarViewMode>('day')
const [yearPageStart, setYearPageStart] = useState<number>(
getYearPageStart((isValidDate(currentDate) ? new Date(currentDate) : new Date()).getFullYear())
)
if (!isOpen) return null
const getDaysInMonth = (date: Date) => {
const year = date.getFullYear()
const month = date.getMonth()
return new Date(year, month + 1, 0).getDate()
}
const getFirstDayOfMonth = (date: Date) => {
const year = date.getFullYear()
const month = date.getMonth()
return new Date(year, month, 1).getDay()
}
const generateCalendar = () => {
const daysInMonth = getDaysInMonth(calendarDate)
const firstDay = getFirstDayOfMonth(calendarDate)
const days: (number | null)[] = []
for (let i = 0; i < firstDay; i++) {
days.push(null)
}
for (let i = 1; i <= daysInMonth; i++) {
days.push(i)
}
return days
}
/**
* 判断某天是否有消息
*/
const hasMessage = (day: number): boolean => {
if (!messageDates || messageDates.size === 0) return true // 未加载时默认全部可点击
const year = calendarDate.getFullYear()
const month = calendarDate.getMonth() + 1
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
return messageDates.has(dateStr)
}
const handleDateClick = (day: number) => {
// 如果已加载日期数据且该日期无消息,则不可点击
if (messageDates && messageDates.size > 0 && !hasMessage(day)) return
const newDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
setSelectedDate(newDate)
}
const handleConfirm = () => {
onSelect(selectedDate)
onClose()
}
const isToday = (day: number) => {
const today = new Date()
return day === today.getDate() &&
calendarDate.getMonth() === today.getMonth() &&
calendarDate.getFullYear() === today.getFullYear()
}
const isSelected = (day: number) => {
return day === selectedDate.getDate() &&
calendarDate.getMonth() === selectedDate.getMonth() &&
calendarDate.getFullYear() === selectedDate.getFullYear()
}
/**
* 获取某天的 CSS 类名
*/
const getDayClassName = (day: number | null): string => {
if (day === null) return 'day-cell empty'
const classes = ['day-cell']
if (isSelected(day)) classes.push('selected')
if (isToday(day)) classes.push('today')
// 仅在已加载消息日期数据时区分有/无消息
if (messageDates && messageDates.size > 0) {
if (hasMessage(day)) {
classes.push('has-message')
} else {
classes.push('no-message')
}
}
return classes.join(' ')
}
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const days = generateCalendar()
const monthNames = ['一月', '二月', '三月', '四月', '五月', '六月', '七月', '八月', '九月', '十月', '十一月', '十二月']
const updateCalendarDate = (nextDate: Date) => {
setCalendarDate(nextDate)
}
const openMonthView = () => setViewMode('month')
const openYearView = () => {
setYearPageStart(getYearPageStart(calendarDate.getFullYear()))
setViewMode('year')
}
const handleTitleClick = () => {
if (viewMode === 'day') {
openMonthView()
return
}
if (viewMode === 'month') {
openYearView()
}
}
const handlePrev = () => {
if (viewMode === 'day') {
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))
return
}
if (viewMode === 'month') {
updateCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))
return
}
setYearPageStart((prev) => prev - 12)
}
const handleNext = () => {
if (viewMode === 'day') {
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))
return
}
if (viewMode === 'month') {
updateCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))
return
}
setYearPageStart((prev) => prev + 12)
}
const navTitle = viewMode === 'day'
? `${calendarDate.getFullYear()}年${calendarDate.getMonth() + 1}月`
: viewMode === 'month'
? `${calendarDate.getFullYear()}年`
: `${yearPageStart}年 - ${yearPageStart + 11}年`
return (
<div className="jump-date-overlay" onClick={onClose}>
<div className="jump-date-modal" onClick={e => e.stopPropagation()}>
<div className="jump-date-header">
<div className="title-area">
<CalendarIcon size={18} />
<h3>跳转到日期</h3>
</div>
<button className="close-btn" onClick={onClose}>
<X size={18} />
</button>
</div>
<div className="calendar-view">
<div className="calendar-nav">
<button
className="nav-btn"
onClick={handlePrev}
>
<ChevronLeft size={18} />
</button>
<button
className={`current-month ${viewMode === 'year' ? '' : 'clickable'}`.trim()}
onClick={handleTitleClick}
type="button"
>
{navTitle}
</button>
<button
className="nav-btn"
onClick={handleNext}
>
<ChevronRight size={18} />
</button>
</div>
{viewMode === 'month' ? (
<div className="year-month-picker">
<div className="month-grid">
{monthNames.map((name, i) => (
<button
key={i}
className={`month-btn ${i === calendarDate.getMonth() ? 'active' : ''}`}
onClick={() => {
updateCalendarDate(new Date(calendarDate.getFullYear(), i, 1))
setViewMode('day')
}}
>{name}</button>
))}
</div>
</div>
) : viewMode === 'year' ? (
<div className="year-month-picker">
<div className="year-grid">
{Array.from({ length: 12 }, (_, i) => yearPageStart + i).map((year) => (
<button
key={year}
className={`year-btn ${year === calendarDate.getFullYear() ? 'active' : ''}`}
onClick={() => {
updateCalendarDate(new Date(year, calendarDate.getMonth(), 1))
setViewMode('month')
}}
>
{year}年
</button>
))}
</div>
</div>
) : (
<div className={`calendar-grid ${loadingDates ? 'loading' : ''}`}>
{loadingDates && (
<div className="calendar-loading">
<Loader2 size={20} className="spin" />
<span>正在加载...</span>
</div>
)}
<div className="weekdays" style={{ visibility: loadingDates ? 'hidden' : 'visible' }}>
{weekdays.map(d => <div key={d} className="weekday">{d}</div>)}
</div>
<div className="days" style={{ visibility: loadingDates ? 'hidden' : 'visible' }}>
{days.map((day, i) => (
<div
key={i}
className={getDayClassName(day)}
style={{ visibility: loadingDates ? 'hidden' : 'visible' }}
onClick={() => day !== null && handleDateClick(day)}
>
{day}
{day !== null && messageDates && messageDates.size > 0 && hasMessage(day) && (
<span className="message-dot" />
)}
</div>
))}
</div>
</div>
)}
</div>
<div className="quick-options">
<button onClick={() => {
const d = new Date()
setSelectedDate(d)
setCalendarDate(new Date(d))
setViewMode('day')
}}>今天</button>
<button onClick={() => {
const d = new Date()
d.setDate(d.getDate() - 7)
setSelectedDate(d)
setCalendarDate(new Date(d))
setViewMode('day')
}}>一周前</button>
<button onClick={() => {
const d = new Date()
d.setMonth(d.getMonth() - 1)
setSelectedDate(d)
setCalendarDate(new Date(d))
setViewMode('day')
}}>一月前</button>
</div>
<div className="dialog-footer">
<button className="cancel-btn" onClick={onClose}>取消</button>
<button className="confirm-btn" onClick={handleConfirm}>跳转</button>
</div>
</div>
</div>
)
}
export default JumpToDateDialog
-215
View File
@@ -1,215 +0,0 @@
.jump-date-popover {
position: absolute;
top: calc(100% + 10px);
right: 0;
width: 312px;
border-radius: 14px;
border: 1px solid var(--border-color);
background: none;
background-color: var(--bg-secondary-solid, #ffffff) !important;
opacity: 1;
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
mix-blend-mode: normal;
isolation: isolate;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
padding: 12px;
z-index: 1600;
}
.jump-date-popover .calendar-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.jump-date-popover .current-month {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
border: none;
background: transparent;
border-radius: 8px;
padding: 4px 8px;
}
.jump-date-popover .current-month.clickable {
cursor: pointer;
transition: all 0.18s ease;
}
.jump-date-popover .current-month.clickable:hover {
color: var(--primary);
background: var(--bg-hover);
}
.jump-date-popover .nav-btn {
width: 28px;
height: 28px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: none;
background-color: var(--bg-secondary-solid, #ffffff) !important;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.18s ease;
}
.jump-date-popover .nav-btn:hover {
border-color: var(--primary);
color: var(--primary);
background: var(--bg-hover);
}
.jump-date-popover .status-line {
min-height: 16px;
margin-bottom: 6px;
}
.jump-date-popover .status-item {
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--text-tertiary);
font-size: 11px;
}
.jump-date-popover .calendar-grid .weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
margin-bottom: 6px;
}
.jump-date-popover .calendar-grid .weekday {
text-align: center;
font-size: 11px;
color: var(--text-tertiary);
}
.jump-date-popover .calendar-grid .days {
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: repeat(6, 36px);
gap: 4px;
}
.jump-date-popover .month-grid,
.jump-date-popover .year-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
min-height: 256px;
}
.jump-date-popover .month-cell,
.jump-date-popover .year-cell {
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font-size: 13px;
transition: all 0.18s ease;
}
.jump-date-popover .month-cell:hover,
.jump-date-popover .year-cell:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.jump-date-popover .month-cell.active,
.jump-date-popover .year-cell.active {
background: var(--primary);
color: #fff;
}
.jump-date-popover .day-cell {
position: relative;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: var(--text-primary);
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
padding: 0;
font-size: 13px;
transition: all 0.18s ease;
}
.jump-date-popover .day-cell .day-number {
position: relative;
z-index: 1;
font-size: 12px;
line-height: 1;
font-weight: 500;
}
.jump-date-popover .day-cell.empty {
cursor: default;
background: transparent;
}
.jump-date-popover .day-cell:not(.empty):not(.no-message):hover {
background: var(--bg-hover);
}
.jump-date-popover .day-cell.today {
border-color: var(--primary-light);
color: var(--primary);
}
.jump-date-popover .day-cell.selected {
background: var(--primary);
color: #fff;
}
.jump-date-popover .day-cell.no-message {
opacity: 0.5;
cursor: default;
}
.jump-date-popover .day-count {
position: static;
margin-top: 1px;
font-size: 13px;
line-height: 1;
color: var(--primary, #07c160);
font-weight: 700;
}
.jump-date-popover .day-cell.selected .day-count {
color: color-mix(in srgb, #ffffff 78%, var(--primary, #07c160) 22%);
}
.jump-date-popover .day-count-loading {
position: static;
margin-top: 1px;
color: var(--primary, #07c160);
}
.jump-date-popover .day-cell.selected .day-count-loading {
color: color-mix(in srgb, #ffffff 78%, var(--primary, #07c160) 22%);
}
.jump-date-popover .spin {
animation: jump-date-spin 1s linear infinite;
}
@keyframes jump-date-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
-300
View File
@@ -1,300 +0,0 @@
import React, { useEffect, useState } from 'react'
import { ChevronLeft, ChevronRight, Loader2 } from 'lucide-react'
import './JumpToDatePopover.scss'
interface JumpToDatePopoverProps {
isOpen: boolean
onClose: () => void
onSelect: (date: Date) => void
onMonthChange?: (date: Date) => void
className?: string
style?: React.CSSProperties
currentDate?: Date
messageDates?: Set<string>
hasLoadedMessageDates?: boolean
messageDateCounts?: Record<string, number>
loadingDates?: boolean
loadingDateCounts?: boolean
maxDate?: Date
}
const JumpToDatePopover: React.FC<JumpToDatePopoverProps> = ({
isOpen,
onClose,
onSelect,
onMonthChange,
className,
style,
currentDate = new Date(),
messageDates,
hasLoadedMessageDates = false,
messageDateCounts,
loadingDates = false,
loadingDateCounts = false,
maxDate
}) => {
type CalendarViewMode = 'day' | 'month' | 'year'
const getYearPageStart = (year: number): number => Math.floor(year / 12) * 12
const [calendarDate, setCalendarDate] = useState<Date>(new Date(currentDate))
const [selectedDate, setSelectedDate] = useState<Date>(new Date(currentDate))
const [viewMode, setViewMode] = useState<CalendarViewMode>('day')
const [yearPageStart, setYearPageStart] = useState<number>(getYearPageStart(new Date(currentDate).getFullYear()))
useEffect(() => {
if (!isOpen) return
const normalized = new Date(currentDate)
setCalendarDate(normalized)
setSelectedDate(normalized)
setViewMode('day')
setYearPageStart(getYearPageStart(normalized.getFullYear()))
}, [isOpen, currentDate])
if (!isOpen) return null
const getDaysInMonth = (date: Date): number => {
const year = date.getFullYear()
const month = date.getMonth()
return new Date(year, month + 1, 0).getDate()
}
const getFirstDayOfMonth = (date: Date): number => {
const year = date.getFullYear()
const month = date.getMonth()
return new Date(year, month, 1).getDay()
}
const toDateKey = (day: number): string => {
const year = calendarDate.getFullYear()
const month = calendarDate.getMonth() + 1
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
}
const hasMessage = (day: number): boolean => {
if (!hasLoadedMessageDates) return true
if (!messageDates || messageDates.size === 0) return false
return messageDates.has(toDateKey(day))
}
const isAfterMaxDate = (day: number): boolean => {
if (!maxDate) return false
const max = new Date(maxDate)
max.setHours(23, 59, 59, 999)
const candidate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day, 0, 0, 0, 0)
return candidate.getTime() > max.getTime()
}
const isToday = (day: number): boolean => {
const today = new Date()
return day === today.getDate()
&& calendarDate.getMonth() === today.getMonth()
&& calendarDate.getFullYear() === today.getFullYear()
}
const isSelected = (day: number): boolean => {
return day === selectedDate.getDate()
&& calendarDate.getMonth() === selectedDate.getMonth()
&& calendarDate.getFullYear() === selectedDate.getFullYear()
}
const generateCalendar = (): Array<number | null> => {
const daysInMonth = getDaysInMonth(calendarDate)
const firstDay = getFirstDayOfMonth(calendarDate)
const days: Array<number | null> = []
for (let i = 0; i < firstDay; i++) {
days.push(null)
}
for (let i = 1; i <= daysInMonth; i++) {
days.push(i)
}
return days
}
const handleDateClick = (day: number) => {
if (hasLoadedMessageDates && !hasMessage(day)) return
if (isAfterMaxDate(day)) return
const targetDate = new Date(calendarDate.getFullYear(), calendarDate.getMonth(), day)
setSelectedDate(targetDate)
onSelect(targetDate)
onClose()
}
const getDayClassName = (day: number | null): string => {
if (day === null) return 'day-cell empty'
const classes = ['day-cell']
if (isToday(day)) classes.push('today')
if (isSelected(day)) classes.push('selected')
if ((hasLoadedMessageDates && !hasMessage(day)) || isAfterMaxDate(day)) classes.push('no-message')
return classes.join(' ')
}
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
const days = generateCalendar()
const mergedClassName = ['jump-date-popover', className || ''].join(' ').trim()
const updateCalendarDate = (nextDate: Date) => {
setCalendarDate(nextDate)
onMonthChange?.(nextDate)
}
const openMonthView = () => setViewMode('month')
const openYearView = () => {
setYearPageStart(getYearPageStart(calendarDate.getFullYear()))
setViewMode('year')
}
const handleTitleClick = () => {
if (viewMode === 'day') {
openMonthView()
return
}
if (viewMode === 'month') {
openYearView()
}
}
const handlePrev = () => {
if (viewMode === 'day') {
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() - 1, 1))
return
}
if (viewMode === 'month') {
updateCalendarDate(new Date(calendarDate.getFullYear() - 1, calendarDate.getMonth(), 1))
return
}
setYearPageStart((prev) => prev - 12)
}
const handleNext = () => {
if (viewMode === 'day') {
updateCalendarDate(new Date(calendarDate.getFullYear(), calendarDate.getMonth() + 1, 1))
return
}
if (viewMode === 'month') {
updateCalendarDate(new Date(calendarDate.getFullYear() + 1, calendarDate.getMonth(), 1))
return
}
setYearPageStart((prev) => prev + 12)
}
const navTitle = viewMode === 'day'
? `${calendarDate.getFullYear()}年${calendarDate.getMonth() + 1}月`
: viewMode === 'month'
? `${calendarDate.getFullYear()}年`
: `${yearPageStart}年 - ${yearPageStart + 11}年`
return (
<div className={mergedClassName} style={style} role="dialog" aria-label="跳转日期">
<div className="calendar-nav">
<button
className="nav-btn"
onClick={handlePrev}
aria-label="上一月"
>
<ChevronLeft size={16} />
</button>
<button
className={`current-month ${viewMode === 'year' ? '' : 'clickable'}`.trim()}
onClick={handleTitleClick}
type="button"
>
{navTitle}
</button>
<button
className="nav-btn"
onClick={handleNext}
aria-label="下一月"
>
<ChevronRight size={16} />
</button>
</div>
<div className="status-line">
{loadingDates && (
<span className="status-item">
<Loader2 size={12} className="spin" />
<span>日期加载中</span>
</span>
)}
{!loadingDates && loadingDateCounts && (
<span className="status-item">
<Loader2 size={12} className="spin" />
<span>条数加载中</span>
</span>
)}
</div>
{viewMode === 'day' && (
<div className="calendar-grid">
<div className="weekdays">
{weekdays.map(day => (
<div key={day} className="weekday">{day}</div>
))}
</div>
<div className="days">
{days.map((day, index) => {
if (day === null) return <div key={index} className="day-cell empty" />
const dateKey = toDateKey(day)
const hasMessageOnDay = hasMessage(day)
const isDisabled = (hasLoadedMessageDates && !hasMessageOnDay) || isAfterMaxDate(day)
const count = Number(messageDateCounts?.[dateKey] || 0)
const showCount = count > 0
const showCountLoading = hasMessageOnDay && loadingDateCounts && !showCount
return (
<button
key={index}
className={getDayClassName(day)}
onClick={() => handleDateClick(day)}
disabled={isDisabled}
type="button"
>
<span className="day-number">{day}</span>
{showCount && <span className="day-count">{count}</span>}
{showCountLoading && <Loader2 size={11} className="day-count-loading spin" />}
</button>
)
})}
</div>
</div>
)}
{viewMode === 'month' && (
<div className="month-grid">
{['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'].map((name, monthIndex) => (
<button
key={name}
className={`month-cell ${monthIndex === calendarDate.getMonth() ? 'active' : ''}`}
onClick={() => {
updateCalendarDate(new Date(calendarDate.getFullYear(), monthIndex, 1))
setViewMode('day')
}}
type="button"
>
{name}
</button>
))}
</div>
)}
{viewMode === 'year' && (
<div className="year-grid">
{Array.from({ length: 12 }, (_, i) => yearPageStart + i).map((year) => (
<button
key={year}
className={`year-cell ${year === calendarDate.getFullYear() ? 'active' : ''}`}
onClick={() => {
updateCalendarDate(new Date(year, calendarDate.getMonth(), 1))
setViewMode('month')
}}
type="button"
>
{year}年
</button>
))}
</div>
)}
</div>
)
}
export default JumpToDatePopover
-29
View File
@@ -1,29 +0,0 @@
import React from 'react';
interface LivePhotoIconProps {
size?: number | string;
className?: string;
style?: React.CSSProperties;
}
export const LivePhotoIcon: React.FC<LivePhotoIconProps> = ({ size = 24, className = '', style = {} }) => {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
className={className}
style={style}
>
<g stroke="none" strokeWidth="1" fill="none" fillRule="evenodd" strokeLinecap="round" strokeLinejoin="round">
<g stroke="currentColor" strokeWidth="2">
<circle fill="currentColor" stroke="none" cx="12" cy="12" r="2.5"></circle>
<circle cx="12" cy="12" r="5.5"></circle>
<circle cx="12" cy="12" r="9" strokeDasharray="1 3.7"></circle>
</g>
</g>
</svg>
);
};
-185
View File
@@ -1,185 +0,0 @@
.lock-screen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: var(--bg-primary);
z-index: 9999;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
user-select: none;
-webkit-app-region: drag;
transition: all 0.5s cubic-bezier(0.22, 1, 0.36, 1);
backdrop-filter: blur(25px) saturate(180%);
background-color: var(--bg-primary);
// 让背景带一点透明度以增强毛玻璃效果
opacity: 1;
&.unlocked {
opacity: 0;
pointer-events: none;
backdrop-filter: blur(0) saturate(100%);
transform: scale(1.02);
.lock-content {
transform: translateY(-20px) scale(0.95);
filter: blur(10px);
opacity: 0;
}
}
.lock-content {
display: flex;
flex-direction: column;
align-items: center;
width: 320px;
-webkit-app-region: no-drag;
animation: fadeIn 0.5s cubic-bezier(0.4, 0, 0.2, 1);
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
.lock-avatar {
width: 100px;
height: 100px;
border-radius: 50%;
margin-bottom: 24px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
border: 4px solid var(--bg-total);
background-color: var(--bg-secondary);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary);
}
.lock-title {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 32px;
}
.lock-form {
width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
.input-group {
position: relative;
width: 100%;
input {
width: 100%;
height: 48px;
padding: 0 16px;
padding-right: 48px;
border-radius: 12px;
border: 1px solid var(--border-color);
background-color: var(--bg-input);
color: var(--text-primary);
font-size: 16px;
outline: none;
transition: all 0.2s;
&:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 2px var(--primary-color-alpha);
}
}
.submit-btn {
position: absolute;
right: 8px;
top: 8px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
border: none;
background: var(--primary-color);
color: white;
cursor: pointer;
transition: opacity 0.2s;
&:hover {
opacity: 0.9;
}
}
}
.hello-btn {
width: 100%;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 12px;
border: 1px solid var(--border-color);
background-color: var(--bg-secondary);
color: var(--text-primary);
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
&:hover {
background-color: var(--bg-hover);
transform: translateY(-1px);
}
&.loading {
opacity: 0.7;
pointer-events: none;
}
}
}
.lock-error {
margin-top: 16px;
color: #ff4d4f;
font-size: 14px;
animation: shake 0.5s ease-in-out;
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
10%,
30%,
50%,
70%,
90% {
transform: translateX(-4px);
}
20%,
40%,
60%,
80% {
transform: translateX(4px);
}
}
-144
View File
@@ -1,144 +0,0 @@
import { useState, useEffect, useRef } from 'react'
import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react'
import './LockScreen.scss'
interface LockScreenProps {
onUnlock: () => void
avatar?: string
useHello?: boolean
}
export default function LockScreen({ onUnlock, avatar, useHello = false }: LockScreenProps) {
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [isVerifying, setIsVerifying] = useState(false)
const [isUnlocked, setIsUnlocked] = useState(false)
const [showHello, setShowHello] = useState(false)
const [helloAvailable, setHelloAvailable] = useState(false)
// 用于取消 WebAuthn 请求
const abortControllerRef = useRef<AbortController | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
// 快速检查配置并启动
quickStartHello()
inputRef.current?.focus()
return () => {
// 组件卸载时取消请求
abortControllerRef.current?.abort()
}
}, [])
const handleUnlock = () => {
setIsUnlocked(true)
setTimeout(() => {
onUnlock()
}, 1500)
}
const quickStartHello = async () => {
try {
if (useHello) {
setHelloAvailable(true)
setShowHello(true)
verifyHello()
}
} catch (e) {
console.error('Quick start hello failed', e)
}
}
const verifyHello = async () => {
if (isVerifying || isUnlocked) return
setIsVerifying(true)
setError('')
try {
const result = await window.electronAPI.auth.hello()
if (result.success) {
handleUnlock()
} else {
console.error('Hello verification failed:', result.error)
setError(result.error || '验证失败')
}
} catch (e: any) {
console.error('Hello verification error:', e)
setError(`验证失败: ${e.message || String(e)}`)
} finally {
setIsVerifying(false)
}
}
const handlePasswordSubmit = async (e?: React.FormEvent) => {
e?.preventDefault()
if (!password || isUnlocked) return
setIsVerifying(true)
setError('')
try {
// 发送原始密码到主进程,由主进程验证并解密密钥
const result = await window.electronAPI.auth.unlock(password)
if (result.success) {
handleUnlock()
} else {
setError(result.error || '密码错误')
setPassword('')
setIsVerifying(false)
}
} catch (e) {
setError('验证失败')
setIsVerifying(false)
}
}
return (
<div className={`lock-screen ${isUnlocked ? 'unlocked' : ''}`}>
<div className="lock-content">
<div className="lock-avatar">
{avatar ? (
<img src={avatar} alt="User" style={{ width: '100%', height: '100%', borderRadius: '50%' }} />
) : (
<Lock size={40} />
)}
</div>
<h2 className="lock-title">WeFlow 已锁定</h2>
<form className="lock-form" onSubmit={handlePasswordSubmit}>
<div className="input-group">
<input
ref={inputRef}
type="password"
placeholder="输入应用密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
// 移除 disabled,允许用户随时输入
/>
<button type="submit" className="submit-btn" disabled={!password}>
<ArrowRight size={18} />
</button>
</div>
{showHello && (
<button
type="button"
className={`hello-btn ${isVerifying ? 'loading' : ''}`}
onClick={verifyHello}
>
<Fingerprint size={20} />
{isVerifying ? '验证中...' : '使用 Windows Hello 解锁'}
</button>
)}
</form>
{error && <div className="lock-error">{error}</div>}
</div>
</div>
)
}
-242
View File
@@ -1,242 +0,0 @@
.notification-toast-container {
position: fixed;
z-index: 9999;
width: 320px;
background: var(--bg-secondary);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--border-light);
// 浅色模式下使用完全不透明背景,并禁用毛玻璃效果
[data-mode="light"] &,
:not([data-mode]) & {
background: rgba(255, 255, 255, 1);
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
padding: 12px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0;
transform: scale(0.95);
pointer-events: none; // Allow clicking through when hidden
&.visible {
opacity: 1;
transform: scale(1);
pointer-events: auto;
}
&.static {
position: relative !important;
width: calc(100% - 4px) !important; // Leave 2px margin for anti-aliasing saftey
height: auto !important; // Fits content
min-height: 0;
top: 0 !important;
bottom: 0 !important;
left: 0 !important;
right: 0 !important;
transform: none !important;
margin: 2px !important; // 2px centered margin
border-radius: 12px !important; // Rounded corners
// Disable backdrop filter
backdrop-filter: none !important;
-webkit-backdrop-filter: none !important;
// 独立通知窗口:默认使用浅色模式硬编码值,确保不依赖 <html> 上的主题属性
background: #ffffff;
color: #3d3d3d;
--text-primary: #3d3d3d;
--text-secondary: #666666;
--text-tertiary: #999999;
--border-light: rgba(0, 0, 0, 0.08);
// 深色模式覆盖
[data-mode="dark"] & {
background: var(--bg-secondary-solid, #282420);
color: var(--text-primary, #F0EEE9);
--text-primary: #F0EEE9;
--text-secondary: #b3b0aa;
--text-tertiary: #807d78;
--border-light: rgba(255, 255, 255, 0.1);
}
box-shadow: none !important; // NO SHADOW
border: 1px solid var(--border-light);
display: flex;
padding: 16px;
padding-right: 32px; // Make space for close button
box-sizing: border-box;
// Force close button to be visible but transparent background
.notification-close {
opacity: 1 !important;
top: 12px;
right: 12px;
background: transparent !important; // Transparent per user request
&:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.1) !important; // Subtle hover effect
}
}
.notification-time {
top: 24px; // Match padding
right: 40px; // Left of close button (12px + 20px + 8px)
}
}
// Position variants
&.bottom-right {
bottom: 24px;
right: 24px;
transform: translate(0, 20px) scale(0.95);
&.visible {
transform: translate(0, 0) scale(1);
}
}
&.top-right {
top: 24px;
right: 24px;
transform: translate(0, -20px) scale(0.95);
&.visible {
transform: translate(0, 0) scale(1);
}
}
&.bottom-left {
bottom: 24px;
left: 24px;
transform: translate(0, 20px) scale(0.95);
&.visible {
transform: translate(0, 0) scale(1);
}
}
&.top-left {
top: 24px;
left: 24px;
transform: translate(0, -20px) scale(0.95);
&.visible {
transform: translate(0, 0) scale(1);
}
}
&.top-center {
top: 24px;
left: 50%;
transform: translate(-50%, -20px) scale(0.95);
&.visible {
transform: translate(-50%, 0) scale(1);
}
// 灵动岛样式
border-radius: 40px !important;
padding: 12px 16px;
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2);
&.static {
border-radius: 40px !important;
}
}
&:hover {
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.16) !important;
}
.notification-content {
display: flex;
align-items: flex-start;
gap: 12px;
}
.notification-avatar {
flex-shrink: 0;
}
.notification-text {
flex: 1;
min-width: 0;
.notification-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
.notification-title {
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%; // 允许缩放
flex: 1; // 占据剩余空间
min-width: 0; // 关键:允许 flex 子项收缩到内容以下
margin-right: 60px; // Make space for absolute time + close button
}
.notification-time {
font-size: 12px;
color: var(--text-tertiary);
position: absolute;
top: 16px;
right: 36px; // Left of close button (8px + 20px + 8px)
font-variant-numeric: tabular-nums;
}
}
.notification-body {
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-all;
}
}
.notification-close {
position: absolute;
top: 8px;
right: 8px;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
opacity: 0;
transition: all 0.2s;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
}
&:hover .notification-close {
opacity: 1;
}
}
-111
View File
@@ -1,111 +0,0 @@
import React, { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { Avatar } from './Avatar'
import './NotificationToast.scss'
export interface NotificationData {
id: string
sessionId: string
channel?: string
insightRecordId?: string
targetRoute?: string
avatarUrl?: string
title: string
content: string
timestamp: number
}
interface NotificationToastProps {
data: NotificationData | null
onClose: () => void
onClick: (data: NotificationData) => void
duration?: number
position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center'
isStatic?: boolean
initialVisible?: boolean
}
export function NotificationToast({
data,
onClose,
onClick,
duration = 5000,
position = 'top-right',
isStatic = false,
initialVisible = false
}: NotificationToastProps) {
const [isVisible, setIsVisible] = useState(initialVisible)
const [currentData, setCurrentData] = useState<NotificationData | null>(null)
useEffect(() => {
if (data) {
setCurrentData(data)
setIsVisible(true)
const timer = setTimeout(() => {
setIsVisible(false)
// clean up data after animation
setTimeout(onClose, 300)
}, duration)
return () => clearTimeout(timer)
} else {
setIsVisible(false)
}
}, [data, duration, onClose])
if (!currentData) return null
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation()
setIsVisible(false)
setTimeout(onClose, 300)
}
const handleClick = () => {
setIsVisible(false)
setTimeout(() => {
onClose()
onClick(currentData)
}, 300)
}
const content = (
<div
className={`notification-toast-container ${position} ${isVisible ? 'visible' : ''} ${isStatic ? 'static' : ''}`}
onClick={handleClick}
>
<div className="notification-content">
<div className="notification-avatar">
<Avatar
src={currentData.avatarUrl}
name={currentData.title}
size={40}
/>
</div>
<div className="notification-text">
<div className="notification-header">
<span className="notification-title">{currentData.title}</span>
<span className="notification-time">
{new Date(currentData.timestamp * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className="notification-body">
{currentData.content}
</div>
</div>
<button className="notification-close" onClick={handleClose}>
<X size={14} />
</button>
</div>
</div>
)
if (isStatic) {
return content
}
// Portal to document.body to ensure it's on top
return createPortal(content, document.body)
}
-142
View File
@@ -1,142 +0,0 @@
// Shared styles for Report components (Heatmap, WordCloud)
// --- Heatmap ---
.heatmap-wrapper {
margin-top: 24px;
width: 100%;
}
.heatmap-header {
display: grid;
grid-template-columns: 28px 1fr;
gap: 3px;
margin-bottom: 6px;
color: var(--ar-text-sub); // Assumes --ar-text-sub is defined in parent context or globally
font-size: 10px;
}
.time-labels {
display: grid;
grid-template-columns: repeat(24, 1fr);
gap: 3px;
span {
text-align: center;
}
}
.heatmap {
display: grid;
grid-template-columns: 28px 1fr;
gap: 3px;
}
.heatmap-week-col {
display: grid;
grid-template-rows: repeat(7, 1fr);
gap: 3px;
font-size: 10px;
color: var(--ar-text-sub);
}
.week-label {
display: flex;
align-items: center;
}
.heatmap-grid {
display: grid;
grid-template-columns: repeat(24, 1fr);
gap: 3px;
}
.h-cell {
aspect-ratio: 1;
border-radius: 2px;
min-height: 10px;
transition: transform 0.15s;
&:hover {
transform: scale(1.3);
z-index: 1;
}
}
// --- Word Cloud ---
.word-cloud-wrapper {
margin: 24px auto 0;
padding: 0;
max-width: 520px;
display: flex;
justify-content: center;
--cloud-scale: clamp(0.72, 80vw / 520, 1);
}
.word-cloud-inner {
position: relative;
width: 520px;
height: 520px;
margin: 0;
border-radius: 50%;
transform: scale(var(--cloud-scale));
transform-origin: center;
&::before {
content: "";
position: absolute;
inset: -6%;
background:
radial-gradient(circle at 35% 45%, rgba(var(--ar-primary-rgb, 7, 193, 96), 0.12), transparent 55%),
radial-gradient(circle at 65% 50%, rgba(var(--ar-accent-rgb, 242, 170, 0), 0.10), transparent 58%),
radial-gradient(circle at 50% 65%, var(--bg-tertiary, rgba(0, 0, 0, 0.04)), transparent 60%);
filter: blur(18px);
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
}
.word-tag {
display: inline-block;
padding: 0;
background: transparent;
border-radius: 0;
border: none;
line-height: 1.2;
white-space: nowrap;
transition: transform 0.2s ease, color 0.2s ease;
cursor: default;
color: var(--ar-text-main);
font-weight: 600;
opacity: 0;
animation: wordPopIn 0.55s ease forwards;
position: absolute;
z-index: 1;
transform: translate(-50%, -50%) scale(0.8);
&:hover {
transform: translate(-50%, -50%) scale(1.08);
color: var(--ar-primary);
z-index: 2;
}
}
@keyframes wordPopIn {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.6);
}
100% {
opacity: var(--final-opacity, 1);
transform: translate(-50%, -50%) scale(1);
}
}
.word-cloud-note {
margin-top: 24px;
font-size: 14px !important;
color: var(--ar-text-sub) !important;
text-align: center;
}
-51
View File
@@ -1,51 +0,0 @@
import React from 'react'
import './ReportComponents.scss'
interface ReportHeatmapProps {
data: number[][]
}
const ReportHeatmap: React.FC<ReportHeatmapProps> = ({ data }) => {
if (!data || data.length === 0) return null
const maxHeat = Math.max(...data.flat())
const weekLabels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
return (
<div className="heatmap-wrapper">
<div className="heatmap-header">
<div></div>
<div className="time-labels">
{[0, 6, 12, 18].map(h => (
<span key={h} style={{ gridColumn: h + 1 }}>{h}</span>
))}
</div>
</div>
<div className="heatmap">
<div className="heatmap-week-col">
{weekLabels.map(w => <div key={w} className="week-label">{w}</div>)}
</div>
<div className="heatmap-grid">
{data.map((row, wi) =>
row.map((val, hi) => {
const alpha = maxHeat > 0 ? (val / maxHeat * 0.85 + 0.1).toFixed(2) : '0.1'
return (
<div
key={`${wi}-${hi}`}
className="h-cell"
style={{
backgroundColor: 'var(--primary)',
opacity: alpha
}}
title={`${weekLabels[wi]} ${hi}:00 - ${val}条`}
/>
)
})
)}
</div>
</div>
</div>
)
}
export default ReportHeatmap
-113
View File
@@ -1,113 +0,0 @@
import React from 'react'
import './ReportComponents.scss'
interface ReportWordCloudProps {
words: { phrase: string; count: number }[]
}
const ReportWordCloud: React.FC<ReportWordCloudProps> = ({ words }) => {
if (!words || words.length === 0) return null
const maxCount = words.length > 0 ? words[0].count : 1
const topWords = words.slice(0, 32)
const baseSize = 520
// 使用确定性随机数生成器
const seededRandom = (seed: number) => {
const x = Math.sin(seed) * 10000
return x - Math.floor(x)
}
// 计算词云位置
const placedItems: { x: number; y: number; w: number; h: number }[] = []
const canPlace = (x: number, y: number, w: number, h: number): boolean => {
const halfW = w / 2
const halfH = h / 2
const dx = x - 50
const dy = y - 50
const dist = Math.sqrt(dx * dx + dy * dy)
const maxR = 49 - Math.max(halfW, halfH)
if (dist > maxR) return false
const pad = 1.8
for (const p of placedItems) {
if ((x - halfW - pad) < (p.x + p.w / 2) &&
(x + halfW + pad) > (p.x - p.w / 2) &&
(y - halfH - pad) < (p.y + p.h / 2) &&
(y + halfH + pad) > (p.y - p.h / 2)) {
return false
}
}
return true
}
const wordItems = topWords.map((item, i) => {
const ratio = item.count / maxCount
const fontSize = Math.round(12 + Math.pow(ratio, 0.65) * 20)
const opacity = Math.min(1, Math.max(0.35, 0.35 + ratio * 0.65))
const delay = (i * 0.04).toFixed(2)
// 计算词语宽度
const charCount = Math.max(1, item.phrase.length)
const hasCjk = /[\u4e00-\u9fff]/.test(item.phrase)
const hasLatin = /[A-Za-z0-9]/.test(item.phrase)
const widthFactor = hasCjk && hasLatin ? 0.85 : hasCjk ? 0.98 : 0.6
const widthPx = fontSize * (charCount * widthFactor)
const heightPx = fontSize * 1.1
const widthPct = (widthPx / baseSize) * 100
const heightPct = (heightPx / baseSize) * 100
// 寻找位置
let x = 50, y = 50
let placedOk = false
const tries = i === 0 ? 1 : 420
for (let t = 0; t < tries; t++) {
if (i === 0) {
x = 50
y = 50
} else {
const idx = i + t * 0.28
const radius = Math.sqrt(idx) * 7.6 + (seededRandom(i * 1000 + t) * 1.2 - 0.6)
const angle = idx * 2.399963 + seededRandom(i * 2000 + t) * 0.35
x = 50 + radius * Math.cos(angle)
y = 50 + radius * Math.sin(angle)
}
if (canPlace(x, y, widthPct, heightPct)) {
placedOk = true
break
}
}
if (!placedOk) return null
placedItems.push({ x, y, w: widthPct, h: heightPct })
return (
<span
key={i}
className="word-tag"
style={{
'--final-opacity': opacity,
left: `${x.toFixed(2)}%`,
top: `${y.toFixed(2)}%`,
fontSize: `${fontSize}px`,
animationDelay: `${delay}s`,
} as React.CSSProperties}
title={`${item.phrase} (出现 ${item.count} 次)`}
>
{item.phrase}
</span>
)
}).filter(Boolean)
return (
<div className="word-cloud-wrapper">
<div className="word-cloud-inner">
{wordItems}
</div>
</div>
)
}
export default ReportWordCloud
-28
View File
@@ -1,28 +0,0 @@
import { useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useAppStore } from '../stores/appStore'
interface RouteGuardProps {
children: React.ReactNode
}
const PUBLIC_ROUTES = ['/', '/home', '/settings', '/account-management']
function RouteGuard({ children }: RouteGuardProps) {
const navigate = useNavigate()
const location = useLocation()
const isDbConnected = useAppStore(state => state.isDbConnected)
useEffect(() => {
const isPublicRoute = PUBLIC_ROUTES.includes(location.pathname)
// 未连接数据库且不在公开页面,跳转到欢迎页
if (!isDbConnected && !isPublicRoute) {
navigate('/', { replace: true })
}
}, [isDbConnected, location.pathname, navigate])
return <>{children}</>
}
export default RouteGuard
-324
View File
@@ -1,324 +0,0 @@
// Redesigned sidebar — premium feel with left accent bar, refined spacing
.sidebar {
width: var(--sidebar-width, 260px);
background: var(--bg-sidebar, var(--bg-secondary));
display: flex;
flex-direction: column;
padding: 0;
transition: width 0.2s cubic-bezier(0.4, 0, 0.2, 1);
flex-shrink: 0;
overflow: hidden;
border-right: 1px solid var(--border-color);
&.collapsed {
width: 68px;
.sidebar-user-card-wrap {
margin: 0 8px 8px;
}
.sidebar-user-card {
padding: 8px 0;
justify-content: center;
.user-meta {
display: none;
}
.user-menu-caret {
display: none;
}
}
.nav-menu {
padding: 0 8px;
}
.sidebar-footer {
padding: 0 8px;
padding-top: 8px;
}
.nav-label {
display: none;
}
.nav-badge:not(.icon-badge) {
display: none;
}
.nav-item {
justify-content: center;
padding: 10px;
gap: 0;
&::before {
display: none;
}
}
}
}
// ---- Navigation ----
.nav-menu {
flex: 1;
display: flex;
flex-direction: column;
gap: 1px;
padding: 12px 10px;
overflow-y: auto;
overflow-x: hidden;
}
.nav-item {
position: relative;
display: flex;
align-items: center;
gap: 12px;
padding: 9px 14px;
border-radius: 10px;
color: var(--text-secondary);
text-decoration: none;
transition: background 0.15s ease, color 0.15s ease;
white-space: nowrap;
border: none;
background: transparent;
cursor: pointer;
font-family: inherit;
font-size: 14px;
margin: 1px 0;
// Left accent bar for active state
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%) scaleY(0);
width: 3px;
height: 16px;
border-radius: 0 2px 2px 0;
background: var(--primary);
transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.active {
background: var(--bg-hover);
color: var(--text-primary);
font-weight: 600;
&::before {
transform: translateY(-50%) scaleY(1);
}
.nav-icon {
color: var(--primary);
}
}
}
.nav-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex-shrink: 0;
transition: color 0.15s ease;
}
.nav-icon-with-badge {
position: relative;
}
.nav-label {
font-size: 14px;
font-weight: 500;
}
.nav-badge {
margin-left: auto;
min-width: 20px;
height: 20px;
border-radius: 999px;
padding: 0 6px;
background: #ef4444;
color: #ffffff;
font-size: 11px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.nav-badge.icon-badge {
position: absolute;
top: -7px;
right: -10px;
margin-left: 0;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 10px;
box-shadow: 0 0 0 2px var(--bg-sidebar, var(--bg-secondary));
}
// ---- Footer ----
.sidebar-footer {
padding: 4px 10px;
border-top: 1px solid var(--border-color);
padding-top: 8px;
margin-top: 4px;
display: flex;
flex-direction: column;
gap: 1px;
}
// ---- User card ----
.sidebar-user-card-wrap {
position: relative;
margin: 0 10px 10px;
--sidebar-user-menu-width: 172px;
}
.sidebar-user-menu {
position: absolute;
left: 0;
right: auto;
bottom: calc(100% + 8px);
width: max(100%, var(--sidebar-user-menu-width));
z-index: 12;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-secondary-solid, var(--bg-secondary));
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px;
box-shadow: var(--shadow-md);
opacity: 0;
transform: translateY(6px) scale(0.97);
pointer-events: none;
transition: opacity 0.15s ease, transform 0.15s ease;
&.open {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
}
.sidebar-user-menu-item {
width: 100%;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-primary);
padding: 8px 10px;
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
text-align: left;
transition: background 0.15s ease;
&:hover {
background: var(--bg-hover);
}
&.danger {
color: #ef4444;
&:hover {
background: rgba(239, 68, 68, 0.08);
}
}
}
.sidebar-user-card {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
background: transparent;
display: flex;
align-items: center;
gap: 10px;
min-height: 52px;
cursor: pointer;
border: none;
transition: background 0.15s ease;
&:hover {
background: var(--bg-hover);
}
&.menu-open {
background: var(--bg-hover);
}
.user-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
overflow: hidden;
background: var(--primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 0 0 2px var(--bg-sidebar, var(--bg-secondary));
img {
width: 100%;
height: 100%;
object-fit: cover;
}
span {
color: var(--on-primary);
font-size: 13px;
font-weight: 600;
}
}
.user-meta {
min-width: 0;
flex: 1;
}
.user-name {
font-size: 13px;
color: var(--text-primary);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-wxid {
margin-top: 1px;
font-size: 11px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-menu-caret {
color: var(--text-tertiary);
display: inline-flex;
transition: transform 0.15s ease;
&.open {
transform: rotate(180deg);
}
}
}
-511
View File
@@ -1,511 +0,0 @@
import { useState, useEffect, useRef } from 'react'
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, FolderClosed, Footprints, Users, ArchiveRestore, Sparkles } from 'lucide-react'
import { useAppStore } from '../stores/appStore'
import * as configService from '../services/config'
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
import './Sidebar.scss'
interface SidebarUserProfile {
wxid: string
displayName: string
alias?: string
avatarUrl?: string
}
const SIDEBAR_USER_PROFILE_CACHE_KEY = 'sidebar_user_profile_cache_v1'
const ACCOUNT_PROFILES_CACHE_KEY = 'account_profiles_cache_v1'
const DEFAULT_DISPLAY_NAME = '微信用户'
const DEFAULT_SUBTITLE = '微信账号'
interface SidebarUserProfileCache extends SidebarUserProfile {
updatedAt: number
}
interface AccountProfilesCache {
[wxid: string]: {
displayName: string
avatarUrl?: string
alias?: string
updatedAt: number
}
}
const readSidebarUserProfileCache = (): SidebarUserProfile | null => {
try {
const raw = window.localStorage.getItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
if (!raw) return null
const parsed = JSON.parse(raw) as SidebarUserProfileCache
if (!parsed || typeof parsed !== 'object') return null
if (!parsed.wxid) return null
return {
wxid: parsed.wxid,
displayName: typeof parsed.displayName === 'string' ? parsed.displayName : '',
alias: parsed.alias,
avatarUrl: parsed.avatarUrl
}
} catch {
return null
}
}
const writeSidebarUserProfileCache = (profile: SidebarUserProfile): void => {
if (!profile.wxid) return
try {
const payload: SidebarUserProfileCache = {
...profile,
updatedAt: Date.now()
}
window.localStorage.setItem(SIDEBAR_USER_PROFILE_CACHE_KEY, JSON.stringify(payload))
// 同时写入账号缓存池
const accountsCache = readAccountProfilesCache()
accountsCache[profile.wxid] = {
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
alias: profile.alias,
updatedAt: Date.now()
}
window.localStorage.setItem(ACCOUNT_PROFILES_CACHE_KEY, JSON.stringify(accountsCache))
} catch {
// 忽略本地缓存失败,不影响主流程
}
}
const readAccountProfilesCache = (): AccountProfilesCache => {
try {
const raw = window.localStorage.getItem(ACCOUNT_PROFILES_CACHE_KEY)
if (!raw) return {}
const parsed = JSON.parse(raw)
return typeof parsed === 'object' && parsed ? parsed : {}
} catch {
return {}
}
}
const normalizeAccountId = (value?: string | null): string => {
const trimmed = String(value || '').trim()
if (!trimmed) return ''
if (trimmed.toLowerCase().startsWith('wxid_')) {
const match = trimmed.match(/^(wxid_[^_]+)/i)
return match?.[1] || trimmed
}
const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/)
return suffixMatch ? suffixMatch[1] : trimmed
}
interface SidebarProps {
collapsed: boolean
}
function Sidebar({ collapsed }: SidebarProps) {
const location = useLocation()
const navigate = useNavigate()
const [authEnabled, setAuthEnabled] = useState(false)
const [activeExportTaskCount, setActiveExportTaskCount] = useState(0)
const [userProfile, setUserProfile] = useState<SidebarUserProfile>({
wxid: '',
displayName: DEFAULT_DISPLAY_NAME
})
const [isAccountMenuOpen, setIsAccountMenuOpen] = useState(false)
const accountCardWrapRef = useRef<HTMLDivElement | null>(null)
const setLocked = useAppStore(state => state.setLocked)
useEffect(() => {
window.electronAPI.auth.verifyEnabled().then(setAuthEnabled)
}, [])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (!isAccountMenuOpen) return
const target = event.target as Node | null
if (accountCardWrapRef.current && target && !accountCardWrapRef.current.contains(target)) {
setIsAccountMenuOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [isAccountMenuOpen])
useEffect(() => {
const unsubscribe = onExportSessionStatus((payload) => {
const countFromPayload = typeof payload?.activeTaskCount === 'number'
? payload.activeTaskCount
: Array.isArray(payload?.inProgressSessionIds)
? payload.inProgressSessionIds.length
: 0
const normalized = Math.max(0, Math.floor(countFromPayload))
setActiveExportTaskCount(normalized)
})
requestExportSessionStatus()
const timer = window.setTimeout(() => requestExportSessionStatus(), 120)
return () => {
unsubscribe()
window.clearTimeout(timer)
}
}, [])
useEffect(() => {
let disposed = false
let loadSeq = 0
const loadCurrentUser = async () => {
const seq = ++loadSeq
const patchUserProfile = (patch: Partial<SidebarUserProfile>) => {
if (disposed || seq !== loadSeq) return
setUserProfile(prev => {
const next: SidebarUserProfile = {
...prev,
...patch
}
if (typeof next.displayName !== 'string' || next.displayName.length === 0) {
next.displayName = DEFAULT_DISPLAY_NAME
}
writeSidebarUserProfileCache(next)
return next
})
}
try {
const wxid = await configService.getMyWxid()
if (disposed || seq !== loadSeq) return
const resolvedWxidRaw = String(wxid || '').trim()
const cleanedWxid = normalizeAccountId(resolvedWxidRaw)
const resolvedWxid = cleanedWxid || resolvedWxidRaw
if (!resolvedWxidRaw && !resolvedWxid) {
window.localStorage.removeItem(SIDEBAR_USER_PROFILE_CACHE_KEY)
patchUserProfile({
wxid: '',
displayName: DEFAULT_DISPLAY_NAME,
alias: undefined,
avatarUrl: undefined
})
return
}
setUserProfile((prev) => {
if (prev.wxid === resolvedWxid) return prev
const seeded: SidebarUserProfile = {
wxid: resolvedWxid,
displayName: DEFAULT_DISPLAY_NAME,
alias: undefined,
avatarUrl: undefined
}
writeSidebarUserProfileCache(seeded)
return seeded
})
const wxidCandidates = new Set<string>([
resolvedWxidRaw.toLowerCase(),
resolvedWxid.trim().toLowerCase(),
cleanedWxid.trim().toLowerCase()
].filter(Boolean))
const normalizeName = (value?: string | null): string | undefined => {
if (typeof value !== 'string') return undefined
if (value.length === 0) return undefined
const lowered = value.trim().toLowerCase()
if (lowered === 'self') return undefined
if (lowered.startsWith('wxid_')) return undefined
if (wxidCandidates.has(lowered)) return undefined
return value
}
const pickFirstValidName = (...candidates: Array<string | null | undefined>): string | undefined => {
for (const candidate of candidates) {
const normalized = normalizeName(candidate)
if (normalized) return normalized
}
return undefined
}
// 并行获取名称和头像
const [contactResult, avatarResult] = await Promise.allSettled([
(async () => {
const candidates = Array.from(new Set([resolvedWxidRaw, resolvedWxid, cleanedWxid].filter(Boolean)))
for (const candidate of candidates) {
const contact = await window.electronAPI.chat.getContact(candidate)
if (contact?.remark || contact?.nickName || contact?.alias) {
return contact
}
}
return null
})(),
window.electronAPI.chat.getMyAvatarUrl()
])
if (disposed || seq !== loadSeq) return
const myContact = contactResult.status === 'fulfilled' ? contactResult.value : null
const displayName = pickFirstValidName(
myContact?.remark,
myContact?.nickName,
myContact?.alias
) || DEFAULT_DISPLAY_NAME
const alias = normalizeName(myContact?.alias)
patchUserProfile({
wxid: resolvedWxid,
displayName,
alias,
avatarUrl: avatarResult.status === 'fulfilled' && avatarResult.value.success
? avatarResult.value.avatarUrl
: undefined
})
} catch (error) {
console.error('加载侧边栏用户信息失败:', error)
}
}
const cachedProfile = readSidebarUserProfileCache()
if (cachedProfile) {
setUserProfile(cachedProfile)
}
void loadCurrentUser()
const onWxidChanged = () => { void loadCurrentUser() }
const onWindowFocus = () => { void loadCurrentUser() }
const onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
void loadCurrentUser()
}
}
window.addEventListener('wxid-changed', onWxidChanged as EventListener)
window.addEventListener('focus', onWindowFocus)
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
disposed = true
loadSeq += 1
window.removeEventListener('wxid-changed', onWxidChanged as EventListener)
window.removeEventListener('focus', onWindowFocus)
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}, [])
const getAvatarLetter = (name: string): string => {
if (!name) return '微'
const visible = name.trim()
return (visible && [...visible][0]) || '微'
}
const openSettingsFromAccountMenu = () => {
setIsAccountMenuOpen(false)
navigate('/settings', {
state: {
backgroundLocation: location
}
})
}
const openAccountManagement = () => {
setIsAccountMenuOpen(false)
navigate('/account-management')
}
const isActive = (path: string) => {
return location.pathname === path || location.pathname.startsWith(`${path}/`)
}
const exportTaskBadge = activeExportTaskCount > 99 ? '99+' : `${activeExportTaskCount}`
return (
<>
<aside className={`sidebar ${collapsed ? 'collapsed' : ''}`}>
<nav className="nav-menu">
{/* 首页 */}
<NavLink
to="/home"
className={`nav-item ${isActive('/home') ? 'active' : ''}`}
title={collapsed ? '首页' : undefined}
>
<span className="nav-icon"><Home size={20} /></span>
<span className="nav-label">首页</span>
</NavLink>
{/* 聊天 */}
<NavLink
to="/chat"
className={`nav-item ${isActive('/chat') ? 'active' : ''}`}
title={collapsed ? '聊天' : undefined}
>
<span className="nav-icon"><MessageSquare size={20} /></span>
<span className="nav-label">聊天</span>
</NavLink>
{/* 朋友圈 */}
<NavLink
to="/sns"
className={`nav-item ${isActive('/sns') ? 'active' : ''}`}
title={collapsed ? '朋友圈' : undefined}
>
<span className="nav-icon"><Aperture size={20} /></span>
<span className="nav-label">朋友圈</span>
</NavLink>
<NavLink
to="/insight-inbox"
className={`nav-item ${isActive('/insight-inbox') ? 'active' : ''}`}
title={collapsed ? '灵感信箱' : undefined}
>
<span className="nav-icon"><Sparkles size={20} /></span>
<span className="nav-label">灵感信箱</span>
</NavLink>
{/* 通讯录 */}
<NavLink
to="/contacts"
className={`nav-item ${isActive('/contacts') ? 'active' : ''}`}
title={collapsed ? '通讯录' : undefined}
>
<span className="nav-icon"><UserCircle size={20} /></span>
<span className="nav-label">通讯录</span>
</NavLink>
{/* 资源浏览 */}
<NavLink
to="/resources"
className={`nav-item ${isActive('/resources') ? 'active' : ''}`}
title={collapsed ? '资源浏览' : undefined}
>
<span className="nav-icon"><FolderClosed size={20} /></span>
<span className="nav-label">资源浏览</span>
</NavLink>
{/* 聊天分析 */}
<NavLink
to="/analytics"
className={`nav-item ${isActive('/analytics') ? 'active' : ''}`}
title={collapsed ? '聊天分析' : undefined}
>
<span className="nav-icon"><BarChart3 size={20} /></span>
<span className="nav-label">聊天分析</span>
</NavLink>
{/* 年度报告 */}
<NavLink
to="/annual-report"
className={`nav-item ${isActive('/annual-report') ? 'active' : ''}`}
title={collapsed ? '年度报告' : undefined}
>
<span className="nav-icon"><FileText size={20} /></span>
<span className="nav-label">年度报告</span>
</NavLink>
{/* 我的足迹 */}
<NavLink
to="/footprint"
className={`nav-item ${isActive('/footprint') ? 'active' : ''}`}
title={collapsed ? '我的足迹' : undefined}
>
<span className="nav-icon"><Footprints size={20} /></span>
<span className="nav-label">我的足迹</span>
</NavLink>
{/* 导出 */}
<NavLink
to="/export"
className={`nav-item ${isActive('/export') ? 'active' : ''}`}
title={collapsed ? '导出' : undefined}
>
<span className="nav-icon nav-icon-with-badge">
<Download size={20} />
{collapsed && activeExportTaskCount > 0 && (
<span className="nav-badge icon-badge">{exportTaskBadge}</span>
)}
</span>
<span className="nav-label">导出</span>
{!collapsed && activeExportTaskCount > 0 && (
<span className="nav-badge">{exportTaskBadge}</span>
)}
</NavLink>
<NavLink
to="/backup"
className={`nav-item ${isActive('/backup') ? 'active' : ''}`}
title={collapsed ? '数据库备份' : undefined}
>
<span className="nav-icon"><ArchiveRestore size={20} /></span>
<span className="nav-label">数据库备份</span>
</NavLink>
</nav>
<div className="sidebar-footer">
<button
className="nav-item"
onClick={() => {
if (authEnabled) {
setLocked(true)
return
}
navigate('/settings', {
state: {
initialTab: 'security',
backgroundLocation: location
}
})
}}
title={collapsed ? (authEnabled ? '锁定' : '未锁定') : undefined}
>
<span className="nav-icon">{authEnabled ? <Lock size={20} /> : <LockOpen size={20} />}</span>
<span className="nav-label">{authEnabled ? '锁定' : '未锁定'}</span>
</button>
<div className="sidebar-user-card-wrap" ref={accountCardWrapRef}>
<div className={`sidebar-user-menu ${isAccountMenuOpen ? 'open' : ''}`} role="menu" aria-label="账号菜单">
<button
className="sidebar-user-menu-item"
onClick={openAccountManagement}
type="button"
role="menuitem"
>
<Users size={14} />
<span>账号管理</span>
</button>
<button
className="sidebar-user-menu-item"
onClick={openSettingsFromAccountMenu}
type="button"
role="menuitem"
>
<Settings size={14} />
<span>设置</span>
</button>
</div>
<div
className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`}
title={collapsed ? `${userProfile.displayName}${(userProfile.alias) ? `\n${userProfile.alias}` : ''}` : undefined}
onClick={() => setIsAccountMenuOpen(prev => !prev)}
role="button"
tabIndex={0}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
setIsAccountMenuOpen(prev => !prev)
}
}}
>
<div className="user-avatar">
{userProfile.avatarUrl ? <img src={userProfile.avatarUrl} alt="" /> : <span>{getAvatarLetter(userProfile.displayName)}</span>}
</div>
<div className="user-meta">
<div className="user-name">{userProfile.displayName || DEFAULT_DISPLAY_NAME}</div>
<div className="user-wxid">{userProfile.alias || DEFAULT_SUBTITLE}</div>
</div>
{!collapsed && (
<span className={`user-menu-caret ${isAccountMenuOpen ? 'open' : ''}`}>
<ChevronUp size={14} />
</span>
)}
</div>
</div>
</div>
</aside>
</>
)
}
export default Sidebar
@@ -1,318 +0,0 @@
.contact-sns-dialog-overlay {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
background: rgba(15, 23, 42, 0.28);
}
.contact-sns-dialog {
width: min(720px, 100%);
max-height: min(84vh, 820px);
border-radius: 10px;
border: 1px solid var(--border-color);
background: var(--bg-secondary-solid, #ffffff);
box-shadow: 0 18px 34px rgba(0, 0, 0, 0.18);
display: flex;
flex-direction: column;
overflow: hidden;
.spin {
animation: contactSnsDialogSpin 1s linear infinite;
}
.contact-sns-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--border-color);
}
.contact-sns-dialog-header-main {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.contact-sns-dialog-avatar {
width: 36px;
height: 36px;
border-radius: 8px;
background: linear-gradient(135deg, var(--primary), var(--primary-hover));
overflow: hidden;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
span {
color: #fff;
font-size: 14px;
font-weight: 600;
}
}
.contact-sns-dialog-meta {
min-width: 0;
h4 {
margin: 0;
font-size: 14px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.contact-sns-dialog-username {
margin-top: 2px;
font-size: 11px;
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contact-sns-dialog-stats {
margin-top: 4px;
font-size: 11px;
color: var(--text-secondary);
}
.contact-sns-dialog-header-actions {
display: flex;
align-items: flex-start;
gap: 8px;
flex-shrink: 0;
}
.contact-sns-dialog-rank-switch {
position: relative;
display: inline-flex;
align-items: center;
gap: 6px;
}
.contact-sns-dialog-rank-btn {
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
color: var(--text-secondary);
height: 30px;
padding: 0 9px;
font-size: 11px;
line-height: 1;
cursor: pointer;
white-space: nowrap;
&:hover {
color: var(--text-primary);
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
}
&.active {
color: var(--primary);
border-color: color-mix(in srgb, var(--primary) 52%, var(--border-color));
background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
}
}
.contact-sns-dialog-rank-panel {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 228px;
max-height: calc((26px * 15) + 16px);
overflow-y: auto;
border: 1px solid color-mix(in srgb, var(--primary) 30%, var(--border-color));
border-radius: 10px;
background: var(--bg-primary);
box-shadow: 0 14px 26px rgba(0, 0, 0, 0.18);
padding: 8px;
z-index: 12;
}
.contact-sns-dialog-rank-empty {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.5;
text-align: center;
padding: 6px 0;
}
.contact-sns-dialog-rank-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 28px;
padding: 4px 0 8px;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.5;
}
.contact-sns-dialog-rank-row {
display: grid;
grid-template-columns: 20px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 0 4px;
border-radius: 7px;
&:hover {
background: var(--bg-hover);
}
}
.contact-sns-dialog-rank-index {
font-size: 12px;
color: var(--text-tertiary);
text-align: right;
font-variant-numeric: tabular-nums;
}
.contact-sns-dialog-rank-name {
font-size: 12px;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contact-sns-dialog-rank-count {
font-size: 12px;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.contact-sns-dialog-close-btn {
border: none;
background: transparent;
color: var(--text-secondary);
width: 28px;
height: 28px;
border-radius: 7px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
.contact-sns-dialog-tip {
display: none;
}
.contact-sns-dialog-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 10px 12px 12px;
}
.contact-sns-dialog-posts-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.contact-sns-dialog-posts-list .post-header-actions {
display: none;
}
.contact-sns-dialog-status {
padding: 16px 10px;
text-align: center;
font-size: 12px;
color: var(--text-secondary);
&.empty {
color: var(--text-tertiary);
}
}
.contact-sns-dialog-load-more {
display: block;
margin: 12px auto 0;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
border-radius: 10px;
padding: 8px 14px;
font-size: 12px;
cursor: pointer;
&:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color));
color: var(--primary);
}
&:disabled {
cursor: not-allowed;
opacity: 0.72;
}
}
}
@media (max-width: 768px) {
.contact-sns-dialog-overlay {
padding: 10px 8px;
}
.contact-sns-dialog {
width: min(100vw - 16px, 720px);
max-height: calc(100vh - 24px);
.contact-sns-dialog-header {
padding: 10px 12px;
}
.contact-sns-dialog-header-actions {
gap: 6px;
}
.contact-sns-dialog-rank-btn {
height: 26px;
padding: 0 8px;
font-size: 10px;
}
.contact-sns-dialog-rank-panel {
width: min(78vw, 232px);
}
.contact-sns-dialog-body {
padding: 10px 10px 12px;
}
}
}
@keyframes contactSnsDialogSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@@ -1,589 +0,0 @@
import { createPortal } from 'react-dom'
import { Loader2, X } from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { SnsPostItem } from './SnsPostItem'
import type { SnsPost } from '../../types/sns'
import {
type ContactSnsRankItem,
type ContactSnsRankMode,
type ContactSnsTimelineTarget,
getAvatarLetter
} from './contactSnsTimeline'
import './ContactSnsTimelineDialog.scss'
const TIMELINE_PAGE_SIZE = 20
const SNS_RANK_PAGE_SIZE = 50
const SNS_RANK_DISPLAY_LIMIT = 15
interface ContactSnsRankCacheEntry {
likes: ContactSnsRankItem[]
comments: ContactSnsRankItem[]
totalPosts: number
}
interface ContactSnsTimelineDialogProps {
target: ContactSnsTimelineTarget | null
onClose: () => void
initialTotalPosts?: number | null
initialTotalPostsLoading?: boolean
isProtected?: boolean
onDeletePost?: (postId: string, username: string) => void
}
const normalizeTotalPosts = (value?: number | null): number | null => {
if (!Number.isFinite(value)) return null
return Math.max(0, Math.floor(Number(value)))
}
const formatYmdDateFromSeconds = (timestamp?: number): string => {
if (!timestamp || !Number.isFinite(timestamp)) return '—'
const date = new Date(timestamp * 1000)
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${year}-${month}-${day}`
}
const buildContactSnsRankings = (posts: SnsPost[]): { likes: ContactSnsRankItem[]; comments: ContactSnsRankItem[] } => {
const likeMap = new Map<string, ContactSnsRankItem>()
const commentMap = new Map<string, ContactSnsRankItem>()
for (const post of posts) {
const createTime = Number(post?.createTime) || 0
const likes = Array.isArray(post?.likes) ? post.likes : []
const comments = Array.isArray(post?.comments) ? post.comments : []
for (const likeNameRaw of likes) {
const name = String(likeNameRaw || '').trim() || '未知用户'
const current = likeMap.get(name)
if (current) {
current.count += 1
if (createTime > current.latestTime) current.latestTime = createTime
continue
}
likeMap.set(name, { name, count: 1, latestTime: createTime })
}
for (const comment of comments) {
const name = String(comment?.nickname || '').trim() || '未知用户'
const current = commentMap.get(name)
if (current) {
current.count += 1
if (createTime > current.latestTime) current.latestTime = createTime
continue
}
commentMap.set(name, { name, count: 1, latestTime: createTime })
}
}
const sorter = (left: ContactSnsRankItem, right: ContactSnsRankItem): number => {
if (right.count !== left.count) return right.count - left.count
if (right.latestTime !== left.latestTime) return right.latestTime - left.latestTime
return left.name.localeCompare(right.name, 'zh-CN')
}
return {
likes: [...likeMap.values()].sort(sorter),
comments: [...commentMap.values()].sort(sorter)
}
}
export function ContactSnsTimelineDialog({
target,
onClose,
initialTotalPosts = null,
initialTotalPostsLoading = false,
isProtected = false,
onDeletePost
}: ContactSnsTimelineDialogProps) {
const [timelinePosts, setTimelinePosts] = useState<SnsPost[]>([])
const [timelineLoading, setTimelineLoading] = useState(false)
const [timelineLoadingMore, setTimelineLoadingMore] = useState(false)
const [timelineHasMore, setTimelineHasMore] = useState(false)
const [timelineTotalPosts, setTimelineTotalPosts] = useState<number | null>(null)
const [timelineStatsLoading, setTimelineStatsLoading] = useState(false)
const [rankMode, setRankMode] = useState<ContactSnsRankMode | null>(null)
const [likeRankings, setLikeRankings] = useState<ContactSnsRankItem[]>([])
const [commentRankings, setCommentRankings] = useState<ContactSnsRankItem[]>([])
const [rankLoading, setRankLoading] = useState(false)
const [rankError, setRankError] = useState<string | null>(null)
const [rankLoadedPosts, setRankLoadedPosts] = useState(0)
const [rankTotalPosts, setRankTotalPosts] = useState<number | null>(null)
const timelinePostsRef = useRef<SnsPost[]>([])
const timelineLoadingRef = useRef(false)
const timelineRequestTokenRef = useRef(0)
const totalPostsRequestTokenRef = useRef(0)
const rankRequestTokenRef = useRef(0)
const rankLoadingRef = useRef(false)
const rankCacheRef = useRef<Record<string, ContactSnsRankCacheEntry>>({})
const targetUsername = String(target?.username || '').trim()
const targetDisplayName = target?.displayName || targetUsername
const targetAvatarUrl = target?.avatarUrl
useEffect(() => {
timelinePostsRef.current = timelinePosts
}, [timelinePosts])
const loadTimelinePosts = useCallback(async (nextTarget: ContactSnsTimelineTarget, options?: { reset?: boolean }) => {
const reset = Boolean(options?.reset)
if (timelineLoadingRef.current) return
timelineLoadingRef.current = true
if (reset) {
setTimelineLoading(true)
setTimelineLoadingMore(false)
setTimelineHasMore(false)
} else {
setTimelineLoadingMore(true)
}
const requestToken = ++timelineRequestTokenRef.current
try {
let endTime: number | undefined
if (!reset && timelinePostsRef.current.length > 0) {
endTime = timelinePostsRef.current[timelinePostsRef.current.length - 1].createTime - 1
}
const result = await window.electronAPI.sns.getTimeline(
TIMELINE_PAGE_SIZE,
0,
[nextTarget.username],
'',
undefined,
endTime
)
if (requestToken !== timelineRequestTokenRef.current) return
if (!result.success || !Array.isArray(result.timeline)) {
if (reset) {
setTimelinePosts([])
setTimelineHasMore(false)
}
return
}
const timeline = [...(result.timeline as SnsPost[])].sort((left, right) => right.createTime - left.createTime)
if (reset) {
setTimelinePosts(timeline)
setTimelineHasMore(timeline.length >= TIMELINE_PAGE_SIZE)
return
}
const existingIds = new Set(timelinePostsRef.current.map((post) => post.id))
const uniqueOlder = timeline.filter((post) => !existingIds.has(post.id))
if (uniqueOlder.length > 0) {
const merged = [...timelinePostsRef.current, ...uniqueOlder].sort((left, right) => right.createTime - left.createTime)
setTimelinePosts(merged)
}
if (timeline.length < TIMELINE_PAGE_SIZE) {
setTimelineHasMore(false)
}
} catch (error) {
console.error('加载联系人朋友圈失败:', error)
if (requestToken === timelineRequestTokenRef.current && reset) {
setTimelinePosts([])
setTimelineHasMore(false)
}
} finally {
if (requestToken === timelineRequestTokenRef.current) {
timelineLoadingRef.current = false
setTimelineLoading(false)
setTimelineLoadingMore(false)
}
}
}, [])
const loadTimelineTotalPosts = useCallback(async (nextTarget: ContactSnsTimelineTarget) => {
const requestToken = ++totalPostsRequestTokenRef.current
setTimelineStatsLoading(true)
try {
const result = await window.electronAPI.sns.getUserPostCounts()
if (requestToken !== totalPostsRequestTokenRef.current) return
if (!result.success || !result.counts) {
setTimelineTotalPosts(null)
setRankTotalPosts(null)
return
}
const rawCount = Number(result.counts[nextTarget.username] || 0)
const normalized = Number.isFinite(rawCount) ? Math.max(0, Math.floor(rawCount)) : 0
setTimelineTotalPosts(normalized)
setRankTotalPosts(normalized)
} catch (error) {
console.error('加载联系人朋友圈条数失败:', error)
if (requestToken !== totalPostsRequestTokenRef.current) return
setTimelineTotalPosts(null)
setRankTotalPosts(null)
} finally {
if (requestToken === totalPostsRequestTokenRef.current) {
setTimelineStatsLoading(false)
}
}
}, [])
const loadRankings = useCallback(async (nextTarget: ContactSnsTimelineTarget) => {
const normalizedUsername = String(nextTarget?.username || '').trim()
if (!normalizedUsername || rankLoadingRef.current) return
const normalizedKnownTotal = normalizeTotalPosts(timelineTotalPosts)
const cached = rankCacheRef.current[normalizedUsername]
if (cached && (normalizedKnownTotal === null || cached.totalPosts === normalizedKnownTotal)) {
setLikeRankings(cached.likes)
setCommentRankings(cached.comments)
setRankLoadedPosts(cached.totalPosts)
setRankTotalPosts(cached.totalPosts)
setRankError(null)
setRankLoading(false)
return
}
rankLoadingRef.current = true
const requestToken = ++rankRequestTokenRef.current
setRankLoading(true)
setRankError(null)
setRankLoadedPosts(0)
setRankTotalPosts(normalizedKnownTotal)
try {
const allPosts: SnsPost[] = []
let endTime: number | undefined
let hasMore = true
while (hasMore) {
const result = await window.electronAPI.sns.getTimeline(
SNS_RANK_PAGE_SIZE,
0,
[normalizedUsername],
'',
undefined,
endTime
)
if (requestToken !== rankRequestTokenRef.current) return
if (!result.success) {
throw new Error(result.error || '加载朋友圈排行失败')
}
const pagePosts = Array.isArray(result.timeline)
? [...(result.timeline as SnsPost[])].sort((left, right) => right.createTime - left.createTime)
: []
if (pagePosts.length === 0) {
hasMore = false
break
}
allPosts.push(...pagePosts)
setRankLoadedPosts(allPosts.length)
if (normalizedKnownTotal === null) {
setRankTotalPosts(allPosts.length)
}
endTime = pagePosts[pagePosts.length - 1].createTime - 1
hasMore = pagePosts.length >= SNS_RANK_PAGE_SIZE
}
if (requestToken !== rankRequestTokenRef.current) return
const rankings = buildContactSnsRankings(allPosts)
const totalPosts = allPosts.length
rankCacheRef.current[normalizedUsername] = {
likes: rankings.likes,
comments: rankings.comments,
totalPosts
}
setLikeRankings(rankings.likes)
setCommentRankings(rankings.comments)
setRankLoadedPosts(totalPosts)
setRankTotalPosts(totalPosts)
setRankError(null)
} catch (error) {
if (requestToken !== rankRequestTokenRef.current) return
const message = error instanceof Error ? error.message : String(error)
setLikeRankings([])
setCommentRankings([])
setRankError(message || '加载朋友圈排行失败')
} finally {
if (requestToken === rankRequestTokenRef.current) {
rankLoadingRef.current = false
setRankLoading(false)
}
}
}, [timelineTotalPosts])
useEffect(() => {
if (!targetUsername) return
totalPostsRequestTokenRef.current += 1
rankRequestTokenRef.current += 1
rankLoadingRef.current = false
setRankMode(null)
setLikeRankings([])
setCommentRankings([])
setRankLoading(false)
setRankError(null)
setRankLoadedPosts(0)
setRankTotalPosts(null)
setTimelinePosts([])
setTimelineTotalPosts(null)
setTimelineStatsLoading(false)
setTimelineHasMore(false)
setTimelineLoadingMore(false)
setTimelineLoading(false)
void loadTimelinePosts({
username: targetUsername,
displayName: targetDisplayName,
avatarUrl: targetAvatarUrl
}, { reset: true })
}, [loadTimelinePosts, targetAvatarUrl, targetDisplayName, targetUsername])
useEffect(() => {
if (!targetUsername) return
const normalizedTotal = normalizeTotalPosts(initialTotalPosts)
if (normalizedTotal !== null) {
setTimelineTotalPosts(normalizedTotal)
setRankTotalPosts(normalizedTotal)
setTimelineStatsLoading(false)
return
}
if (initialTotalPostsLoading) {
setTimelineTotalPosts(null)
setRankTotalPosts(null)
setTimelineStatsLoading(true)
return
}
void loadTimelineTotalPosts({
username: targetUsername,
displayName: targetDisplayName,
avatarUrl: targetAvatarUrl
})
}, [
initialTotalPosts,
initialTotalPostsLoading,
loadTimelineTotalPosts,
targetAvatarUrl,
targetDisplayName,
targetUsername
])
useEffect(() => {
if (timelineTotalPosts === null) return
if (timelinePosts.length >= timelineTotalPosts) {
setTimelineHasMore(false)
}
}, [timelinePosts.length, timelineTotalPosts])
useEffect(() => {
if (!rankMode || !targetUsername) return
void loadRankings({
username: targetUsername,
displayName: targetDisplayName,
avatarUrl: targetAvatarUrl
})
}, [loadRankings, rankMode, targetAvatarUrl, targetDisplayName, targetUsername])
useEffect(() => {
if (!targetUsername) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onClose, targetUsername])
const timelineStatsText = useMemo(() => {
const loadedCount = timelinePosts.length
const loadPart = timelineStatsLoading
? `已加载 ${loadedCount} / 总数统计中...`
: timelineTotalPosts === null
? `已加载 ${loadedCount} 条`
: `已加载 ${loadedCount} / 共 ${timelineTotalPosts} 条`
if (timelineLoading && loadedCount === 0) return `${loadPart} | 加载中...`
if (loadedCount === 0) return loadPart
const latest = timelinePosts[0]?.createTime
const earliest = timelinePosts[timelinePosts.length - 1]?.createTime
return `${loadPart} | ${formatYmdDateFromSeconds(earliest)} ~ ${formatYmdDateFromSeconds(latest)}`
}, [timelineLoading, timelinePosts, timelineStatsLoading, timelineTotalPosts])
const activeRankings = useMemo(() => {
if (rankMode === 'likes') return likeRankings
if (rankMode === 'comments') return commentRankings
return []
}, [commentRankings, likeRankings, rankMode])
const loadMore = useCallback(() => {
if (!targetUsername || timelineLoading || timelineLoadingMore || !timelineHasMore) return
void loadTimelinePosts({
username: targetUsername,
displayName: targetDisplayName,
avatarUrl: targetAvatarUrl
}, { reset: false })
}, [
loadTimelinePosts,
targetAvatarUrl,
targetDisplayName,
targetUsername,
timelineHasMore,
timelineLoading,
timelineLoadingMore
])
const handleBodyScroll = useCallback((event: React.UIEvent<HTMLDivElement>) => {
const element = event.currentTarget
const remaining = element.scrollHeight - element.scrollTop - element.clientHeight
if (remaining <= 160) {
loadMore()
}
}, [loadMore])
const toggleRankMode = useCallback((mode: ContactSnsRankMode) => {
setRankMode((previous) => (previous === mode ? null : mode))
}, [])
if (!target) return null
return createPortal(
<div className="contact-sns-dialog-overlay" onClick={onClose}>
<div
className="contact-sns-dialog"
role="dialog"
aria-modal="true"
aria-label="联系人朋友圈"
onClick={(event) => event.stopPropagation()}
>
<div className="contact-sns-dialog-header">
<div className="contact-sns-dialog-header-main">
<div className="contact-sns-dialog-avatar">
{targetAvatarUrl ? (
<img src={targetAvatarUrl} alt="" />
) : (
<span>{getAvatarLetter(targetDisplayName)}</span>
)}
</div>
<div className="contact-sns-dialog-meta">
<h4>{targetDisplayName}</h4>
<div className="contact-sns-dialog-username">@{targetUsername}</div>
<div className="contact-sns-dialog-stats">{timelineStatsText}</div>
</div>
</div>
<div className="contact-sns-dialog-header-actions">
<div className="contact-sns-dialog-rank-switch">
<button
type="button"
className={`contact-sns-dialog-rank-btn ${rankMode === 'likes' ? 'active' : ''}`}
onClick={() => toggleRankMode('likes')}
>
点赞排行
</button>
<button
type="button"
className={`contact-sns-dialog-rank-btn ${rankMode === 'comments' ? 'active' : ''}`}
onClick={() => toggleRankMode('comments')}
>
评论排行
</button>
{rankMode && (
<div
className="contact-sns-dialog-rank-panel"
role="region"
aria-label={rankMode === 'likes' ? '点赞排行' : '评论排行'}
>
{rankLoading && (
<div className="contact-sns-dialog-rank-loading">
<Loader2 size={12} className="spin" />
<span>
{rankTotalPosts !== null && rankTotalPosts > 0
? `统计中,已加载 ${rankLoadedPosts} / ${rankTotalPosts} 条`
: `统计中,已加载 ${rankLoadedPosts} 条`}
</span>
</div>
)}
{!rankLoading && rankError ? (
<div className="contact-sns-dialog-rank-empty">{rankError}</div>
) : !rankLoading && activeRankings.length === 0 ? (
<div className="contact-sns-dialog-rank-empty">
{rankMode === 'likes' ? '暂无点赞数据' : '暂无评论数据'}
</div>
) : (
activeRankings.slice(0, SNS_RANK_DISPLAY_LIMIT).map((item, index) => (
<div className="contact-sns-dialog-rank-row" key={`${rankMode}-${item.name}`}>
<span className="contact-sns-dialog-rank-index">{index + 1}</span>
<span className="contact-sns-dialog-rank-name" title={item.name}>{item.name}</span>
<span className="contact-sns-dialog-rank-count">
{item.count.toLocaleString('zh-CN')}
{rankMode === 'likes' ? '次' : '条'}
</span>
</div>
))
)}
</div>
)}
</div>
<button className="contact-sns-dialog-close-btn" type="button" onClick={onClose}>
<X size={16} />
</button>
</div>
</div>
<div
className="contact-sns-dialog-body"
onScroll={handleBodyScroll}
>
{timelinePosts.length > 0 && (
<div className="contact-sns-dialog-posts-list">
{timelinePosts.map((post) => (
<SnsPostItem
key={post.id}
post={{ ...post, isProtected }}
onPreview={(src, isVideo, liveVideoPath) => {
if (isVideo) {
void window.electronAPI.window.openVideoPlayerWindow(src)
} else {
void window.electronAPI.window.openImageViewerWindow(src, liveVideoPath || undefined)
}
}}
onDebug={() => {}}
onDelete={onDeletePost}
hideAuthorMeta
/>
))}
</div>
)}
{timelineLoading && (
<div className="contact-sns-dialog-status">正在加载该联系人的朋友圈...</div>
)}
{!timelineLoading && timelinePosts.length === 0 && (
<div className="contact-sns-dialog-status empty">该联系人暂无朋友圈</div>
)}
{!timelineLoading && timelineHasMore && (
<button
className="contact-sns-dialog-load-more"
type="button"
onClick={loadMore}
disabled={timelineLoadingMore}
>
{timelineLoadingMore ? '正在加载...' : '加载更多'}
</button>
)}
</div>
</div>
</div>,
document.body
)
}
-264
View File
@@ -1,264 +0,0 @@
import React from 'react'
import { Search, User, X, Loader2, CheckSquare, Square, Download } from 'lucide-react'
import { Virtuoso } from 'react-virtuoso'
import { Avatar } from '../Avatar'
interface Contact {
username: string
displayName: string
avatarUrl?: string
postCount?: number
postCountStatus?: 'idle' | 'loading' | 'ready'
}
interface ContactsCountProgress {
resolved: number
total: number
running: boolean
}
interface SnsFilterPanelProps {
searchKeyword: string
setSearchKeyword: (val: string) => void
totalFriendsLabel?: string
contacts: Contact[]
contactSearch: string
setContactSearch: (val: string) => void
loading?: boolean
contactsCountProgress?: ContactsCountProgress
selectedContactUsernames: string[]
activeContactUsername?: string
onOpenContactTimeline: (contact: Contact) => void
onToggleContactSelected: (contact: Contact) => void
onToggleFilteredContacts: (usernames: string[], shouldSelect: boolean) => void
onClearSelectedContacts: () => void
onExportSelectedContacts: () => void
}
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
searchKeyword,
setSearchKeyword,
totalFriendsLabel,
contacts,
contactSearch,
setContactSearch,
loading,
contactsCountProgress,
selectedContactUsernames,
activeContactUsername,
onOpenContactTimeline,
onToggleContactSelected,
onToggleFilteredContacts,
onClearSelectedContacts,
onExportSelectedContacts
}) => {
const filteredContacts = React.useMemo(() => {
const keyword = contactSearch.trim().toLowerCase()
if (!keyword) return contacts
return contacts.filter(c =>
(c.displayName || '').toLowerCase().includes(keyword) ||
c.username.toLowerCase().includes(keyword)
)
}, [contacts, contactSearch])
const selectedContactLookup = React.useMemo(
() => new Set(selectedContactUsernames),
[selectedContactUsernames]
)
const filteredContactUsernames = React.useMemo(
() => filteredContacts.map((contact) => contact.username),
[filteredContacts]
)
const selectedFilteredCount = React.useMemo(
() => filteredContactUsernames.filter((username) => selectedContactLookup.has(username)).length,
[filteredContactUsernames, selectedContactLookup]
)
const hasFilteredContacts = filteredContactUsernames.length > 0
const allFilteredSelected = hasFilteredContacts && selectedFilteredCount === filteredContactUsernames.length
const clearFilters = () => {
setSearchKeyword('')
setContactSearch('')
}
const getEmptyStateText = () => {
if (loading && contacts.length === 0) {
return '正在加载联系人...'
}
if (contacts.length === 0) {
return '暂无好友或曾经的好友'
}
return '没有找到联系人'
}
const renderContactRow = React.useCallback((_: number, contact: Contact) => {
const isPostCountReady = contact.postCountStatus === 'ready'
const isSelected = selectedContactLookup.has(contact.username)
const isActive = activeContactUsername === contact.username
return (
<div
className={`contact-row${isSelected ? ' is-selected' : ''}${isActive ? ' is-active' : ''}`}
>
<button
type="button"
className={`contact-select-btn${isSelected ? ' checked' : ''}`}
onClick={() => onToggleContactSelected(contact)}
title={isSelected ? `取消选择 ${contact.displayName}` : `选择 ${contact.displayName}`}
aria-pressed={isSelected}
>
{isSelected ? <CheckSquare size={14} /> : <Square size={14} />}
</button>
<button
type="button"
className="contact-main-btn"
onClick={() => onOpenContactTimeline(contact)}
title={`查看 ${contact.displayName} 的朋友圈`}
>
<Avatar src={contact.avatarUrl} name={contact.displayName} size={28} shape="rounded" />
<div className="contact-meta">
<span className="contact-name">{contact.displayName}</span>
</div>
<div className="contact-post-count-wrap">
{isPostCountReady ? (
<span className="contact-post-count">{Math.max(0, Math.floor(Number(contact.postCount || 0)))}条</span>
) : (
<span className="contact-post-count-loading" title="统计中">
<Loader2 size={12} className="spinning" />
</span>
)}
</div>
</button>
</div>
)
}, [activeContactUsername, onOpenContactTimeline, onToggleContactSelected, selectedContactLookup])
return (
<aside className="sns-filter-panel">
<div className="filter-header">
<h3>筛选</h3>
{(searchKeyword || contactSearch) && (
<button className="reset-all-btn" onClick={clearFilters} title="重置所有筛选">
<RefreshCw size={14} />
</button>
)}
</div>
<div className="filter-widgets">
{/* Search Widget */}
<div className="filter-widget search-widget">
<div className="widget-header">
<Search size={14} />
<span>关键词</span>
</div>
<div className="input-group">
<input
type="text"
placeholder="搜索动态"
value={searchKeyword}
onChange={e => setSearchKeyword(e.target.value)}
/>
{searchKeyword && (
<button className="clear-input-btn" onClick={() => setSearchKeyword('')}>
<X size={14} />
</button>
)}
</div>
</div>
{/* Contact Widget */}
<div className="filter-widget contact-widget">
<div className="widget-header">
<User size={14} />
<span>联系人</span>
{totalFriendsLabel && (
<span className="widget-header-summary">{totalFriendsLabel}</span>
)}
</div>
<div className="contact-search-bar">
<input
type="text"
placeholder="查找联系人"
value={contactSearch}
onChange={e => setContactSearch(e.target.value)}
/>
<Search size={14} className="search-icon" />
{contactSearch && (
<X size={14} className="clear-icon" onClick={() => setContactSearch('')} />
)}
</div>
<div className="contact-selection-toolbar">
<span className="contact-selection-summary">
当前 {filteredContactUsernames.length} 人,已选 {selectedFilteredCount} 人
</span>
<button
type="button"
className={`contact-selection-toggle${allFilteredSelected ? ' active' : ''}`}
onClick={() => onToggleFilteredContacts(filteredContactUsernames, !allFilteredSelected)}
disabled={!hasFilteredContacts}
>
{allFilteredSelected ? '取消全选' : '全选'}
</button>
</div>
{contactsCountProgress && contactsCountProgress.total > 0 && (
<div className="contact-count-progress">
{contactsCountProgress.running
? `朋友圈条数统计中 ${contactsCountProgress.resolved}/${contactsCountProgress.total}`
: `朋友圈条数已统计 ${contactsCountProgress.total}/${contactsCountProgress.total}`}
</div>
)}
<div className="contact-list-scroll">
{filteredContacts.length > 0 ? (
<Virtuoso
className="contact-list-virtuoso"
data={filteredContacts}
computeItemKey={(_, contact) => contact.username}
fixedItemHeight={40}
itemContent={renderContactRow}
overscan={320}
/>
) : (
<div className="empty-state">{getEmptyStateText()}</div>
)}
</div>
{selectedContactUsernames.length > 0 && (
<div className="contact-batch-bar">
<span className="contact-batch-summary">已选 {selectedContactUsernames.length} 人</span>
<button type="button" className="contact-batch-btn" onClick={onClearSelectedContacts}>
清空
</button>
<button type="button" className="contact-batch-btn primary" onClick={onExportSelectedContacts}>
<Download size={14} />
<span>下载所选</span>
</button>
</div>
)}
</div>
</div>
</aside>
)
}
function RefreshCw({ size, className }: { size?: number, className?: string }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={size || 24}
height={size || 24}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M23 4v6h-6"></path>
<path d="M1 20v-6h6"></path>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
)
}
-360
View File
@@ -1,360 +0,0 @@
import React, { useState, useRef } from 'react'
import { Play, Lock, Download, ImageOff } from 'lucide-react'
import { LivePhotoIcon } from '../../components/LivePhotoIcon'
import { RefreshCw } from 'lucide-react'
interface SnsMedia {
url: string
thumb: string
md5?: string
token?: string
key?: string
encIdx?: string
livePhoto?: {
url: string
thumb: string
token?: string
key?: string
encIdx?: string
}
}
interface SnsMediaGridProps {
mediaList: SnsMedia[]
postType?: number
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
onMediaDeleted?: () => void
}
const isSnsVideoUrl = (url?: string): boolean => {
if (!url) return false
const lower = url.toLowerCase()
return (lower.includes('snsvideodownload') || lower.includes('.mp4') || lower.includes('video')) && !lower.includes('vweixinthumb')
}
const extractVideoFrame = async (videoPath: string): Promise<string> => {
return new Promise((resolve, reject) => {
const video = document.createElement('video')
video.preload = 'auto'
video.src = videoPath
video.muted = true
video.currentTime = 0 // Initial reset
// video.crossOrigin = 'anonymous' // Not needed for file:// usually
const onSeeked = () => {
try {
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
const dataUrl = canvas.toDataURL('image/jpeg', 0.8)
resolve(dataUrl)
} else {
reject(new Error('Canvas context failed'))
}
} catch (e) {
reject(e)
} finally {
// Cleanup
video.removeEventListener('seeked', onSeeked)
video.src = ''
video.load()
}
}
video.onloadedmetadata = () => {
if (video.duration === Infinity || isNaN(video.duration)) {
// Determine duration failed, try a fixed small offset
video.currentTime = 1
} else {
video.currentTime = Math.max(0.1, video.duration / 2)
}
}
video.onseeked = onSeeked
video.onerror = (e) => {
reject(new Error('Video load failed'))
}
})
}
const MediaItem = ({ media, postType, onPreview, onMediaDeleted }: { media: SnsMedia; postType?: number; onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void; onMediaDeleted?: () => void }) => {
const [error, setError] = useState(false)
const [deleted, setDeleted] = useState(false)
const [loading, setLoading] = useState(true)
const markDeleted = () => { setDeleted(true); onMediaDeleted?.() }
const retryCount = useRef(0)
const [retryKey, setRetryKey] = useState(0)
const [thumbSrc, setThumbSrc] = useState<string>('')
const [videoPath, setVideoPath] = useState<string>('')
const [liveVideoPath, setLiveVideoPath] = useState<string>('')
const [isDecrypting, setIsDecrypting] = useState(false)
const [isGeneratingCover, setIsGeneratingCover] = useState(false)
const isVideo = isSnsVideoUrl(media.url)
const isLive = !!media.livePhoto
const targetUrl = media.thumb || media.url
// type 7 的朋友圈媒体不需要解密,直接使用原始 URL
const skipDecrypt = postType === 7
// 视频重试:失败时重试最多2次,耗尽才标记删除
const videoRetryOrDelete = () => {
if (retryCount.current < 2) {
retryCount.current++
setRetryKey(k => k + 1)
} else {
markDeleted()
}
}
// Simple effect to load image/decrypt
// Simple effect to load image/decrypt
React.useEffect(() => {
let cancelled = false
setLoading(true)
const load = async () => {
try {
if (!isVideo) {
// For images, we proxy to get the local path/base64
const result = await window.electronAPI.sns.proxyImage({
url: targetUrl,
key: skipDecrypt ? undefined : media.key
})
if (cancelled) return
if (result.success) {
if (result.dataUrl) setThumbSrc(result.dataUrl)
else if (result.videoPath) setThumbSrc(`file://${result.videoPath.replace(/\\/g, '/')}`)
} else {
markDeleted()
}
// Pre-load live photo video if needed
if (isLive && media.livePhoto?.url) {
window.electronAPI.sns.proxyImage({
url: media.livePhoto.url,
key: skipDecrypt ? undefined : (media.livePhoto.key || media.key)
}).then((res: any) => {
if (!cancelled && res.success && res.videoPath) {
setLiveVideoPath(`file://${res.videoPath.replace(/\\/g, '/')}`)
}
}).catch(() => { })
}
setLoading(false)
} else {
// Video logic: Decrypt -> Extract Frame
setIsGeneratingCover(true)
// First check if we already have it decryptable?
// Usually we need to call proxyImage with the video URL to decrypt it to cache
const result = await window.electronAPI.sns.proxyImage({
url: media.url,
key: skipDecrypt ? undefined : media.key
})
if (cancelled) return
if (result.success && result.videoPath) {
const localPath = `file://${result.videoPath.replace(/\\/g, '/')}`
setVideoPath(localPath)
try {
const coverDataUrl = await extractVideoFrame(localPath)
if (!cancelled) setThumbSrc(coverDataUrl)
} catch (err) {
console.error('Frame extraction failed', err)
// 封面提取失败,用视频路径作为 fallback,让 <video> 标签显示
if (!cancelled) setThumbSrc(localPath)
}
} else {
videoRetryOrDelete()
}
setIsGeneratingCover(false)
setLoading(false)
}
} catch (e) {
console.error(e)
if (!cancelled) {
if (isVideo) {
videoRetryOrDelete()
} else {
markDeleted()
}
setLoading(false)
setIsGeneratingCover(false)
}
}
}
load()
return () => { cancelled = true }
}, [media, isVideo, isLive, targetUrl, retryKey])
const handlePreview = async (e: React.MouseEvent) => {
e.stopPropagation()
if (isVideo) {
// Decrypt video on demand if not already
if (!videoPath) {
setIsDecrypting(true)
try {
const res = await window.electronAPI.sns.proxyImage({
url: media.url,
key: skipDecrypt ? undefined : media.key
})
if (res.success && res.videoPath) {
const local = `file://${res.videoPath.replace(/\\/g, '/')}`
setVideoPath(local)
onPreview(local, true, undefined)
} else {
alert('视频解密失败')
}
} catch (e) {
console.error(e)
} finally {
setIsDecrypting(false)
}
} else {
onPreview(videoPath, true, undefined)
}
} else {
onPreview(thumbSrc || targetUrl, false, liveVideoPath)
}
}
const handleDownload = async (e: React.MouseEvent) => {
e.stopPropagation()
setLoading(true)
try {
const result = await window.electronAPI.sns.proxyImage({
url: media.url,
key: skipDecrypt ? undefined : media.key
})
if (result.success) {
const link = document.createElement('a')
link.download = `sns_media_${Date.now()}.${isVideo ? 'mp4' : 'jpg'}`
if (result.dataUrl) {
link.href = result.dataUrl
} else if (result.videoPath) {
// For local video files, we need to fetch as blob to force download behavior
// or just use the file protocol url if the browser supports it
try {
const response = await fetch(`file://${result.videoPath}`)
const blob = await response.blob()
const url = URL.createObjectURL(blob)
link.href = url
setTimeout(() => URL.revokeObjectURL(url), 60000)
} catch (err) {
console.error('Video fetch failed, falling back to direct link', err)
link.href = `file://${result.videoPath}`
}
}
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
} else {
alert('下载失败: 无法获取资源')
}
} catch (e) {
console.error('Download error:', e)
alert('下载出错')
} finally {
setLoading(false)
}
}
if (deleted) {
return (
<div className="sns-media-item deleted-media">
<div className="deleted-placeholder">
<ImageOff size={24} />
<span>已删除</span>
</div>
</div>
)
}
return (
<div
className={`sns-media-item ${isDecrypting ? 'decrypting' : ''}`}
onClick={handlePreview}
>
{(thumbSrc && !thumbSrc.startsWith('data:') && (thumbSrc.toLowerCase().endsWith('.mp4') || thumbSrc.includes('video'))) ? (
<video
key={thumbSrc}
src={`${thumbSrc}#t=0.1`}
className="media-image"
preload="auto"
muted
playsInline
disablePictureInPicture
disableRemotePlayback
onLoadedMetadata={(e) => {
e.currentTarget.currentTime = 0.1
}}
/>
) : thumbSrc ? (
<img
src={thumbSrc}
className="media-image"
loading="lazy"
onError={() => { if (!loading && !isVideo) markDeleted() }}
alt=""
/>
) : null}
{isGeneratingCover && (
<div className="media-decrypting-mask">
<RefreshCw className="spin" size={24} />
<span>解密中...</span>
</div>
)}
{isVideo && (
<div className="media-badge video">
{/* If we have a cover, show Play. If decrypting for preview, show spin. Generating cover has its own mask. */}
{isDecrypting ? <RefreshCw className="spin" size={16} /> : <Play size={16} fill="currentColor" />}
</div>
)}
{isLive && !isVideo && (
<div className="media-badge live">
<LivePhotoIcon size={16} />
</div>
)}
<div className="media-download-btn" onClick={handleDownload} title="下载">
<Download size={16} />
</div>
</div>
)
}
export const SnsMediaGrid: React.FC<SnsMediaGridProps> = ({ mediaList, postType, onPreview, onMediaDeleted }) => {
if (!mediaList || mediaList.length === 0) return null
const count = mediaList.length
let gridClass = ''
if (count === 1) gridClass = 'grid-1'
else if (count === 2) gridClass = 'grid-2'
else if (count === 3) gridClass = 'grid-3'
else if (count === 4) gridClass = 'grid-4' // 2x2
else if (count <= 6) gridClass = 'grid-6' // 3 cols
else gridClass = 'grid-9' // 3x3
return (
<div className={`sns-media-grid ${gridClass}`}>
{mediaList.map((media, idx) => (
<MediaItem key={idx} media={media} postType={postType} onPreview={onPreview} onMediaDeleted={onMediaDeleted} />
))}
</div>
)
}
-624
View File
@@ -1,624 +0,0 @@
import React, { useState, useMemo, useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { Heart, ChevronRight, ImageIcon, Code, Trash2, MapPin } from 'lucide-react'
import { SnsPost, SnsLinkCardData, SnsLocation } from '../../types/sns'
import { Avatar } from '../Avatar'
import { SnsMediaGrid } from './SnsMediaGrid'
import { getEmojiPath } from 'wechat-emojis'
// Helper functions (extracted from SnsPage.tsx but simplified/reused)
const LINK_XML_URL_TAGS = ['url', 'shorturl', 'weburl', 'webpageurl', 'jumpurl']
const LINK_XML_DIRECT_URL_TAGS = ['contentUrl', ...LINK_XML_URL_TAGS]
const LINK_XML_TITLE_TAGS = ['title', 'linktitle', 'webtitle']
const MEDIA_HOST_HINTS = ['mmsns.qpic.cn', 'vweixinthumb', 'snstimeline', 'snsvideodownload']
const isSnsVideoUrl = (url?: string): boolean => {
if (!url) return false
const lower = url.toLowerCase()
return (lower.includes('snsvideodownload') || lower.includes('.mp4') || lower.includes('video')) && !lower.includes('vweixinthumb')
}
const decodeHtmlEntities = (text: string): string => {
if (!text) return ''
return text
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;/gi, "'")
.trim()
}
const normalizeRawXmlForParsing = (xml: string): string => {
if (!xml) return ''
return decodeHtmlEntities(xml)
.replace(/\\+"/g, '"')
.replace(/\\+'/g, "'")
}
const normalizeUrlCandidate = (raw: string): string | null => {
const value = decodeHtmlEntities(raw).replace(/[)\],.;]+$/, '').trim()
if (!value) return null
if (!/^https?:\/\//i.test(value)) return null
return value
}
const simplifyUrlForCompare = (value: string): string => {
const normalized = value.trim().toLowerCase().replace(/^https?:\/\//, '')
const [withoutQuery] = normalized.split('?')
return withoutQuery.replace(/\/+$/, '')
}
const getXmlTagValues = (xml: string, tags: string[]): string[] => {
const normalizedXml = normalizeRawXmlForParsing(xml)
if (!normalizedXml) return []
const results: string[] = []
for (const tag of tags) {
const reg = new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, 'ig')
let match: RegExpExecArray | null
while ((match = reg.exec(normalizedXml)) !== null) {
if (match[1]) results.push(match[1])
}
}
return results
}
const getUrlLikeStrings = (text: string): string[] => {
if (!text) return []
return text.match(/https?:\/\/[^\s<>"']+/gi) || []
}
const isLikelyMediaAssetUrl = (url: string): boolean => {
const lower = url.toLowerCase()
return MEDIA_HOST_HINTS.some((hint) => lower.includes(hint))
}
const normalizeSnsAssetUrl = (url: string, token?: string, encIdx?: string): string => {
const base = decodeHtmlEntities(url).trim()
if (!base) return ''
let fixed = base.replace(/^http:\/\//i, 'https://')
const normalizedToken = decodeHtmlEntities(String(token || '')).trim()
const normalizedEncIdx = decodeHtmlEntities(String(encIdx || '')).trim()
const effectiveIdx = normalizedEncIdx || (normalizedToken ? '1' : '')
const appendParams: string[] = []
if (normalizedToken && !/[?&]token=/i.test(fixed)) {
appendParams.push(`token=${normalizedToken}`)
}
if (effectiveIdx && !/[?&]idx=/i.test(fixed)) {
appendParams.push(`idx=${effectiveIdx}`)
}
if (appendParams.length > 0) {
const connector = fixed.includes('?') ? '&' : '?'
fixed = `${fixed}${connector}${appendParams.join('&')}`
}
return fixed
}
const extractCardThumbMetaFromXml = (xml: string): { thumb?: string; thumbKey?: string } => {
const normalizedXml = normalizeRawXmlForParsing(xml)
if (!normalizedXml) return {}
const mediaMatch = normalizedXml.match(/<media>([\s\S]*?)<\/media>/i)
if (!mediaMatch?.[1]) return {}
const mediaXml = mediaMatch[1]
const thumbMatch = mediaXml.match(/<thumb([^>]*)>([^<]+)<\/thumb>/i)
if (!thumbMatch) return {}
const attrs = thumbMatch[1] || ''
const getAttr = (name: string): string | undefined => {
const reg = new RegExp(`${name}\\s*=\\s*(?:\"([^\"]+)\"|'([^']+)'|([^\\s>]+))`, 'i')
const m = attrs.match(reg)
return decodeHtmlEntities((m?.[1] || m?.[2] || m?.[3] || '').trim()) || undefined
}
const thumbRawUrl = thumbMatch[2] || ''
const thumbToken = getAttr('token')
const thumbKey = getAttr('key')
const thumbEncIdx = getAttr('enc_idx')
const thumb = normalizeSnsAssetUrl(thumbRawUrl, thumbToken, thumbEncIdx)
return {
thumb: thumb || undefined,
thumbKey: thumbKey ? decodeHtmlEntities(thumbKey).trim() : undefined
}
}
const pickCardTitle = (post: SnsPost): string => {
const titleCandidates = [
post.linkTitle || '',
...getXmlTagValues(post.rawXml || '', LINK_XML_TITLE_TAGS),
post.contentDesc || ''
]
return titleCandidates
.map((value) => decodeHtmlEntities(value))
.find((value) => Boolean(value) && !/^https?:\/\//i.test(value)) || '网页链接'
}
const buildLinkCardData = (post: SnsPost): SnsLinkCardData | null => {
// type 3 / 5 是链接卡片类型,优先按卡片链接解析
if (post.type === 3 || post.type === 5) {
const thumbMeta = extractCardThumbMetaFromXml(post.rawXml || '')
const directUrlCandidates = [
post.linkUrl || '',
...getXmlTagValues(post.rawXml || '', LINK_XML_DIRECT_URL_TAGS),
...post.media.map((item) => item.url || '')
]
const url = directUrlCandidates
.map(normalizeUrlCandidate)
.find((value): value is string => Boolean(value))
if (!url) return null
return {
url,
title: pickCardTitle(post),
thumb: thumbMeta.thumb || post.media[0]?.thumb || post.media[0]?.url,
thumbKey: thumbMeta.thumbKey || post.media[0]?.key
}
}
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
if (hasVideoMedia) return null
const mediaValues = post.media
.flatMap((item) => [item.url, item.thumb])
.filter((value): value is string => Boolean(value))
const mediaSet = new Set(mediaValues.map((value) => simplifyUrlForCompare(value)))
const urlCandidates: string[] = [
post.linkUrl || '',
...getXmlTagValues(post.rawXml || '', LINK_XML_URL_TAGS),
...getUrlLikeStrings(post.rawXml || ''),
...getUrlLikeStrings(post.contentDesc || '')
]
const normalizedCandidates = urlCandidates
.map(normalizeUrlCandidate)
.filter((value): value is string => Boolean(value))
const dedupedCandidates: string[] = []
const seen = new Set<string>()
for (const candidate of normalizedCandidates) {
if (seen.has(candidate)) continue
seen.add(candidate)
dedupedCandidates.push(candidate)
}
const linkUrl = dedupedCandidates.find((candidate) => {
const simplified = simplifyUrlForCompare(candidate)
if (mediaSet.has(simplified)) return false
if (isLikelyMediaAssetUrl(candidate)) return false
return true
})
if (!linkUrl) return null
return {
url: linkUrl,
title: pickCardTitle(post),
thumb: post.media[0]?.thumb || post.media[0]?.url
}
}
const buildLocationText = (location?: SnsLocation): string => {
if (!location) return ''
const normalize = (value?: string): string => (
decodeHtmlEntities(String(value || '')).replace(/\s+/g, ' ').trim()
)
const primary = [
normalize(location.poiName),
normalize(location.poiAddressName),
normalize(location.label),
normalize(location.poiAddress)
].find(Boolean) || ''
const region = [normalize(location.country), normalize(location.city)]
.filter(Boolean)
.join(' ')
if (primary && region && !primary.includes(region)) {
return `${primary} · ${region}`
}
return primary || region
}
const SnsLinkCard = ({ card, thumbKey }: { card: SnsLinkCardData; thumbKey?: string }) => {
const [thumbFailed, setThumbFailed] = useState(false)
const [thumbSrc, setThumbSrc] = useState(card.thumb || '')
const [reloadNonce, setReloadNonce] = useState(0)
const retryCountRef = useRef(0)
const hostname = useMemo(() => {
try {
return new URL(card.url).hostname.replace(/^www\./i, '')
} catch {
return card.url
}
}, [card.url])
useEffect(() => {
retryCountRef.current = 0
}, [card.thumb, thumbKey])
const scheduleRetry = () => {
if (retryCountRef.current >= 2) return
retryCountRef.current += 1
window.setTimeout(() => {
setReloadNonce((v) => v + 1)
}, 900)
}
useEffect(() => {
const rawThumb = card.thumb || ''
setThumbFailed(false)
setThumbSrc(rawThumb)
if (!rawThumb) return
let cancelled = false
const loadThumb = async () => {
try {
const result = await window.electronAPI.sns.proxyImage({
url: rawThumb,
key: thumbKey
})
if (cancelled) return
if (!result.success) {
console.warn('[SnsLinkCard] thumb decrypt failed', {
url: rawThumb,
key: thumbKey,
error: result.error
})
scheduleRetry()
return
}
if (result.dataUrl) {
setThumbSrc(result.dataUrl)
return
}
if (result.videoPath) {
setThumbSrc(`file://${result.videoPath.replace(/\\/g, '/')}`)
}
} catch {
// noop: keep raw thumb fallback
scheduleRetry()
}
}
loadThumb()
return () => { cancelled = true }
}, [card.thumb, thumbKey, reloadNonce])
const handleClick = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
try {
await window.electronAPI.shell.openExternal(card.url)
} catch (error) {
console.error('[SnsLinkCard] openExternal failed:', error)
}
}
return (
<button type="button" className="post-link-card" onClick={handleClick}>
<div className="link-thumb">
{thumbSrc && !thumbFailed ? (
<img
src={thumbSrc}
alt=""
referrerPolicy="no-referrer"
loading="lazy"
onError={() => {
const rawThumb = card.thumb || ''
if (thumbSrc !== rawThumb && rawThumb) {
console.warn('[SnsLinkCard] thumb render failed, fallback raw thumb', {
failedSrc: thumbSrc,
rawThumb,
key: thumbKey
})
setThumbSrc(rawThumb)
return
}
console.warn('[SnsLinkCard] thumb render failed, fallback exhausted', {
failedSrc: thumbSrc,
rawThumb,
key: thumbKey
})
setThumbFailed(true)
scheduleRetry()
}}
/>
) : (
<div className="link-thumb-fallback">
<ImageIcon size={18} />
</div>
)}
</div>
<div className="link-meta">
<div className="link-title">{card.title}</div>
<div className="link-url">{hostname}</div>
</div>
<ChevronRight size={16} className="link-arrow" />
</button>
)
}
// 表情包内存缓存
const emojiLocalCache = new Map<string, string>()
// 评论表情包组件
const CommentEmoji: React.FC<{
emoji: { url: string; md5: string; width: number; height: number; encryptUrl?: string; aesKey?: string }
onPreview?: (src: string) => void
}> = ({ emoji, onPreview }) => {
const cacheKey = emoji.encryptUrl || emoji.url
const [localSrc, setLocalSrc] = useState<string>(() => emojiLocalCache.get(cacheKey) || '')
useEffect(() => {
if (!cacheKey) return
if (emojiLocalCache.has(cacheKey)) {
setLocalSrc(emojiLocalCache.get(cacheKey)!)
return
}
let cancelled = false
const load = async () => {
try {
const res = await window.electronAPI.sns.downloadEmoji({
url: emoji.url,
encryptUrl: emoji.encryptUrl,
aesKey: emoji.aesKey
})
if (cancelled) return
if (res.success && res.localPath) {
const fileUrl = res.localPath.startsWith('file:')
? res.localPath
: `file://${res.localPath.replace(/\\/g, '/')}`
emojiLocalCache.set(cacheKey, fileUrl)
setLocalSrc(fileUrl)
}
} catch { /* 静默失败 */ }
}
load()
return () => { cancelled = true }
}, [cacheKey])
if (!localSrc) return null
return (
<img
src={localSrc}
alt="emoji"
className="comment-custom-emoji"
draggable={false}
onClick={(e) => { e.stopPropagation(); onPreview?.(localSrc) }}
style={{
width: Math.min(emoji.width || 24, 30),
height: Math.min(emoji.height || 24, 30),
verticalAlign: 'middle',
marginLeft: 2,
borderRadius: 4,
cursor: onPreview ? 'pointer' : 'default'
}}
/>
)
}
interface SnsPostItemProps {
post: SnsPost
onPreview: (src: string, isVideo?: boolean, liveVideoPath?: string) => void
onDebug: (post: SnsPost) => void
onDelete?: (postId: string, username: string) => void
onOpenAuthorPosts?: (post: SnsPost) => void
hideAuthorMeta?: boolean
}
export const SnsPostItem: React.FC<SnsPostItemProps> = ({ post, onPreview, onDebug, onDelete, onOpenAuthorPosts, hideAuthorMeta = false }) => {
const [mediaDeleted, setMediaDeleted] = useState(false)
const [dbDeleted, setDbDeleted] = useState(false)
const [deleting, setDeleting] = useState(false)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const linkCard = buildLinkCardData(post)
const linkCardThumbKey = linkCard?.thumbKey || post.media[0]?.key
const locationText = useMemo(() => buildLocationText(post.location), [post.location])
const hasVideoMedia = post.type === 15 || post.media.some((item) => isSnsVideoUrl(item.url))
const isLinkCardType = post.type === 3 || post.type === 5
const showLinkCard = Boolean(linkCard) && !hasVideoMedia && (isLinkCardType || post.media.length <= 1)
const showMediaGrid = post.media.length > 0 && !showLinkCard
const formatTime = (ts: number) => {
const date = new Date(ts * 1000)
const isCurrentYear = date.getFullYear() === new Date().getFullYear()
return date.toLocaleString('zh-CN', {
year: isCurrentYear ? undefined : 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
// 解析微信表情
const renderTextWithEmoji = (text: string) => {
if (!text) return text
const parts = text.split(/\[(.*?)\]/g)
return parts.map((part, index) => {
if (index % 2 === 1) {
// @ts-ignore
const path = getEmojiPath(part as any)
if (path) {
return <img key={index} src={`${import.meta.env.BASE_URL}${path}`} alt={`[${part}]`} className="inline-emoji" style={{ width: 22, height: 22, verticalAlign: 'bottom', margin: '0 1px' }} />
}
return `[${part}]`
}
return part
})
}
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation()
if (deleting || dbDeleted) return
setShowDeleteConfirm(true)
}
const handleDeleteConfirm = async () => {
setShowDeleteConfirm(false)
setDeleting(true)
try {
const r = await window.electronAPI.sns.deleteSnsPost(post.tid ?? post.id)
if (r.success) {
setDbDeleted(true)
onDelete?.(post.id, post.username)
}
} finally {
setDeleting(false)
}
}
const handleOpenAuthorPosts = (e: React.MouseEvent) => {
e.stopPropagation()
onOpenAuthorPosts?.(post)
}
return (
<>
<div className={`sns-post-item ${(mediaDeleted || dbDeleted) ? 'post-deleted' : ''}`}>
{!hideAuthorMeta && (
<div className="post-avatar-col">
<button
type="button"
className="author-trigger-btn avatar-trigger"
onClick={handleOpenAuthorPosts}
title="查看该发布者的全部朋友圈"
>
<Avatar
src={post.avatarUrl}
name={post.nickname}
size={36}
shape="rounded"
/>
</button>
</div>
)}
<div className="post-content-col">
<div className="post-header-row">
{hideAuthorMeta ? (
<span className="post-time post-time-standalone">{formatTime(post.createTime)}</span>
) : (
<div className="post-author-info">
<button
type="button"
className="author-trigger-btn author-name-trigger"
onClick={handleOpenAuthorPosts}
title="查看该发布者的全部朋友圈"
>
<span className="author-name">{decodeHtmlEntities(post.nickname)}</span>
</button>
<span className="post-time">{formatTime(post.createTime)}</span>
</div>
)}
<div className="post-header-actions">
{(mediaDeleted || dbDeleted) && (
<span className="post-deleted-badge">
<Trash2 size={12} />
<span>已删除</span>
</span>
)}
<button
className="icon-btn-ghost debug-btn delete-btn"
onClick={handleDeleteClick}
disabled={deleting || dbDeleted}
title="从数据库删除此条记录"
>
<Trash2 size={14} />
</button>
<button className="icon-btn-ghost debug-btn" onClick={(e) => {
e.stopPropagation();
onDebug(post);
}} title="查看原始数据">
<Code size={14} />
</button>
</div>
</div>
{post.contentDesc && (
<div className="post-text">{renderTextWithEmoji(decodeHtmlEntities(post.contentDesc))}</div>
)}
{locationText && (
<div className="post-location" title={locationText}>
<MapPin size={14} />
<span className="post-location-text">{locationText}</span>
</div>
)}
{showLinkCard && linkCard && (
<SnsLinkCard card={linkCard} thumbKey={linkCardThumbKey} />
)}
{showMediaGrid && (
<div className="post-media-container">
<SnsMediaGrid mediaList={post.media} postType={post.type} onPreview={onPreview} onMediaDeleted={[1, 54].includes(post.type ?? 0) ? () => setMediaDeleted(true) : undefined} />
</div>
)}
{(post.likes.length > 0 || post.comments.length > 0) && (
<div className="post-interactions">
{post.likes.length > 0 && (
<div className="likes-block">
<Heart size={14} className="like-icon" />
<span className="likes-text">{post.likes.join('、')}</span>
</div>
)}
{post.comments.length > 0 && (
<div className="comments-block">
{post.comments.map((c, idx) => (
<div key={idx} className="comment-row">
<span className="comment-user">{c.nickname}</span>
{c.refNickname && (
<>
<span className="reply-text">回复</span>
<span className="comment-user">{c.refNickname}</span>
</>
)}
<span className="comment-colon">:</span>
{c.content && (
<span className="comment-content">{renderTextWithEmoji(c.content)}</span>
)}
{c.emojis && c.emojis.map((emoji, ei) => (
<CommentEmoji
key={ei}
emoji={emoji}
onPreview={(src) => onPreview(src)}
/>
))}
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
{/* 删除确认弹窗 - 用 Portal 挂到 body,避免父级 transform 影响 fixed 定位 */}
{showDeleteConfirm && createPortal(
<div className="sns-confirm-overlay" onClick={() => setShowDeleteConfirm(false)}>
<div className="sns-confirm-dialog" onClick={(e) => e.stopPropagation()}>
<div className="sns-confirm-icon">
<Trash2 size={22} />
</div>
<div className="sns-confirm-title">删除这条记录?</div>
<div className="sns-confirm-desc">将从本地数据库中永久删除,无法恢复。</div>
<div className="sns-confirm-actions">
<button className="sns-confirm-cancel" onClick={() => setShowDeleteConfirm(false)}>取消</button>
<button className="sns-confirm-ok" onClick={handleDeleteConfirm}>删除</button>
</div>
</div>
</div>,
document.body
)}
</>
)
}
-26
View File
@@ -1,26 +0,0 @@
export interface ContactSnsTimelineTarget {
username: string
displayName: string
avatarUrl?: string
}
export interface ContactSnsRankItem {
name: string
count: number
latestTime: number
}
export type ContactSnsRankMode = 'likes' | 'comments'
export const isSingleContactSession = (sessionId: string): boolean => {
const normalized = String(sessionId || '').trim()
if (!normalized) return false
if (normalized.includes('@chatroom')) return false
if (normalized.startsWith('gh_')) return false
return true
}
export const getAvatarLetter = (name: string): string => {
if (!name) return '?'
return [...name][0] || '?'
}
-138
View File
@@ -1,138 +0,0 @@
.title-bar {
height: 48px;
background: transparent;
display: flex;
align-items: center;
justify-content: space-between;
padding-left: 16px;
padding-right: 8px;
-webkit-app-region: drag;
flex-shrink: 0;
gap: 8px;
position: relative;
z-index: 2101;
}
.title-brand {
display: inline-flex;
align-items: center;
gap: 8px;
}
.title-logo {
width: 20px;
height: 20px;
object-fit: contain;
}
.titles {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
letter-spacing: -0.01em;
}
.title-sidebar-toggle {
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-tertiary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
-webkit-app-region: no-drag;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
.title-window-controls {
display: inline-flex;
align-items: center;
gap: 2px;
-webkit-app-region: no-drag;
}
.title-window-control-btn {
width: 36px;
height: 28px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-tertiary);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&.is-close:hover {
background: #e5484d;
color: #fff;
}
}
.image-controls {
display: flex;
align-items: center;
gap: 8px;
margin-right: auto;
padding-left: 16px;
-webkit-app-region: no-drag;
button {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 6px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
&:disabled {
cursor: default;
opacity: 1;
}
&.live-play-btn.active {
background: var(--primary-light);
color: var(--primary);
}
}
.scale-text {
min-width: 50px;
text-align: center;
color: var(--text-secondary);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.divider {
width: 1px;
height: 14px;
background: var(--border-color);
margin: 0 4px;
}
}
-89
View File
@@ -1,89 +0,0 @@
import { useEffect, useState } from 'react'
import { Copy, Minus, PanelLeftClose, PanelLeftOpen, Square, X } from 'lucide-react'
import './TitleBar.scss'
interface TitleBarProps {
title?: string
sidebarCollapsed?: boolean
onToggleSidebar?: () => void
showWindowControls?: boolean
customControls?: React.ReactNode
showLogo?: boolean
}
function TitleBar({
title,
sidebarCollapsed = false,
onToggleSidebar,
showWindowControls = true,
customControls,
showLogo = true
}: TitleBarProps = {}) {
const [isMaximized, setIsMaximized] = useState(false)
useEffect(() => {
if (!showWindowControls) return
void window.electronAPI.window.isMaximized().then(setIsMaximized).catch(() => {
setIsMaximized(false)
})
return window.electronAPI.window.onMaximizeStateChanged((maximized) => {
setIsMaximized(maximized)
})
}, [showWindowControls])
return (
<div className="title-bar">
<div className="title-brand">
{showLogo && <img src="./logo.png" alt="WeFlow" className="title-logo" />}
<span className="titles">{title || 'WeFlow'}</span>
{onToggleSidebar ? (
<button
type="button"
className="title-sidebar-toggle"
onClick={onToggleSidebar}
title={sidebarCollapsed ? '展开菜单' : '收起菜单'}
aria-label={sidebarCollapsed ? '展开菜单' : '收起菜单'}
>
{sidebarCollapsed ? <PanelLeftOpen size={16} /> : <PanelLeftClose size={16} />}
</button>
) : null}
</div>
{customControls}
{showWindowControls ? (
<div className="title-window-controls">
<button
type="button"
className="title-window-control-btn"
aria-label="最小化"
title="最小化"
onClick={() => window.electronAPI.window.minimize()}
>
<Minus size={14} />
</button>
<button
type="button"
className="title-window-control-btn"
aria-label={isMaximized ? '还原' : '最大化'}
title={isMaximized ? '还原' : '最大化'}
onClick={() => window.electronAPI.window.maximize()}
>
{isMaximized ? <Copy size={12} /> : <Square size={12} />}
</button>
<button
type="button"
className="title-window-control-btn is-close"
aria-label="关闭"
title="关闭"
onClick={() => window.electronAPI.window.close()}
>
<X size={14} />
</button>
</div>
) : null}
</div>
)
}
export default TitleBar
-294
View File
@@ -1,294 +0,0 @@
.update-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
animation: fadeIn 0.3s ease-out;
.update-dialog {
width: 680px;
background: var(--bg-secondary, #f5f5f5);
border-radius: 24px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.2);
overflow: hidden;
position: relative;
animation: slideUp 0.3s ease-out;
display: flex;
flex-direction: column;
/* Top Section (White/Gradient) */
.dialog-header {
background: var(--bg-primary, #ffffff);
padding: 40px 20px 30px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
position: relative;
/* Subtle radial gradient effect in top left as seen in image */
&::before {
content: '';
position: absolute;
top: -50px;
left: -50px;
width: 200px;
height: 200px;
background: radial-gradient(circle, rgba(255, 235, 220, 0.15) 0%, rgba(255, 255, 255, 0) 70%);
opacity: 0.5;
pointer-events: none;
}
.version-tag {
background: var(--bg-tertiary, #f0eee9);
color: var(--text-tertiary, #8c7b6e);
padding: 4px 16px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
margin-bottom: 24px;
letter-spacing: 0.5px;
}
h2 {
font-size: 32px;
font-weight: 800;
color: var(--text-primary, #333333);
margin: 0 0 12px;
letter-spacing: -0.5px;
}
.subtitle {
font-size: 15px;
color: var(--text-secondary, #999999);
font-weight: 400;
}
}
/* Content Section (Light Gray) */
.dialog-content {
background: var(--bg-tertiary, #f2f2f2);
padding: 24px 40px 40px;
flex: 1;
display: flex;
flex-direction: column;
.update-notes-container {
display: flex;
align-items: flex-start;
padding: 20px 0;
margin-bottom: 30px;
.icon-box {
background: var(--bg-primary, #fbfbfb);
width: 48px;
height: 48px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20px;
flex-shrink: 0;
color: var(--text-tertiary, #8c7b6e);
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.03);
svg {
opacity: 0.8;
}
}
.text-box {
flex: 1;
h1, h2, h3, h4, h5, h6 {
color: var(--text-primary, #333333);
font-weight: 700;
margin: 16px 0 8px;
&:first-child {
margin-top: 0;
}
}
h2 {
font-size: 16px;
}
h3 {
font-size: 15px;
}
p {
font-size: 14px;
color: var(--text-secondary, #666666);
line-height: 1.6;
margin: 4px 0;
}
ul {
margin: 4px 0 0 18px;
padding: 0;
li {
font-size: 14px;
color: var(--text-secondary, #666666);
line-height: 1.6;
}
}
}
}
.progress-section {
margin-bottom: 30px;
.progress-info-row {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
font-size: 12px;
color: var(--text-secondary, #888);
font-weight: 500;
}
.progress-bar-bg {
height: 6px;
background: var(--border-color, #e0e0e0);
border-radius: 3px;
overflow: hidden;
.progress-bar-fill {
height: 100%;
background: var(--text-primary, #000000);
border-radius: 3px;
transition: width 0.3s ease;
}
}
.status-text {
text-align: center;
margin-top: 12px;
font-size: 13px;
color: var(--text-secondary, #666);
}
}
.actions {
display: flex;
justify-content: center;
gap: 12px;
.btn-ignore {
background: transparent;
color: var(--text-secondary, #666666);
border: 1px solid var(--border-color, #d0d0d0);
padding: 16px 32px;
border-radius: 20px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: var(--bg-hover, #f5f5f5);
border-color: var(--text-secondary, #999999);
color: var(--text-primary, #333333);
}
&:active {
transform: scale(0.98);
}
}
.btn-update {
background: var(--text-primary, #000000);
color: var(--bg-primary, #ffffff);
border: none;
padding: 16px 48px;
border-radius: 20px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s, box-shadow 0.2s;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
&:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
}
&:active {
transform: translateY(0);
}
&:disabled {
opacity: 0.7;
cursor: not-allowed;
transform: none;
}
}
}
}
.close-btn {
position: absolute;
top: 16px;
right: 16px;
background: rgba(0, 0, 0, 0.05);
border: none;
color: var(--text-secondary, #999);
cursor: pointer;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
z-index: 10;
&:hover {
background: rgba(0, 0, 0, 0.1);
color: var(--text-primary, #333);
transform: rotate(90deg);
}
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.mandatory-tip {
color: #e53e3e;
font-size: 13px;
text-align: center;
margin: 0 0 8px;
padding: 6px 12px;
background: rgba(229, 62, 62, 0.08);
border-radius: 6px;
}
-143
View File
@@ -1,143 +0,0 @@
import React, { useEffect, useState } from 'react'
import { Quote, X } from 'lucide-react'
import './UpdateDialog.scss'
interface UpdateInfo {
version?: string
releaseNotes?: string
}
interface UpdateDialogProps {
open: boolean
updateInfo: UpdateInfo | null
onClose: () => void
onUpdate: () => void
onIgnore?: () => void
isDownloading: boolean
isMandatory?: boolean
progress: number | {
percent: number
bytesPerSecond?: number
transferred?: number
total?: number
remaining?: number // seconds
}
}
const UpdateDialog: React.FC<UpdateDialogProps> = ({
open,
updateInfo,
onClose,
onUpdate,
onIgnore,
isDownloading,
isMandatory,
progress
}) => {
if (!open || !updateInfo) return null
// Safe normalize progress
const safeProgress = typeof progress === 'number' ? { percent: progress } : (progress || { percent: 0 })
const percent = safeProgress.percent || 0
const bytesPerSecond = safeProgress.bytesPerSecond
const total = safeProgress.total
const transferred = safeProgress.transferred
const remaining = safeProgress.remaining
// Format bytes
const formatBytes = (bytes: number) => {
if (!Number.isFinite(bytes) || bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
const unitIndex = Math.max(0, Math.min(i, sizes.length - 1))
return parseFloat((bytes / Math.pow(k, unitIndex)).toFixed(1)) + ' ' + sizes[unitIndex]
}
// Format speed
const formatSpeed = (bytesPerSecond: number) => {
return `${formatBytes(bytesPerSecond)}/s`
}
// Format time
const formatTime = (seconds: number) => {
if (!Number.isFinite(seconds)) return '计算中...'
if (seconds < 60) return `${Math.ceil(seconds)} 秒`
const minutes = Math.floor(seconds / 60)
const remainingSeconds = Math.ceil(seconds % 60)
return `${minutes} 分 ${remainingSeconds} 秒`
}
return (
<div className="update-dialog-overlay">
<div className="update-dialog">
{!isDownloading && !isMandatory && (
<button className="close-btn" onClick={onClose}>
<X size={20} />
</button>
)}
<div className="dialog-header">
<div className="version-tag">
新版本 {updateInfo.version}
</div>
<h2>欢迎体验全新的 WeFlow</h2>
<div className="subtitle">我们带来了一些改进</div>
</div>
<div className="dialog-content">
<div className="update-notes-container">
<div className="icon-box">
<Quote size={20} />
</div>
<div className="text-box">
{updateInfo.releaseNotes ? (
<div dangerouslySetInnerHTML={{ __html: updateInfo.releaseNotes }} />
) : (
<p>修复了一些已知问题,提升了稳定性。</p>
)}
</div>
</div>
{isDownloading ? (
<div className="progress-section">
<div className="progress-info-row">
<span>{bytesPerSecond ? formatSpeed(bytesPerSecond) : '下载中...'}</span>
<span>{total ? `${formatBytes(transferred || 0)} / ${formatBytes(total)}` : `${percent.toFixed(1)}%`}</span>
{remaining !== undefined && <span>剩余 {formatTime(remaining)}</span>}
</div>
<div className="progress-bar-bg">
<div
className="progress-bar-fill"
style={{ width: `${percent}%` }}
/>
</div>
{/* Fallback status text if detailed info is missing */}
{(!bytesPerSecond && !total) && (
<div className="status-text">{percent.toFixed(0)}% 已下载</div>
)}
</div>
) : (
<div className="actions">
{onIgnore && !isMandatory && (
<button className="btn-ignore" onClick={onIgnore}>
忽略本次更新
</button>
)}
{isMandatory && (
<p className="mandatory-tip">此版本存在安全风险,必须更新后才能继续使用</p>
)}
<button className="btn-update" onClick={onUpdate}>
开启新旅程
</button>
</div>
)}
</div>
</div>
</div>
)
}
export default UpdateDialog
-192
View File
@@ -1,192 +0,0 @@
.update-progress-capsule {
position: fixed;
top: 38px; // Just below title bar
left: 50%;
transform: translateX(-50%);
z-index: 9998;
cursor: pointer;
animation: capsuleSlideDown 0.4s cubic-bezier(0.16, 1, 0.3, 1);
user-select: none;
&:hover {
.capsule-content {
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12);
transform: scale(1.02);
}
}
.capsule-content {
background: var(--bg-primary);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
padding: 8px 18px;
border-radius: 24px;
border: 1px solid var(--border-color);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.12);
display: flex;
align-items: center;
gap: 12px;
height: 40px;
position: relative;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
.icon-wrapper {
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
.download-icon {
animation: capsulePulse 2s infinite ease-in-out;
}
}
.info-wrapper {
display: flex;
align-items: baseline;
gap: 10px;
z-index: 1;
.percent-text {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
}
.speed-text {
font-size: 13px;
color: var(--text-tertiary);
font-weight: 500;
font-variant-numeric: tabular-nums;
}
.error-text {
font-size: 15px;
color: #ff4d4f;
font-weight: 600;
}
.available-text {
font-size: 15px;
color: var(--text-primary);
font-weight: 600;
}
}
.progress-bg {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
background: rgba(0, 0, 0, 0.05);
.progress-fill {
height: 100%;
background: var(--primary);
transition: width 0.3s ease;
}
}
.capsule-close {
background: none;
border: none;
padding: 4px;
margin-left: -4px;
margin-right: -8px;
cursor: pointer;
opacity: 0.5;
transition: all 0.2s ease;
display: flex;
align-items: center;
color: var(--text-secondary);
&:hover {
opacity: 1;
background: var(--bg-tertiary);
border-radius: 50%;
}
}
}
// State Modifiers
&.state-available {
.capsule-content {
background: var(--primary);
border-color: rgba(255, 255, 255, 0.1);
color: white;
.icon-wrapper {
color: white;
}
.info-wrapper {
.available-text {
color: white;
}
}
.capsule-close {
color: rgba(255, 255, 255, 0.8);
&:hover {
background: rgba(255, 255, 255, 0.1);
}
}
}
}
&.state-downloading {
.capsule-content {
background: var(--bg-primary);
}
}
&.state-error {
.capsule-content {
background: #fff1f0;
border-color: #ffa39e;
.icon-wrapper {
color: #ff4d4f;
}
.info-wrapper .error-text {
color: #cf1322;
}
.capsule-close {
color: #cf1322;
}
}
}
}
@keyframes capsuleSlideDown {
from {
transform: translate(-50%, -40px);
opacity: 0;
}
to {
transform: translate(-50%, 0);
opacity: 1;
}
}
@keyframes capsulePulse {
0%,
100% {
transform: translateY(0);
opacity: 1;
}
50% {
transform: translateY(2px);
opacity: 0.6;
}
}
-118
View File
@@ -1,118 +0,0 @@
import React from 'react'
import { useAppStore } from '../stores/appStore'
import { Download, X, AlertCircle, Info } from 'lucide-react'
import './UpdateProgressCapsule.scss'
const UpdateProgressCapsule: React.FC = () => {
const {
isDownloading,
downloadProgress,
showUpdateDialog,
setShowUpdateDialog,
updateInfo,
setUpdateInfo,
updateError,
setUpdateError
} = useAppStore()
// Control visibility
// If dialog is open, we usually hide the capsule UNLESS we want it as a mini-indicator
// For now, let's hide it if the dialog is open
if (showUpdateDialog) return null
// State mapping
const hasError = !!updateError
const hasUpdate = !!updateInfo && updateInfo.hasUpdate
if (!hasError && !isDownloading && !hasUpdate) return null
// Safe normalize progress
const safeProgress = typeof downloadProgress === 'number' ? { percent: downloadProgress } : (downloadProgress || { percent: 0 })
const percent = safeProgress.percent || 0
const bytesPerSecond = safeProgress.bytesPerSecond
const formatBytes = (bytes: number) => {
if (!Number.isFinite(bytes) || bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
const unitIndex = Math.max(0, Math.min(i, sizes.length - 1))
return parseFloat((bytes / Math.pow(k, unitIndex)).toFixed(1)) + ' ' + sizes[unitIndex]
}
const formatSpeed = (bps: number) => {
return `${formatBytes(bps)}/s`
}
const handleClose = (e: React.MouseEvent) => {
e.stopPropagation()
if (hasError) {
setUpdateError(null)
} else if (hasUpdate && !isDownloading) {
setUpdateInfo(null)
}
}
// Determine appearance class and content
let capsuleClass = 'update-progress-capsule'
let content = null
if (hasError) {
capsuleClass += ' state-error'
content = (
<>
<div className="icon-wrapper">
<AlertCircle size={14} />
</div>
<div className="info-wrapper">
<span className="error-text">更新失败: {updateError}</span>
</div>
</>
)
} else if (isDownloading) {
capsuleClass += ' state-downloading'
content = (
<>
<div className="icon-wrapper">
<Download size={14} className="download-icon" />
</div>
<div className="info-wrapper">
<span className="percent-text">{percent.toFixed(0)}%</span>
{bytesPerSecond > 0 && (
<span className="speed-text">{formatSpeed(bytesPerSecond)}</span>
)}
</div>
<div className="progress-bg">
<div className="progress-fill" style={{ width: `${percent}%` }} />
</div>
</>
)
} else if (hasUpdate) {
capsuleClass += ' state-available'
content = (
<>
<div className="icon-wrapper">
<Info size={14} />
</div>
<div className="info-wrapper">
<span className="available-text">发现新版本 v{updateInfo?.version}</span>
</div>
</>
)
}
return (
<div className={capsuleClass} onClick={() => setShowUpdateDialog(true)}>
<div className="capsule-content">
{content}
{!isDownloading && (
<button className="capsule-close" onClick={handleClose}>
<X size={12} />
</button>
)}
</div>
</div>
)
}
export default UpdateProgressCapsule
-262
View File
@@ -1,262 +0,0 @@
.voice-transcribe-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
animation: fadeIn 0.2s ease-out;
}
.voice-transcribe-dialog {
background: var(--bg-secondary);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
width: 90%;
max-width: 480px;
animation: slideUp 0.3s ease-out;
overflow: hidden;
}
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.close-button {
background: none;
border: none;
cursor: pointer;
padding: 4px;
color: var(--text-secondary);
border-radius: 6px;
transition: all 0.15s ease;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
}
}
.dialog-content {
padding: 24px;
}
.info-section {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 16px;
.info-icon {
color: var(--primary);
opacity: 0.8;
}
.info-text {
font-size: 15px;
color: var(--text-primary);
margin: 0;
}
.model-info {
width: 100%;
background: var(--bg-tertiary);
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
.model-item {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
.label {
color: var(--text-secondary);
}
.value {
color: var(--text-primary);
font-weight: 500;
}
}
}
}
.download-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 20px 0;
.download-icon {
.downloading-icon {
color: var(--primary);
animation: bounce 1s ease-in-out infinite;
}
}
.download-text {
font-size: 15px;
color: var(--text-primary);
margin: 0;
}
.progress-bar {
width: 100%;
height: 6px;
background: var(--bg-tertiary);
border-radius: 3px;
overflow: hidden;
.progress-fill {
height: 100%;
background: var(--primary-gradient);
border-radius: 3px;
transition: width 0.3s ease;
}
}
.progress-text {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
font-variant-numeric: tabular-nums;
}
.download-hint {
font-size: 12px;
color: var(--text-tertiary);
margin: 8px 0 0 0;
text-align: center;
}
}
.complete-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 20px 0;
.complete-icon {
color: #10b981;
}
.complete-text {
font-size: 15px;
color: var(--text-primary);
margin: 0;
}
}
.error-message {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 8px;
color: #ef4444;
font-size: 14px;
margin-top: 16px;
}
.dialog-actions {
display: flex;
gap: 12px;
margin-top: 24px;
button {
flex: 1;
padding: 12px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
border: none;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
&.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-primary);
&:hover {
background: var(--bg-hover);
}
}
&.btn-primary {
background: var(--primary);
color: white;
&:hover {
opacity: 0.9;
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
-160
View File
@@ -1,160 +0,0 @@
import React, { useState, useEffect } from 'react'
import { Download, X, CheckCircle, AlertCircle } from 'lucide-react'
import './VoiceTranscribeDialog.scss'
interface VoiceTranscribeDialogProps {
onClose: () => void
onDownloadComplete: () => void
}
export const VoiceTranscribeDialog: React.FC<VoiceTranscribeDialogProps> = ({
onClose,
onDownloadComplete
}) => {
const [isDownloading, setIsDownloading] = useState(false)
const [downloadProgress, setDownloadProgress] = useState(0)
const [downloadError, setDownloadError] = useState<string | null>(null)
const [isComplete, setIsComplete] = useState(false)
useEffect(() => {
// 监听下载进度
if (!window.electronAPI?.whisper?.onDownloadProgress) {
console.warn('[VoiceTranscribeDialog] whisper API 不可用')
return
}
const removeListener = window.electronAPI.whisper.onDownloadProgress((payload: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => {
if (payload.percent !== undefined) {
setDownloadProgress(payload.percent)
}
})
return () => {
removeListener?.()
}
}, [])
const handleDownload = async () => {
if (!window.electronAPI?.whisper?.downloadModel) {
setDownloadError('语音转文字功能不可用')
return
}
setIsDownloading(true)
setDownloadError(null)
setDownloadProgress(0)
try {
const result = await window.electronAPI.whisper.downloadModel()
if (result?.success) {
setIsComplete(true)
setDownloadProgress(100)
// 延迟关闭弹窗并触发转写
setTimeout(() => {
onDownloadComplete()
}, 1000)
} else {
setDownloadError(result?.error || '下载失败')
setIsDownloading(false)
}
} catch (error) {
setDownloadError(String(error))
setIsDownloading(false)
}
}
const handleCancel = () => {
if (!isDownloading && !isComplete) {
onClose()
}
}
return (
<div className="voice-transcribe-dialog-overlay" onClick={handleCancel}>
<div className="voice-transcribe-dialog" onClick={(e) => e.stopPropagation()}>
<div className="dialog-header">
<h3>语音转文字</h3>
{!isDownloading && !isComplete && (
<button className="close-button" onClick={onClose}>
<X size={20} />
</button>
)}
</div>
<div className="dialog-content">
{!isDownloading && !isComplete && (
<>
<div className="info-section">
<AlertCircle size={48} className="info-icon" />
<p className="info-text">
首次使用语音转文字功能需要下载 AI 模型
</p>
<div className="model-info">
<div className="model-item">
<span className="label">模型名称:</span>
<span className="value">SenseVoiceSmall</span>
</div>
<div className="model-item">
<span className="label">文件大小:</span>
<span className="value">约 240 MB</span>
</div>
<div className="model-item">
<span className="label">支持语言:</span>
<span className="value">中文、粤语、英文、日文、韩文</span>
</div>
</div>
</div>
{downloadError && (
<div className="error-message">
<AlertCircle size={16} />
<span>{downloadError}</span>
</div>
)}
<div className="dialog-actions">
<button className="btn-secondary" onClick={onClose}>
取消
</button>
<button className="btn-primary" onClick={handleDownload}>
<Download size={16} />
<span>立即下载</span>
</button>
</div>
</>
)}
{isDownloading && !isComplete && (
<div className="download-section">
<div className="download-icon">
<Download size={48} className="downloading-icon" />
</div>
<p className="download-text">
{downloadProgress < 1 ? '正在连接服务器...' : '正在下载模型...'}
</p>
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${downloadProgress}%` }}
/>
</div>
<p className="progress-text">{downloadProgress.toFixed(1)}%</p>
{downloadProgress < 1 && (
<p className="download-hint">首次连接可能需要较长时间,请耐心等待</p>
)}
</div>
)}
{isComplete && (
<div className="complete-section">
<CheckCircle size={48} className="complete-icon" />
<p className="complete-text">下载完成!正在转写语音...</p>
</div>
)}
</div>
</div>
</div>
)
}
-306
View File
@@ -1,306 +0,0 @@
.window-close-dialog-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
background:
radial-gradient(circle at top, rgba(36, 42, 54, 0.18), transparent 48%),
rgba(7, 10, 18, 0.56);
backdrop-filter: blur(10px);
z-index: 3000;
animation: windowCloseDialogFadeIn 0.2s ease-out;
}
.window-close-dialog {
width: min(560px, 100%);
border: 1px solid color-mix(in srgb, var(--border-color) 78%, transparent);
border-radius: 24px;
background:
linear-gradient(180deg, color-mix(in srgb, var(--bg-primary) 94%, white 6%) 0%, var(--bg-primary) 100%);
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.32);
overflow: hidden;
position: relative;
animation: windowCloseDialogSlideUp 0.24s cubic-bezier(0.16, 1, 0.3, 1);
}
.window-close-dialog-header {
padding: 28px 30px 18px;
border-bottom: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
.window-close-dialog-kicker {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
background: color-mix(in srgb, var(--primary) 12%, transparent);
color: var(--primary);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
h2 {
margin: 14px 0 8px;
font-size: 26px;
line-height: 1.1;
color: var(--text-primary);
}
p {
margin: 0;
font-size: 14px;
line-height: 1.7;
color: var(--text-secondary);
}
}
.window-close-dialog-body {
padding: 20px 24px 10px;
display: flex;
flex-direction: column;
gap: 12px;
}
.window-close-dialog-option {
width: 100%;
display: flex;
align-items: flex-start;
gap: 14px;
padding: 18px 18px 18px 16px;
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
border-radius: 18px;
background:
linear-gradient(180deg, color-mix(in srgb, var(--bg-secondary) 86%, white 14%) 0%, var(--bg-secondary) 100%);
color: inherit;
cursor: pointer;
text-align: left;
transition:
transform 0.18s ease,
border-color 0.18s ease,
box-shadow 0.18s ease,
background 0.18s ease;
&:hover {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--primary) 34%, var(--border-color));
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.1);
}
&:active {
transform: translateY(0);
}
&.is-danger:hover {
border-color: rgba(205, 73, 73, 0.42);
}
}
.window-close-dialog-option-icon {
width: 42px;
height: 42px;
flex: 0 0 42px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 14px;
background: color-mix(in srgb, var(--primary) 14%, transparent);
color: var(--primary);
}
.window-close-dialog-option.is-danger .window-close-dialog-option-icon {
background: rgba(205, 73, 73, 0.12);
color: #cd4949;
}
.window-close-dialog-option-text {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
strong {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
span {
font-size: 13px;
line-height: 1.6;
color: var(--text-secondary);
}
}
.window-close-dialog-actions {
padding: 8px 24px 24px;
display: flex;
justify-content: flex-end;
}
.window-close-dialog-remember {
display: flex;
align-items: center;
gap: 10px;
margin: 4px 24px 0;
padding: 12px 14px;
border: 1px solid color-mix(in srgb, var(--border-color) 72%, transparent);
border-radius: 16px;
background: color-mix(in srgb, var(--bg-secondary) 76%, transparent);
cursor: pointer;
user-select: none;
input {
position: absolute;
opacity: 0;
pointer-events: none;
}
}
.window-close-dialog-checkbox {
width: 18px;
height: 18px;
flex: 0 0 18px;
border: 1.5px solid color-mix(in srgb, var(--border-color) 88%, transparent);
border-radius: 6px;
background: var(--bg-primary);
position: relative;
transition:
border-color 0.18s ease,
background 0.18s ease,
box-shadow 0.18s ease;
&::after {
content: '';
position: absolute;
left: 5px;
top: 1px;
width: 5px;
height: 10px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg) scale(0.7);
opacity: 0;
transition:
opacity 0.18s ease,
transform 0.18s ease;
}
}
.window-close-dialog-remember input:checked + .window-close-dialog-checkbox {
background: var(--primary);
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 16%, transparent);
}
.window-close-dialog-remember input:checked + .window-close-dialog-checkbox::after {
opacity: 1;
transform: rotate(45deg) scale(1);
}
.window-close-dialog-remember-text {
font-size: 13px;
line-height: 1.5;
color: var(--text-secondary);
}
.window-close-dialog-cancel {
min-width: 112px;
padding: 12px 18px;
border: 1px solid color-mix(in srgb, var(--border-color) 76%, transparent);
border-radius: 999px;
background: var(--bg-tertiary);
color: var(--text-secondary);
cursor: pointer;
transition:
background 0.18s ease,
color 0.18s ease,
border-color 0.18s ease;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
border-color: color-mix(in srgb, var(--primary) 24%, var(--border-color));
}
}
.window-close-dialog-close {
position: absolute;
top: 18px;
right: 18px;
width: 34px;
height: 34px;
border: none;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: color-mix(in srgb, var(--bg-secondary) 84%, transparent);
color: var(--text-secondary);
cursor: pointer;
transition:
background 0.18s ease,
color 0.18s ease,
transform 0.18s ease;
&:hover {
background: var(--bg-hover);
color: var(--text-primary);
transform: rotate(90deg);
}
}
@media (max-width: 640px) {
.window-close-dialog-overlay {
padding: 16px;
align-items: flex-end;
}
.window-close-dialog {
border-radius: 24px 24px 18px 18px;
}
.window-close-dialog-header {
padding: 24px 22px 16px;
h2 {
font-size: 22px;
}
}
.window-close-dialog-body {
padding: 18px 18px 10px;
}
.window-close-dialog-actions {
padding: 8px 18px 18px;
}
.window-close-dialog-cancel {
width: 100%;
}
}
@keyframes windowCloseDialogFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes windowCloseDialogSlideUp {
from {
transform: translateY(24px) scale(0.98);
opacity: 0;
}
to {
transform: translateY(0) scale(1);
opacity: 1;
}
}
-124
View File
@@ -1,124 +0,0 @@
import { Minimize2, Power, X } from 'lucide-react'
import { useEffect, useState } from 'react'
import './WindowCloseDialog.scss'
interface WindowCloseDialogProps {
open: boolean
canMinimizeToTray: boolean
restoreMethod?: 'tray' | 'dock'
onSelect: (action: 'tray' | 'quit', rememberChoice: boolean) => void
onCancel: () => void
}
export default function WindowCloseDialog({
open,
canMinimizeToTray,
restoreMethod = 'tray',
onSelect,
onCancel
}: WindowCloseDialogProps) {
const [rememberChoice, setRememberChoice] = useState(false)
const isDockRestore = restoreMethod === 'dock'
useEffect(() => {
if (!open) return
setRememberChoice(false)
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
onCancel()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [open, onCancel])
if (!open) return null
return (
<div className="window-close-dialog-overlay" onClick={onCancel}>
<div
className="window-close-dialog"
onClick={(event) => event.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="window-close-dialog-title"
>
<button
type="button"
className="window-close-dialog-close"
onClick={onCancel}
aria-label="关闭提示"
>
<X size={18} />
</button>
<div className="window-close-dialog-header">
<span className="window-close-dialog-kicker">退出行为</span>
<h2 id="window-close-dialog-title">关闭 WeFlow</h2>
<p>
{canMinimizeToTray
? isDockRestore
? '你可以隐藏主窗口并保留后台进程与本地 API,稍后可从 Dock 或重新打开应用恢复。'
: '你可以保留后台进程与本地 API,或者直接完全退出应用。'
: '当前系统托盘不可用,本次只能完全退出应用。'}
</p>
</div>
<div className="window-close-dialog-body">
{canMinimizeToTray && (
<button
type="button"
className="window-close-dialog-option"
onClick={() => onSelect('tray', rememberChoice)}
>
<span className="window-close-dialog-option-icon">
<Minimize2 size={18} />
</span>
<span className="window-close-dialog-option-text">
<strong>{isDockRestore ? '隐藏主窗口' : '最小化到系统托盘'}</strong>
<span>
{isDockRestore
? '继续保留后台进程和本地 API,稍后可从 Dock 或重新打开应用恢复。'
: '继续保留后台进程和本地 API,稍后可从托盘恢复。'}
</span>
</span>
</button>
)}
<button
type="button"
className="window-close-dialog-option is-danger"
onClick={() => onSelect('quit', rememberChoice)}
>
<span className="window-close-dialog-option-icon">
<Power size={18} />
</span>
<span className="window-close-dialog-option-text">
<strong>完全关闭</strong>
<span>结束 WeFlow 进程,并停止当前保留的本地 API。</span>
</span>
</button>
</div>
<label className="window-close-dialog-remember">
<input
type="checkbox"
checked={rememberChoice}
onChange={(event) => setRememberChoice(event.target.checked)}
/>
<span className="window-close-dialog-checkbox" aria-hidden="true" />
<span className="window-close-dialog-remember-text">下次不再提示,直接按本次选择处理</span>
</label>
<div className="window-close-dialog-actions">
<button type="button" className="window-close-dialog-cancel" onClick={onCancel}>
取消
</button>
</div>
</div>
</div>
)
}