Compare commits

...

5 Commits

Author SHA1 Message Date
yhh
566e1977fd refactor: 改用 Imgur 图床上传图片 2025-12-04 09:56:10 +08:00
yhh
17f6259f43 chore: 删除测试图片 2025-12-04 09:54:46 +08:00
yhh
5d3483fc65 chore: 更新 pnpm-lock.yaml 2025-12-04 09:47:12 +08:00
yhh
d07a5d81fc Merge branch 'master' into feat/github-forum 2025-12-04 09:46:22 +08:00
yhh
6a4e6fbc04 feat(editor): 添加 GitHub Discussions 社区论坛功能 2025-12-04 09:45:16 +08:00
21 changed files with 5234 additions and 13 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

View File

@@ -18,30 +18,30 @@
"dependencies": {
"@esengine/asset-system": "workspace:*",
"@esengine/asset-system-editor": "workspace:*",
"@esengine/audio": "workspace:*",
"@esengine/behavior-tree": "workspace:*",
"@esengine/material-system": "workspace:*",
"@esengine/material-editor": "workspace:*",
"@esengine/behavior-tree-editor": "workspace:*",
"@esengine/blueprint": "workspace:*",
"@esengine/blueprint-editor": "workspace:*",
"@esengine/editor-runtime": "workspace:*",
"@esengine/engine-core": "workspace:*",
"@esengine/sprite": "workspace:*",
"@esengine/sprite-editor": "workspace:*",
"@esengine/shader-editor": "workspace:*",
"@esengine/camera": "workspace:*",
"@esengine/audio": "workspace:*",
"@esengine/ecs-engine-bindgen": "workspace:*",
"@esengine/ecs-framework": "workspace:*",
"@esengine/editor-core": "workspace:*",
"@esengine/editor-runtime": "workspace:*",
"@esengine/engine": "workspace:*",
"@esengine/engine-core": "workspace:*",
"@esengine/material-editor": "workspace:*",
"@esengine/material-system": "workspace:*",
"@esengine/physics-rapier2d": "workspace:*",
"@esengine/physics-rapier2d-editor": "workspace:*",
"@esengine/runtime-core": "workspace:*",
"@esengine/shader-editor": "workspace:*",
"@esengine/sprite": "workspace:*",
"@esengine/sprite-editor": "workspace:*",
"@esengine/tilemap": "workspace:*",
"@esengine/tilemap-editor": "workspace:*",
"@esengine/ui": "workspace:*",
"@esengine/ui-editor": "workspace:*",
"@esengine/ecs-engine-bindgen": "workspace:*",
"@esengine/ecs-framework": "workspace:*",
"@esengine/editor-core": "workspace:*",
"@esengine/engine": "workspace:*",
"@esengine/runtime-core": "workspace:*",
"@monaco-editor/react": "^4.7.0",
"@tauri-apps/api": "^2.2.0",
"@tauri-apps/plugin-cli": "^2.4.1",

View File

@@ -44,6 +44,7 @@ import { ErrorDialog } from './components/ErrorDialog';
import { ConfirmDialog } from './components/ConfirmDialog';
import { PluginGeneratorWindow } from './components/PluginGeneratorWindow';
import { BuildSettingsWindow } from './components/BuildSettingsWindow';
import { ForumPanel } from './components/forum';
import { ToastProvider, useToast } from './components/Toast';
import { TitleBar } from './components/TitleBar';
import { MainToolbar } from './components/MainToolbar';
@@ -733,6 +734,12 @@ function App() {
title: locale === 'zh' ? '检视器' : 'Inspector',
content: <Inspector entityStore={entityStore} messageHub={messageHub} inspectorRegistry={inspectorRegistry!} projectPath={currentProjectPath} commandManager={commandManager} />,
closable: false
},
{
id: 'forum',
title: locale === 'zh' ? '社区论坛' : 'Forum',
content: <ForumPanel />,
closable: true
}
];

View File

@@ -0,0 +1,255 @@
/**
* 论坛认证样式 - GitHub Device Flow
* Forum auth styles - GitHub Device Flow
*/
.forum-auth {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 20px;
background: #2a2a2a;
}
.forum-auth-card {
width: 100%;
max-width: 360px;
padding: 28px;
background: #333;
border-radius: 6px;
border: 1px solid #444;
text-align: center;
}
.forum-auth-header {
margin-bottom: 24px;
}
.forum-auth-icon {
color: #e0e0e0;
margin-bottom: 12px;
}
.forum-auth-header h2 {
font-size: 16px;
font-weight: 600;
margin: 0 0 8px 0;
color: #e0e0e0;
}
.forum-auth-header p {
font-size: 12px;
color: #888;
margin: 0;
}
/* 初始状态 | Idle state */
.forum-auth-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.forum-auth-instructions {
background: #2a2a2a;
padding: 12px;
border-radius: 4px;
border-left: 3px solid #4a9eff;
text-align: left;
}
.forum-auth-instructions p {
margin: 6px 0;
font-size: 12px;
color: #999;
line-height: 1.5;
}
.forum-auth-instructions p:first-child {
margin-top: 0;
}
.forum-auth-instructions p:last-child {
margin-bottom: 0;
}
.forum-auth-github-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 12px;
font-size: 13px;
font-weight: 500;
background: #24292e;
border: 1px solid #444;
color: #e0e0e0;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
}
.forum-auth-github-btn:hover {
background: #2f363d;
border-color: #555;
}
/* 等待授权 | Pending state */
.forum-auth-pending {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.forum-auth-pending-text {
font-size: 13px;
color: #999;
margin: 0;
}
.forum-auth-code-section {
width: 100%;
display: flex;
flex-direction: column;
gap: 10px;
}
.forum-auth-code-section label {
font-size: 11px;
color: #888;
}
.forum-auth-code-box {
display: flex;
align-items: center;
gap: 8px;
background: #2a2a2a;
padding: 12px 16px;
border-radius: 4px;
border: 1px solid #444;
}
.forum-auth-code {
flex: 1;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 18px;
font-weight: bold;
color: #4a9eff;
letter-spacing: 2px;
}
.forum-auth-copy-btn {
background: none;
border: none;
cursor: pointer;
font-size: 16px;
padding: 4px 8px;
transition: transform 0.1s ease;
}
.forum-auth-copy-btn:hover {
transform: scale(1.1);
}
.forum-auth-copy-btn:active {
transform: scale(0.95);
}
.forum-auth-link-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px;
font-size: 12px;
background: none;
border: none;
color: #4a9eff;
cursor: pointer;
transition: color 0.15s ease;
}
.forum-auth-link-btn:hover {
color: #3a8eef;
text-decoration: underline;
}
/* 授权成功 | Success state */
.forum-auth-success {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px 0;
}
.forum-auth-success-icon {
color: #4ade80;
}
.forum-auth-success p {
font-size: 14px;
color: #4ade80;
margin: 0;
}
/* 授权失败 | Error state */
.forum-auth-error-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px 0;
}
.forum-auth-error-icon {
color: #f87171;
}
.forum-auth-error-state > p {
font-size: 14px;
color: #f87171;
margin: 0;
}
.forum-auth-error-detail {
font-size: 11px;
color: #888;
background: #2a2a2a;
padding: 8px 12px;
border-radius: 4px;
max-width: 100%;
word-break: break-word;
}
.forum-auth-retry-btn {
padding: 8px 20px;
font-size: 12px;
background: #3a3a3a;
border: 1px solid #4a4a4a;
color: #ccc;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
margin-top: 8px;
}
.forum-auth-retry-btn:hover {
background: #444;
border-color: #555;
color: #fff;
}
/* 加载动画 | Loading animation */
.spinning {
animation: spin 1s linear infinite;
color: #4a9eff;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

View File

@@ -0,0 +1,161 @@
/**
* 论坛登录组件 - 使用 GitHub Device Flow
* Forum auth component - using GitHub Device Flow
*/
import { AlertCircle, CheckCircle, ExternalLink, Github, Loader } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { open } from '@tauri-apps/plugin-shell';
import { useForumAuth } from '../../hooks/useForum';
import './ForumAuth.css';
type AuthStatus = 'idle' | 'pending' | 'authorized' | 'error';
export function ForumAuth() {
const { i18n } = useTranslation();
const { requestDeviceCode, authenticateWithDeviceFlow, signInWithGitHubToken } = useForumAuth();
const [authStatus, setAuthStatus] = useState<AuthStatus>('idle');
const [userCode, setUserCode] = useState('');
const [verificationUri, setVerificationUri] = useState('');
const [error, setError] = useState<string | null>(null);
const isEnglish = i18n.language === 'en';
const handleGitHubLogin = async () => {
setAuthStatus('pending');
setError(null);
try {
// 请求 Device Code | Request Device Code
const deviceCodeResp = await requestDeviceCode();
setUserCode(deviceCodeResp.user_code);
setVerificationUri(deviceCodeResp.verification_uri);
// 打开浏览器 | Open browser
await open(deviceCodeResp.verification_uri);
// 轮询等待授权 | Poll for authorization
const accessToken = await authenticateWithDeviceFlow(
deviceCodeResp.device_code,
deviceCodeResp.interval,
(status) => {
if (status === 'authorized') {
setAuthStatus('authorized');
} else if (status === 'error') {
setAuthStatus('error');
}
}
);
// 使用 token 登录 Supabase | Sign in to Supabase with token
const { error: signInError } = await signInWithGitHubToken(accessToken);
if (signInError) {
throw signInError;
}
setAuthStatus('authorized');
} catch (err) {
console.error('[ForumAuth] GitHub login failed:', err);
setAuthStatus('error');
setError(err instanceof Error ? err.message : (isEnglish ? 'Authorization failed' : '授权失败'));
}
};
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
} catch (err) {
console.error('Failed to copy:', err);
}
};
const handleRetry = () => {
setAuthStatus('idle');
setUserCode('');
setVerificationUri('');
setError(null);
};
return (
<div className="forum-auth">
<div className="forum-auth-card">
<div className="forum-auth-header">
<Github size={32} className="forum-auth-icon" />
<h2>{isEnglish ? 'ESEngine Community' : 'ESEngine 社区'}</h2>
<p>{isEnglish ? 'Sign in with GitHub to join the discussion' : '使用 GitHub 登录参与讨论'}</p>
</div>
{/* 初始状态 | Idle state */}
{authStatus === 'idle' && (
<div className="forum-auth-content">
<div className="forum-auth-instructions">
<p>{isEnglish ? '1. Click the button below' : '1. 点击下方按钮'}</p>
<p>{isEnglish ? '2. Enter the code on GitHub' : '2. 在 GitHub 页面输入验证码'}</p>
<p>{isEnglish ? '3. Authorize the application' : '3. 授权应用'}</p>
</div>
<button className="forum-auth-github-btn" onClick={handleGitHubLogin}>
<Github size={16} />
<span>{isEnglish ? 'Continue with GitHub' : '使用 GitHub 登录'}</span>
</button>
</div>
)}
{/* 等待授权 | Pending state */}
{authStatus === 'pending' && (
<div className="forum-auth-pending">
<Loader size={24} className="spinning" />
<p className="forum-auth-pending-text">
{isEnglish ? 'Waiting for authorization...' : '等待授权中...'}
</p>
{userCode && (
<div className="forum-auth-code-section">
<label>{isEnglish ? 'Enter this code on GitHub:' : '在 GitHub 输入此验证码:'}</label>
<div className="forum-auth-code-box">
<span className="forum-auth-code">{userCode}</span>
<button
className="forum-auth-copy-btn"
onClick={() => copyToClipboard(userCode)}
title={isEnglish ? 'Copy code' : '复制验证码'}
>
📋
</button>
</div>
<button
className="forum-auth-link-btn"
onClick={() => open(verificationUri)}
>
<ExternalLink size={14} />
<span>{isEnglish ? 'Open GitHub' : '打开 GitHub'}</span>
</button>
</div>
)}
</div>
)}
{/* 授权成功 | Success state */}
{authStatus === 'authorized' && (
<div className="forum-auth-success">
<CheckCircle size={32} className="forum-auth-success-icon" />
<p>{isEnglish ? 'Authorization successful!' : '授权成功!'}</p>
</div>
)}
{/* 授权失败 | Error state */}
{authStatus === 'error' && (
<div className="forum-auth-error-state">
<AlertCircle size={32} className="forum-auth-error-icon" />
<p>{isEnglish ? 'Authorization failed' : '授权失败'}</p>
{error && <p className="forum-auth-error-detail">{error}</p>}
<button className="forum-auth-retry-btn" onClick={handleRetry}>
{isEnglish ? 'Try Again' : '重试'}
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,603 @@
/**
* 论坛创建帖子样式
* Forum create post styles
*/
.forum-create-post {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
padding: 12px;
}
/* 容器布局 | Container layout */
.forum-create-container {
display: flex;
gap: 16px;
flex: 1;
min-height: 0;
}
/* 主编辑区 | Main editor area */
.forum-create-main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
background: #2d2d2d;
border-radius: 6px;
border: 1px solid #3a3a3a;
overflow: hidden;
}
.forum-create-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
border-bottom: 1px solid #3a3a3a;
background: #333;
}
.forum-create-header h2 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: #e0e0e0;
}
.forum-create-selected-category {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
font-size: 11px;
background: rgba(74, 158, 255, 0.15);
border-radius: 12px;
color: #4a9eff;
}
/* 表单 | Form */
.forum-create-form {
display: flex;
flex-direction: column;
flex: 1;
padding: 16px;
gap: 16px;
overflow-y: auto;
}
.forum-create-field {
display: flex;
flex-direction: column;
gap: 8px;
}
.forum-create-field label {
font-size: 11px;
font-weight: 600;
color: #999;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* 分类选择 | Category selection */
.forum-create-categories {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.forum-create-category {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 14px;
min-width: 80px;
background: #363636;
border: 1px solid #444;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.forum-create-category:hover {
background: #404040;
border-color: #555;
transform: translateY(-1px);
}
.forum-create-category.selected {
background: rgba(74, 158, 255, 0.15);
border-color: #4a9eff;
}
.forum-create-category-emoji {
font-size: 18px;
line-height: 1;
}
.forum-create-category-name {
font-size: 10px;
font-weight: 500;
color: #ccc;
text-align: center;
}
.forum-create-category.selected .forum-create-category-name {
color: #4a9eff;
}
.forum-create-category-desc {
font-size: 9px;
color: #666;
text-align: center;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 标题输入 | Title input */
.forum-create-title-input {
display: flex;
align-items: center;
gap: 8px;
background: #1a1a1a;
border: 1px solid #3a3a3a;
border-radius: 4px;
padding-right: 10px;
transition: border-color 0.15s ease;
}
.forum-create-title-input:focus-within {
border-color: #4a9eff;
}
.forum-create-title-input input {
flex: 1;
padding: 10px 12px;
font-size: 13px;
background: transparent;
border: none;
color: #e0e0e0;
outline: none;
}
.forum-create-title-input input::placeholder {
color: #555;
}
.forum-create-count {
font-size: 10px;
color: #666;
flex-shrink: 0;
}
/* 编辑器字段 | Editor field */
.forum-create-editor-field {
flex: 1;
display: flex;
flex-direction: column;
min-height: 300px;
}
/* 编辑器头部 | Editor header */
.forum-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
background: #363636;
border: 1px solid #3a3a3a;
border-radius: 4px 4px 0 0;
}
/* 编辑器选项卡 | Editor tabs */
.forum-editor-tabs {
display: flex;
gap: 2px;
}
.forum-editor-tab {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
font-size: 11px;
font-weight: 500;
background: transparent;
border: none;
border-radius: 3px;
color: #888;
cursor: pointer;
transition: all 0.15s ease;
}
.forum-editor-tab:hover {
background: rgba(255, 255, 255, 0.05);
color: #ccc;
}
.forum-editor-tab.active {
background: #4a9eff;
color: white;
}
/* 编辑器工具栏 | Editor toolbar */
.forum-editor-toolbar {
display: flex;
align-items: center;
gap: 2px;
}
.forum-editor-tool {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: none;
border-radius: 3px;
color: #888;
cursor: pointer;
transition: all 0.15s ease;
}
.forum-editor-tool:hover {
background: rgba(255, 255, 255, 0.08);
color: #e0e0e0;
}
.forum-editor-help {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
color: #666;
border-radius: 3px;
transition: all 0.15s ease;
}
.forum-editor-help:hover {
background: rgba(255, 255, 255, 0.05);
color: #4a9eff;
}
/* 编辑器内容区 | Editor content */
.forum-editor-content {
flex: 1;
display: flex;
flex-direction: column;
background: #1a1a1a;
border: 1px solid #3a3a3a;
border-top: none;
border-radius: 0 0 4px 4px;
min-height: 200px;
}
.forum-editor-textarea {
flex: 1;
width: 100%;
padding: 12px;
font-size: 12px;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
line-height: 1.6;
background: transparent;
border: none;
color: #e0e0e0;
resize: none;
outline: none;
}
.forum-editor-textarea::placeholder {
color: #555;
}
/* 编辑器内容拖拽状态 | Editor content drag state */
.forum-editor-content {
position: relative;
}
.forum-editor-content.dragging {
border-color: #4a9eff;
background: rgba(74, 158, 255, 0.05);
}
/* 上传覆盖层 | Upload overlay */
.forum-editor-upload-overlay,
.forum-editor-drag-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: rgba(26, 26, 26, 0.95);
z-index: 10;
border-radius: 0 0 4px 4px;
}
.forum-editor-upload-overlay span,
.forum-editor-drag-overlay span {
font-size: 13px;
color: #4a9eff;
font-weight: 500;
}
.forum-editor-upload-overlay svg,
.forum-editor-drag-overlay svg {
color: #4a9eff;
}
.forum-editor-drag-overlay {
border: 2px dashed #4a9eff;
background: rgba(74, 158, 255, 0.1);
}
/* 旋转动画 | Spin animation */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.spin {
animation: spin 1s linear infinite;
}
/* 预览区 | Preview area */
.forum-editor-preview {
flex: 1;
padding: 12px;
overflow-y: auto;
color: #ddd;
font-size: 13px;
line-height: 1.6;
}
.forum-editor-preview-empty {
color: #666;
font-style: italic;
}
/* Markdown 渲染样式 | Markdown render styles */
.forum-editor-preview h1,
.forum-editor-preview h2,
.forum-editor-preview h3,
.forum-editor-preview h4 {
color: #e0e0e0;
margin: 16px 0 8px;
}
.forum-editor-preview h1 { font-size: 20px; }
.forum-editor-preview h2 { font-size: 17px; }
.forum-editor-preview h3 { font-size: 14px; }
.forum-editor-preview p {
margin: 0 0 12px;
}
.forum-editor-preview a {
color: #4a9eff;
text-decoration: none;
}
.forum-editor-preview a:hover {
text-decoration: underline;
}
.forum-editor-preview code {
padding: 2px 6px;
font-size: 11px;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
background: #2a2a2a;
border-radius: 3px;
color: #f8d97c;
}
.forum-editor-preview pre {
margin: 12px 0;
padding: 12px;
background: #1e1e1e;
border-radius: 4px;
overflow-x: auto;
}
.forum-editor-preview pre code {
padding: 0;
background: none;
color: #ddd;
}
.forum-editor-preview blockquote {
margin: 12px 0;
padding: 8px 12px;
border-left: 3px solid #4a9eff;
background: rgba(74, 158, 255, 0.05);
color: #aaa;
}
.forum-editor-preview ul,
.forum-editor-preview ol {
margin: 8px 0;
padding-left: 24px;
}
.forum-editor-preview li {
margin: 4px 0;
}
.forum-editor-preview img {
max-width: 100%;
border-radius: 4px;
}
.forum-editor-preview hr {
border: none;
border-top: 1px solid #3a3a3a;
margin: 16px 0;
}
.forum-editor-preview table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
}
.forum-editor-preview th,
.forum-editor-preview td {
padding: 8px;
border: 1px solid #3a3a3a;
text-align: left;
}
.forum-editor-preview th {
background: #2a2a2a;
font-weight: 600;
}
/* 错误提示 | Error message */
.forum-create-error {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
font-size: 11px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 4px;
color: #f87171;
}
/* 操作按钮 | Action buttons */
.forum-create-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 8px;
border-top: 1px solid #3a3a3a;
}
.forum-btn-submit {
min-width: 140px;
}
/* 侧边栏 | Sidebar */
.forum-create-sidebar {
width: 240px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 12px;
}
.forum-create-tips,
.forum-create-markdown-guide {
background: #2d2d2d;
border: 1px solid #3a3a3a;
border-radius: 6px;
padding: 12px;
}
.forum-create-tips h3,
.forum-create-markdown-guide h3 {
margin: 0 0 10px 0;
font-size: 11px;
font-weight: 600;
color: #999;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.forum-create-tips ul {
margin: 0;
padding: 0;
list-style: none;
}
.forum-create-tips li {
position: relative;
padding: 4px 0 4px 14px;
font-size: 11px;
color: #888;
line-height: 1.5;
}
.forum-create-tips li::before {
content: '•';
position: absolute;
left: 0;
color: #4a9eff;
}
/* Markdown 指南 | Markdown guide */
.forum-create-markdown-examples {
display: flex;
flex-direction: column;
gap: 6px;
}
.markdown-example {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
}
.markdown-example code {
padding: 2px 6px;
background: #1a1a1a;
border-radius: 3px;
color: #f8d97c;
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', monospace;
font-size: 10px;
}
.markdown-example code.inline {
background: #2a2a2a;
color: #4a9eff;
}
.markdown-example span {
color: #666;
}
.markdown-example strong {
color: #e0e0e0;
}
.markdown-example em {
color: #e0e0e0;
}
.markdown-example a {
color: #4a9eff;
text-decoration: none;
}
/* 响应式 | Responsive */
@media (max-width: 800px) {
.forum-create-container {
flex-direction: column;
}
.forum-create-sidebar {
width: 100%;
flex-direction: row;
flex-wrap: wrap;
}
.forum-create-tips,
.forum-create-markdown-guide {
flex: 1;
min-width: 200px;
}
}

