feat(service-party-app): 添加应用状态检测和启动检查功能

## 新增功能

### 1. 启动检测页面 (StartupCheck)
- 应用启动时显示环境检测界面
- 检测三个核心服务: 本地数据库、消息路由、Kava API
- 检测完成后自动进入主界面 (1.5秒延迟)
- 支持查看详细错误信息
- 即使部分服务异常也可进入应用

### 2. 应用状态管理 (appStore)
- 使用 Zustand 管理全局应用状态
- 跟踪各服务的连接状态: unknown/checking/connected/disconnected/error
- 支持操作进度跟踪 (keygen/sign)
- 提供状态辅助函数: getStatusColor, getStatusText, getOverallStatus

### 3. 侧边栏状态面板
- 实时显示三个服务的连接状态
- 显示当前操作进度 (keygen/sign 时)
- 支持手动刷新检测
- 显示整体就绪状态

## 新增文件
- src/stores/appStore.ts: 应用状态管理
- src/components/StartupCheck.tsx: 启动检测组件
- src/components/StartupCheck.module.css: 启动检测样式

## 修改文件
- src/App.tsx: 集成启动检测流程
- src/components/Layout.tsx: 添加状态面板
- src/components/Layout.module.css: 状态面板样式
- src/types/electron.d.ts: 添加 metadata 字段兼容

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-28 20:33:00 -08:00
parent 6b2b9e821e
commit f5cbc855f6
7 changed files with 842 additions and 16 deletions

View File

