Merge pull request #404 from aits2026/codex/ts0306-01-export-opt

feat: 优化聊天分析、设置弹窗与导出会话交互
This commit is contained in:
xuncha
2026-03-10 17:40:35 +08:00
committed by GitHub
28 changed files with 2926 additions and 586 deletions

View File

@@ -0,0 +1,136 @@
.chat-analysis-header {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-height: 28px;
padding: 4px 0;
background: transparent;
border: none;
border-radius: 0;
flex-shrink: 0;
}
.chat-analysis-back {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: color 0.2s ease;
font-size: 13px;
font-weight: 600;
&:hover {
color: var(--text-primary);
}
}
.chat-analysis-breadcrumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-secondary);
.chat-analysis-breadcrumb-separator {
opacity: 0.6;
}
}
.chat-analysis-dropdown {
position: relative;
}
.chat-analysis-current-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0;
border: none;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: color 0.2s ease;
.current {
color: var(--text-primary);
}
svg {
transition: transform 0.2s ease;
}
&:hover {
color: var(--text-primary);
}
&.open svg {
transform: rotate(180deg);
}
}
.chat-analysis-menu {
position: absolute;
top: calc(100% + 10px);
right: 0;
min-width: 120px;
padding: 6px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.12);
z-index: 20;
}
.chat-analysis-menu-item {
width: 100%;
display: block;
padding: 9px 12px;
border: none;
border-radius: 8px;
background: transparent;
color: var(--text-primary);
text-align: left;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background 0.2s ease, color 0.2s ease;
&:hover {
background: var(--bg-hover);
color: var(--primary);
}
}
.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;
}
}

View File

@@ -0,0 +1,105 @@
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

View File