View File

@@ -0,0 +1,459 @@
/**
* 论坛创建帖子组件 - GitHub Discussions
* Forum create post component - GitHub Discussions
*/
import { useState, useRef, useCallback } from 'react';
import {
ArrowLeft, Send, AlertCircle, Eye, Edit3,
Bold, Italic, Code, Link, List, Image, Quote, HelpCircle,
Upload, Loader2
} from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { getForumService } from '../../services/forum';
import type { Category } from '../../services/forum';
import { parseEmoji } from './utils';
import './ForumCreatePost.css';
interface ForumCreatePostProps {
categories: Category[];
isEnglish: boolean;
onBack: () => void;
onCreated: () => void;
}
type EditorTab = 'write' | 'preview';
export function ForumCreatePost({ categories, isEnglish, onBack, onCreated }: ForumCreatePostProps) {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [categoryId, setCategoryId] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<EditorTab>('write');
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const forumService = getForumService();
/**
* 处理图片上传
* Handle image upload
*/
const handleImageUpload = useCallback(async (file: File) => {
if (uploading) return;
setUploading(true);
setUploadProgress(0);
setError(null);
try {
const imageUrl = await forumService.uploadImage(file, (progress) => {
setUploadProgress(progress);
});
// 插入 Markdown 图片语法 | Insert Markdown image syntax
const textarea = textareaRef.current;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const imageMarkdown = `![${file.name}](${imageUrl})`;
const newBody = body.substring(0, start) + imageMarkdown + body.substring(end);
setBody(newBody);
// 恢复光标位置 | Restore cursor position
setTimeout(() => {
textarea.focus();
const newPos = start + imageMarkdown.length;
textarea.setSelectionRange(newPos, newPos);
}, 0);
} else {
// 如果没有 textarea直接追加到末尾 | Append to end if no textarea
setBody(prev => prev + `\n![${file.name}](${imageUrl})`);
}
} catch (err) {
console.error('[ForumCreatePost] Upload failed:', err);
setError(err instanceof Error ? err.message : (isEnglish ? 'Failed to upload image' : '图片上传失败'));
} finally {
setUploading(false);
setUploadProgress(0);
}
}, [body, forumService, isEnglish, uploading]);
/**
* 处理拖拽事件
* Handle drag events
*/
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
const imageFile = files.find(f => f.type.startsWith('image/'));
if (imageFile) {
handleImageUpload(imageFile);
}
}, [handleImageUpload]);
/**
* 处理粘贴事件
* Handle paste event
*/
const handlePaste = useCallback((e: React.ClipboardEvent) => {
const items = Array.from(e.clipboardData.items);
const imageItem = items.find(item => item.type.startsWith('image/'));
if (imageItem) {
e.preventDefault();
const file = imageItem.getAsFile();
if (file) {
handleImageUpload(file);
}
}
}, [handleImageUpload]);
/**
* 处理文件选择
* Handle file selection
*/
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
handleImageUpload(file);
}
// 清空 input 以便重复选择同一文件 | Clear input to allow selecting same file again
e.target.value = '';
}, [handleImageUpload]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// 验证 | Validation
if (!title.trim()) {
setError(isEnglish ? 'Please enter a title' : '请输入标题');
return;
}
if (!body.trim()) {
setError(isEnglish ? 'Please enter content' : '请输入内容');
return;
}
if (!categoryId) {
setError(isEnglish ? 'Please select a category' : '请选择分类');
return;
}
setSubmitting(true);
try {
const post = await forumService.createPost({
title: title.trim(),
body: body.trim(),
categoryId
});
if (post) {
onCreated();
} else {
setError(isEnglish ? 'Failed to create discussion' : '创建讨论失败,请稍后重试');
}
} catch (err) {
console.error('[ForumCreatePost] Error:', err);
setError(err instanceof Error ? err.message : (isEnglish ? 'An error occurred' : '发生错误,请稍后重试'));
} finally {
setSubmitting(false);
}
};
// 插入 Markdown 语法 | Insert Markdown syntax
const insertMarkdown = (prefix: string, suffix: string = '', placeholder: string = '') => {
const textarea = textareaRef.current;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = body.substring(start, end) || placeholder;
const newBody = body.substring(0, start) + prefix + selectedText + suffix + body.substring(end);
setBody(newBody);
// 恢复光标位置 | Restore cursor position
setTimeout(() => {
textarea.focus();
const newCursorPos = start + prefix.length + selectedText.length;
textarea.setSelectionRange(newCursorPos, newCursorPos);
}, 0);
};
const toolbarButtons = [
{ icon: <Bold size={14} />, action: () => insertMarkdown('**', '**', 'bold'), title: isEnglish ? 'Bold' : '粗体' },
{ icon: <Italic size={14} />, action: () => insertMarkdown('*', '*', 'italic'), title: isEnglish ? 'Italic' : '斜体' },
{ icon: <Code size={14} />, action: () => insertMarkdown('`', '`', 'code'), title: isEnglish ? 'Inline code' : '行内代码' },
{ icon: <Link size={14} />, action: () => insertMarkdown('[', '](url)', 'link text'), title: isEnglish ? 'Link' : '链接' },
{ icon: <List size={14} />, action: () => insertMarkdown('\n- ', '', 'list item'), title: isEnglish ? 'List' : '列表' },
{ icon: <Quote size={14} />, action: () => insertMarkdown('\n> ', '', 'quote'), title: isEnglish ? 'Quote' : '引用' },
{ icon: <Upload size={14} />, action: () => fileInputRef.current?.click(), title: isEnglish ? 'Upload image' : '上传图片' },
];
const selectedCategory = categories.find(c => c.id === categoryId);
return (
<div className="forum-create-post">
{/* 返回按钮 | Back button */}
<button className="forum-back-btn" onClick={onBack}>
<ArrowLeft size={18} />
<span>{isEnglish ? 'Back to list' : '返回列表'}</span>
</button>
<div className="forum-create-container">
{/* 左侧:编辑区 | Left: Editor */}
<div className="forum-create-main">
<div className="forum-create-header">
<h2>{isEnglish ? 'Start a Discussion' : '发起讨论'}</h2>
{selectedCategory && (
<span className="forum-create-selected-category">
{parseEmoji(selectedCategory.emoji)} {selectedCategory.name}
</span>
)}
</div>
<form className="forum-create-form" onSubmit={handleSubmit}>
{/* 分类选择 | Category selection */}
<div className="forum-create-field">
<label>{isEnglish ? 'Select Category' : '选择分类'}</label>
<div className="forum-create-categories">
{categories.map(cat => (
<button
key={cat.id}
type="button"
className={`forum-create-category ${categoryId === cat.id ? 'selected' : ''}`}
onClick={() => setCategoryId(cat.id)}
>
<span className="forum-create-category-emoji">{parseEmoji(cat.emoji)}</span>
<span className="forum-create-category-name">{cat.name}</span>
{cat.description && (
<span className="forum-create-category-desc">{cat.description}</span>
)}
</button>
))}
</div>
</div>
{/* 标题 | Title */}
<div className="forum-create-field">
<label>{isEnglish ? 'Title' : '标题'}</label>
<div className="forum-create-title-input">
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={isEnglish ? 'Enter a descriptive title...' : '输入一个描述性的标题...'}
maxLength={200}
/>
<span className="forum-create-count">{title.length}/200</span>
</div>
</div>
{/* 编辑器 | Editor */}
<div className="forum-create-field forum-create-editor-field">
<div className="forum-editor-header">
<div className="forum-editor-tabs">
<button
type="button"
className={`forum-editor-tab ${activeTab === 'write' ? 'active' : ''}`}
onClick={() => setActiveTab('write')}
>
<Edit3 size={14} />
<span>{isEnglish ? 'Write' : '编辑'}</span>
</button>
<button
type="button"
className={`forum-editor-tab ${activeTab === 'preview' ? 'active' : ''}`}
onClick={() => setActiveTab('preview')}
>
<Eye size={14} />
<span>{isEnglish ? 'Preview' : '预览'}</span>
</button>
</div>
{activeTab === 'write' && (
<div className="forum-editor-toolbar">
{toolbarButtons.map((btn, idx) => (
<button
key={idx}
type="button"
className="forum-editor-tool"
onClick={btn.action}
title={btn.title}
>
{btn.icon}
</button>
))}
<a
href="https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax"
target="_blank"
rel="noopener noreferrer"
className="forum-editor-help"
title={isEnglish ? 'Markdown Help' : 'Markdown 帮助'}
>
<HelpCircle size={14} />
</a>
</div>
)}
</div>
<div
className={`forum-editor-content ${isDragging ? 'dragging' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{/* 隐藏的文件输入 | Hidden file input */}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
{/* 上传进度提示 | Upload progress indicator */}
{uploading && (
<div className="forum-editor-upload-overlay">
<Loader2 size={24} className="spin" />
<span>{isEnglish ? 'Uploading...' : '上传中...'} {uploadProgress}%</span>
</div>
)}
{/* 拖拽提示 | Drag hint */}
{isDragging && !uploading && (
<div className="forum-editor-drag-overlay">
<Upload size={32} />
<span>{isEnglish ? 'Drop image here' : '拖放图片到这里'}</span>
</div>
)}
{activeTab === 'write' ? (
<textarea
ref={textareaRef}
className="forum-editor-textarea"
value={body}
onChange={(e) => setBody(e.target.value)}
onPaste={handlePaste}
placeholder={isEnglish
? 'Write your content here...\n\nYou can use Markdown:\n- **bold** and *italic*\n- `code` and ```code blocks```\n- [links](url) and ![images](url)\n- > quotes and - lists\n\nDrag & drop or paste images to upload'
: '在这里写下你的内容...\n\n支持 Markdown 语法:\n- **粗体** 和 *斜体*\n- `代码` 和 ```代码块```\n- [链接](url) 和 ![图片](url)\n- > 引用 和 - 列表\n\n拖拽或粘贴图片即可上传'}
/>
) : (
<div className="forum-editor-preview">
{body ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{body}
</ReactMarkdown>
) : (
<p className="forum-editor-preview-empty">
{isEnglish ? 'Nothing to preview' : '暂无内容可预览'}
</p>
)}
</div>
)}
</div>
</div>
{/* 错误提示 | Error message */}
{error && (
<div className="forum-create-error">
<AlertCircle size={16} />
<span>{error}</span>
</div>
)}
{/* 提交按钮 | Submit button */}
<div className="forum-create-actions">
<button
type="button"
className="forum-btn"
onClick={onBack}
disabled={submitting}
>
{isEnglish ? 'Cancel' : '取消'}
</button>
<button
type="submit"
className="forum-btn forum-btn-primary forum-btn-submit"
disabled={submitting || !title.trim() || !body.trim() || !categoryId}
>
<Send size={16} />
<span>
{submitting
? (isEnglish ? 'Creating...' : '创建中...')
: (isEnglish ? 'Create Discussion' : '创建讨论')}
</span>
</button>
</div>
</form>
</div>
{/* 右侧:提示 | Right: Tips */}
<div className="forum-create-sidebar">
<div className="forum-create-tips">
<h3>{isEnglish ? 'Tips' : '小贴士'}</h3>
<ul>
<li>{isEnglish ? 'Use a clear, descriptive title' : '使用清晰、描述性的标题'}</li>
<li>{isEnglish ? 'Select the right category for your topic' : '为你的话题选择合适的分类'}</li>
<li>{isEnglish ? 'Provide enough context and details' : '提供足够的背景和细节'}</li>
<li>{isEnglish ? 'Use code blocks for code snippets' : '使用代码块展示代码'}</li>
<li>{isEnglish ? 'Be respectful and constructive' : '保持尊重和建设性'}</li>
</ul>
</div>
<div className="forum-create-markdown-guide">
<h3>{isEnglish ? 'Markdown Guide' : 'Markdown 指南'}</h3>
<div className="forum-create-markdown-examples">
<div className="markdown-example">
<code>**bold**</code>
<span></span>
<strong>bold</strong>
</div>
<div className="markdown-example">
<code>*italic*</code>
<span></span>
<em>italic</em>
</div>
<div className="markdown-example">
<code>`code`</code>
<span></span>
<code className="inline">code</code>
</div>
<div className="markdown-example">
<code>[link](url)</code>
<span></span>
<a href="#">link</a>
</div>
<div className="markdown-example">
<code>- item</code>
<span></span>
<span> item</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,170 @@
/**
* 论坛面板样式
* Forum panel styles
*/
.forum-panel {
display: flex;
flex-direction: column;
height: 100%;
background: #2a2a2a;
color: #e0e0e0;
}
.forum-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid #1a1a1a;
background: #333;
}
.forum-header-left {
display: flex;
align-items: center;
gap: 8px;
}
.forum-title {
font-size: 12px;
font-weight: 600;
}
.forum-header-right {
display: flex;
align-items: center;
gap: 10px;
position: relative;
}
.forum-user {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: background 0.15s ease;
}
.forum-user:hover {
background: rgba(255, 255, 255, 0.1);
}
.forum-user-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.forum-user-avatar-placeholder {
width: 24px;
height: 24px;
border-radius: 50%;
background: #4a9eff;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
color: white;
}
.forum-user-name {
font-size: 12px;
color: #999;
}
/* 个人资料下拉面板 | Profile dropdown panel */
.forum-profile-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
z-index: 1000;
min-width: 280px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
.forum-content {
flex: 1;
overflow: auto;
}
.forum-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
color: #888;
}
/* 通用按钮样式 | Common button styles */
.forum-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
font-size: 11px;
background: #3a3a3a;
border: 1px solid #4a4a4a;
color: #ccc;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
}
.forum-btn:hover:not(:disabled) {
background: #444;
border-color: #555;
color: #fff;
}
.forum-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.forum-btn-primary {
background: #4a9eff;
border-color: #4a9eff;
color: white;
}
.forum-btn-primary:hover:not(:disabled) {
background: #3a8eef;
border-color: #3a8eef;
}
/* 返回按钮 | Back button */
.forum-back-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
margin: 10px 12px;
font-size: 11px;
background: transparent;
border: none;
color: #888;
cursor: pointer;
transition: color 0.15s ease;
}
.forum-back-btn:hover {
color: #ccc;
}
/* 旋转动画 | Spin animation */
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}

View File

@@ -0,0 +1,184 @@
/**
* 论坛面板主组件 - GitHub Discussions
* Forum panel main component - GitHub Discussions
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { MessageSquare, RefreshCw } from 'lucide-react';
import { useForumAuth, useCategories, usePosts } from '../../hooks/useForum';
import { ForumAuth } from './ForumAuth';
import { ForumPostList } from './ForumPostList';
import { ForumPostDetail } from './ForumPostDetail';
import { ForumCreatePost } from './ForumCreatePost';
import { ForumProfile } from './ForumProfile';
import type { PostListParams, ForumUser } from '../../services/forum';
import './ForumPanel.css';
type ForumView = 'list' | 'detail' | 'create';
/**
* 认证后的论坛内容组件 | Authenticated forum content component
* 只有在用户认证后才会渲染,确保 hooks 能正常工作
*/
function ForumContent({ user, isEnglish }: { user: ForumUser; isEnglish: boolean }) {
const { categories, refetch: refetchCategories } = useCategories();
const [view, setView] = useState<ForumView>('list');
const [selectedPostNumber, setSelectedPostNumber] = useState<number | null>(null);
const [listParams, setListParams] = useState<PostListParams>({ first: 20 });
const [showProfile, setShowProfile] = useState(false);
const profileRef = useRef<HTMLDivElement>(null);
const { data: posts, loading, totalCount, pageInfo, refetch, loadMore } = usePosts(listParams);
// 点击外部关闭个人资料面板 | Close profile panel when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (profileRef.current && !profileRef.current.contains(e.target as Node)) {
setShowProfile(false);
}
};
if (showProfile) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showProfile]);
const handleViewPost = useCallback((postNumber: number) => {
setSelectedPostNumber(postNumber);
setView('detail');
}, []);
const handleBack = useCallback(() => {
setView('list');
setSelectedPostNumber(null);
}, []);
const handleCreatePost = useCallback(() => {
setView('create');
}, []);
const handlePostCreated = useCallback(() => {
setView('list');
refetch();
}, [refetch]);
const handleCategoryChange = useCallback((categoryId: string | undefined) => {
setListParams(prev => ({ ...prev, categoryId }));
}, []);
const handleSearch = useCallback((search: string) => {
setListParams(prev => ({ ...prev, search }));
}, []);
return (
<>
{/* 顶部栏 | Header */}
<div className="forum-header">
<div className="forum-header-left">
<MessageSquare size={18} />
<span className="forum-title">
{isEnglish ? 'Community' : '社区'}
</span>
</div>
<div className="forum-header-right">
<div
className="forum-user"
onClick={() => setShowProfile(!showProfile)}
title={isEnglish ? 'Click to view profile' : '点击查看资料'}
>
<img
src={user.avatarUrl}
alt={user.login}
className="forum-user-avatar"
/>
<span className="forum-user-name">
{user.login}
</span>
</div>
{/* 个人资料下拉面板 | Profile dropdown panel */}
{showProfile && (
<div className="forum-profile-dropdown" ref={profileRef}>
<ForumProfile onClose={() => setShowProfile(false)} />
</div>
)}
</div>
</div>
{/* 内容区 | Content */}
<div className="forum-content">
{view === 'list' && (
<ForumPostList
posts={posts}
categories={categories}
loading={loading}
totalCount={totalCount}
hasNextPage={pageInfo.hasNextPage}
params={listParams}
isEnglish={isEnglish}
onViewPost={handleViewPost}
onCreatePost={handleCreatePost}
onCategoryChange={handleCategoryChange}
onSearch={handleSearch}
onRefresh={refetch}
onLoadMore={loadMore}
/>
)}
{view === 'detail' && selectedPostNumber && (
<ForumPostDetail
postNumber={selectedPostNumber}
isEnglish={isEnglish}
currentUserId={user.id}
onBack={handleBack}
/>
)}
{view === 'create' && (
<ForumCreatePost
categories={categories}
isEnglish={isEnglish}
onBack={handleBack}
onCreated={handlePostCreated}
/>
)}
</div>
</>
);
}
export function ForumPanel() {
const { i18n } = useTranslation();
const { authState } = useForumAuth();
const isEnglish = i18n.language === 'en';
// 加载状态 | Loading state
if (authState.status === 'loading') {
return (
<div className="forum-panel">
<div className="forum-loading">
<RefreshCw className="spin" size={24} />
<span>{isEnglish ? 'Loading...' : '加载中...'}</span>
</div>
</div>
);
}
// 未登录状态 | Unauthenticated state
if (authState.status === 'unauthenticated') {
return (
<div className="forum-panel">
<ForumAuth />
</div>
);
}
// 已登录状态 - 渲染内容组件 | Authenticated state - render content component
return (
<div className="forum-panel">
<ForumContent user={authState.user} isEnglish={isEnglish} />
</div>
);
}

View File

@@ -0,0 +1,560 @@
/**
* 论坛帖子详情样式
* Forum post detail styles
*/
.forum-post-detail {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
}
.forum-detail-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 12px;
color: #888;
}
/* 文章区 | Article section */
.forum-detail-article {
padding: 0 12px 20px;
}
.forum-detail-header {
margin-bottom: 16px;
}
.forum-detail-category-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.forum-detail-category {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
background: rgba(74, 158, 255, 0.1);
color: #4a9eff;
}
.forum-detail-answered {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
.forum-detail-external {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
font-size: 11px;
background: #3a3a3a;
border: 1px solid #4a4a4a;
color: #888;
border-radius: 12px;
cursor: pointer;
transition: all 0.15s ease;
margin-left: auto;
}
.forum-detail-external:hover {
background: #444;
color: #e0e0e0;
border-color: #555;
}
.forum-detail-title {
margin: 0 0 12px 0;
font-size: 18px;
font-weight: 600;
color: #e0e0e0;
line-height: 1.4;
}
.forum-detail-meta {
display: flex;
align-items: center;
gap: 12px;
}
.forum-detail-author {
display: flex;
align-items: center;
gap: 6px;
}
.forum-detail-author img {
width: 28px;
height: 28px;
border-radius: 50%;
object-fit: cover;
}
.forum-detail-author-placeholder {
width: 28px;
height: 28px;
border-radius: 50%;
background: #4a9eff;
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.forum-detail-author span {
font-size: 12px;
color: #e0e0e0;
font-weight: 500;
}
.forum-detail-time {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #666;
}
/* 内容区 | Content section */
.forum-detail-content {
font-size: 13px;
line-height: 1.7;
color: #bbb;
}
.forum-detail-content p {
margin: 0 0 12px 0;
}
.forum-detail-content p:last-child {
margin-bottom: 0;
}
/* Markdown 样式 | Markdown styles */
.forum-detail-content h1,
.forum-detail-content h2,
.forum-detail-content h3,
.forum-detail-content h4 {
color: #e0e0e0;
margin: 20px 0 10px 0;
font-weight: 600;
}
.forum-detail-content h1 { font-size: 20px; }
.forum-detail-content h2 { font-size: 17px; }
.forum-detail-content h3 { font-size: 15px; }
.forum-detail-content h4 { font-size: 14px; }
.forum-detail-content a {
color: #4a9eff;
text-decoration: none;
}
.forum-detail-content a:hover {
text-decoration: underline;
}
.forum-detail-content code {
background: #1a1a1a;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 12px;
color: #e06c75;
}
.forum-detail-content pre {
background: #1a1a1a;
padding: 12px;
border-radius: 6px;
overflow-x: auto;
margin: 12px 0;
border: 1px solid #333;
}
.forum-detail-content pre code {
background: none;
padding: 0;
color: #abb2bf;
font-size: 12px;
line-height: 1.5;
}
.forum-detail-content blockquote {
margin: 12px 0;
padding: 10px 16px;
border-left: 3px solid #4a9eff;
background: rgba(74, 158, 255, 0.05);
color: #999;
}
.forum-detail-content ul,
.forum-detail-content ol {
margin: 12px 0;
padding-left: 24px;
}
.forum-detail-content li {
margin: 6px 0;
}
.forum-detail-content img {
max-width: 100%;
border-radius: 6px;
margin: 12px 0;
}
.forum-detail-content hr {
border: none;
border-top: 1px solid #3a3a3a;
margin: 20px 0;
}
.forum-detail-content table {
width: 100%;
border-collapse: collapse;
margin: 12px 0;
}
.forum-detail-content th,
.forum-detail-content td {
padding: 8px 12px;
border: 1px solid #3a3a3a;
text-align: left;
}
.forum-detail-content th {
background: #333;
color: #e0e0e0;
font-weight: 600;
}
.forum-detail-content tr:nth-child(even) {
background: rgba(255, 255, 255, 0.02);
}
/* 底部统计 | Footer stats */
.forum-detail-footer {
margin-top: 20px;
padding-top: 12px;
border-top: 1px solid #1a1a1a;
}
.forum-detail-stats {
display: flex;
align-items: center;
gap: 16px;
}
.forum-detail-stat {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
color: #666;
}
.forum-detail-stat.interactive {
padding: 5px 10px;
margin: -5px -10px;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s ease;
background: none;
border: none;
}
.forum-detail-stat.interactive:hover {
background: rgba(255, 255, 255, 0.05);
color: #888;
}
.forum-detail-stat.interactive.liked {
color: #ef4444;
}
.forum-detail-stat.interactive.liked svg {
fill: #ef4444;
}
/* 回复区 | Replies section */
.forum-replies-section {
padding: 0 12px 20px;
border-top: 1px solid #1a1a1a;
}
.forum-replies-title {
display: flex;
align-items: center;
gap: 6px;
margin: 16px 0 12px;
font-size: 14px;
font-weight: 600;
color: #e0e0e0;
}
/* 回复表单 | Reply form */
.forum-reply-form {
margin-bottom: 16px;
}
.forum-reply-form.nested {
margin-top: 10px;
margin-left: 20px;
}
.forum-reply-form textarea {
width: 100%;
padding: 10px;
font-size: 12px;
font-family: inherit;
line-height: 1.5;
background: #1a1a1a;
border: 1px solid #3a3a3a;
border-radius: 3px;
color: #ddd;
resize: vertical;
min-height: 70px;
}
.forum-reply-form textarea:hover {
border-color: #4a4a4a;
}
.forum-reply-form textarea:focus {
outline: none;
border-color: #4a9eff;
}
.forum-reply-form textarea::placeholder {
color: #555;
}
.forum-reply-form-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
margin-top: 8px;
}
/* 回复列表 | Reply list */
.forum-replies-list {
display: flex;
flex-direction: column;
}
.forum-replies-loading {
display: flex;
justify-content: center;
padding: 16px;
color: #666;
}
.forum-replies-empty {
padding: 24px;
text-align: center;
color: #666;
}
.forum-replies-empty p {
margin: 0;
font-size: 12px;
}
/* 单条回复 | Single reply */
.forum-reply {
padding: 12px 0;
border-bottom: 1px solid #1a1a1a;
}
.forum-reply:last-child {
border-bottom: none;
}
.forum-reply-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.forum-reply-author {
display: flex;
align-items: center;
gap: 6px;
}
.forum-reply-author img {
width: 24px;
height: 24px;
border-radius: 50%;
object-fit: cover;
}
.forum-reply-author-placeholder {
width: 24px;
height: 24px;
border-radius: 50%;
background: #4a9eff;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 600;
color: white;
}
.forum-reply-author-name {
font-size: 12px;
font-weight: 500;
color: #e0e0e0;
}
.forum-reply-answer-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 8px;
font-size: 10px;
font-weight: 500;
background: rgba(16, 185, 129, 0.15);
color: #10b981;
border-radius: 12px;
}
.forum-reply-time {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #666;
}
.forum-reply-content {
font-size: 12px;
line-height: 1.6;
color: #bbb;
}
/* 回复内容 Markdown 样式 | Reply content Markdown styles */
.forum-reply-content p {
margin: 0 0 8px 0;
}
.forum-reply-content p:last-child {
margin-bottom: 0;
}
.forum-reply-content a {
color: #4a9eff;
text-decoration: none;
}
.forum-reply-content a:hover {
text-decoration: underline;
}
.forum-reply-content code {
background: #1a1a1a;
padding: 1px 4px;
border-radius: 3px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 11px;
color: #e06c75;
}
.forum-reply-content pre {
background: #1a1a1a;
padding: 10px;
border-radius: 4px;
overflow-x: auto;
margin: 8px 0;
border: 1px solid #333;
}
.forum-reply-content pre code {
background: none;
padding: 0;
color: #abb2bf;
}
.forum-reply-content blockquote {
margin: 8px 0;
padding: 8px 12px;
border-left: 2px solid #4a9eff;
background: rgba(74, 158, 255, 0.05);
color: #999;
}
.forum-reply-content ul,
.forum-reply-content ol {
margin: 8px 0;
padding-left: 20px;
}
.forum-reply-content li {
margin: 4px 0;
}
.forum-reply-content img {
max-width: 100%;
border-radius: 4px;
margin: 8px 0;
}
.forum-reply-actions {
display: flex;
align-items: center;
gap: 10px;
margin-top: 8px;
}
.forum-reply-action {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 6px;
font-size: 11px;
background: none;
border: none;
color: #666;
cursor: pointer;
border-radius: 3px;
transition: all 0.15s ease;
}
.forum-reply-action:hover {
background: rgba(255, 255, 255, 0.05);
color: #888;
}
.forum-reply-action.liked {
color: #ef4444;
}
.forum-reply-action.liked svg {
fill: #ef4444;
}
.forum-reply-action.delete:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
}

View File

@@ -0,0 +1,270 @@
/**
* 论坛帖子详情组件 - GitHub Discussions
* Forum post detail component - GitHub Discussions
*/
import { useState } from 'react';
import {
ArrowLeft, ThumbsUp, MessageCircle, Clock,
Send, RefreshCw, CornerDownRight, ExternalLink, CheckCircle
} from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell';
import { usePost, useReplies } from '../../hooks/useForum';
import { getForumService } from '../../services/forum';
import type { Reply } from '../../services/forum';
import { parseEmoji } from './utils';
import './ForumPostDetail.css';
interface ForumPostDetailProps {
postNumber: number;
isEnglish: boolean;
currentUserId: string;
onBack: () => void;
}
export function ForumPostDetail({ postNumber, isEnglish, currentUserId, onBack }: ForumPostDetailProps) {
const { post, loading: postLoading, toggleUpvote, refetch: refetchPost } = usePost(postNumber);
const { replies, loading: repliesLoading, createReply, refetch: refetchReplies } = useReplies(postNumber);
const [replyContent, setReplyContent] = useState('');
const [replyingTo, setReplyingTo] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const forumService = getForumService();
const formatDate = (dateString: string) => {
const date = new Date(dateString);
return date.toLocaleString();
};
const handleSubmitReply = async (e: React.FormEvent) => {
e.preventDefault();
if (!replyContent.trim() || submitting || !post) return;
setSubmitting(true);
try {
await createReply(post.id, replyContent, replyingTo || undefined);
setReplyContent('');
setReplyingTo(null);
refetchPost();
} finally {
setSubmitting(false);
}
};
const handleToggleReplyUpvote = async (replyId: string, hasUpvoted: boolean) => {
await forumService.toggleReplyUpvote(replyId, hasUpvoted);
refetchReplies();
};
const openInGitHub = async (url: string) => {
await open(url);
};
const renderReply = (reply: Reply, depth: number = 0) => {
return (
<div key={reply.id} className="forum-reply" style={{ marginLeft: depth * 24 }}>
<div className="forum-reply-header">
<div className="forum-reply-author">
<img src={reply.author.avatarUrl} alt={reply.author.login} />
<span className="forum-reply-author-name">
@{reply.author.login}
</span>
{reply.isAnswer && (
<span className="forum-reply-answer-badge">
<CheckCircle size={12} />
{isEnglish ? 'Answer' : '已采纳'}
</span>
)}
</div>
<span className="forum-reply-time">
<Clock size={12} />
{formatDate(reply.createdAt)}
</span>
</div>
<div
className="forum-reply-content"
dangerouslySetInnerHTML={{ __html: reply.bodyHTML }}
/>
<div className="forum-reply-actions">
<button
className={`forum-reply-action ${reply.viewerHasUpvoted ? 'liked' : ''}`}
onClick={() => handleToggleReplyUpvote(reply.id, reply.viewerHasUpvoted)}
>
<ThumbsUp size={14} />
<span>{reply.upvoteCount}</span>
</button>
<button
className="forum-reply-action"
onClick={() => setReplyingTo(replyingTo === reply.id ? null : reply.id)}
>
<CornerDownRight size={14} />
<span>{isEnglish ? 'Reply' : '回复'}</span>
</button>
</div>
{replyingTo === reply.id && post && (
<form className="forum-reply-form nested" onSubmit={handleSubmitReply}>
<textarea
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
placeholder={isEnglish
? `Reply to @${reply.author.login}...`
: `回复 @${reply.author.login}...`}
rows={2}
/>
<div className="forum-reply-form-actions">
<button
type="button"
className="forum-btn"
onClick={() => { setReplyingTo(null); setReplyContent(''); }}
>
{isEnglish ? 'Cancel' : '取消'}
</button>
<button
type="submit"
className="forum-btn forum-btn-primary"
disabled={!replyContent.trim() || submitting}
>
<Send size={14} />
<span>{isEnglish ? 'Reply' : '回复'}</span>
</button>
</div>
</form>
)}
{/* 嵌套回复 | Nested replies */}
{reply.replies?.nodes.map(child => renderReply(child, depth + 1))}
</div>
);
};
if (postLoading || !post) {
return (
<div className="forum-post-detail">
<div className="forum-detail-loading">
<RefreshCw className="spin" size={24} />
<span>{isEnglish ? 'Loading...' : '加载中...'}</span>
</div>
</div>
);
}
return (
<div className="forum-post-detail">
{/* 返回按钮 | Back button */}
<button className="forum-back-btn" onClick={onBack}>
<ArrowLeft size={18} />
<span>{isEnglish ? 'Back to list' : '返回列表'}</span>
</button>
{/* 帖子内容 | Post content */}
<article className="forum-detail-article">
<header className="forum-detail-header">
<div className="forum-detail-category-row">
<span className="forum-detail-category">
{parseEmoji(post.category.emoji)} {post.category.name}
</span>
{post.answerChosenAt && (
<span className="forum-detail-answered">
<CheckCircle size={14} />
{isEnglish ? 'Answered' : '已解决'}
</span>
)}
<button
className="forum-detail-external"
onClick={() => openInGitHub(post.url)}
title={isEnglish ? 'Open in GitHub' : '在 GitHub 中打开'}
>
<ExternalLink size={14} />
<span>GitHub</span>
</button>
</div>
<h1 className="forum-detail-title">{post.title}</h1>
<div className="forum-detail-meta">
<div className="forum-detail-author">
<img src={post.author.avatarUrl} alt={post.author.login} />
<span>@{post.author.login}</span>
</div>
<span className="forum-detail-time">
<Clock size={14} />
{formatDate(post.createdAt)}
</span>
</div>
</header>
<div
className="forum-detail-content"
dangerouslySetInnerHTML={{ __html: post.bodyHTML }}
/>
<footer className="forum-detail-footer">
<div className="forum-detail-stats">
<button
className={`forum-detail-stat interactive ${post.viewerHasUpvoted ? 'liked' : ''}`}
onClick={toggleUpvote}
>
<ThumbsUp size={16} />
<span>{post.upvoteCount}</span>
</button>
<div className="forum-detail-stat">
<MessageCircle size={16} />
<span>{post.comments.totalCount}</span>
</div>
</div>
</footer>
</article>
{/* 回复区 | Replies section */}
<section className="forum-replies-section">
<h2 className="forum-replies-title">
<MessageCircle size={18} />
<span>
{isEnglish ? 'Comments' : '评论'}
{post.comments.totalCount > 0 && ` (${post.comments.totalCount})`}
</span>
</h2>
{/* 回复输入框 | Reply input */}
{replyingTo === null && (
<form className="forum-reply-form" onSubmit={handleSubmitReply}>
<textarea
value={replyContent}
onChange={(e) => setReplyContent(e.target.value)}
placeholder={isEnglish ? 'Write a comment... (Markdown supported)' : '写下你的评论...(支持 Markdown'}
rows={3}
/>
<div className="forum-reply-form-actions">
<button
type="submit"
className="forum-btn forum-btn-primary"
disabled={!replyContent.trim() || submitting}
>
<Send size={14} />
<span>{submitting
? (isEnglish ? 'Posting...' : '发送中...')
: (isEnglish ? 'Post Comment' : '发表评论')}</span>
</button>
</div>
</form>
)}
{/* 回复列表 | Reply list */}
<div className="forum-replies-list">
{repliesLoading ? (
<div className="forum-replies-loading">
<RefreshCw className="spin" size={20} />
</div>
) : replies.length === 0 ? (
<div className="forum-replies-empty">
<p>{isEnglish ? 'No comments yet. Be the first to comment!' : '暂无评论,来发表第一条评论吧!'}</p>
</div>
) : (
replies.map(reply => renderReply(reply))
)}
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,590 @@
/**
* 论坛帖子列表样式
* Forum post list styles
*/
.forum-post-list {
display: flex;
flex-direction: column;
height: 100%;
}
/* 欢迎横幅 | Welcome banner */
.forum-welcome-banner {
background: linear-gradient(135deg, #1a365d 0%, #2d3748 100%);
border-bottom: 1px solid #3a4a5a;
padding: 16px;
}
.forum-welcome-content {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.forum-welcome-text h2 {
margin: 0 0 4px 0;
font-size: 15px;
font-weight: 600;
color: #e0e0e0;
}
.forum-welcome-text p {
margin: 0;
font-size: 11px;
color: #9ca3af;
}
.forum-welcome-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.forum-btn-github {
background: #24292e;
border-color: #444;
color: #e0e0e0;
}
.forum-btn-github:hover:not(:disabled) {
background: #2f363d;
border-color: #555;
}
/* 分类卡片 | Category cards */
.forum-category-cards {
display: flex;
gap: 8px;
padding: 12px;
overflow-x: auto;
background: #2d2d2d;
border-bottom: 1px solid #1a1a1a;
}
.forum-category-cards::-webkit-scrollbar {
height: 4px;
}
.forum-category-cards::-webkit-scrollbar-track {
background: #2a2a2a;
}
.forum-category-cards::-webkit-scrollbar-thumb {
background: #4a4a4a;
border-radius: 2px;
}
.forum-category-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px 16px;
min-width: 80px;
background: #363636;
border: 1px solid #444;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.forum-category-card:hover {
background: #404040;
border-color: #4a9eff;
transform: translateY(-2px);
}
.forum-category-card-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: rgba(74, 158, 255, 0.15);
border-radius: 50%;
color: #4a9eff;
}
.forum-category-card-emoji {
font-size: 16px;
line-height: 1;
}
.forum-category-card-name {
font-size: 10px;
color: #999;
text-align: center;
white-space: nowrap;
}
/* 工具栏 | Toolbar */
.forum-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 8px 12px;
border-bottom: 1px solid #1a1a1a;
background: #333;
}
.forum-toolbar-left {
display: flex;
align-items: center;
gap: 6px;
}
.forum-search {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: #3a3a3a;
border: 1px solid #4a4a4a;
border-radius: 3px;
min-width: 180px;
}
.forum-search svg {
color: #666;
flex-shrink: 0;
}
.forum-search input {
flex: 1;
background: none;
border: none;
outline: none;
font-size: 11px;
color: #e0e0e0;
}
.forum-search input::placeholder {
color: #666;
}
/* 过滤器 | Filters */
.forum-filters {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 12px;
border-bottom: 1px solid #1a1a1a;
background: #2d2d2d;
}
.forum-filter-group {
display: flex;
align-items: center;
gap: 4px;
}
.forum-filter-group svg {
color: #666;
}
.forum-filter-group select {
padding: 3px 6px;
font-size: 11px;
background: #1a1a1a;
border: 1px solid #3a3a3a;
border-radius: 2px;
color: #ddd;
cursor: pointer;
}
.forum-filter-group select:focus {
outline: none;
border-color: #4a9eff;
}
.forum-post-count {
margin-left: auto;
font-size: 11px;
color: #666;
}
/* 帖子统计栏 | Stats bar */
.forum-stats {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #2a2a2a;
border-bottom: 1px solid #1a1a1a;
}
.forum-stats-left {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: #888;
}
.forum-stats-left svg {
color: #4a9eff;
}
.forum-stats-clear {
background: none;
border: none;
font-size: 11px;
color: #4a9eff;
cursor: pointer;
padding: 2px 6px;
border-radius: 3px;
transition: all 0.15s ease;
}
.forum-stats-clear:hover {
background: rgba(74, 158, 255, 0.1);
}
/* 下拉选择框 | Select dropdown */
.forum-select {
padding: 4px 8px;
font-size: 11px;
background: #3a3a3a;
border: 1px solid #4a4a4a;
border-radius: 3px;
color: #ddd;
cursor: pointer;
min-width: 120px;
}
.forum-select:focus {
outline: none;
border-color: #4a9eff;
}
/* 帖子列表 | Post list */
.forum-posts {
flex: 1;
overflow-y: auto;
position: relative;
}
.forum-posts.loading {
pointer-events: none;
}
.forum-posts.loading > *:not(.forum-posts-overlay) {
opacity: 0.5;
transition: opacity 0.15s ease;
}
/* 加载覆盖层 | Loading overlay */
.forum-posts-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(42, 42, 42, 0.7);
z-index: 10;
backdrop-filter: blur(2px);
}
.forum-posts-overlay svg {
color: #4a9eff;
}
.forum-posts-loading,
.forum-posts-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
gap: 12px;
color: #888;
}
.forum-posts-empty svg {
opacity: 0.3;
color: #444;
}
.forum-posts-empty p {
margin: 0;
font-size: 12px;
}
/* 帖子项 | Post item */
.forum-post-item {
display: flex;
gap: 12px;
padding: 14px 12px;
border-bottom: 1px solid #1a1a1a;
cursor: pointer;
transition: all 0.15s ease;
position: relative;
}
.forum-post-item:hover {
background: rgba(255, 255, 255, 0.03);
}
.forum-post-item.hot {
background: linear-gradient(90deg, rgba(239, 68, 68, 0.05) 0%, transparent 50%);
border-left: 2px solid #ef4444;
}
.forum-post-item.hot:hover {
background: linear-gradient(90deg, rgba(239, 68, 68, 0.08) 0%, rgba(255, 255, 255, 0.03) 50%);
}
.forum-post-avatar {
position: relative;
flex-shrink: 0;
}
.forum-post-avatar img {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #3a3a3a;
}
.forum-post-avatar-badge {
position: absolute;
bottom: -2px;
right: -2px;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
border: 2px solid #2a2a2a;
}
.forum-post-avatar-badge.hot {
background: #ef4444;
color: white;
}
.forum-post-content {
flex: 1;
min-width: 0;
}
.forum-post-header {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 6px;
}
.forum-post-badges {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.forum-post-badge {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 6px;
border-radius: 3px;
font-size: 9px;
font-weight: 500;
}
.forum-post-badge.new {
background: rgba(74, 158, 255, 0.15);
color: #4a9eff;
}
.forum-post-badge.hot {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.forum-post-badge.pinned {
background: rgba(245, 158, 11, 0.15);
color: #f59e0b;
}
.forum-post-badge.locked {
background: rgba(107, 114, 128, 0.15);
color: #9ca3af;
}
.forum-post-external {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: none;
border: none;
color: #555;
cursor: pointer;
border-radius: 4px;
transition: all 0.15s ease;
margin-left: auto;
}
.forum-post-external:hover {
background: rgba(255, 255, 255, 0.1);
color: #4a9eff;
}
.forum-post-category {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: 500;
background: rgba(74, 158, 255, 0.1);
color: #4a9eff;
}
.forum-post-title {
flex: 1;
margin: 0;
font-size: 13px;
font-weight: 600;
color: #e0e0e0;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.forum-post-item:hover .forum-post-title {
color: #4a9eff;
}
.forum-post-excerpt {
margin: 0 0 8px 0;
font-size: 11px;
color: #888;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.forum-post-meta {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
}
.forum-post-author {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
color: #888;
}
.forum-post-author-avatar {
width: 16px;
height: 16px;
border-radius: 50%;
object-fit: cover;
}
.forum-post-author-placeholder {
width: 18px;
height: 18px;
border-radius: 50%;
background: #4a9eff;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 600;
color: white;
}
.forum-post-time {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #666;
}
/* 帖子统计 | Post stats */
.forum-post-stats {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
}
.forum-post-stat {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #666;
padding: 2px 6px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.03);
}
.forum-post-stat.active {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
}
.forum-post-stat.active svg {
fill: #ef4444;
}
.forum-post-answered {
display: flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: #10b981;
background: rgba(16, 185, 129, 0.1);
padding: 2px 8px;
border-radius: 4px;
}
/* 加载更多 | Load more */
.forum-load-more {
display: flex;
justify-content: center;
padding: 16px;
border-top: 1px solid #1a1a1a;
}
.forum-load-more .forum-btn {
min-width: 120px;
}
/* 分页 | Pagination */
.forum-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 10px 12px;
border-top: 1px solid #1a1a1a;
background: #2d2d2d;
}
.forum-pagination-info {
font-size: 11px;
color: #888;
}

View File

@@ -0,0 +1,341 @@
/**
* 帖子列表组件 - GitHub Discussions
* Post list component - GitHub Discussions
*/
import { useState } from 'react';
import {
Plus, RefreshCw, Search, MessageCircle, ThumbsUp,
ExternalLink, CheckCircle, Flame, Clock, TrendingUp,
Lightbulb, HelpCircle, Megaphone, BarChart3, Github
} from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell';
import type { Post, Category, PostListParams } from '../../services/forum';
import { parseEmoji } from './utils';
import './ForumPostList.css';
interface ForumPostListProps {
posts: Post[];
categories: Category[];
loading: boolean;
totalCount: number;
hasNextPage: boolean;
params: PostListParams;
isEnglish: boolean;
onViewPost: (postNumber: number) => void;
onCreatePost: () => void;
onCategoryChange: (categoryId: string | undefined) => void;
onSearch: (search: string) => void;
onRefresh: () => void;
onLoadMore: () => void;
}
/**
* 获取分类图标 | Get category icon
*/
function getCategoryIcon(name: string) {
const lowerName = name.toLowerCase();
if (lowerName.includes('idea') || lowerName.includes('建议')) return <Lightbulb size={14} />;
if (lowerName.includes('q&a') || lowerName.includes('问答')) return <HelpCircle size={14} />;
if (lowerName.includes('show') || lowerName.includes('展示')) return <Megaphone size={14} />;
if (lowerName.includes('poll') || lowerName.includes('投票')) return <BarChart3 size={14} />;
return <MessageCircle size={14} />;
}
export function ForumPostList({
posts,
categories,
loading,
totalCount,
hasNextPage,
params,
isEnglish,
onViewPost,
onCreatePost,
onCategoryChange,
onSearch,
onRefresh,
onLoadMore
}: ForumPostListProps) {
const [searchInput, setSearchInput] = useState(params.search || '');
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSearch(searchInput);
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
const hours = Math.floor(diff / (1000 * 60 * 60));
if (hours === 0) {
const mins = Math.floor(diff / (1000 * 60));
if (mins < 1) return isEnglish ? 'Just now' : '刚刚';
return isEnglish ? `${mins}m ago` : `${mins}分钟前`;
}
return isEnglish ? `${hours}h ago` : `${hours}小时前`;
}
if (days === 1) return isEnglish ? 'Yesterday' : '昨天';
if (days < 7) return isEnglish ? `${days}d ago` : `${days}天前`;
return date.toLocaleDateString();
};
const openInGitHub = async (url: string, e: React.MouseEvent) => {
e.stopPropagation();
await open(url);
};
// 检查帖子是否是热门(高点赞或评论)| Check if post is hot
const isHotPost = (post: Post) => post.upvoteCount >= 5 || post.comments.totalCount >= 3;
// 检查帖子是否是新帖24小时内| Check if post is recent
const isRecentPost = (post: Post) => {
const diff = Date.now() - new Date(post.createdAt).getTime();
return diff < 24 * 60 * 60 * 1000;
};
const openGitHubDiscussions = async () => {
await open('https://github.com/esengine/ecs-framework/discussions');
};
return (
<div className="forum-post-list">
{/* 欢迎横幅 | Welcome banner */}
{!params.categoryId && !params.search && (
<div className="forum-welcome-banner">
<div className="forum-welcome-content">
<div className="forum-welcome-text">
<h2>{isEnglish ? 'ESEngine Community' : 'ESEngine 社区'}</h2>
<p>
{isEnglish
? 'Ask questions, share ideas, and connect with other developers'
: '提出问题、分享想法,与其他开发者交流'}
</p>
</div>
<div className="forum-welcome-actions">
<button className="forum-btn forum-btn-primary" onClick={onCreatePost}>
<Plus size={14} />
<span>{isEnglish ? 'New Discussion' : '发起讨论'}</span>
</button>
<button className="forum-btn forum-btn-github" onClick={openGitHubDiscussions}>
<Github size={14} />
<span>{isEnglish ? 'View on GitHub' : '在 GitHub 查看'}</span>
</button>
</div>
</div>
</div>
)}
{/* 分类卡片 | Category cards */}
{!params.categoryId && !params.search && categories.length > 0 && (
<div className="forum-category-cards">
{categories.map(cat => (
<button
key={cat.id}
className="forum-category-card"
onClick={() => onCategoryChange(cat.id)}
>
<span className="forum-category-card-icon">
{getCategoryIcon(cat.name)}
</span>
<span className="forum-category-card-emoji">{parseEmoji(cat.emoji)}</span>
<span className="forum-category-card-name">{cat.name}</span>
</button>
))}
</div>
)}
{/* 工具栏 | Toolbar */}
<div className="forum-toolbar">
<div className="forum-toolbar-left">
<select
className="forum-select"
value={params.categoryId || ''}
onChange={(e) => onCategoryChange(e.target.value || undefined)}
>
<option value="">{isEnglish ? 'All Categories' : '全部分类'}</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>
{parseEmoji(cat.emoji)} {cat.name}
</option>
))}
</select>
<form onSubmit={handleSearchSubmit} className="forum-search">
<Search size={14} />
<input
type="text"
placeholder={isEnglish ? 'Search discussions...' : '搜索讨论...'}
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
/>
</form>
</div>
<div className="forum-toolbar-right">
<button
className="forum-btn"
onClick={onRefresh}
disabled={loading}
title={isEnglish ? 'Refresh' : '刷新'}
>
<RefreshCw size={14} className={loading ? 'spin' : ''} />
</button>
<button
className="forum-btn forum-btn-primary"
onClick={onCreatePost}
>
<Plus size={14} />
<span>{isEnglish ? 'New' : '发帖'}</span>
</button>
</div>
</div>
{/* 帖子统计 | Post stats */}
<div className="forum-stats">
<div className="forum-stats-left">
<TrendingUp size={14} />
<span>{totalCount} {isEnglish ? 'discussions' : '条讨论'}</span>
</div>
{params.categoryId && (
<button
className="forum-stats-clear"
onClick={() => onCategoryChange(undefined)}
>
{isEnglish ? 'Clear filter' : '清除筛选'}
</button>
)}
</div>
{/* 帖子列表 | Post list */}
<div className={`forum-posts ${loading ? 'loading' : ''}`}>
{/* 加载覆盖层 | Loading overlay */}
{loading && posts.length > 0 && (
<div className="forum-posts-overlay">
<RefreshCw size={20} className="spin" />
</div>
)}
{loading && posts.length === 0 ? (
<div className="forum-posts-loading">
<RefreshCw size={16} className="spin" />
<span>{isEnglish ? 'Loading...' : '加载中...'}</span>
</div>
) : posts.length === 0 ? (
<div className="forum-posts-empty">
<MessageCircle size={32} />
<p>{isEnglish ? 'No discussions yet' : '暂无讨论'}</p>
<button className="forum-btn forum-btn-primary" onClick={onCreatePost}>
<Plus size={14} />
<span>{isEnglish ? 'Start a discussion' : '发起讨论'}</span>
</button>
</div>
) : (
<>
{posts.map(post => (
<div
key={post.id}
className={`forum-post-item ${isHotPost(post) ? 'hot' : ''}`}
onClick={() => onViewPost(post.number)}
>
<div className="forum-post-avatar">
<img
src={post.author.avatarUrl}
alt={post.author.login}
/>
{isHotPost(post) && (
<span className="forum-post-avatar-badge hot">
<Flame size={10} />
</span>
)}
</div>
<div className="forum-post-content">
<div className="forum-post-header">
<div className="forum-post-badges">
{isRecentPost(post) && (
<span className="forum-post-badge new">
<Clock size={10} />
{isEnglish ? 'New' : '新'}
</span>
)}
{isHotPost(post) && (
<span className="forum-post-badge hot">
<Flame size={10} />
{isEnglish ? 'Hot' : '热门'}
</span>
)}
</div>
<h3 className="forum-post-title">{post.title}</h3>
<button
className="forum-post-external"
onClick={(e) => openInGitHub(post.url, e)}
title={isEnglish ? 'Open in GitHub' : '在 GitHub 中打开'}
>
<ExternalLink size={12} />
</button>
</div>
<div className="forum-post-meta">
<span className="forum-post-category">
{parseEmoji(post.category.emoji)} {post.category.name}
</span>
<span className="forum-post-author">
<img
src={post.author.avatarUrl}
alt={post.author.login}
className="forum-post-author-avatar"
/>
@{post.author.login}
</span>
<span className="forum-post-time">
<Clock size={11} />
{formatDate(post.createdAt)}
</span>
</div>
<div className="forum-post-stats">
<span className={`forum-post-stat ${post.viewerHasUpvoted ? 'active' : ''}`}>
<ThumbsUp size={12} />
{post.upvoteCount}
</span>
<span className="forum-post-stat">
<MessageCircle size={12} />
{post.comments.totalCount}
</span>
{post.answerChosenAt && (
<span className="forum-post-answered">
<CheckCircle size={12} />
{isEnglish ? 'Answered' : '已解决'}
</span>
)}
</div>
</div>
</div>
))}
{/* 加载更多 | Load more */}
{hasNextPage && (
<div className="forum-load-more">
<button
className="forum-btn"
onClick={onLoadMore}
disabled={loading}
>
{loading ? (
<>
<RefreshCw size={14} className="spin" />
<span>{isEnglish ? 'Loading...' : '加载中...'}</span>
</>
) : (
<span>{isEnglish ? 'Load More' : '加载更多'}</span>
)}
</button>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,97 @@
/**
* 用户资料样式 - GitHub
* User profile styles - GitHub
*/
.forum-profile {
padding: 16px;
background: #2a2a2a;
border-radius: 6px;
border: 1px solid #3a3a3a;
}
.forum-profile-header {
display: flex;
gap: 12px;
}
.forum-profile-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
overflow: hidden;
flex-shrink: 0;
}
.forum-profile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.forum-profile-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.forum-profile-name {
font-size: 14px;
font-weight: 600;
color: #e0e0e0;
margin: 0;
}
.forum-profile-github-link {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: #888;
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: color 0.15s ease;
}
.forum-profile-github-link:hover {
color: #4a9eff;
}
.forum-profile-divider {
height: 1px;
background: #3a3a3a;
margin: 12px 0;
}
.forum-profile-actions {
display: flex;
justify-content: flex-end;
}
.forum-profile-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 12px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s ease;
border: 1px solid transparent;
}
.forum-profile-btn.logout {
background: transparent;
border-color: #4a4a4a;
color: #888;
}
.forum-profile-btn.logout:hover {
background: rgba(239, 68, 68, 0.1);
border-color: #f87171;
color: #f87171;
}

View File

@@ -0,0 +1,66 @@
/**
* 用户资料组件 - GitHub
* User profile component - GitHub
*/
import { useTranslation } from 'react-i18next';
import { Github, LogOut, ExternalLink } from 'lucide-react';
import { open } from '@tauri-apps/plugin-shell';
import { useForumAuth } from '../../hooks/useForum';
import './ForumProfile.css';
interface ForumProfileProps {
onClose?: () => void;
}
export function ForumProfile({ onClose }: ForumProfileProps) {
const { i18n } = useTranslation();
const { authState, signOut } = useForumAuth();
const isEnglish = i18n.language === 'en';
const user = authState.status === 'authenticated' ? authState.user : null;
const handleSignOut = async () => {
await signOut();
onClose?.();
};
const openGitHubProfile = async () => {
if (user) {
await open(`https://github.com/${user.login}`);
}
};
if (!user) {
return null;
}
return (
<div className="forum-profile">
<div className="forum-profile-header">
<div className="forum-profile-avatar">
<img src={user.avatarUrl} alt={user.login} />
</div>
<div className="forum-profile-info">
<h3 className="forum-profile-name">@{user.login}</h3>
<button
className="forum-profile-github-link"
onClick={openGitHubProfile}
>
<Github size={12} />
<span>{isEnglish ? 'View GitHub Profile' : '查看 GitHub 主页'}</span>
<ExternalLink size={10} />
</button>
</div>
</div>
<div className="forum-profile-divider" />
<div className="forum-profile-actions">
<button className="forum-profile-btn logout" onClick={handleSignOut}>
<LogOut size={14} />
<span>{isEnglish ? 'Sign Out' : '退出登录'}</span>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
/**
* 论坛组件导出
* Forum components exports
*/
export { ForumPanel } from './ForumPanel';
export { ForumAuth } from './ForumAuth';
export { ForumPostList } from './ForumPostList';
export { ForumPostDetail } from './ForumPostDetail';
export { ForumCreatePost } from './ForumCreatePost';

View File

@@ -0,0 +1,138 @@
/**
* 论坛工具函数 | Forum utility functions
*/
/**
* GitHub emoji 短代码映射表 | GitHub emoji shortcode mapping
*/
const EMOJI_MAP: Record<string, string> = {
':speech_balloon:': '💬',
':bulb:': '💡',
':pray:': '🙏',
':raised_hands:': '🙌',
':ballot_box:': '🗳️',
':rocket:': '🚀',
':bug:': '🐛',
':sparkles:': '✨',
':memo:': '📝',
':question:': '❓',
':fire:': '🔥',
':star:': '⭐',
':heart:': '❤️',
':thumbsup:': '👍',
':warning:': '⚠️',
':book:': '📖',
':wrench:': '🔧',
':gear:': '⚙️',
':zap:': '⚡',
':art:': '🎨',
':package:': '📦',
':lock:': '🔒',
':tada:': '🎉',
':wave:': '👋',
':eyes:': '👀',
':thinking:': '🤔',
':100:': '💯',
':clap:': '👏',
':hammer_and_wrench:': '🛠️',
':world_map:': '🗺️',
':video_game:': '🎮',
':computer:': '💻',
':pencil:': '✏️',
':pencil2:': '✏️',
':notebook:': '📓',
':clipboard:': '📋',
':pushpin:': '📌',
':loudspeaker:': '📢',
':mega:': '📣',
':bell:': '🔔',
':email:': '📧',
':mailbox:': '📫',
':inbox_tray:': '📥',
':outbox_tray:': '📤',
':file_folder:': '📁',
':open_file_folder:': '📂',
':card_index:': '📇',
':chart_with_upwards_trend:': '📈',
':chart_with_downwards_trend:': '📉',
':bar_chart:': '📊',
':date:': '📅',
':calendar:': '📆',
':card_index_dividers:': '🗂️',
':triangular_ruler:': '📐',
':straight_ruler:': '📏',
':scissors:': '✂️',
':link:': '🔗',
':paperclip:': '📎',
':hourglass:': '⌛',
':watch:': '⌚',
':alarm_clock:': '⏰',
':stopwatch:': '⏱️',
':timer_clock:': '⏲️',
':telephone:': '☎️',
':telephone_receiver:': '📞',
':pager:': '📟',
':fax:': '📠',
':battery:': '🔋',
':electric_plug:': '🔌',
':desktop_computer:': '🖥️',
':printer:': '🖨️',
':keyboard:': '⌨️',
':computer_mouse:': '🖱️',
':trackball:': '🖲️',
':minidisc:': '💽',
':floppy_disk:': '💾',
':cd:': '💿',
':dvd:': '📀',
':abacus:': '🧮',
':movie_camera:': '🎥',
':film_strip:': '🎞️',
':film_projector:': '📽️',
':clapper:': '🎬',
':tv:': '📺',
':camera:': '📷',
':camera_flash:': '📸',
':video_camera:': '📹',
':mag:': '🔍',
':mag_right:': '🔎',
':candle:': '🕯️',
':bulb_lightbulb:': '💡',
':flashlight:': '🔦',
':izakaya_lantern:': '🏮',
':diya_lamp:': '🪔',
':notebook_with_decorative_cover:': '📔',
':closed_book:': '📕',
':green_book:': '📗',
':blue_book:': '📘',
':orange_book:': '📙',
':books:': '📚',
':ledger:': '📒',
':page_with_curl:': '📃',
':scroll:': '📜',
':page_facing_up:': '📄',
':newspaper:': '📰',
':rolled_up_newspaper:': '🗞️',
':bookmark_tabs:': '📑',
':bookmark:': '🔖',
':label:': '🏷️',
':moneybag:': '💰',
':coin:': '🪙',
':yen:': '💴',
':dollar:': '💵',
':euro:': '💶',
':pound:': '💷',
':money_with_wings:': '💸',
':credit_card:': '💳',
':receipt:': '🧾',
':chart:': '💹',
};
/**
* 转换 GitHub emoji 短代码为 Unicode | Convert GitHub emoji shortcode to Unicode
*/
export function parseEmoji(emojiCode: string | undefined | null): string {
if (!emojiCode) return '💬';
// 如果已经是 emoji直接返回 | If already emoji, return directly
if (!emojiCode.startsWith(':')) return emojiCode;
return EMOJI_MAP[emojiCode] || emojiCode.replace(/:/g, '');
}

View File

@@ -0,0 +1,254 @@
/**
* 论坛 React Hooks - GitHub Discussions
* Forum React hooks - GitHub Discussions
*/
import { useState, useEffect, useCallback } from 'react';
import { getForumService } from '../services/forum';
import type {
AuthState,
Category,
Post,
Reply,
PostListParams,
PaginatedResponse
} from '../services/forum';
/**
* 认证状态 hook
* Auth state hook
*/
export function useForumAuth() {
const [authState, setAuthState] = useState<AuthState>({ status: 'loading' });
const forumService = getForumService();
useEffect(() => {
const unsubscribe = forumService.onAuthStateChange(setAuthState);
// 超时保护5秒后如果还在 loading则设置为未认证
// Timeout protection: if still loading after 5s, set to unauthenticated
const timeout = setTimeout(() => {
setAuthState(prev => {
if (prev.status === 'loading') {
console.warn('[useForumAuth] Timeout waiting for auth state, setting to unauthenticated');
return { status: 'unauthenticated' };
}
return prev;
});
}, 5000);
return () => {
unsubscribe();
clearTimeout(timeout);
};
}, []);
const requestDeviceCode = useCallback(async () => {
return forumService.requestDeviceCode();
}, []);
const authenticateWithDeviceFlow = useCallback(async (
deviceCode: string,
interval: number,
onStatusChange?: (status: 'pending' | 'authorized' | 'error') => void
) => {
return forumService.authenticateWithDeviceFlow(deviceCode, interval, onStatusChange);
}, []);
const signInWithGitHubToken = useCallback(async (accessToken: string) => {
return forumService.signInWithGitHubToken(accessToken);
}, []);
const signOut = useCallback(async () => {
return forumService.signOut();
}, []);
return {
authState,
requestDeviceCode,
authenticateWithDeviceFlow,
signInWithGitHubToken,
signOut
};
}
/**
* 分类列表 hook
* Categories hook
*/
export function useCategories() {
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const forumService = getForumService();
const fetchCategories = useCallback(async () => {
try {
setLoading(true);
const data = await forumService.getCategories();
setCategories(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch categories'));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchCategories();
}, [fetchCategories]);
return { categories, loading, error, refetch: fetchCategories };
}
/**
* 帖子列表 hook
* Post list hook
*/
export function usePosts(params: PostListParams = {}) {
const [data, setData] = useState<PaginatedResponse<Post>>({
data: [],
totalCount: 0,
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null
}
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const forumService = getForumService();
const fetchPosts = useCallback(async (fetchParams: PostListParams = params) => {
try {
setLoading(true);
const result = await forumService.getPosts(fetchParams);
setData(result);
setError(null);
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch posts'));
} finally {
setLoading(false);
}
}, [JSON.stringify(params)]);
useEffect(() => {
fetchPosts();
}, [fetchPosts]);
const loadMore = useCallback(async () => {
if (!data.pageInfo.hasNextPage || !data.pageInfo.endCursor) return;
try {
const result = await forumService.getPosts({
...params,
after: data.pageInfo.endCursor
});
setData(prev => ({
...result,
data: [...prev.data, ...result.data]
}));
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to load more posts'));
}
}, [data.pageInfo, params]);
return { ...data, loading, error, refetch: fetchPosts, loadMore };
}
/**
* 单个帖子 hook
* Single post hook
*/
export function usePost(postNumber: number | null) {
const [post, setPost] = useState<Post | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const forumService = getForumService();
const fetchPost = useCallback(async () => {
if (postNumber === null) {
setPost(null);
setLoading(false);
return;
}
try {
setLoading(true);
const result = await forumService.getPost(postNumber);
setPost(result);
setError(null);
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch post'));
} finally {
setLoading(false);
}
}, [postNumber]);
useEffect(() => {
fetchPost();
}, [fetchPost]);
const toggleUpvote = useCallback(async () => {
if (!post) return;
const success = await forumService.togglePostUpvote(post.id, post.viewerHasUpvoted);
if (success) {
setPost({
...post,
viewerHasUpvoted: !post.viewerHasUpvoted,
upvoteCount: post.viewerHasUpvoted ? post.upvoteCount - 1 : post.upvoteCount + 1
});
}
}, [post]);
return { post, loading, error, refetch: fetchPost, toggleUpvote };
}
/**
* 回复列表 hook
* Replies hook
*/
export function useReplies(postNumber: number | null) {
const [replies, setReplies] = useState<Reply[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const forumService = getForumService();
const fetchReplies = useCallback(async () => {
if (postNumber === null) {
setReplies([]);
setLoading(false);
return;
}
try {
setLoading(true);
const result = await forumService.getReplies(postNumber);
setReplies(result);
setError(null);
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch replies'));
} finally {
setLoading(false);
}
}, [postNumber]);
useEffect(() => {
fetchReplies();
}, [fetchReplies]);
const createReply = useCallback(async (discussionId: string, content: string, replyToId?: string) => {
const reply = await forumService.createReply({
discussionId,
body: content,
replyToId
});
if (reply) {
await fetchReplies();
}
return reply;
}, [fetchReplies]);
return { replies, loading, error, refetch: fetchReplies, createReply };
}

View File

@@ -0,0 +1,904 @@
/**
* 论坛服务 - GitHub Discussions
* Forum service - GitHub Discussions
*/
import { fetch } from '@tauri-apps/plugin-http';
import type {
Category,
Post,
Reply,
PostListParams,
PaginatedResponse,
CreatePostParams,
CreateReplyParams,
ForumUser,
AuthState,
PageInfo
} from './types';
type AuthStateCallback = (state: AuthState) => void;
/**
* GitHub Device Flow 响应类型
* GitHub Device Flow response types
*/
export interface DeviceCodeResponse {
device_code: string;
user_code: string;
verification_uri: string;
expires_in: number;
interval: number;
}
interface OAuthTokenResponse {
access_token?: string;
token_type?: string;
scope?: string;
error?: string;
error_description?: string;
}
/** GitHub GraphQL API 端点 | GitHub GraphQL API endpoint */
const GITHUB_GRAPHQL_API = 'https://api.github.com/graphql';
/** 仓库信息 | Repository info */
const REPO_OWNER = 'esengine';
const REPO_NAME = 'ecs-framework';
export class ForumService {
private authCallbacks = new Set<AuthStateCallback>();
private currentUser: ForumUser | null = null;
private isInitialized = false;
private repositoryId: string | null = null;
/** GitHub OAuth App Client ID for Forum */
private readonly GITHUB_CLIENT_ID = 'Ov23liu5on5ud8oloMj2';
/** localStorage key for token */
private readonly TOKEN_STORAGE_KEY = 'esengine_forum_github_token';
constructor() {
this.initialize();
}
// =====================================================
// GraphQL 请求 | GraphQL Request
// =====================================================
/**
* 发送 GraphQL 请求
* Send GraphQL request
*/
private async graphql<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
const token = this.currentUser?.accessToken;
if (!token) {
throw new Error('Not authenticated');
}
const response = await fetch(GITHUB_GRAPHQL_API, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query, variables })
});
const result = await response.json();
if (result.errors) {
console.error('[ForumService] GraphQL errors:', result.errors);
throw new Error(result.errors[0]?.message || 'GraphQL request failed');
}
return result.data as T;
}
// =====================================================
// 认证相关 | Authentication
// =====================================================
/**
* 初始化服务
* Initialize service
*/
private async initialize(): Promise<void> {
try {
// 从 localStorage 恢复 token | Restore token from localStorage
const savedToken = localStorage.getItem(this.TOKEN_STORAGE_KEY);
if (savedToken) {
// 验证 token 是否有效 | Verify token is valid
const user = await this.verifyAndGetUser(savedToken);
if (user) {
this.currentUser = user;
this.notifyAuthChange({ status: 'authenticated', user });
} else {
localStorage.removeItem(this.TOKEN_STORAGE_KEY);
this.notifyAuthChange({ status: 'unauthenticated' });
}
} else {
this.notifyAuthChange({ status: 'unauthenticated' });
}
} catch (err) {
console.error('[ForumService] Initialize error:', err);
this.notifyAuthChange({ status: 'unauthenticated' });
} finally {
this.isInitialized = true;
}
}
/**
* 验证 token 并获取用户信息
* Verify token and get user info
*/
private async verifyAndGetUser(token: string): Promise<ForumUser | null> {
try {
const response = await fetch('https://api.github.com/user', {
headers: {
'Authorization': `Bearer ${token}`,
'Accept': 'application/json'
}
});
if (!response.ok) {
return null;
}
const data = await response.json();
return {
id: data.id.toString(),
login: data.login,
avatarUrl: data.avatar_url,
accessToken: token
};
} catch {
return null;
}
}
/**
* 订阅认证状态变化
* Subscribe to auth state changes
*/
onAuthStateChange(callback: AuthStateCallback): () => void {
this.authCallbacks.add(callback);
if (this.isInitialized) {
if (this.currentUser) {
callback({ status: 'authenticated', user: this.currentUser });
} else {
callback({ status: 'unauthenticated' });
}
} else {
callback({ status: 'loading' });
}
return () => {
this.authCallbacks.delete(callback);
};
}
private notifyAuthChange(state: AuthState): void {
this.authCallbacks.forEach(cb => cb(state));
}
/**
* 获取当前用户
* Get current user
*/
getCurrentUser(): ForumUser | null {
return this.currentUser;
}
/**
* 请求 GitHub Device Code
* Request GitHub Device Code for Device Flow
*/
async requestDeviceCode(): Promise<DeviceCodeResponse> {
const response = await fetch('https://github.com/login/device/code', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
client_id: this.GITHUB_CLIENT_ID,
scope: 'read:user public_repo write:discussion'
})
});
if (!response.ok) {
throw new Error(`Failed to request device code: ${response.status}`);
}
return response.json();
}
/**
* 使用 Device Flow 认证 GitHub
* Authenticate with GitHub using Device Flow
*/
async authenticateWithDeviceFlow(
deviceCode: string,
interval: number,
onStatusChange?: (status: 'pending' | 'authorized' | 'error') => void
): Promise<string> {
const pollInterval = Math.max(interval, 5) * 1000;
return new Promise((resolve, reject) => {
const poll = async () => {
try {
const response = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
client_id: this.GITHUB_CLIENT_ID,
device_code: deviceCode,
grant_type: 'urn:ietf:params:oauth:grant-type:device_code'
})
});
const data: OAuthTokenResponse = await response.json();
if (data.access_token) {
onStatusChange?.('authorized');
resolve(data.access_token);
return;
}
if (data.error === 'authorization_pending') {
onStatusChange?.('pending');
setTimeout(poll, pollInterval);
return;
}
if (data.error === 'slow_down') {
setTimeout(poll, pollInterval + 5000);
return;
}
onStatusChange?.('error');
reject(new Error(data.error_description || data.error || 'Authorization failed'));
} catch (err) {
onStatusChange?.('error');
reject(err);
}
};
poll();
});
}
/**
* 使用 GitHub Access Token 登录
* Sign in with GitHub access token
*/
async signInWithGitHubToken(accessToken: string): Promise<{ error: Error | null }> {
try {
const user = await this.verifyAndGetUser(accessToken);
if (!user) {
return { error: new Error('Failed to verify GitHub token') };
}
// 保存 token | Save token
localStorage.setItem(this.TOKEN_STORAGE_KEY, accessToken);
this.currentUser = user;
this.notifyAuthChange({ status: 'authenticated', user });
return { error: null };
} catch (err) {
console.error('[ForumService] Sign in failed:', err);
return { error: err instanceof Error ? err : new Error('Sign in failed') };
}
}
/**
* 登出
* Sign out
*/
async signOut(): Promise<void> {
localStorage.removeItem(this.TOKEN_STORAGE_KEY);
this.currentUser = null;
this.notifyAuthChange({ status: 'unauthenticated' });
}
// =====================================================
// 仓库信息 | Repository Info
// =====================================================
/**
* 获取仓库 ID
* Get repository ID
*/
private async getRepositoryId(): Promise<string> {
if (this.repositoryId) {
return this.repositoryId;
}
const data = await this.graphql<{
repository: { id: string }
}>(`
query {
repository(owner: "${REPO_OWNER}", name: "${REPO_NAME}") {
id
}
}
`);
this.repositoryId = data.repository.id;
return this.repositoryId;
}
// =====================================================
// 分类 | Categories
// =====================================================
/**
* 获取所有分类
* Get all categories
*/
async getCategories(): Promise<Category[]> {
const data = await this.graphql<{
repository: {
discussionCategories: {
nodes: Category[]
}
}
}>(`
query {
repository(owner: "${REPO_OWNER}", name: "${REPO_NAME}") {
discussionCategories(first: 20) {
nodes {
id
name
slug
emoji
description
isAnswerable
}
}
}
}
`);
return data.repository.discussionCategories.nodes;
}
// =====================================================
// 帖子 | Posts (Discussions)
// =====================================================
/**
* 获取帖子列表
* Get post list
*/
async getPosts(params: PostListParams = {}): Promise<PaginatedResponse<Post>> {
const { categoryId, first = 20, after } = params;
let categoryFilter = '';
if (categoryId) {
categoryFilter = `, categoryId: "${categoryId}"`;
}
let afterCursor = '';
if (after) {
afterCursor = `, after: "${after}"`;
}
const data = await this.graphql<{
repository: {
discussions: {
totalCount: number;
pageInfo: PageInfo;
nodes: Post[];
}
}
}>(`
query {
repository(owner: "${REPO_OWNER}", name: "${REPO_NAME}") {
discussions(first: ${first}${afterCursor}${categoryFilter}, orderBy: {field: CREATED_AT, direction: DESC}) {
totalCount
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
nodes {
id
number
title
body
bodyHTML
createdAt
updatedAt
upvoteCount
url
viewerHasUpvoted
viewerCanUpvote
answerChosenAt
author {
... on User {
id
login
avatarUrl
url
}
}
category {
id
name
slug
emoji
description
isAnswerable
}
comments {
totalCount
}
answerChosenBy {
... on User {
login
}
}
}
}
}
}
`);
return {
data: data.repository.discussions.nodes,
totalCount: data.repository.discussions.totalCount,
pageInfo: data.repository.discussions.pageInfo
};
}
/**
* 获取单个帖子
* Get single post
*/
async getPost(number: number): Promise<Post | null> {
const data = await this.graphql<{
repository: {
discussion: Post | null
}
}>(`
query {
repository(owner: "${REPO_OWNER}", name: "${REPO_NAME}") {
discussion(number: ${number}) {
id
number
title
body
bodyHTML
createdAt
updatedAt
upvoteCount
url
viewerHasUpvoted
viewerCanUpvote
answerChosenAt
author {
... on User {
id
login
avatarUrl
url
}
}
category {
id
name
slug
emoji
description
isAnswerable
}
comments {
totalCount
}
answerChosenBy {
... on User {
login
}
}
}
}
}
`);
return data.repository.discussion;
}
/**
* 创建帖子
* Create post
*/
async createPost(params: CreatePostParams): Promise<Post | null> {
const repoId = await this.getRepositoryId();
const data = await this.graphql<{
createDiscussion: {
discussion: Post
}
}>(`
mutation CreateDiscussion($input: CreateDiscussionInput!) {
createDiscussion(input: $input) {
discussion {
id
number
title
body
bodyHTML
createdAt
updatedAt
upvoteCount
url
viewerHasUpvoted
viewerCanUpvote
author {
... on User {
id
login
avatarUrl
url
}
}
category {
id
name
slug
emoji
description
isAnswerable
}
comments {
totalCount
}
}
}
}
`, {
input: {
repositoryId: repoId,
categoryId: params.categoryId,
title: params.title,
body: params.body
}
});
return data.createDiscussion.discussion;
}
/**
* 点赞/取消点赞帖子
* Upvote/remove upvote from post
*/
async togglePostUpvote(discussionId: string, hasUpvoted: boolean): Promise<boolean> {
try {
if (hasUpvoted) {
await this.graphql(`
mutation {
removeUpvote(input: { subjectId: "${discussionId}" }) {
subject {
id
}
}
}
`);
} else {
await this.graphql(`
mutation {
addUpvote(input: { subjectId: "${discussionId}" }) {
subject {
id
}
}
}
`);
}
return true;
} catch (err) {
console.error('[ForumService] Toggle upvote failed:', err);
return false;
}
}
// =====================================================
// 回复 | Replies (Comments)
// =====================================================
/**
* 获取帖子的回复列表
* Get post replies
*/
async getReplies(discussionNumber: number): Promise<Reply[]> {
const data = await this.graphql<{
repository: {
discussion: {
comments: {
nodes: Reply[]
}
} | null
}
}>(`
query {
repository(owner: "${REPO_OWNER}", name: "${REPO_NAME}") {
discussion(number: ${discussionNumber}) {
comments(first: 100) {
nodes {
id
body
bodyHTML
createdAt
updatedAt
upvoteCount
isAnswer
viewerHasUpvoted
viewerCanUpvote
author {
... on User {
id
login
avatarUrl
url
}
}
replies(first: 50) {
totalCount
nodes {
id
body
bodyHTML
createdAt
updatedAt
upvoteCount
isAnswer
viewerHasUpvoted
viewerCanUpvote
author {
... on User {
id
login
avatarUrl
url
}
}
}
}
}
}
}
}
}
`);
return data.repository.discussion?.comments.nodes || [];
}
/**
* 创建回复
* Create reply
*/
async createReply(params: CreateReplyParams): Promise<Reply | null> {
try {
if (params.replyToId) {
// 回复评论 | Reply to comment
const data = await this.graphql<{
addDiscussionComment: {
comment: Reply
}
}>(`
mutation AddReply($input: AddDiscussionCommentInput!) {
addDiscussionComment(input: $input) {
comment {
id
body
bodyHTML
createdAt
updatedAt
upvoteCount
isAnswer
viewerHasUpvoted
viewerCanUpvote
author {
... on User {
id
login
avatarUrl
url
}
}
}
}
}
`, {
input: {
discussionId: params.discussionId,
replyToId: params.replyToId,
body: params.body
}
});
return data.addDiscussionComment.comment;
} else {
// 直接评论帖子 | Direct comment on discussion
const data = await this.graphql<{
addDiscussionComment: {
comment: Reply
}
}>(`
mutation AddComment($input: AddDiscussionCommentInput!) {
addDiscussionComment(input: $input) {
comment {
id
body
bodyHTML
createdAt
updatedAt
upvoteCount
isAnswer
viewerHasUpvoted
viewerCanUpvote
author {
... on User {
id
login
avatarUrl
url
}
}
}
}
}
`, {
input: {
discussionId: params.discussionId,
body: params.body
}
});
return data.addDiscussionComment.comment;
}
} catch (err) {
console.error('[ForumService] Create reply failed:', err);
return null;
}
}
/**
* 点赞/取消点赞回复
* Upvote/remove upvote from reply
*/
async toggleReplyUpvote(commentId: string, hasUpvoted: boolean): Promise<boolean> {
try {
if (hasUpvoted) {
await this.graphql(`
mutation {
removeUpvote(input: { subjectId: "${commentId}" }) {
subject {
id
}
}
}
`);
} else {
await this.graphql(`
mutation {
addUpvote(input: { subjectId: "${commentId}" }) {
subject {
id
}
}
}
`);
}
return true;
} catch (err) {
console.error('[ForumService] Toggle reply upvote failed:', err);
return false;
}
}
// =====================================================
// 图片上传 | Image Upload
// =====================================================
/** Imgur Client ID (匿名上传) | Imgur Client ID (anonymous upload) */
private readonly IMGUR_CLIENT_ID = '546c25a59c58ad7';
/**
* 上传图片到 Imgur 图床
* Upload image to Imgur
* @param file 图片文件 | Image file
* @param onProgress 进度回调 | Progress callback
* @returns 图片 URL | Image URL
*/
async uploadImage(
file: File,
onProgress?: (progress: number) => void
): Promise<string> {
// 验证文件类型 | Validate file type
const allowedTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
throw new Error('Only PNG, JPEG, GIF, and WebP images are allowed');
}
// 限制文件大小 (10MB - Imgur 限制) | Limit file size (10MB - Imgur limit)
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
throw new Error('Image size must be less than 10MB');
}
onProgress?.(10);
// 读取文件为 base64 | Read file as base64
const base64Content = await this.fileToBase64(file);
onProgress?.(30);
// 使用 Imgur API 上传 | Upload using Imgur API
const response = await fetch('https://api.imgur.com/3/image', {
method: 'POST',
headers: {
'Authorization': `Client-ID ${this.IMGUR_CLIENT_ID}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
image: base64Content,
type: 'base64'
})
});
onProgress?.(80);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error('[ForumService] Imgur upload failed:', errorData);
throw new Error(`Failed to upload image: ${response.status}`);
}
const data = await response.json();
onProgress?.(100);
if (!data.success || !data.data?.link) {
throw new Error('Imgur upload failed: invalid response');
}
return data.data.link;
}
/**
* 将文件转换为 base64
* Convert file to base64
*/
private fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
// 移除 data:image/xxx;base64, 前缀 | Remove data:image/xxx;base64, prefix
const base64 = result.split(',')[1] || '';
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
}
// 单例实例 | Singleton instance
let forumServiceInstance: ForumService | null = null;
export function getForumService(): ForumService {
if (!forumServiceInstance) {
forumServiceInstance = new ForumService();
}
return forumServiceInstance;
}

View File

@@ -0,0 +1,7 @@
/**
* 论坛服务导出 - GitHub Discussions
* Forum service exports - GitHub Discussions
*/
export { ForumService, getForumService } from './ForumService';
export type { DeviceCodeResponse } from './ForumService';
export * from './types';

View File

@@ -0,0 +1,146 @@
/**
* 论坛类型定义 - GitHub Discussions
* Forum type definitions - GitHub Discussions
*/
/**
* GitHub 用户信息
* GitHub user info
*/
export interface GitHubUser {
id: string;
login: string;
avatarUrl: string;
url: string;
}
/**
* Discussion 分类
* Discussion category
*/
export interface Category {
id: string;
name: string;
slug: string;
emoji: string;
description: string;
isAnswerable: boolean;
}
/**
* Discussion 帖子
* Discussion post
*/
export interface Post {
id: string;
number: number;
title: string;
body: string;
bodyHTML: string;
author: GitHubUser;
category: Category;
createdAt: string;
updatedAt: string;
upvoteCount: number;
comments: {
totalCount: number;
};
answerChosenAt?: string;
answerChosenBy?: GitHubUser;
url: string;
viewerHasUpvoted: boolean;
viewerCanUpvote: boolean;
}
/**
* Discussion 评论
* Discussion comment
*/
export interface Reply {
id: string;
body: string;
bodyHTML: string;
author: GitHubUser;
createdAt: string;
updatedAt: string;
upvoteCount: number;
isAnswer: boolean;
viewerHasUpvoted: boolean;
viewerCanUpvote: boolean;
replies?: {
totalCount: number;
nodes: Reply[];
};
}
/**
* 帖子列表查询参数
* Post list query parameters
*/
export interface PostListParams {
categoryId?: string;
search?: string;
first?: number;
after?: string;
}
/**
* 分页信息
* Pagination info
*/
export interface PageInfo {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string | null;
endCursor: string | null;
}
/**
* 分页响应
* Paginated response
*/
export interface PaginatedResponse<T> {
data: T[];
totalCount: number;
pageInfo: PageInfo;
}
/**
* 创建帖子参数
* Create post parameters
*/
export interface CreatePostParams {
title: string;
body: string;
categoryId: string;
}
/**
* 创建回复参数
* Create reply parameters
*/
export interface CreateReplyParams {
discussionId: string;
body: string;
replyToId?: string;
}
/**
* 论坛用户状态
* Forum user state
*/
export interface ForumUser {
id: string;
login: string;
avatarUrl: string;
accessToken: string;
}
/**
* 认证状态
* Auth state
*/
export type AuthState =
| { status: 'loading' }
| { status: 'authenticated'; user: ForumUser }
| { status: 'unauthenticated' };