Files
esengine/packages/editor-app/src/components/UserDashboard.tsx
YHH bce3a6e253 refactor(editor): 提取行为树编辑器为独立包并重构编辑器架构 (#216)
* refactor(editor): 提取行为树编辑器为独立包并重构编辑器架构

* feat(editor): 添加插件市场功能

* feat(editor): 重构插件市场以支持版本管理和ZIP打包

* feat(editor): 重构插件发布流程并修复React渲染警告

* fix(plugin): 修复插件发布和市场的路径不一致问题

* feat: 重构插件发布流程并添加插件删除功能

* fix(editor): 完善插件删除功能并修复多个关键问题

* fix(auth): 修复自动登录与手动登录的竞态条件问题

* feat(editor): 重构插件管理流程

* feat(editor): 支持 ZIP 文件直接发布插件

- 新增 PluginSourceParser 解析插件源
- 重构发布流程支持文件夹和 ZIP 两种方式
- 优化发布向导 UI

* feat(editor): 插件市场支持多版本安装

- 插件解压到项目 plugins 目录
- 新增 Tauri 后端安装/卸载命令
- 支持选择任意版本安装
- 修复打包逻辑,保留完整 dist 目录结构

* feat(editor): 个人中心支持多版本管理

- 合并同一插件的不同版本
- 添加版本历史展开/折叠功能
- 禁止有待审核 PR 时更新插件

* fix(editor): 修复 InspectorRegistry 服务注册

- InspectorRegistry 实现 IService 接口
- 注册到 Core.services 供插件使用

* feat(behavior-tree-editor): 完善插件注册和文件操作

- 添加文件创建模板和操作处理器
- 实现右键菜单创建行为树功能
- 修复文件读取权限问题(使用 Tauri 命令)
- 添加 BehaviorTreeEditorPanel 组件
- 修复 rollup 配置支持动态导入

* feat(plugin): 完善插件构建和发布流程

* fix(behavior-tree-editor): 完整恢复编辑器并修复 Toast 集成

* fix(behavior-tree-editor): 修复节点选中、连线跟随和文件加载问题并优化性能

* fix(behavior-tree-editor): 修复端口连接失败问题并优化连线样式

* refactor(behavior-tree-editor): 移除调试面板功能简化代码结构

* refactor(behavior-tree-editor): 清理冗余代码合并重复逻辑

* feat(behavior-tree-editor): 完善编辑器核心功能增强扩展性

* fix(lint): 修复ESLint错误确保CI通过

* refactor(behavior-tree-editor): 优化编辑器工具栏和编译器功能

* refactor(behavior-tree-editor): 清理技术债务,优化代码质量

* fix(editor-app): 修复字符串替换安全问题
2025-11-18 14:46:51 +08:00

1096 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect } from 'react';
import { X, Package, GitPullRequest, ExternalLink, RefreshCw, Trash2, CheckCircle, XCircle, AlertCircle, Clock, MessageSquare, User, Upload, Plus } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { open } from '@tauri-apps/plugin-shell';
import type { GitHubService, PublishedPlugin, PendingReview, CheckStatus, PRComment, PRReview } from '../services/GitHubService';
import { PluginUpdateDialog } from './PluginUpdateDialog';
import { PluginPublishWizard } from './PluginPublishWizard';
import '../styles/UserDashboard.css';
interface UserDashboardProps {
githubService: GitHubService;
onClose: () => void;
locale: string;
}
type Tab = 'published' | 'pending';
type PRFilter = 'all' | 'open' | 'merged' | 'closed';
export function UserDashboard({ githubService, onClose, locale }: UserDashboardProps) {
const [activeTab, setActiveTab] = useState<Tab>('published');
const [publishedPlugins, setPublishedPlugins] = useState<PublishedPlugin[]>([]);
const [pendingReviews, setPendingReviews] = useState<PendingReview[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [deletingPR, setDeletingPR] = useState<number | null>(null);
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
const [prFilter, setPRFilter] = useState<PRFilter>('open');
const [expandedComments, setExpandedComments] = useState<Set<number>>(new Set());
const [confirmDeletePlugin, setConfirmDeletePlugin] = useState<PublishedPlugin | null>(null);
const [deleteReason, setDeleteReason] = useState('');
const [deletingPlugin, setDeletingPlugin] = useState(false);
const [deleteProgress, setDeleteProgress] = useState({ message: '', progress: 0 });
const [resolvingConflicts, setResolvingConflicts] = useState<number | null>(null);
const [recreatingPR, setRecreatingPR] = useState<number | null>(null);
const [pluginToUpdate, setPluginToUpdate] = useState<PublishedPlugin | null>(null);
const [showPublishWizard, setShowPublishWizard] = useState(false);
const [expandedVersions, setExpandedVersions] = useState<Set<string>>(new Set());
const user = githubService.getUser();
const t = (key: string) => {
const translations: Record<string, Record<string, string>> = {
zh: {
title: '个人中心',
published: '已发布插件',
pending: '审核中',
refresh: '刷新',
loading: '加载中...',
error: '加载失败',
retry: '重试',
loadError: '无法加载数据',
networkError: '可能是网络连接问题,请检查您的网络设置后重试',
noPublished: '还没有发布任何插件',
noPending: '没有待审核的插件',
version: '版本',
latestVersion: '最新版本',
allVersions: '所有版本',
showVersions: '显示版本历史',
hideVersions: '隐藏版本历史',
versionCount: '{{count}} 个版本',
category: '分类',
publishedAt: '发布于',
createdAt: '提交于',
status: '状态',
viewPR: '查看PR',
viewRepo: '查看仓库',
statusOpen: '审核中',
statusMerged: '已合并',
statusClosed: '已关闭',
deletePR: '删除PR',
confirmDeleteTitle: '确认删除',
confirmDeleteMessage: '确定要关闭并删除这个 Pull Request 吗?此操作无法撤销。',
confirm: '确认',
cancel: '取消',
deleting: '删除中...',
deleteSuccess: '删除成功',
deleteFailed: '删除失败',
filterAll: '全部',
filterOpen: '进行中',
filterMerged: '已合并',
filterClosed: '已关闭',
ciChecks: 'CI检查',
ciPassing: '通过',
ciFailed: '失败',
ciPending: '等待中',
viewDetails: '查看详情',
comments: '评论',
reviews: '审查意见',
reviewApproved: '已批准',
reviewChangesRequested: '需要修改',
reviewCommented: '已评论',
noCommentsYet: '暂无评论',
showComments: '显示评论',
hideComments: '隐藏评论',
updatePlugin: '更新插件',
publishNewPlugin: '发布新插件',
cannotUpdate: '无法更新',
hasPendingPR: '该插件有待审核的 PR',
pleaseDealWithPR: '请先处理现有的 PR #{{number}},然后再提交新的更新。',
deletePlugin: '删除插件',
confirmDeletePluginTitle: '确认删除插件',
confirmDeletePluginMessage: '确定要删除插件 "{{name}}" 吗这将创建一个删除请求PR需要审核后才会从市场移除。',
deleteReasonLabel: '删除原因(必填)',
deleteReasonPlaceholder: '请说明删除此插件的原因...',
confirmDelete: '确认删除',
deletePluginError: '删除插件失败',
deletePluginSuccess: '删除请求已提交!',
openPRQuestion: '是否打开PR查看详情',
deleteConflictError: '删除时发生冲突,可能是因为分支版本不一致。',
retryDeleteQuestion: '是否重试?(将删除旧分支并重新创建)',
deletePending: '删除请求中',
conflicts: '冲突',
conflictsDetected: '检测到冲突',
conflictFilesLabel: '冲突文件',
resolveConflicts: '解决冲突',
resolving: '解决中...',
resolveConflictsSuccess: '冲突已解决!正在刷新...',
resolveConflictsFailed: '解决冲突失败',
resolveConflictsHint: '点击此按钮将主分支的最新代码合并到此PR可能会自动解决冲突',
manualResolveRequired: '此冲突需要手动解决',
manualResolveHint: '自动合并失败,存在代码冲突。',
openInGitHub: '在GitHub上解决',
manualResolveInstructions: '请点击下面的按钮在GitHub上手动解决冲突或者在本地克隆仓库后解决。',
recreatePR: '重新创建PR',
recreatePRHint: '删除当前分支并基于最新主分支重新创建删除请求(推荐)',
recreatePRConfirm: '确定要关闭当前PR并重新创建吗\n\n这将\n1. 关闭当前PR\n2. 删除冲突的分支\n3. 基于最新主分支重新创建删除请求\n\n删除原因会保留。',
recreating: '重新创建中...',
recreatePRSuccess: '已重新创建删除请求!',
recreatePRFailed: '重新创建失败'
},
en: {
title: 'User Dashboard',
published: 'Published Plugins',
pending: 'Pending Reviews',
refresh: 'Refresh',
loading: 'Loading...',
error: 'Failed to load',
retry: 'Retry',
loadError: 'Unable to load data',
networkError: 'This might be a network connection issue. Please check your network settings and try again',
noPublished: 'No published plugins yet',
noPending: 'No pending reviews',
version: 'Version',
latestVersion: 'Latest Version',
allVersions: 'All Versions',
showVersions: 'Show Version History',
hideVersions: 'Hide Version History',
versionCount: '{{count}} versions',
category: 'Category',
publishedAt: 'Published at',
createdAt: 'Submitted at',
status: 'Status',
viewPR: 'View PR',
viewRepo: 'View Repository',
statusOpen: 'Open',
statusMerged: 'Merged',
statusClosed: 'Closed',
deletePR: 'Delete PR',
confirmDeleteTitle: 'Confirm Delete',
confirmDeleteMessage: 'Are you sure you want to close and delete this Pull Request? This action cannot be undone.',
confirm: 'Confirm',
cancel: 'Cancel',
deleting: 'Deleting...',
deleteSuccess: 'Deleted successfully',
deleteFailed: 'Failed to delete',
filterAll: 'All',
filterOpen: 'Open',
filterMerged: 'Merged',
filterClosed: 'Closed',
ciChecks: 'CI Checks',
ciPassing: 'Passing',
ciFailed: 'Failed',
ciPending: 'Pending',
viewDetails: 'View Details',
comments: 'Comments',
reviews: 'Reviews',
reviewApproved: 'Approved',
reviewChangesRequested: 'Changes Requested',
reviewCommented: 'Commented',
noCommentsYet: 'No comments yet',
showComments: 'Show Comments',
hideComments: 'Hide Comments',
updatePlugin: 'Update Plugin',
publishNewPlugin: 'Publish New Plugin',
cannotUpdate: 'Cannot Update',
hasPendingPR: 'This plugin has a pending PR',
pleaseDealWithPR: 'Please handle the existing PR #{{number}} before submitting a new update.',
deletePlugin: 'Delete Plugin',
confirmDeletePluginTitle: 'Confirm Plugin Deletion',
confirmDeletePluginMessage: 'Are you sure you want to delete plugin "{{name}}"? This will create a deletion request PR that requires review before removal from marketplace.',
deleteReasonLabel: 'Reason for Deletion (Required)',
deleteReasonPlaceholder: 'Please explain why you want to delete this plugin...',
confirmDelete: 'Confirm Delete',
deletePluginError: 'Failed to delete plugin',
deletePluginSuccess: 'Deletion request submitted!',
openPRQuestion: 'Open PR to view details?',
deleteConflictError: 'Conflict occurred during deletion, possibly due to branch version mismatch.',
retryDeleteQuestion: 'Retry? (Will delete old branch and recreate)',
deletePending: 'Deletion Pending',
conflicts: 'Conflicts',
conflictsDetected: 'Conflicts Detected',
conflictFilesLabel: 'Conflict Files',
resolveConflicts: 'Resolve Conflicts',
resolving: 'Resolving...',
resolveConflictsSuccess: 'Conflicts resolved! Refreshing...',
resolveConflictsFailed: 'Failed to resolve conflicts',
resolveConflictsHint: 'Click to merge latest changes from main branch into this PR, which may automatically resolve conflicts',
manualResolveRequired: 'Manual resolution required',
manualResolveHint: 'Automatic merge failed due to code conflicts.',
openInGitHub: 'Resolve on GitHub',
manualResolveInstructions: 'Please click the button below to resolve conflicts on GitHub, or clone the repository locally.',
recreatePR: 'Recreate PR',
recreatePRHint: 'Delete current branch and recreate deletion request based on latest main branch (Recommended)',
recreatePRConfirm: 'Are you sure you want to close current PR and recreate?\n\nThis will:\n1. Close current PR\n2. Delete conflicting branch\n3. Recreate deletion request based on latest main branch\n\nDeletion reason will be preserved.',
recreating: 'Recreating...',
recreatePRSuccess: 'Deletion request recreated!',
recreatePRFailed: 'Failed to recreate'
}
};
return translations[locale]?.[key] || translations.en?.[key] || key;
};
const loadData = async () => {
setLoading(true);
setError('');
try {
const [published, pending] = await Promise.all([
githubService.getPublishedPlugins(),
githubService.getPendingReviews()
]);
setPublishedPlugins(published);
setPendingReviews(pending);
} catch (err) {
console.error('[UserDashboard] Failed to load data:', err);
setError(err instanceof Error ? err.message : 'Failed to load data');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleDateString(locale === 'zh' ? 'zh-CN' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
};
const handleUpdatePlugin = (plugin: PublishedPlugin) => {
setPluginToUpdate(plugin);
};
const getStatusBadgeClass = (status: string) => {
switch (status) {
case 'open':
return 'status-badge status-open';
case 'merged':
return 'status-badge status-merged';
case 'closed':
return 'status-badge status-closed';
default:
return 'status-badge';
}
};
const handleDeletePlugin = async () => {
if (!confirmDeletePlugin || !deleteReason.trim()) {
return;
}
setDeletingPlugin(true);
setDeleteProgress({ message: '', progress: 0 });
try {
const { PluginPublishService } = await import('../services/PluginPublishService');
const publishService = new PluginPublishService(githubService);
publishService.setProgressCallback((progress) => {
setDeleteProgress({ message: progress.message, progress: progress.progress });
});
const prUrl = await publishService.deletePlugin(
confirmDeletePlugin.id,
confirmDeletePlugin.name,
confirmDeletePlugin.category_type as 'official' | 'community',
deleteReason
);
console.log(`[UserDashboard] Delete PR created:`, prUrl);
setConfirmDeletePlugin(null);
setDeleteReason('');
await loadData();
if (confirm(t('deletePluginSuccess') + '\n\n' + t('openPRQuestion'))) {
open(prUrl);
}
} catch (err) {
console.error('[UserDashboard] Failed to delete plugin:', err);
const errorMsg = err instanceof Error ? err.message : String(err);
if (errorMsg.includes('conflicts') || errorMsg.includes('does not match')) {
if (confirm(t('deleteConflictError') + '\n\n' + t('retryDeleteQuestion'))) {
setDeletingPlugin(false);
setDeleteProgress({ message: '', progress: 0 });
setTimeout(async () => {
setDeletingPlugin(true);
try {
const { PluginPublishService } = await import('../services/PluginPublishService');
const publishService = new PluginPublishService(githubService);
publishService.setProgressCallback((progress) => {
setDeleteProgress({ message: progress.message, progress: progress.progress });
});
const prUrl = await publishService.deletePlugin(
confirmDeletePlugin.id,
confirmDeletePlugin.name,
confirmDeletePlugin.category_type as 'official' | 'community',
deleteReason,
true
);
setConfirmDeletePlugin(null);
setDeleteReason('');
await loadData();
if (confirm(t('deletePluginSuccess') + '\n\n' + t('openPRQuestion'))) {
open(prUrl);
}
} catch (retryErr) {
console.error('[UserDashboard] Retry failed:', retryErr);
alert(t('deletePluginError') + ': ' + (retryErr instanceof Error ? retryErr.message : String(retryErr)));
} finally {
setDeletingPlugin(false);
}
}, 100);
return;
}
}
alert(t('deletePluginError') + ': ' + errorMsg);
} finally {
setDeletingPlugin(false);
}
};
const handleDeletePR = async (prNumber: number) => {
setDeletingPR(prNumber);
try {
await githubService.closePullRequest('esengine', 'ecs-editor-plugins', prNumber);
console.log(`[UserDashboard] Successfully closed PR #${prNumber}`);
await loadData();
setConfirmDelete(null);
} catch (err) {
console.error('[UserDashboard] Failed to close PR:', err);
setError(t('deleteFailed') + ': ' + (err instanceof Error ? err.message : String(err)));
} finally {
setDeletingPR(null);
}
};
const handleResolveConflicts = async (review: PendingReview) => {
setResolvingConflicts(review.prNumber);
try {
await githubService.updatePRBranch('esengine', 'ecs-editor-plugins', review.prNumber);
console.log(`[UserDashboard] Successfully resolved conflicts for PR #${review.prNumber}`);
alert(t('resolveConflictsSuccess'));
await loadData();
} catch (err) {
console.error('[UserDashboard] Failed to resolve conflicts:', err);
const errorMsg = err instanceof Error ? err.message : String(err);
if (errorMsg.includes('422') || errorMsg.includes('merge conflict')) {
const message = `${t('manualResolveRequired')}\n\n${t('manualResolveHint')}\n${t('manualResolveInstructions')}\n\n${t('openInGitHub')}?`;
if (confirm(message)) {
open(review.prUrl);
}
} else {
alert(t('resolveConflictsFailed') + ': ' + errorMsg);
}
} finally {
setResolvingConflicts(null);
}
};
const handleRecreatePR = async (review: PendingReview) => {
if (!confirm(t('recreatePRConfirm'))) {
return;
}
const removeMatch = review.pluginName.match(/^(.+)$/);
if (!removeMatch) {
alert(t('recreatePRFailed') + ': Cannot extract plugin name');
return;
}
const pluginName = removeMatch[0];
const plugin = publishedPlugins.find(p => p.name === pluginName);
if (!plugin) {
alert(t('recreatePRFailed') + ': Plugin not found in published list');
return;
}
setRecreatingPR(review.prNumber);
try {
await githubService.closePullRequest('esengine', 'ecs-editor-plugins', review.prNumber);
console.log(`[UserDashboard] Closed PR #${review.prNumber}`);
if (review.headBranch && review.headRepo) {
const [repoOwner, repoName] = review.headRepo.split('/');
if (repoOwner && repoName) {
try {
await githubService.deleteBranch(repoOwner, repoName, review.headBranch);
console.log(`[UserDashboard] Deleted branch ${review.headBranch}`);
} catch (err) {
console.warn('[UserDashboard] Failed to delete branch:', err);
}
}
}
const { PluginPublishService } = await import('../services/PluginPublishService');
const publishService = new PluginPublishService(githubService);
const deleteReason = 'Recreated due to conflicts';
const prUrl = await publishService.deletePlugin(
plugin.id,
plugin.name,
plugin.category_type as 'official' | 'community',
deleteReason,
true
);
console.log(`[UserDashboard] Recreated delete PR:`, prUrl);
alert(t('recreatePRSuccess'));
await loadData();
if (confirm(t('openPRQuestion'))) {
open(prUrl);
}
} catch (err) {
console.error('[UserDashboard] Failed to recreate PR:', err);
alert(t('recreatePRFailed') + ': ' + (err instanceof Error ? err.message : String(err)));
} finally {
setRecreatingPR(null);
}
};
const getCheckIcon = (check: CheckStatus) => {
if (!check.conclusion || check.conclusion === 'pending') {
return <Clock size={16} className="check-icon-pending" />;
}
if (check.conclusion === 'success') {
return <CheckCircle size={16} className="check-icon-success" />;
}
return <XCircle size={16} className="check-icon-failure" />;
};
const getCheckClassName = (check: CheckStatus) => {
if (!check.conclusion || check.conclusion === 'pending') {
return 'check-status check-pending';
}
if (check.conclusion === 'success') {
return 'check-status check-success';
}
return 'check-status check-failure';
};
const getFilteredReviews = () => {
if (prFilter === 'all') {
return pendingReviews;
}
return pendingReviews.filter(review => review.status === prFilter);
};
const handleLinkClick = (href: string) => (e: React.MouseEvent) => {
e.preventDefault();
open(href).catch(err => {
console.error('[UserDashboard] Failed to open link:', err);
});
};
const MarkdownLink = ({ href, children }: { href?: string; children: React.ReactNode }) => {
if (!href) return <a>{children}</a>;
return (
<a href={href} onClick={handleLinkClick(href)} className="markdown-link">
{children}
</a>
);
};
const toggleComments = (prNumber: number) => {
const newExpanded = new Set(expandedComments);
if (newExpanded.has(prNumber)) {
newExpanded.delete(prNumber);
} else {
newExpanded.add(prNumber);
}
setExpandedComments(newExpanded);
};
const getReviewStateLabel = (state: string) => {
switch (state) {
case 'APPROVED':
return t('reviewApproved');
case 'CHANGES_REQUESTED':
return t('reviewChangesRequested');
case 'COMMENTED':
return t('reviewCommented');
default:
return state;
}
};
const getReviewStateClass = (state: string) => {
switch (state) {
case 'APPROVED':
return 'review-state-approved';
case 'CHANGES_REQUESTED':
return 'review-state-changes';
case 'COMMENTED':
return 'review-state-commented';
default:
return '';
}
};
const renderPublishedPlugins = () => {
if (loading) {
return <div className="dashboard-loading">{t('loading')}</div>;
}
if (error) {
return (
<div className="dashboard-error-container">
<AlertCircle size={48} className="error-icon" />
<h3>{t('loadError')}</h3>
<p className="error-description">{t('networkError')}</p>
<div className="error-details">
<p className="error-message">{error}</p>
</div>
<button className="retry-button" onClick={loadData}>
<RefreshCw size={16} />
{t('retry')}
</button>
</div>
);
}
if (publishedPlugins.length === 0) {
return <div className="dashboard-empty">{t('noPublished')}</div>;
}
return (
<div className="plugin-list">
{publishedPlugins.map((plugin) => {
const isExpanded = expandedVersions.has(plugin.id);
const hasMultipleVersions = plugin.versions.length > 1;
const pendingPR = pendingReviews.find(pr => pr.pluginName === plugin.name && pr.status === 'open');
return (
<div key={plugin.id} className="plugin-card">
<div className="plugin-header">
<Package size={20} />
<div className="plugin-info">
<h3 className="plugin-name">{plugin.name}</h3>
<p className="plugin-description">{plugin.description}</p>
</div>
</div>
<div className="plugin-meta">
<span className="plugin-meta-item">
{t('latestVersion')}: <strong>{plugin.latestVersion}</strong>
{hasMultipleVersions && (
<button
className="btn-version-toggle"
onClick={() => {
const newExpanded = new Set(expandedVersions);
if (isExpanded) {
newExpanded.delete(plugin.id);
} else {
newExpanded.add(plugin.id);
}
setExpandedVersions(newExpanded);
}}
title={isExpanded ? t('hideVersions') : t('showVersions')}
>
({t('versionCount').replace('{{count}}', String(plugin.versions.length))})
</button>
)}
</span>
<span className="plugin-meta-item">
{t('category')}: <strong>{plugin.category}</strong>
</span>
<span className="plugin-meta-item">
{t('publishedAt')}: {formatDate(plugin.versions[0]?.publishedAt || '')}
</span>
</div>
{/* 版本历史列表 */}
{isExpanded && hasMultipleVersions && (
<div className="version-history-list">
<h4>{t('allVersions')}</h4>
<div className="versions-container">
{plugin.versions.map((version) => (
<div key={version.version} className="version-item">
<span className="version-number">v{version.version}</span>
<span className="version-date">{formatDate(version.publishedAt)}</span>
<a
href={version.prUrl}
onClick={(e) => {
e.preventDefault();
open(version.prUrl);
}}
className="version-pr-link"
title={t('viewPR')}
>
<ExternalLink size={14} />
</a>
</div>
))}
</div>
</div>
)}
<div className="plugin-actions">
{pendingPR && (
<div className="pending-pr-badge" title={t('pleaseDealWithPR').replace('{{number}}', String(pendingPR.prNumber))}>
<AlertCircle size={14} />
<span>PR #{pendingPR.prNumber} {t('statusOpen')}</span>
</div>
)}
<button
className="btn-update"
onClick={() => handleUpdatePlugin(plugin)}
disabled={!!pendingPR}
title={pendingPR
? t('pleaseDealWithPR').replace('{{number}}', String(pendingPR.prNumber))
: t('updatePlugin')
}
>
<Upload size={14} />
{t('updatePlugin')}
</button>
{plugin.repositoryUrl && (
<a
href={plugin.repositoryUrl}
target="_blank"
rel="noopener noreferrer"
className="plugin-link"
>
{t('viewRepo')} <ExternalLink size={14} />
</a>
)}
{plugin.versions[0]?.prUrl && (
<a
href={plugin.versions[0].prUrl}
target="_blank"
rel="noopener noreferrer"
className="plugin-link"
>
{t('viewPR')} <ExternalLink size={14} />
</a>
)}
<button
className="btn-delete"
onClick={() => setConfirmDeletePlugin(plugin)}
title={t('deletePlugin')}
>
<Trash2 size={14} />
{t('deletePlugin')}
</button>
</div>
</div>
);
})}
</div>
);
};
const renderPendingReviews = () => {
if (loading) {
return <div className="dashboard-loading">{t('loading')}</div>;
}
if (error) {
return (
<div className="dashboard-error-container">
<AlertCircle size={48} className="error-icon" />
<h3>{t('loadError')}</h3>
<p className="error-description">{t('networkError')}</p>
<div className="error-details">
<p className="error-message">{error}</p>
</div>
<button className="retry-button" onClick={loadData}>
<RefreshCw size={16} />
{t('retry')}
</button>
</div>
);
}
const filteredReviews = getFilteredReviews();
if (filteredReviews.length === 0) {
return <div className="dashboard-empty">{t('noPending')}</div>;
}
return (
<>
<div className="review-filters">
<button
className={`filter-btn ${prFilter === 'all' ? 'active' : ''}`}
onClick={() => setPRFilter('all')}
>
{t('filterAll')} ({pendingReviews.length})
</button>
<button
className={`filter-btn ${prFilter === 'open' ? 'active' : ''}`}
onClick={() => setPRFilter('open')}
>
{t('filterOpen')} ({pendingReviews.filter(r => r.status === 'open').length})
</button>
<button
className={`filter-btn ${prFilter === 'merged' ? 'active' : ''}`}
onClick={() => setPRFilter('merged')}
>
{t('filterMerged')} ({pendingReviews.filter(r => r.status === 'merged').length})
</button>
<button
className={`filter-btn ${prFilter === 'closed' ? 'active' : ''}`}
onClick={() => setPRFilter('closed')}
>
{t('filterClosed')} ({pendingReviews.filter(r => r.status === 'closed').length})
</button>
</div>
<div className="review-list">
{filteredReviews.map((review) => (
<div key={review.prNumber} className="review-card">
<div className="review-header">
<GitPullRequest size={20} />
<div className="review-info">
<h3 className="review-name">
{review.pluginName} <span className="review-version">v{review.version}</span>
</h3>
<span className={getStatusBadgeClass(review.status)}>
{t(`status${review.status.charAt(0).toUpperCase()}${review.status.slice(1)}`)}
</span>
</div>
</div>
<div className="review-meta">
<span className="review-meta-item">
PR #{review.prNumber}
</span>
<span className="review-meta-item">
{t('createdAt')}: {formatDate(review.createdAt)}
</span>
</div>
{review.hasConflicts && (
<div className="review-conflicts">
<div className="conflicts-header">
<AlertCircle size={16} className="conflicts-icon" />
{t('conflictsDetected')}
</div>
{review.conflictFiles && review.conflictFiles.length > 0 && (
<div className="conflicts-files">
<div className="conflicts-files-label">{t('conflictFilesLabel')}:</div>
<ul className="conflicts-files-list">
{review.conflictFiles.map((file, index) => (
<li key={index} className="conflict-file-item">{file}</li>
))}
</ul>
</div>
)}
<div className="conflicts-actions">
<button
className="resolve-conflicts-btn"
onClick={() => handleResolveConflicts(review)}
disabled={resolvingConflicts === review.prNumber || recreatingPR === review.prNumber}
title={t('resolveConflictsHint')}
>
{resolvingConflicts === review.prNumber ? t('resolving') : t('resolveConflicts')}
</button>
<button
className="recreate-pr-btn"
onClick={() => handleRecreatePR(review)}
disabled={recreatingPR === review.prNumber || resolvingConflicts === review.prNumber}
title={t('recreatePRHint')}
>
{recreatingPR === review.prNumber ? t('recreating') : t('recreatePR')}
</button>
<button
className="open-github-btn"
onClick={handleLinkClick(review.prUrl)}
title={t('manualResolveInstructions')}
disabled={recreatingPR === review.prNumber || resolvingConflicts === review.prNumber}
>
<ExternalLink size={14} />
{t('openInGitHub')}
</button>
</div>
</div>
)}
{review.checks && review.checks.length > 0 && (
<div className="review-checks">
<div className="checks-header">{t('ciChecks')}:</div>
<div className="checks-list">
{review.checks.map((check, index) => (
<div key={index} className={getCheckClassName(check)}>
{getCheckIcon(check)}
<span className="check-name">{check.name}</span>
{check.detailsUrl && (
<a
href={check.detailsUrl}
target="_blank"
rel="noopener noreferrer"
className="check-details-link"
>
{t('viewDetails')}
</a>
)}
</div>
))}
</div>
</div>
)}
{((review.reviews && review.reviews.length > 0) || (review.comments && review.comments.length > 0)) && (
<div className="review-feedback">
<button
className="feedback-toggle-btn"
onClick={() => toggleComments(review.prNumber)}
>
<MessageSquare size={14} />
{expandedComments.has(review.prNumber) ? t('hideComments') : t('showComments')}
{' '}({(review.reviews?.length || 0) + (review.comments?.length || 0)})
</button>
{expandedComments.has(review.prNumber) && (
<div className="feedback-content">
{review.reviews && review.reviews.length > 0 && (
<div className="reviews-section">
<div className="section-header">{t('reviews')}:</div>
{review.reviews.map((reviewItem) => (
<div key={reviewItem.id} className="review-item">
<div className="review-item-header">
<img
src={reviewItem.user.avatar_url}
alt={reviewItem.user.login}
className="reviewer-avatar"
/>
<div className="reviewer-info">
<span className="reviewer-name">{reviewItem.user.login}</span>
<span className={`review-state-badge ${getReviewStateClass(reviewItem.state)}`}>
{getReviewStateLabel(reviewItem.state)}
</span>
</div>
<span className="review-date">
{formatDate(reviewItem.submitted_at)}
</span>
</div>
<div className="review-item-body markdown-content">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
a: MarkdownLink as any
}}
>
{reviewItem.body}
</ReactMarkdown>
</div>
</div>
))}
</div>
)}
{review.comments && review.comments.length > 0 && (
<div className="comments-section">
<div className="section-header">{t('comments')}:</div>
{review.comments.map((comment) => (
<div key={comment.id} className="comment-item">
<div className="comment-item-header">
<img
src={comment.user.avatar_url}
alt={comment.user.login}
className="commenter-avatar"
/>
<div className="commenter-info">
<span className="commenter-name">{comment.user.login}</span>
<span className="comment-date">
{formatDate(comment.created_at)}
</span>
</div>
</div>
<div className="comment-item-body markdown-content">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw]}
components={{
a: MarkdownLink as any
}}
>
{comment.body}
</ReactMarkdown>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
<div className="review-actions">
<a href={review.prUrl} target="_blank" rel="noopener noreferrer" className="review-link">
{t('viewPR')} <ExternalLink size={14} />
</a>
{review.status === 'open' && (
<button
className="review-delete-btn"
onClick={() => setConfirmDelete(review.prNumber)}
disabled={deletingPR === review.prNumber}
title={t('deletePR')}
>
<Trash2 size={14} />
{deletingPR === review.prNumber ? t('deleting') : t('deletePR')}
</button>
)}
</div>
</div>
))}
</div>
</>
);
};
return (
<div className="user-dashboard-overlay">
<div className="user-dashboard">
<div className="dashboard-header">
<h2 className="dashboard-title">{t('title')}</h2>
<div className="dashboard-header-actions">
<button className="dashboard-publish-btn" onClick={() => setShowPublishWizard(true)}>
<Plus size={16} />
{t('publishNewPlugin')}
</button>
<button className="dashboard-refresh-btn" onClick={loadData} disabled={loading}>
<RefreshCw size={16} className={loading ? 'spinning' : ''} />
{t('refresh')}
</button>
<button className="dashboard-close-btn" onClick={onClose}>
<X size={20} />
</button>
</div>
</div>
{user && (
<div className="dashboard-user-info">
<img src={user.avatar_url} alt={user.name} className="dashboard-user-avatar" />
<div className="dashboard-user-details">
<div className="dashboard-user-name">{user.name || user.login}</div>
<div className="dashboard-user-login">@{user.login}</div>
</div>
</div>
)}
<div className="dashboard-tabs">
<button
className={`dashboard-tab ${activeTab === 'published' ? 'active' : ''}`}
onClick={() => setActiveTab('published')}
>
<Package size={16} />
{t('published')} ({publishedPlugins.length})
</button>
<button
className={`dashboard-tab ${activeTab === 'pending' ? 'active' : ''}`}
onClick={() => setActiveTab('pending')}
>
<GitPullRequest size={16} />
{t('pending')} ({pendingReviews.filter((r) => r.status === 'open').length})
</button>
</div>
<div className="dashboard-content">
{activeTab === 'published' ? renderPublishedPlugins() : renderPendingReviews()}
</div>
{confirmDelete && (
<div className="confirm-dialog-overlay">
<div className="confirm-dialog">
<h3>{t('confirmDeleteTitle')}</h3>
<p>{t('confirmDeleteMessage')}</p>
<div className="confirm-dialog-actions">
<button
className="confirm-dialog-cancel"
onClick={() => setConfirmDelete(null)}
disabled={deletingPR !== null}
>
{t('cancel')}
</button>
<button
className="confirm-dialog-confirm"
onClick={() => handleDeletePR(confirmDelete)}
disabled={deletingPR !== null}
>
{deletingPR === confirmDelete ? t('deleting') : t('confirm')}
</button>
</div>
</div>
</div>
)}
{confirmDeletePlugin && (
<div className="confirm-dialog-overlay">
<div className="confirm-dialog">
<h3>{t('confirmDeletePluginTitle')}</h3>
<p>{t('confirmDeletePluginMessage').replace('{{name}}', confirmDeletePlugin.name)}</p>
<div className="confirm-dialog-input-group">
<label htmlFor="delete-reason">{t('deleteReasonLabel')}</label>
<textarea
id="delete-reason"
className="confirm-dialog-textarea"
value={deleteReason}
onChange={(e) => setDeleteReason(e.target.value)}
placeholder={t('deleteReasonPlaceholder')}
rows={4}
disabled={deletingPlugin}
/>
</div>
{deletingPlugin && (
<div className="confirm-dialog-progress">
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${deleteProgress.progress}%` }}></div>
</div>
<p className="progress-message">{deleteProgress.message}</p>
</div>
)}
<div className="confirm-dialog-actions">
<button
className="confirm-dialog-cancel"
onClick={() => {
setConfirmDeletePlugin(null);
setDeleteReason('');
}}
disabled={deletingPlugin}
>
{t('cancel')}
</button>
<button
className="confirm-dialog-confirm confirm-dialog-danger"
onClick={handleDeletePlugin}
disabled={deletingPlugin || !deleteReason.trim()}
>
{deletingPlugin ? t('deleting') : t('confirmDelete')}
</button>
</div>
</div>
</div>
)}
{pluginToUpdate && (
<PluginUpdateDialog
plugin={pluginToUpdate}
githubService={githubService}
onClose={() => setPluginToUpdate(null)}
onSuccess={() => {
loadData();
}}
locale={locale}
/>
)}
{showPublishWizard && (
<PluginPublishWizard
githubService={githubService}
onClose={() => setShowPublishWizard(false)}
locale={locale}
inline={false}
/>
)}
</div>
</div>
);
}