@@ -43,31 +43,52 @@
.sidebar-user-card-wrap {
position: relative;
margin: 0 12px 10px;
--sidebar-user-menu-width: 172px;
}
.sidebar-user-clear-trigger {
.sidebar-user-menu {
position: absolute;
left: 0;
right: 0;
right: auto;
bottom: calc(100% + 8px);
width: max(100%, var(--sidebar-user-menu-width));
z-index: 12;
border: 1px solid rgba(255, 59, 48, 0.28);
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-secondary-solid, var(--bg-primary));
display: flex;
flex-direction: column;
gap: 4px;
padding: 6px;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
}
.sidebar-user-menu-item {
width: 100%;
border: none;
border-radius: 10px;
background: var(--bg-secondary);
color: #d93025;
padding: 8px 10px;
background: transparent;
color: var(--text-primary);
padding: 9px 10px;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
font-weight: 600;
font-size: 13px;
font-weight: 500;
cursor: pointer;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.12);
transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
text-align: left;
transition: background 0.2s ease, color 0.2s ease;
&:hover {
background: rgba(255, 59, 48, 0.08);
border-color: rgba(255, 59, 48, 0.46);
background: var(--bg-tertiary);
}
&.danger {
color: #d93025;
&:hover {
background: rgba(255, 59, 48, 0.08);
}
}
}
@@ -244,26 +265,6 @@
gap: 4px;
}
.collapse-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 8px;
border: none;
background: transparent;
color: var(--text-tertiary);
cursor: pointer;
border-radius: 9999px;
transition: all 0.2s ease;
margin-top: 4px;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
}
.sidebar-clear-dialog-overlay {
position: fixed;
inset: 0;

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import { NavLink, useLocation, useNavigate } from 'react-router-dom'
import { Home, MessageSquare, BarChart3, Users, FileText, Settings, ChevronLeft, ChevronRight, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, Trash2 } from 'lucide-react'
import { Home, MessageSquare, BarChart3, FileText, Settings, Download, Aperture, UserCircle, Lock, LockOpen, ChevronUp, Trash2 } from 'lucide-react'
import { useAppStore } from '../stores/appStore'
import * as configService from '../services/config'
import { onExportSessionStatus, requestExportSessionStatus } from '../services/exportBridge'
@@ -62,10 +62,13 @@ const normalizeAccountId = (value?: string | null): string => {
return suffixMatch ? suffixMatch[1] : trimmed
}
function Sidebar() {
interface SidebarProps {
collapsed: boolean
}
function Sidebar({ collapsed }: SidebarProps) {
const location = useLocation()
const navigate = useNavigate()
const [collapsed, setCollapsed] = useState(false)
const [authEnabled, setAuthEnabled] = useState(false)
const [activeExportTaskCount, setActiveExportTaskCount] = useState(0)
const [userProfile, setUserProfile] = useState<SidebarUserProfile>({
@@ -279,6 +282,15 @@ function Sidebar() {
setShowClearAccountDialog(true)
}
const openSettingsFromAccountMenu = () => {
setIsAccountMenuOpen(false)
navigate('/settings', {
state: {
backgroundLocation: location
}
})
}
const handleConfirmClearAccountData = async () => {
if (!canConfirmClear || isClearingAccountData) return
setIsClearingAccountData(true)
@@ -375,24 +387,14 @@ function Sidebar() {
<span className="nav-label"></span>
</NavLink>
{/* 聊分析 */}
{/* 聊分析 */}
<NavLink
to="/analytics"
className={`nav-item ${isActive('/analytics') ? 'active' : ''}`}
title={collapsed ? '聊分析' : undefined}
title={collapsed ? '聊分析' : undefined}
>
<span className="nav-icon"><BarChart3 size={20} /></span>
<span className="nav-label"></span>
</NavLink>
{/* 群聊分析 */}
<NavLink
to="/group-analytics"
className={`nav-item ${isActive('/group-analytics') ? 'active' : ''}`}
title={collapsed ? '群聊分析' : undefined}
>
<span className="nav-icon"><Users size={20} /></span>
<span className="nav-label"></span>
<span className="nav-label"></span>
</NavLink>
{/* 年度报告 */}
@@ -427,16 +429,48 @@ function Sidebar() {
</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}>
{isAccountMenuOpen && (
<button
className="sidebar-user-clear-trigger"
onClick={openClearAccountDialog}
type="button"
>
<Trash2 size={14} />
<span></span>
</button>
<div className="sidebar-user-menu" role="menu" aria-label="账号菜单">
<button
className="sidebar-user-menu-item"
onClick={openSettingsFromAccountMenu}
type="button"
role="menuitem"
>
<Settings size={14} />
<span></span>
</button>
<button
className="sidebar-user-menu-item danger"
onClick={openClearAccountDialog}
type="button"
role="menuitem"
>
<Trash2 size={14} />
<span></span>
</button>
</div>
)}
<div
className={`sidebar-user-card ${isAccountMenuOpen ? 'menu-open' : ''}`}
@@ -465,40 +499,6 @@ function Sidebar() {
)}
</div>
</div>
<button
className="nav-item"
onClick={() => {
if (authEnabled) {
setLocked(true)
return
}
navigate('/settings', { state: { initialTab: 'security' } })
}}
title={collapsed ? (authEnabled ? '锁定' : '未锁定') : undefined}
>
<span className="nav-icon">{authEnabled ? <Lock size={20} /> : <LockOpen size={20} />}</span>
<span className="nav-label">{authEnabled ? '锁定' : '未锁定'}</span>
</button>
<NavLink
to="/settings"
className={`nav-item ${isActive('/settings') ? 'active' : ''}`}
title={collapsed ? '设置' : undefined}
>
<span className="nav-icon">
<Settings size={20} />
</span>
<span className="nav-label"></span>
</NavLink>
<button
className="collapse-btn"
onClick={() => setCollapsed(!collapsed)}
title={collapsed ? '展开菜单' : '收起菜单'}
>
{collapsed ? <ChevronRight size={18} /> : <ChevronLeft size={18} />}
</button>
</div>
{showClearAccountDialog && (

View File

@@ -1,5 +1,5 @@
import React from 'react'
import { Search, User, X, Loader2 } from 'lucide-react'
import { Search, User, X, Loader2, CheckSquare, Square, Download } from 'lucide-react'
import { Avatar } from '../Avatar'
interface Contact {
@@ -25,7 +25,12 @@ interface SnsFilterPanelProps {
setContactSearch: (val: string) => void
loading?: boolean
contactsCountProgress?: ContactsCountProgress
selectedContactUsernames: string[]
activeContactUsername?: string
onOpenContactTimeline: (contact: Contact) => void
onToggleContactSelected: (contact: Contact) => void
onClearSelectedContacts: () => void
onExportSelectedContacts: () => void
}
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
@@ -37,12 +42,21 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
setContactSearch,
loading,
contactsCountProgress,
onOpenContactTimeline
selectedContactUsernames,
activeContactUsername,
onOpenContactTimeline,
onToggleContactSelected,
onClearSelectedContacts,
onExportSelectedContacts
}) => {
const filteredContacts = contacts.filter(c =>
(c.displayName || '').toLowerCase().includes(contactSearch.toLowerCase()) ||
c.username.toLowerCase().includes(contactSearch.toLowerCase())
)
const selectedContactLookup = React.useMemo(
() => new Set(selectedContactUsernames),
[selectedContactUsernames]
)
const clearFilters = () => {
setSearchKeyword('')
@@ -122,35 +136,69 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
</div>
)}
<div className="contact-interaction-hint">
</div>
<div className="contact-list-scroll">
{filteredContacts.map(contact => {
const isPostCountReady = contact.postCountStatus === 'ready'
const isSelected = selectedContactLookup.has(contact.username)
const isActive = activeContactUsername === contact.username
return (
<div
key={contact.username}
className="contact-row"
onClick={() => onOpenContactTimeline(contact)}
>
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} shape="rounded" />
<div className="contact-meta">
<span className="contact-name">{contact.displayName}</span>
<div
key={contact.username}
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={16} /> : <Square size={16} />}
</button>
<button
type="button"
className="contact-main-btn"
onClick={() => onOpenContactTimeline(contact)}
title={`查看 ${contact.displayName} 的朋友圈`}
>
<Avatar src={contact.avatarUrl} name={contact.displayName} size={36} 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={13} className="spinning" />
</span>
)}
</div>
</button>
</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={13} className="spinning" />
</span>
)}
</div>
</div>
)
})}
{filteredContacts.length === 0 && (
<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>

View File

@@ -4,10 +4,13 @@
display: flex;
align-items: center;
padding-left: 16px;
padding-right: 16px;
border-bottom: 1px solid var(--border-color);
-webkit-app-region: drag;
flex-shrink: 0;
gap: 8px;
position: relative;
z-index: 2101;
}
// 繁花如梦:标题栏毛玻璃
@@ -16,6 +19,12 @@
-webkit-backdrop-filter: blur(20px);
}
.title-brand {
display: inline-flex;
align-items: center;
gap: 8px;
}
.title-logo {
width: 20px;
height: 20px;
@@ -27,3 +36,24 @@
font-weight: 500;
color: var(--text-secondary);
}
.title-sidebar-toggle {
width: 28px;
height: 28px;
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.2s ease, color 0.2s ease;
-webkit-app-region: no-drag;
&:hover {
background: var(--bg-tertiary);
color: var(--text-primary);
}
}

View File

@@ -1,14 +1,30 @@
import { PanelLeftClose, PanelLeftOpen } from 'lucide-react'
import './TitleBar.scss'
interface TitleBarProps {
title?: string
sidebarCollapsed?: boolean
onToggleSidebar?: () => void
}
function TitleBar({ title }: TitleBarProps = {}) {
function TitleBar({ title, sidebarCollapsed = false, onToggleSidebar }: TitleBarProps = {}) {
return (
<div className="title-bar">
<img src="./logo.png" alt="WeFlow" className="title-logo" />
<span className="titles">{title || 'WeFlow'}</span>
<div className="title-brand">
<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>
</div>
)
}