@ -1,5 +1,7 @@
import { useState } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import Layout from './components/Layout';
import StartupCheck from './components/StartupCheck';
import Home from './pages/Home';
import Join from './pages/Join';
import Create from './pages/Create';
@ -8,6 +10,13 @@ import Sign from './pages/Sign';
import Settings from './pages/Settings';
function App() {
const [startupComplete, setStartupComplete] = useState(false);
// 显示启动检测页面
if (!startupComplete) {
return <StartupCheck onComplete={() => setStartupComplete(true)} />;
}
return (
<Layout>
<Routes>

View File

@ -78,31 +78,119 @@
border-top: 1px solid var(--border-color);
}
.connectionStatus {
/* 状态面板 */
.statusPanel {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.statusRow {
display: flex;
align-items: center;
gap: var(--spacing-sm);
font-size: 12px;
justify-content: space-between;
font-size: 11px;
}
.statusLabel {
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 4px;
}
.statusValue {
display: flex;
align-items: center;
gap: 4px;
}
.statusDot {
width: 8px;
height: 8px;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--text-disabled);
flex-shrink: 0;
}
.statusDot[data-status="connected"] {
background-color: var(--success-color);
.statusText {
color: var(--text-secondary);
font-size: 10px;
max-width: 80px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.statusDot[data-status="connecting"] {
background-color: var(--warning-color);
/* 操作进度 */
.operationPanel {
margin-top: var(--spacing-sm);
padding: var(--spacing-sm);
background-color: var(--background-color);
border-radius: var(--radius-sm);
}
.statusDot[data-status="disconnected"] {
background-color: var(--error-color);
.operationHeader {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: 11px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--spacing-xs);
}
.operationSpinner {
width: 12px;
height: 12px;
border: 2px solid var(--border-color);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.operationProgress {
width: 100%;
height: 4px;
background-color: var(--border-color);
border-radius: 2px;
overflow: hidden;
margin-bottom: var(--spacing-xs);
}
.operationProgressBar {
height: 100%;
background-color: var(--primary-color);
transition: width 0.3s ease;
}
.operationStep {
font-size: 10px;
color: var(--text-secondary);
}
/* 刷新按钮 */
.refreshButton {
background: none;
border: none;
padding: 2px;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.2s;
font-size: 12px;
}
.refreshButton:hover {
opacity: 1;
}
.refreshButton.spinning {
animation: spin 1s linear infinite;
}
.main {

View File

@ -1,5 +1,6 @@
import { ReactNode } from 'react';
import { ReactNode, useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useAppStore, getStatusColor } from '../stores/appStore';
import styles from './Layout.module.css';
interface LayoutProps {
@ -16,6 +17,26 @@ const navItems = [
export default function Layout({ children }: LayoutProps) {
const location = useLocation();
const [isRefreshing, setIsRefreshing] = useState(false);
const { environment, operation, checkAllServices, appReady } = useAppStore();
// 启动时检测环境
useEffect(() => {
checkAllServices();
}, [checkAllServices]);
const handleRefresh = async () => {
setIsRefreshing(true);
await checkAllServices();
setIsRefreshing(false);
};
const getOperationTitle = () => {
if (operation.type === 'keygen') return '密钥生成中';
if (operation.type === 'sign') return '签名进行中';
return '操作进行中';
};
return (
<div className={styles.layout}>
@ -40,10 +61,108 @@ export default function Layout({ children }: LayoutProps) {
))}
</ul>
<div className={styles.footer}>
<div className={styles.connectionStatus}>
<span className={styles.statusDot} data-status="connected" />
<span></span>
{/* 状态面板 */}
<div className={styles.statusPanel}>
{/* 消息路由 */}
<div className={styles.statusRow}>
<span className={styles.statusLabel}></span>
<span className={styles.statusValue}>
<span
className={styles.statusDot}
style={{ backgroundColor: getStatusColor(environment.messageRouter.status) }}
/>
<span className={styles.statusText} title={environment.messageRouter.message}>
{environment.messageRouter.status === 'connected'
? '已连接'
: environment.messageRouter.status === 'checking'
? '检测中...'
: environment.messageRouter.status === 'error'
? '失败'
: '未连接'}
</span>
</span>
</div>
{/* Kava API */}
<div className={styles.statusRow}>
<span className={styles.statusLabel}>Kava API</span>
<span className={styles.statusValue}>
<span
className={styles.statusDot}
style={{ backgroundColor: getStatusColor(environment.kavaApi.status) }}
/>
<span className={styles.statusText} title={environment.kavaApi.message}>
{environment.kavaApi.status === 'connected'
? '已连接'
: environment.kavaApi.status === 'checking'
? '检测中...'
: environment.kavaApi.status === 'error'
? '失败'
: '未连接'}
</span>
</span>
</div>
{/* 本地存储 */}
<div className={styles.statusRow}>
<span className={styles.statusLabel}></span>
<span className={styles.statusValue}>
<span
className={styles.statusDot}
style={{ backgroundColor: getStatusColor(environment.database.status) }}
/>
<span className={styles.statusText} title={environment.database.message}>
{environment.database.status === 'connected'
? environment.database.message
: environment.database.status === 'checking'
? '检测中...'
: environment.database.status === 'error'
? '错误'
: '未知'}
</span>
</span>
</div>
{/* 刷新按钮 */}
<div className={styles.statusRow}>
<span className={styles.statusLabel}>
{appReady === 'ready' ? '✅ 就绪' : appReady === 'error' ? '⚠️ 部分异常' : '🔄 初始化中'}
</span>
<button
className={`${styles.refreshButton} ${isRefreshing ? styles.spinning : ''}`}
onClick={handleRefresh}
disabled={isRefreshing}
title="重新检测"
>
🔄
</button>
</div>
</div>
{/* 操作进度面板 */}
{(operation.status === 'connecting' || operation.status === 'in_progress') && (
<div className={styles.operationPanel}>
<div className={styles.operationHeader}>
<span className={styles.operationSpinner} />
<span>{getOperationTitle()}</span>
</div>
{operation.currentStep !== undefined && operation.totalSteps !== undefined && (
<>
<div className={styles.operationProgress}>
<div
className={styles.operationProgressBar}
style={{
width: `${(operation.currentStep / operation.totalSteps) * 100}%`,
}}
/>
</div>
<div className={styles.operationStep}>
{operation.stepDescription || `步骤 ${operation.currentStep}/${operation.totalSteps}`}
</div>
</>
)}
</div>
)}
</div>
</nav>
<main className={styles.main}>{children}</main>

View File

@ -0,0 +1,171 @@
.container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
padding: var(--spacing-lg);
}
.card {
background-color: var(--surface-color);
border-radius: var(--radius-lg);
padding: var(--spacing-xl);
width: 100%;
max-width: 420px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.logo {
text-align: center;
margin-bottom: var(--spacing-xl);
}
.logoIcon {
font-size: 48px;
display: block;
margin-bottom: var(--spacing-md);
}
.title {
font-size: 24px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 var(--spacing-xs) 0;
}
.subtitle {
font-size: 14px;
color: var(--text-secondary);
margin: 0;
}
.checkList {
display: flex;
flex-direction: column;
gap: var(--spacing-md);
margin-bottom: var(--spacing-xl);
}
.checkItem {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-md);
background-color: var(--background-color);
border-radius: var(--radius-md);
}
.checkIcon {
font-size: 18px;
width: 24px;
text-align: center;
}
.checkLabel {
flex: 1;
font-size: 14px;
color: var(--text-primary);
}
.checkStatus {
font-size: 12px;
font-weight: 500;
}
.summary {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
padding: var(--spacing-md);
font-size: 14px;
color: var(--text-secondary);
min-height: 44px;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid var(--border-color);
border-top-color: var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.successIcon {
font-size: 20px;
}
.warningIcon {
font-size: 20px;
}
.detailsButton {
display: block;
width: 100%;
background: none;
border: none;
color: var(--primary-color);
font-size: 12px;
padding: var(--spacing-sm);
cursor: pointer;
margin-top: var(--spacing-sm);
}
.detailsButton:hover {
text-decoration: underline;
}
.details {
margin-top: var(--spacing-md);
padding: var(--spacing-md);
background-color: var(--background-color);
border-radius: var(--radius-md);
font-size: 11px;
}
.detailRow {
display: flex;
gap: var(--spacing-sm);
margin-bottom: var(--spacing-xs);
}
.detailRow:last-child {
margin-bottom: 0;
}
.detailLabel {
color: var(--text-secondary);
min-width: 70px;
}
.detailValue {
color: var(--text-primary);
word-break: break-all;
}
.enterButton {
display: block;
width: 100%;
padding: var(--spacing-md);
margin-top: var(--spacing-lg);
background-color: var(--primary-color);
color: white;
border: none;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.enterButton:hover {
background-color: var(--primary-light);
}

View File

@ -0,0 +1,172 @@
import { useEffect, useState } from 'react';
import { useAppStore, getStatusColor } from '../stores/appStore';
import styles from './StartupCheck.module.css';
interface StartupCheckProps {
onComplete: () => void;
}
export default function StartupCheck({ onComplete }: StartupCheckProps) {
const { environment, checkAllServices, appReady } = useAppStore();
const [showDetails, setShowDetails] = useState(false);
useEffect(() => {
const runCheck = async () => {
await checkAllServices();
};
runCheck();
}, [checkAllServices]);
useEffect(() => {
// 检测完成后自动进入主界面(成功或失败都进入)
if (appReady === 'ready' || appReady === 'error') {
const timer = setTimeout(() => {
onComplete();
}, 1500); // 1.5秒后自动进入
return () => clearTimeout(timer);
}
}, [appReady, onComplete]);
const getStatusIcon = (status: string) => {
switch (status) {
case 'connected':
return '✅';
case 'checking':
return '🔄';
case 'error':
return '❌';
default:
return '⏳';
}
};
return (
<div className={styles.container}>
<div className={styles.card}>
{/* Logo */}
<div className={styles.logo}>
<span className={styles.logoIcon}>🍈</span>
<h1 className={styles.title}>绿</h1>
<p className={styles.subtitle}> · </p>
</div>
{/* 状态检测 */}
<div className={styles.checkList}>
<div className={styles.checkItem}>
<span className={styles.checkIcon}>{getStatusIcon(environment.database.status)}</span>
<span className={styles.checkLabel}></span>
<span
className={styles.checkStatus}
style={{ color: getStatusColor(environment.database.status) }}
>
{environment.database.status === 'connected'
? '正常'
: environment.database.status === 'checking'
? '检测中...'
: environment.database.status === 'error'
? '异常'
: '等待检测'}
</span>
</div>
<div className={styles.checkItem}>
<span className={styles.checkIcon}>{getStatusIcon(environment.messageRouter.status)}</span>
<span className={styles.checkLabel}></span>
<span
className={styles.checkStatus}
style={{ color: getStatusColor(environment.messageRouter.status) }}
>
{environment.messageRouter.status === 'connected'
? '已连接'
: environment.messageRouter.status === 'checking'
? '检测中...'
: environment.messageRouter.status === 'error'
? '连接失败'
: '等待检测'}
</span>
</div>
<div className={styles.checkItem}>
<span className={styles.checkIcon}>{getStatusIcon(environment.kavaApi.status)}</span>
<span className={styles.checkLabel}>Kava API</span>
<span
className={styles.checkStatus}
style={{ color: getStatusColor(environment.kavaApi.status) }}
>
{environment.kavaApi.status === 'connected'
? '已连接'
: environment.kavaApi.status === 'checking'
? '检测中...'
: environment.kavaApi.status === 'error'
? '连接失败'
: '等待检测'}
</span>
</div>
</div>
{/* 总体状态 */}
<div className={styles.summary}>
{appReady === 'initializing' && (
<>
<div className={styles.spinner} />
<span>...</span>
</>
)}
{appReady === 'ready' && (
<>
<span className={styles.successIcon}></span>
<span></span>
</>
)}
{appReady === 'error' && (
<>
<span className={styles.warningIcon}></span>
<span>使</span>
</>
)}
</div>
{/* 详情按钮 */}
{(environment.messageRouter.message || environment.kavaApi.message) && (
<button
className={styles.detailsButton}
onClick={() => setShowDetails(!showDetails)}
>
{showDetails ? '隐藏详情' : '查看详情'}
</button>
)}
{/* 详细信息 */}
{showDetails && (
<div className={styles.details}>
{environment.messageRouter.message && (
<div className={styles.detailRow}>
<span className={styles.detailLabel}>:</span>
<span className={styles.detailValue}>{environment.messageRouter.message}</span>
</div>
)}
{environment.kavaApi.message && (
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Kava API:</span>
<span className={styles.detailValue}>{environment.kavaApi.message}</span>
</div>
)}
{environment.database.message && (
<div className={styles.detailRow}>
<span className={styles.detailLabel}>:</span>
<span className={styles.detailValue}>{environment.database.message}</span>
</div>
)}
</div>
)}
{/* 手动进入按钮 */}
{(appReady === 'ready' || appReady === 'error') && (
<button className={styles.enterButton} onClick={onComplete}>
</button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,263 @@
import { create } from 'zustand';
// =============================================================================
// 类型定义
// =============================================================================
export type ConnectionStatus = 'unknown' | 'checking' | 'connected' | 'disconnected' | 'error';
export type AppReadyState = 'initializing' | 'ready' | 'error';
export type OperationStatus = 'idle' | 'connecting' | 'in_progress' | 'completed' | 'failed';
export interface ServiceStatus {
status: ConnectionStatus;
message?: string;
lastChecked?: Date;
}
export interface OperationProgress {
status: OperationStatus;
type?: 'keygen' | 'sign';
sessionId?: string;
currentStep?: number;
totalSteps?: number;
stepDescription?: string;
error?: string;
}
export interface EnvironmentCheck {
messageRouter: ServiceStatus;
kavaApi: ServiceStatus;
database: ServiceStatus;
}
interface AppState {
// 应用就绪状态
appReady: AppReadyState;
appError?: string;
// 环境检测结果
environment: EnvironmentCheck;
// 当前操作进度
operation: OperationProgress;
// Actions
setAppReady: (state: AppReadyState, error?: string) => void;
setMessageRouterStatus: (status: ServiceStatus) => void;
setKavaApiStatus: (status: ServiceStatus) => void;
setDatabaseStatus: (status: ServiceStatus) => void;
setOperation: (operation: Partial<OperationProgress>) => void;
resetOperation: () => void;
checkAllServices: () => Promise<void>;
}
// =============================================================================
// Store
// =============================================================================
export const useAppStore = create<AppState>((set, get) => ({
// 初始状态
appReady: 'initializing',
appError: undefined,
environment: {
messageRouter: { status: 'unknown' },
kavaApi: { status: 'unknown' },
database: { status: 'unknown' },
},
operation: {
status: 'idle',
},
// Actions
setAppReady: (state, error) => set({ appReady: state, appError: error }),
setMessageRouterStatus: (status) =>
set((s) => ({
environment: { ...s.environment, messageRouter: status },
})),
setKavaApiStatus: (status) =>
set((s) => ({
environment: { ...s.environment, kavaApi: status },
})),
setDatabaseStatus: (status) =>
set((s) => ({
environment: { ...s.environment, database: status },
})),
setOperation: (operation) =>
set((s) => ({
operation: { ...s.operation, ...operation },
})),
resetOperation: () =>
set({
operation: { status: 'idle' },
}),
checkAllServices: async () => {
const { setMessageRouterStatus, setKavaApiStatus, setDatabaseStatus, setAppReady } = get();
// 检测 Message Router
setMessageRouterStatus({ status: 'checking', message: '正在检测...' });
try {
if (window.electronAPI) {
const settings = await window.electronAPI.storage.getSettings();
const routerUrl = settings?.messageRouterUrl || 'mpc-grpc.szaiai.com:443';
const result = await window.electronAPI.grpc.testConnection(routerUrl);
if (result.success) {
setMessageRouterStatus({
status: 'connected',
message: routerUrl,
lastChecked: new Date(),
});
} else {
setMessageRouterStatus({
status: 'error',
message: result.error || '连接失败',
lastChecked: new Date(),
});
}
} else {
setMessageRouterStatus({
status: 'disconnected',
message: '非 Electron 环境',
});
}
} catch (error) {
setMessageRouterStatus({
status: 'error',
message: (error as Error).message,
lastChecked: new Date(),
});
}
// 检测 Kava API
setKavaApiStatus({ status: 'checking', message: '正在检测...' });
try {
if (window.electronAPI) {
// 使用一个已知的测试地址查询余额来验证 API
const testAddress = 'kava1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqnxwpql';
const result = await window.electronAPI.kava.getBalance(testAddress);
if (result.success || result.error?.includes('account')) {
// 即使账户不存在API 能响应也说明连接正常
const config = await window.electronAPI.kava.getConfig();
setKavaApiStatus({
status: 'connected',
message: config?.lcdEndpoint || 'Kava Mainnet',
lastChecked: new Date(),
});
} else {
setKavaApiStatus({
status: 'error',
message: result.error || 'API 响应异常',
lastChecked: new Date(),
});
}
} else {
setKavaApiStatus({
status: 'disconnected',
message: '非 Electron 环境',
});
}
} catch (error) {
setKavaApiStatus({
status: 'error',
message: (error as Error).message,
lastChecked: new Date(),
});
}
// 检测数据库
setDatabaseStatus({ status: 'checking', message: '正在检测...' });
try {
if (window.electronAPI) {
const result = await window.electronAPI.storage.listShares();
if (result.success) {
setDatabaseStatus({
status: 'connected',
message: `${result.data?.length || 0} 个钱包`,
lastChecked: new Date(),
});
} else {
setDatabaseStatus({
status: 'error',
message: result.error || '数据库错误',
lastChecked: new Date(),
});
}
} else {
setDatabaseStatus({
status: 'disconnected',
message: '非 Electron 环境',
});
}
} catch (error) {
setDatabaseStatus({
status: 'error',
message: (error as Error).message,
lastChecked: new Date(),
});
}
// 判断整体就绪状态
const env = get().environment;
const hasError =
env.messageRouter.status === 'error' ||
env.kavaApi.status === 'error' ||
env.database.status === 'error';
if (hasError) {
setAppReady('error', '部分服务连接失败,请检查设置');
} else {
setAppReady('ready');
}
},
}));
// =============================================================================
// 辅助函数
// =============================================================================
export function getOverallStatus(env: EnvironmentCheck): ConnectionStatus {
const statuses = [env.messageRouter.status, env.kavaApi.status, env.database.status];
if (statuses.includes('checking')) return 'checking';
if (statuses.includes('error')) return 'error';
if (statuses.every((s) => s === 'connected')) return 'connected';
if (statuses.includes('disconnected')) return 'disconnected';
return 'unknown';
}
export function getStatusColor(status: ConnectionStatus): string {
switch (status) {
case 'connected':
return '#22c55e'; // green
case 'checking':
return '#f59e0b'; // yellow
case 'disconnected':
return '#6b7280'; // gray
case 'error':
return '#ef4444'; // red
default:
return '#6b7280'; // gray
}
}
export function getStatusText(status: ConnectionStatus): string {
switch (status) {
case 'connected':
return '已连接';
case 'checking':
return '检测中...';
case 'disconnected':
return '未连接';
case 'error':
return '连接失败';
default:
return '未知';
}
}

View File

@ -15,6 +15,10 @@ interface ShareEntry {
createdAt: string;
lastUsedAt?: string;
participants: Array<{ partyId: string; name: string }>;
// 兼容页面组件的 metadata 格式
metadata: {
participants: Array<{ partyId: string; name: string }>;
};
}
interface ShareWithRawData extends ShareEntry {