mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
feat: add configuration backup, sync, and version checker services
- Implemented ConfigBackupService for backing up local configuration files. - Added ConfigSyncService to download configuration files from GitHub with remote-first conflict resolution. - Created VersionChecker to check application version against the latest GitHub release with caching. - Introduced security validation utilities for input validation to prevent common vulnerabilities. - Developed utility functions to start and stop Docusaurus documentation server.
This commit is contained in:
336
ccw/frontend/src/components/shared/ConfigSync.tsx
Normal file
336
ccw/frontend/src/components/shared/ConfigSync.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
// ========================================
|
||||
// Config Sync Component
|
||||
// ========================================
|
||||
// UI for GitHub config sync, backup operations, and directory selection
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Github,
|
||||
RefreshCw,
|
||||
HardDrive,
|
||||
FolderOpen,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { ConfigSyncModal } from './ConfigSyncModal';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface ConfigSyncProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export interface BackupInfo {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
fileCount: number;
|
||||
sizeBytes: number;
|
||||
configDirs: string[];
|
||||
}
|
||||
|
||||
export interface SyncResult {
|
||||
success: boolean;
|
||||
syncedFiles: string[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface BackupResult {
|
||||
success: boolean;
|
||||
fileCount: number;
|
||||
backupPath?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
const AVAILABLE_DIRS = ['.claude', '.codex', '.gemini', '.qwen'] as const;
|
||||
const DEFAULT_REPO_URL = 'https://github.com/dyw0830/ccw';
|
||||
const DEFAULT_BRANCH = 'main';
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
export function ConfigSync({ className }: ConfigSyncProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [backingUp, setBackingUp] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const [repoUrl, setRepoUrl] = useState(DEFAULT_REPO_URL);
|
||||
const [branch, setBranch] = useState(DEFAULT_BRANCH);
|
||||
const [selectedDirs, setSelectedDirs] = useState<string[]>(['.claude']);
|
||||
const [lastSyncTime, setLastSyncTime] = useState<Date | null>(null);
|
||||
const [lastBackupTime, setLastBackupTime] = useState<Date | null>(null);
|
||||
// Backup list loaded for future display (TODO: add backup list UI)
|
||||
const [_backupList, setBackupList] = useState<BackupInfo[]>([]);
|
||||
|
||||
// Load backups on mount
|
||||
useEffect(() => {
|
||||
loadBackups();
|
||||
}, []);
|
||||
|
||||
// Parse owner and repo from URL
|
||||
const parseRepoInfo = (): { owner: string; repo: string } | null => {
|
||||
try {
|
||||
const url = new URL(repoUrl);
|
||||
const pathParts = url.pathname.split('/').filter(Boolean);
|
||||
if (pathParts.length >= 2) {
|
||||
return { owner: pathParts[0], repo: pathParts[1] };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle sync operation
|
||||
const handleSync = async () => {
|
||||
const repoInfo = parseRepoInfo();
|
||||
if (!repoInfo) {
|
||||
toast.error('Invalid GitHub repository URL');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedDirs.length === 0) {
|
||||
toast.error('Please select at least one configuration directory');
|
||||
return;
|
||||
}
|
||||
|
||||
setSyncing(true);
|
||||
try {
|
||||
const response = await fetch('/api/config/sync', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
owner: repoInfo.owner,
|
||||
repo: repoInfo.repo,
|
||||
branch,
|
||||
configDirs: selectedDirs
|
||||
})
|
||||
});
|
||||
|
||||
const data: SyncResult = await response.json();
|
||||
if (data.success) {
|
||||
setLastSyncTime(new Date());
|
||||
toast.success(`Sync completed! ${data.syncedFiles.length} file(s) updated`, {
|
||||
description: data.syncedFiles.slice(0, 3).join(', ') + (data.syncedFiles.length > 3 ? '...' : '')
|
||||
});
|
||||
} else {
|
||||
toast.error('Sync failed', {
|
||||
description: data.error || 'Unknown error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Sync failed', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle backup operation
|
||||
const handleBackup = async () => {
|
||||
if (selectedDirs.length === 0) {
|
||||
toast.error('Please select at least one configuration directory');
|
||||
return;
|
||||
}
|
||||
|
||||
setBackingUp(true);
|
||||
try {
|
||||
const response = await fetch('/api/config/backup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ configDirs: selectedDirs })
|
||||
});
|
||||
|
||||
const data: BackupResult = await response.json();
|
||||
if (data.success) {
|
||||
setLastBackupTime(new Date());
|
||||
toast.success(`Backup completed! ${data.fileCount} file(s) backed up`, {
|
||||
description: data.backupPath || 'Backup created successfully'
|
||||
});
|
||||
loadBackups();
|
||||
} else {
|
||||
toast.error('Backup failed', {
|
||||
description: data.error || 'Unknown error'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Backup failed', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
} finally {
|
||||
setBackingUp(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load available backups
|
||||
const loadBackups = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/config/backups');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setBackupList(data.data || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load backups:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle directory selection
|
||||
const toggleDir = (dir: string) => {
|
||||
setSelectedDirs((prev) =>
|
||||
prev.includes(dir) ? prev.filter((d) => d !== dir) : [...prev, dir]
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Github className="w-5 h-5 text-primary" />
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
{formatMessage({ id: 'configSync.title' }) || 'Configuration Sync & Backup'}
|
||||
</h3>
|
||||
</div>
|
||||
{(lastSyncTime || lastBackupTime) && (
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
{lastSyncTime && (
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
Last sync: {lastSyncTime.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
{lastBackupTime && (
|
||||
<span className="flex items-center gap-1">
|
||||
<HardDrive className="w-3 h-3" />
|
||||
Last backup: {lastBackupTime.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* GitHub Configuration */}
|
||||
<div className="space-y-3">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'configSync.githubRepo' }) || 'GitHub Repository'}
|
||||
</label>
|
||||
<Input
|
||||
value={repoUrl}
|
||||
onChange={(e) => setRepoUrl(e.target.value)}
|
||||
placeholder="https://github.com/owner/repo"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={branch}
|
||||
onChange={(e) => setBranch(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'configSync.branch' }) || 'Branch (e.g., main)'}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => setShowModal(true)}
|
||||
variant="outline"
|
||||
className="shrink-0"
|
||||
>
|
||||
<FolderOpen className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'configSync.selectDirs' }) || 'Select Directories'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Directories */}
|
||||
{selectedDirs.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'configSync.selectedDirs' }) || 'Selected Directories'}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedDirs.map((dir) => (
|
||||
<Badge
|
||||
key={dir}
|
||||
variant="secondary"
|
||||
className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground transition-colors"
|
||||
onClick={() => toggleDir(dir)}
|
||||
>
|
||||
{dir}
|
||||
<button className="ml-1 hover:text-white" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleDir(dir);
|
||||
}}>
|
||||
×
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button
|
||||
onClick={handleSync}
|
||||
disabled={syncing || selectedDirs.length === 0}
|
||||
className="w-full"
|
||||
>
|
||||
<RefreshCw className={cn("w-4 h-4 mr-2", syncing && "animate-spin")} />
|
||||
{syncing
|
||||
? (formatMessage({ id: 'configSync.syncing' }) || 'Syncing...')
|
||||
: (formatMessage({ id: 'configSync.syncNow' }) || 'Sync Now')
|
||||
}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleBackup}
|
||||
disabled={backingUp || selectedDirs.length === 0}
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
>
|
||||
<HardDrive className={cn("w-4 h-4 mr-2", backingUp && "animate-pulse")} />
|
||||
{backingUp
|
||||
? (formatMessage({ id: 'configSync.backingUp' }) || 'Backing up...')
|
||||
: (formatMessage({ id: 'configSync.backupNow' }) || 'Backup Now')
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Available Directories Quick Select */}
|
||||
<div className="pt-4 border-t border-border">
|
||||
<label className="text-sm font-medium text-foreground mb-2 block">
|
||||
{formatMessage({ id: 'configSync.availableDirs' }) || 'Quick Select'}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{AVAILABLE_DIRS.map((dir) => (
|
||||
<Badge
|
||||
key={dir}
|
||||
variant={selectedDirs.includes(dir) ? 'default' : 'outline'}
|
||||
className="cursor-pointer hover:bg-accent transition-colors"
|
||||
onClick={() => toggleDir(dir)}
|
||||
>
|
||||
{dir}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Directory Selection Modal */}
|
||||
{showModal && (
|
||||
<ConfigSyncModal
|
||||
selectedDirs={selectedDirs}
|
||||
onSelectedDirsChange={setSelectedDirs}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigSync;
|
||||
182
ccw/frontend/src/components/shared/ConfigSyncModal.tsx
Normal file
182
ccw/frontend/src/components/shared/ConfigSyncModal.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
// ========================================
|
||||
// Config Sync Modal Component
|
||||
// ========================================
|
||||
// Modal for selecting configuration directories to sync/backup
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/Dialog';
|
||||
import { Checkbox } from '@/components/ui/Checkbox';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import { Folder, FolderOpen } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
const AVAILABLE_DIRS = ['.claude', '.codex', '.gemini', '.qwen'] as const;
|
||||
|
||||
const DIR_DESCRIPTIONS: Record<string, string> = {
|
||||
'.claude': 'CCW global configuration and workflow settings',
|
||||
'.codex': 'Codex CLI tool configuration and history',
|
||||
'.gemini': 'Gemini CLI tool configuration and history',
|
||||
'.qwen': 'Qwen CLI tool configuration and history',
|
||||
};
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface ConfigSyncModalProps {
|
||||
selectedDirs: string[];
|
||||
onSelectedDirsChange: (dirs: string[]) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// ========== Helper Components ==========
|
||||
|
||||
interface DirectoryItemProps {
|
||||
dir: string;
|
||||
description: string;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function DirectoryItem({ dir, description, selected, onToggle }: DirectoryItemProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-3 p-3 rounded-lg border transition-colors cursor-pointer",
|
||||
"hover:bg-accent hover:border-accent-foreground/20",
|
||||
selected && "bg-accent/50 border-accent-foreground/30"
|
||||
)}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
onCheckedChange={onToggle}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{selected ? (
|
||||
<FolderOpen className="w-4 h-4 text-primary" />
|
||||
) : (
|
||||
<Folder className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
<Label htmlFor={`dir-${dir}`} className="font-medium cursor-pointer">
|
||||
{dir}
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
export function ConfigSyncModal({
|
||||
selectedDirs,
|
||||
onSelectedDirsChange,
|
||||
onClose
|
||||
}: ConfigSyncModalProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const toggleDir = (dir: string) => {
|
||||
if (selectedDirs.includes(dir)) {
|
||||
onSelectedDirsChange(selectedDirs.filter((d) => d !== dir));
|
||||
} else {
|
||||
onSelectedDirsChange([...selectedDirs, dir]);
|
||||
}
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
onSelectedDirsChange([...AVAILABLE_DIRS]);
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
onSelectedDirsChange([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<FolderOpen className="w-5 h-5" />
|
||||
{formatMessage({ id: 'configSync.modal.title' }) || 'Select Configuration Directories'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{formatMessage({ id: 'configSync.modal.description' }) ||
|
||||
'Choose which configuration directories to sync or backup'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="flex gap-2 pb-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={selectAll}
|
||||
disabled={selectedDirs.length === AVAILABLE_DIRS.length}
|
||||
className="flex-1"
|
||||
>
|
||||
{formatMessage({ id: 'configSync.modal.selectAll' }) || 'Select All'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearAll}
|
||||
disabled={selectedDirs.length === 0}
|
||||
className="flex-1"
|
||||
>
|
||||
{formatMessage({ id: 'configSync.modal.clearAll' }) || 'Clear All'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Directory List */}
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||
{AVAILABLE_DIRS.map((dir) => (
|
||||
<DirectoryItem
|
||||
key={dir}
|
||||
dir={dir}
|
||||
description={DIR_DESCRIPTIONS[dir]}
|
||||
selected={selectedDirs.includes(dir)}
|
||||
onToggle={() => toggleDir(dir)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{selectedDirs.length > 0 && (
|
||||
<div className="pt-2 border-t border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage(
|
||||
{ id: 'configSync.modal.selectedCount' },
|
||||
{ count: selectedDirs.length }
|
||||
) || `${selectedDirs.length} director${selectedDirs.length > 1 ? 'ies' : 'y'} selected`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{formatMessage({ id: 'common.actions.cancel' }) || 'Cancel'}
|
||||
</Button>
|
||||
<Button onClick={onClose}>
|
||||
{formatMessage({ id: 'common.actions.confirm' }) || 'Confirm'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigSyncModal;
|
||||
141
ccw/frontend/src/components/shared/VersionCheck.tsx
Normal file
141
ccw/frontend/src/components/shared/VersionCheck.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
import { VersionCheckModal } from './VersionCheckModal';
|
||||
|
||||
interface VersionData {
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
updateAvailable: boolean;
|
||||
}
|
||||
|
||||
interface VersionCheckResponse {
|
||||
success: boolean;
|
||||
data?: VersionData;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely parse localStorage item
|
||||
* Returns default value if parsing fails
|
||||
*/
|
||||
function safeParseLocalStorage<T>(key: string, defaultValue: T): T {
|
||||
try {
|
||||
const saved = localStorage.getItem(key);
|
||||
if (saved === null) return defaultValue;
|
||||
return JSON.parse(saved) as T;
|
||||
} catch {
|
||||
// Corrupted data, remove and return default
|
||||
localStorage.removeItem(key);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
export function VersionCheck() {
|
||||
const [versionData, setVersionData] = useState<VersionData | null>(null);
|
||||
const [autoCheck, setAutoCheck] = useState(() => {
|
||||
return safeParseLocalStorage('ccw.autoUpdate', true);
|
||||
});
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const checkVersion = async (silent = false) => {
|
||||
if (!silent) setChecking(true);
|
||||
try {
|
||||
const response = await fetch('/api/config/version');
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: VersionCheckResponse = await response.json();
|
||||
|
||||
// Validate response structure
|
||||
if (!data || typeof data !== 'object') {
|
||||
throw new Error('Invalid response format');
|
||||
}
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Version check failed');
|
||||
}
|
||||
|
||||
if (!data.data || typeof data.data !== 'object') {
|
||||
throw new Error('Invalid version data in response');
|
||||
}
|
||||
|
||||
setVersionData(data.data);
|
||||
|
||||
if (data.data.updateAvailable && !silent) {
|
||||
toast.info('新版本可用: ' + data.data.latestVersion);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Version check failed:', error);
|
||||
if (!silent) {
|
||||
toast.error('版本检查失败: ' + (error instanceof Error ? error.message : '未知错误'));
|
||||
}
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-check on mount and interval
|
||||
useEffect(() => {
|
||||
if (!autoCheck) return;
|
||||
|
||||
// Initial check after 2 seconds delay
|
||||
const timer = setTimeout(() => checkVersion(true), 2000);
|
||||
|
||||
// Periodic check every hour
|
||||
const interval = setInterval(() => checkVersion(true), 60 * 60 * 1000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [autoCheck]);
|
||||
|
||||
const toggleAutoCheck = (enabled: boolean) => {
|
||||
setAutoCheck(enabled);
|
||||
localStorage.setItem('ccw.autoUpdate', JSON.stringify(enabled));
|
||||
};
|
||||
|
||||
if (!versionData?.updateAvailable) {
|
||||
return null; // Don't show anything if no update
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Badge
|
||||
variant="warning"
|
||||
className="cursor-pointer"
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
有新版本
|
||||
</Badge>
|
||||
|
||||
<button
|
||||
onClick={() => checkVersion()}
|
||||
disabled={checking}
|
||||
className="ml-2 px-2 py-1 text-sm"
|
||||
>
|
||||
{checking ? '检查中...' : '立即检查'}
|
||||
</button>
|
||||
|
||||
<label className="ml-4 flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoCheck}
|
||||
onChange={(e) => toggleAutoCheck(e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm">自动检查</span>
|
||||
</label>
|
||||
|
||||
{showModal && (
|
||||
<VersionCheckModal
|
||||
currentVersion={versionData.currentVersion}
|
||||
latestVersion={versionData.latestVersion}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
59
ccw/frontend/src/components/shared/VersionCheckModal.tsx
Normal file
59
ccw/frontend/src/components/shared/VersionCheckModal.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
|
||||
interface VersionCheckModalProps {
|
||||
currentVersion: string;
|
||||
latestVersion: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function VersionCheckModal({ currentVersion, latestVersion, onClose }: VersionCheckModalProps) {
|
||||
return (
|
||||
<Dialog open onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新版本可用</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between">
|
||||
<span>当前版本:</span>
|
||||
<span className="font-mono">{currentVersion}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>最新版本:</span>
|
||||
<span className="font-mono text-green-600">{latestVersion}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted p-4 rounded">
|
||||
<h4 className="font-medium mb-2">更新说明</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
请访问 GitHub Releases 页面查看详细更新内容。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<a
|
||||
href="https://github.com/dyw0830/ccw/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded hover:opacity-90"
|
||||
>
|
||||
查看更新
|
||||
</a>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2 border rounded hover:bg-muted"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -146,3 +146,13 @@ export type { ExplorerToolbarProps } from './ExplorerToolbar';
|
||||
// Ticker components
|
||||
export { TickerMarquee } from './TickerMarquee';
|
||||
export type { TickerMarqueeProps } from './TickerMarquee';
|
||||
|
||||
// Version check components
|
||||
export { VersionCheck } from './VersionCheck';
|
||||
export { VersionCheckModal } from './VersionCheckModal';
|
||||
|
||||
// Config sync components
|
||||
export { ConfigSync } from './ConfigSync';
|
||||
export { ConfigSyncModal } from './ConfigSyncModal';
|
||||
export type { ConfigSyncProps, BackupInfo, SyncResult, BackupResult } from './ConfigSync';
|
||||
export type { ConfigSyncModalProps } from './ConfigSyncModal';
|
||||
|
||||
@@ -24,11 +24,12 @@ import {
|
||||
MultiKeySettingsModal,
|
||||
ManageModelsModal,
|
||||
} from '@/components/api-settings';
|
||||
import { ConfigSync } from '@/components/shared';
|
||||
import { useProviders, useEndpoints, useModelPools, useCliSettings } from '@/hooks/useApiSettings';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
|
||||
// Tab type definitions
|
||||
type TabType = 'providers' | 'endpoints' | 'cache' | 'modelPools' | 'cliSettings';
|
||||
type TabType = 'providers' | 'endpoints' | 'cache' | 'modelPools' | 'cliSettings' | 'configSync';
|
||||
|
||||
export function ApiSettingsPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
@@ -207,6 +208,7 @@ export function ApiSettingsPage() {
|
||||
{ value: 'cache', label: formatMessage({ id: 'apiSettings.tabs.cache' }) },
|
||||
{ value: 'modelPools', label: formatMessage({ id: 'apiSettings.tabs.modelPools' }) },
|
||||
{ value: 'cliSettings', label: formatMessage({ id: 'apiSettings.tabs.cliSettings' }) },
|
||||
{ value: 'configSync', label: formatMessage({ id: 'apiSettings.tabs.configSync' }) || 'Config Sync' },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -256,6 +258,12 @@ export function ApiSettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'configSync' && (
|
||||
<div className="mt-4">
|
||||
<ConfigSync />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<ProviderModal
|
||||
open={providerModalOpen}
|
||||
|
||||
Reference in New Issue
Block a user