完善用户登录态的感知

This commit is contained in:
2025-11-12 15:07:24 +08:00
parent 7b8d31f899
commit 787906c566
4 changed files with 477 additions and 43 deletions
+62
View File
@@ -1,6 +1,7 @@
import { app, BrowserWindow, ipcMain, screen, globalShortcut, clipboard } from 'electron'
import { join } from 'path'
import { chromium, BrowserContext } from 'playwright'
import { existsSync, rmSync } from 'fs'
import { ScraperFactory } from './scrapers'
import { XiaoheiheScrap } from './scrapers/xiaoheihe'
import { GenericScraper } from './scrapers/generic'
@@ -237,6 +238,33 @@ async function waitForQrCodeLogin(): Promise<{
}
}
// Check login status for a platform (fast - cookie-based only)
async function checkPlatformLoginFast(url: string): Promise<{
success: boolean
isLoggedIn: boolean
username?: string
error?: string
}> {
try {
const service = platformServiceFactory.getService(url)
if (!service) {
return { success: false, isLoggedIn: false, error: '不支持的平台' }
}
const context = await getPersistentContext()
const loginStatus = await service.checkLoginStatusFast(context)
return { success: true, ...loginStatus }
} catch (error) {
console.error('Check platform login fast error:', error)
return {
success: false,
isLoggedIn: false,
error: error instanceof Error ? error.message : '检查登录状态失败'
}
}
}
// Check login status for a platform
async function checkPlatformLogin(url: string): Promise<{
success: boolean
@@ -387,6 +415,11 @@ app.whenReady().then(() => {
}
})
// Handle check platform login status (fast - cookie-based only)
ipcMain.handle('check-platform-login-fast', async (_, url: string) => {
return await checkPlatformLoginFast(url)
})
// Handle check platform login status
ipcMain.handle('check-platform-login', async (_, url: string) => {
return await checkPlatformLogin(url)
@@ -407,6 +440,35 @@ app.whenReady().then(() => {
return await waitForQrCodeLogin()
})
// Handle logout
ipcMain.handle('logout-platform', async (_, platform: string) => {
try {
if (platform === 'xiaoheihe') {
// Close the persistent context to clear cookies and session
if (persistentContext) {
await persistentContext.close()
persistentContext = null
}
// Delete the user data directory to completely clear all browser data
if (existsSync(userDataDir)) {
console.log('Deleting user data directory:', userDataDir)
rmSync(userDataDir, { recursive: true, force: true })
console.log('User data directory deleted successfully')
}
return { success: true }
}
return { success: false, error: '不支持的平台' }
} catch (error) {
console.error('Logout error:', error)
return {
success: false,
error: error instanceof Error ? error.message : '退出登录失败'
}
}
})
createFloatingWindow()
registerGlobalShortcuts()
+6
View File
@@ -5,6 +5,12 @@ export interface PlatformService {
// 平台标识
canHandle(url: string): boolean
// 快速检查登录状态(仅基于 cookie,不加载页面)
checkLoginStatusFast(context: BrowserContext): Promise<{
isLoggedIn: boolean
username?: string
}>
// 检查登录状态
checkLoginStatus(page: Page): Promise<{
isLoggedIn: boolean
+99 -17
View File
@@ -6,30 +6,91 @@ export class XiaoheiheService implements PlatformService {
return url.includes('xiaoheihe.cn')
}
// 快速检查登录状态(仅基于 cookie,不加载页面)
async checkLoginStatusFast(context: BrowserContext): Promise<{
isLoggedIn: boolean
username?: string
}> {
try {
// 直接检查 cookie,无需加载页面
const cookies = await context.cookies('https://www.xiaoheihe.cn')
const hasLoginCookie = cookies.some(cookie =>
cookie.name === 'heybox_id' ||
cookie.name === 'pkey' ||
cookie.name.includes('token')
)
if (!hasLoginCookie) {
return { isLoggedIn: false }
}
// 如果有 cookie,快速加载一个简单的 API 页面来获取用户名
// 这比加载完整的首页快得多
return { isLoggedIn: true }
} catch (error) {
console.error('Fast check login status error:', error)
return { isLoggedIn: false }
}
}
async checkLoginStatus(page: Page): Promise<{
isLoggedIn: boolean
username?: string
}> {
try {
// 检查是否存在登录按钮(未登录状态)
const loginButton = await page.locator('.user-box__login').count()
if (loginButton > 0) {
return { isLoggedIn: false }
}
// 等待页面稳定
await page.waitForTimeout(1000)
// 检查是否存在用户名(已登录状态
const usernameElement = await page.locator('.user-box__username').first()
const usernameCount = await usernameElement.count()
if (usernameCount > 0) {
const username = await usernameElement.textContent()
return {
isLoggedIn: true,
username: username?.trim() || undefined
// 尝试多种方式检查登录状态
const loginStatus = await page.evaluate(() => {
// 方法1: 检查登录按钮
const loginButton = document.querySelector('.user-box__login')
if (loginButton) {
return { isLoggedIn: false, method: 'loginButton' }
}
}
return { isLoggedIn: false }
// 方法2: 检查用户名元素
const usernameElement = document.querySelector('.user-box__username')
if (usernameElement) {
const username = usernameElement.textContent?.trim()
return { isLoggedIn: true, username, method: 'username' }
}
// 方法3: 检查用户头像或其他登录标识
const userAvatar = document.querySelector('.user-box__avatar')
if (userAvatar) {
// 尝试从其他位置获取用户名
const nameElement = document.querySelector('.user-name, .username, [class*="user"] [class*="name"]')
return {
isLoggedIn: true,
username: nameElement?.textContent?.trim(),
method: 'avatar'
}
}
// 方法4: 检查 localStorage 或 cookie 中的登录信息(带错误处理)
try {
const hasAuthToken = !!localStorage.getItem('token') ||
!!localStorage.getItem('auth') ||
document.cookie.includes('heybox_id')
if (hasAuthToken) {
return { isLoggedIn: true, method: 'token' }
}
} catch (e) {
// localStorage 访问失败,尝试只检查 cookie
if (document.cookie.includes('heybox_id')) {
return { isLoggedIn: true, method: 'cookie' }
}
}
return { isLoggedIn: false, method: 'default' }
})
console.log('Login status check result:', loginStatus)
return {
isLoggedIn: loginStatus.isLoggedIn,
username: loginStatus.username
}
} catch (error) {
console.error('Check login status error:', error)
return { isLoggedIn: false }
@@ -138,7 +199,7 @@ export class XiaoheiheService implements PlatformService {
console.log(`waitForQrCodeLogin: Check #${checkCount} - Checking login status...`)
const loginStatus = await this.checkLoginStatus(page)
console.log(`waitForQrCodeLogin: Check #${checkCount} - isLoggedIn:`, loginStatus.isLoggedIn, 'username:', loginStatus.username)
console.log(`waitForQrCodeLogin: Check #${checkCount} - Login status:`, loginStatus)
if (loginStatus.isLoggedIn) {
console.log('waitForQrCodeLogin: Login detected! Closing page and returning success')
@@ -149,6 +210,27 @@ export class XiaoheiheService implements PlatformService {
}
}
// 检查 cookie 中是否有登录凭证(扫码成功后 cookie 会先更新)
const cookies = await context.cookies()
const hasLoginCookie = cookies.some(cookie =>
cookie.name === 'heybox_id' ||
cookie.name === 'pkey' ||
cookie.name.includes('token')
)
console.log(`waitForQrCodeLogin: Check #${checkCount} - Has login cookie:`, hasLoginCookie)
if (hasLoginCookie) {
console.log('waitForQrCodeLogin: Login cookie detected! Login successful.')
// Cookie 的存在就是最可靠的登录凭证,不需要通过页面验证
// 直接认定登录成功
await page.close()
return {
success: true,
username: undefined
}
}
console.log(`waitForQrCodeLogin: Check #${checkCount} - Not logged in yet, waiting 2 seconds...`)
await page.waitForTimeout(2000) // 每2秒检查一次
}
+310 -26
View File
@@ -1,15 +1,22 @@
import React, { useState, useEffect, useRef } from 'react'
import { Input, Button, Typography, Space, Modal, message } from 'antd'
import { SendOutlined, CommentOutlined } from '@ant-design/icons'
import { Input, Button, Typography, Space, Modal, message, Drawer, Skeleton } from 'antd'
import { SendOutlined, CommentOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons'
const { TextArea } = Input
const { Text } = Typography
interface MessageMetadata {
type: 'article' | 'chat'
articleUrl?: string
originalUserInput?: string // 用于重新生成
}
interface Message {
id: string
role: 'user' | 'assistant'
content: string
timestamp: Date
metadata?: MessageMetadata
}
interface ModelConfig {
@@ -34,12 +41,18 @@ const Chat: React.FC = () => {
const [isLoading, setIsLoading] = useState(false)
const [currentArticleUrl, setCurrentArticleUrl] = useState<string>('')
const [lastAiResponse, setLastAiResponse] = useState<string>('')
const [isPostingComment, setIsPostingComment] = useState(false)
const [isQrModalVisible, setIsQrModalVisible] = useState(false)
const [qrCodeDataUrl, setQrCodeDataUrl] = useState<string>('')
const [qrCodeError, setQrCodeError] = useState<string>('')
const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false)
const [confirmUsername, setConfirmUsername] = useState<string>('')
const [regeneratingMessageId, setRegeneratingMessageId] = useState<string | null>(null)
const [isAccountDrawerVisible, setIsAccountDrawerVisible] = useState(false)
const [isCheckingLoginStatus, setIsCheckingLoginStatus] = useState(false)
const [xiaoheiheLoginStatus, setXiaoheiheLoginStatus] = useState<{
isLoggedIn: boolean
username?: string
}>({ isLoggedIn: false })
const messagesEndRef = useRef<HTMLDivElement>(null)
const scrollTimerRef = useRef<NodeJS.Timeout | null>(null)
const messagesContainerRef = useRef<HTMLDivElement>(null)
@@ -96,10 +109,14 @@ const Chat: React.FC = () => {
let finalContent = input
// Store article URL in a local variable for immediate use
let articleUrl = ''
// If URL detected, try to fetch article content using Playwright
if (urls && urls.length > 0) {
const url = urls[0]
setCurrentArticleUrl(url) // 保存文章 URL
articleUrl = url // 保存到局部变量以便立即使用
setCurrentArticleUrl(url) // 保存文章 URL 到状态
setLastAiResponse('') // 清空之前的 AI 回复
isArticleRequestRef.current = true // 标记这是文章请求
@@ -222,7 +239,10 @@ const Chat: React.FC = () => {
id: Date.now().toString(),
role: 'user',
content: finalContent,
timestamp: new Date()
timestamp: new Date(),
metadata: isArticleRequestRef.current
? { type: 'article', originalUserInput: input }
: { type: 'chat', originalUserInput: input }
}
setMessages((prev) => [...prev, userMessage])
@@ -235,7 +255,14 @@ const Chat: React.FC = () => {
id: assistantId,
role: 'assistant',
content: '',
timestamp: new Date()
timestamp: new Date(),
metadata: isArticleRequestRef.current
? {
type: 'article',
articleUrl: articleUrl, // 使用局部变量而不是状态变量
originalUserInput: input
}
: { type: 'chat', originalUserInput: input }
}
setMessages((prev) => [...prev, assistantMessage])
@@ -371,6 +398,130 @@ const Chat: React.FC = () => {
}
}
// 重新生成消息
const handleRegenerateMessage = async (messageId: string): Promise<void> => {
// 找到要重新生成的消息
const messageIndex = messages.findIndex((msg) => msg.id === messageId)
if (messageIndex === -1) return
const messageToRegenerate = messages[messageIndex]
if (!messageToRegenerate.metadata?.originalUserInput) {
message.error('无法重新生成:缺少原始输入')
return
}
// 找到对应的用户消息(前一条消息)
const userMessageIndex = messageIndex - 1
if (userMessageIndex < 0) return
setRegeneratingMessageId(messageId)
// 清空当前消息内容,显示加载状态
setMessages((prev) =>
prev.map((msg) => (msg.id === messageId ? { ...msg, content: '' } : msg))
)
try {
// 获取模型配置
const savedConfigs = localStorage.getItem('ai-model-configs')
const activeConfigId = localStorage.getItem('active-ai-model')
if (!savedConfigs || !activeConfigId) {
throw new Error('请先在设置中配置 AI 模型')
}
const configs = JSON.parse(savedConfigs)
const activeConfig = configs.find((c: ModelConfig) => c.id === activeConfigId)
if (!activeConfig) {
throw new Error('未找到活动的模型配置')
}
// 构建对话历史(只包含重新生成之前的消息)
const conversationHistory = messages.slice(0, userMessageIndex + 1).map((msg) => ({
role: msg.role,
content: msg.content
}))
// 调用 AI API
const response = await fetch(`${activeConfig.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${activeConfig.apiKey}`
},
body: JSON.stringify({
model: activeConfig.model,
messages: conversationHistory,
stream: true
})
})
if (!response.ok) {
throw new Error(`API 请求失败: ${response.statusText}`)
}
const reader = response.body?.getReader()
const decoder = new TextDecoder()
if (!reader) {
throw new Error('无法读取响应流')
}
let accumulatedContent = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
const lines = chunk.split('\n').filter((line) => line.trim() !== '')
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6)
if (data === '[DONE]') continue
try {
const parsed = JSON.parse(data)
const content = parsed.choices[0]?.delta?.content
if (content) {
accumulatedContent += content
setMessages((prev) =>
prev.map((msg) =>
msg.id === messageId ? { ...msg, content: accumulatedContent } : msg
)
)
}
} catch (e) {
console.error('Error parsing SSE data:', e)
}
}
}
}
// 如果是文章请求,更新 lastAiResponse
if (messageToRegenerate.metadata?.type === 'article') {
setLastAiResponse(accumulatedContent)
setCurrentArticleUrl(messageToRegenerate.metadata.articleUrl || '')
}
message.success('重新生成成功')
} catch (error) {
console.error('Regenerate error:', error)
message.error(error instanceof Error ? error.message : '重新生成失败')
// 恢复原内容
setMessages((prev) =>
prev.map((msg) =>
msg.id === messageId ? { ...msg, content: messageToRegenerate.content } : msg
)
)
} finally {
setRegeneratingMessageId(null)
}
}
const showQrCodeLogin = async (): Promise<void> => {
console.log('showQrCodeLogin called')
@@ -413,9 +564,17 @@ const Chat: React.FC = () => {
message.success(`登录成功!欢迎 ${result.username}`)
setIsQrModalVisible(false)
// 登录成功后,直接显示确认发送对话框
console.log('Showing confirm dialog with username:', result.username)
showConfirmDialog(result.username)
// 更新登录状态
setXiaoheiheLoginStatus({
isLoggedIn: true,
username: result.username
})
// 只有在有待发送评论时才显示确认对话框
if (currentArticleUrl && lastAiResponse) {
console.log('Showing confirm dialog with username:', result.username)
showConfirmDialog(result.username)
}
} else {
console.log('Login failed:', result.error)
message.error(result.error || '登录失败')
@@ -441,7 +600,6 @@ const Chat: React.FC = () => {
console.log('lastAiResponse length:', lastAiResponse?.length)
setIsConfirmModalVisible(false)
setIsPostingComment(true)
message.loading({ content: '正在发送评论...', key: 'posting', duration: 0 })
try {
@@ -467,7 +625,6 @@ const Chat: React.FC = () => {
message.error(error instanceof Error ? error.message : '发送评论时出错')
} finally {
console.log('handleConfirmOk finally block')
setIsPostingComment(false)
}
}
@@ -481,6 +638,47 @@ const Chat: React.FC = () => {
setQrCodeError('')
}
// 检查小黑盒登录状态
const checkXiaoheiheLoginStatus = async (): Promise<void> => {
setIsCheckingLoginStatus(true)
try {
// 使用快速检查方法(仅基于 cookie,不加载页面)
const result = await window.electron.ipcRenderer.invoke(
'check-platform-login-fast',
'https://www.xiaoheihe.cn/app/bbs/home'
)
if (result.success) {
setXiaoheiheLoginStatus({
isLoggedIn: result.isLoggedIn,
username: result.username
})
}
} catch (error) {
console.error('Check login status error:', error)
} finally {
setIsCheckingLoginStatus(false)
}
}
const handleLogout = async (): Promise<void> => {
try {
message.loading({ content: '正在退出登录...', key: 'logout', duration: 0 })
const result = await window.electron.ipcRenderer.invoke('logout-platform', 'xiaoheihe')
message.destroy('logout')
if (result.success) {
setXiaoheiheLoginStatus({ isLoggedIn: false })
message.success('已退出登录')
} else {
message.error(result.error || '退出登录失败')
}
} catch (error) {
message.destroy('logout')
message.error(error instanceof Error ? error.message : '退出登录失败')
}
}
const handlePostComment = async (): Promise<void> => {
console.log('handlePostComment called')
console.log('currentArticleUrl:', currentArticleUrl)
@@ -545,12 +743,25 @@ const Chat: React.FC = () => {
padding: '16px 24px',
background: '#fff',
borderBottom: '1px solid #e8e8e8',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)'
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.06)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<Text strong style={{ fontSize: '16px' }}>
AI
</Text>
<Button
icon={<SettingOutlined />}
onClick={() => {
setIsAccountDrawerVisible(true)
// 打开 Drawer 时检查登录状态
checkXiaoheiheLoginStatus()
}}
>
</Button>
</div>
{/* Messages List */}
@@ -623,6 +834,36 @@ const Chat: React.FC = () => {
minute: '2-digit'
})}
</div>
{/* AI 消息的操作按钮 */}
{message.role === 'assistant' && message.content && (
<div style={{ marginTop: '12px', display: 'flex', gap: '8px' }}>
<Button
size="small"
icon={<ReloadOutlined />}
onClick={() => handleRegenerateMessage(message.id)}
loading={regeneratingMessageId === message.id}
disabled={regeneratingMessageId !== null}
>
</Button>
{message.metadata?.type === 'article' && message.metadata.articleUrl && (
<Button
size="small"
type="primary"
icon={<CommentOutlined />}
onClick={() => {
// 更新当前文章 URL 和 AI 回复
setCurrentArticleUrl(message.metadata!.articleUrl!)
setLastAiResponse(message.content)
handlePostComment()
}}
>
</Button>
)}
</div>
)}
</div>
</div>
))}
@@ -661,20 +902,6 @@ const Chat: React.FC = () => {
</Button>
</Space.Compact>
{/* 发送评论按钮 */}
{currentArticleUrl && lastAiResponse && (
<Button
type="default"
icon={<CommentOutlined />}
onClick={handlePostComment}
disabled={isPostingComment}
loading={isPostingComment}
style={{ width: '100%' }}
>
AI
</Button>
)}
</Space>
</div>
</div>
@@ -748,6 +975,63 @@ const Chat: React.FC = () => {
</div>
</div>
</Modal>
{/* Account Management Drawer */}
<Drawer
title="账号管理"
placement="right"
onClose={() => setIsAccountDrawerVisible(false)}
open={isAccountDrawerVisible}
width={400}
>
<div>
<div style={{ marginBottom: 24 }}>
<Text strong style={{ fontSize: 16 }}>
</Text>
<div
style={{
marginTop: 12,
padding: 16,
background: '#f5f5f5',
borderRadius: 8
}}
>
{isCheckingLoginStatus ? (
<Skeleton active paragraph={{ rows: 2 }} />
) : (
<>
<div style={{ marginBottom: 8 }}>
<Text type="secondary"></Text>
<Text strong style={{ marginLeft: 8 }}>
{xiaoheiheLoginStatus.isLoggedIn ? '已登录' : '未登录'}
</Text>
</div>
{xiaoheiheLoginStatus.isLoggedIn && xiaoheiheLoginStatus.username && (
<div style={{ marginBottom: 8 }}>
<Text type="secondary"></Text>
<Text strong style={{ marginLeft: 8 }}>
{xiaoheiheLoginStatus.username}
</Text>
</div>
)}
<div style={{ marginTop: 12 }}>
{xiaoheiheLoginStatus.isLoggedIn ? (
<Button type="default" block danger onClick={handleLogout}>
退
</Button>
) : (
<Button type="primary" block onClick={showQrCodeLogin}>
</Button>
)}
</div>
</>
)}
</div>
</div>
</div>
</Drawer>
</>
)
}