Compare commits

..

32 Commits

Author SHA1 Message Date
yhh
34de1e5edf feat(docs): 添加中英文国际化支持 2025-12-03 22:44:04 +08:00
yhh
94e0979941 fix: 修复CodeQL检测到的代码问题 2025-12-03 22:11:37 +08:00
yhh
0a3f2a3e21 fix: 修复CodeQL检测到的代码问题 2025-12-03 21:31:18 +08:00
yhh
9c30ab26a6 fix(editor-core): 修复Rollup构建配置添加tauri external 2025-12-03 21:22:09 +08:00
yhh
3c50795dee Merge remote-tracking branch 'origin/master' into develop 2025-12-03 21:05:27 +08:00
yhh
5a0d67b3f6 fix(material-system): 修复tsconfig配置支持TypeScript项目引用 2025-12-03 21:04:59 +08:00
yhh
d1ba10564a fix: 修复类型检查错误 2025-12-03 18:37:02 +08:00
yhh
cf00e062f7 fix: 修复构建错误和缺失依赖 2025-12-03 18:25:08 +08:00
yhh
293ac2dca3 fix: 修复CodeQL检测到的代码问题 2025-12-03 18:15:34 +08:00
yhh
f7535a2aac fix: 添加缺失的包依赖修复CI构建 2025-12-03 18:01:13 +08:00
yhh
ca18be32a8 fix(ecs-engine-bindgen): 添加缺失的ecs-framework-math依赖 2025-12-03 17:44:19 +08:00
yhh
025ce89ded feat(ui): 添加UI屏幕适配系统(CanvasScaler/SafeArea) 2025-12-03 17:39:58 +08:00
yhh
2311419e71 fix(tilemap-editor): 修复画布高DPI屏幕分辨率适配问题 2025-12-03 17:29:57 +08:00
yhh
373bdd5d2b fix: 修复Rust文档测试和添加rapier2d WASM绑定 2025-12-03 17:27:54 +08:00
yhh
b58e75d9a4 docs: 更新README和文档主题样式 2025-12-03 17:15:54 +08:00
yhh
099809a98c chore: 移除BehaviourTree-ai和ecs-astar子模块 2025-12-03 16:25:49 +08:00
yhh
83aee02540 chore: 添加第三方依赖库 2025-12-03 16:24:08 +08:00
yhh
cb1b171216 refactor(plugins): 更新插件模板使用ModuleManifest 2025-12-03 16:23:35 +08:00
yhh
b64b489b89 chore: 更新依赖和构建配置 2025-12-03 16:21:11 +08:00
yhh
13cb670a16 feat(core): 添加module.json和类型定义更新 2025-12-03 16:20:59 +08:00
yhh
37ab494e4a feat(modules): 添加module.json配置 2025-12-03 16:20:48 +08:00
yhh
e1d494b415 feat(tilemap): 增强tilemap编辑器和动画系统 2025-12-03 16:20:34 +08:00
yhh
243b929d5e feat(material): 新增材质系统和着色器编辑器 2025-12-03 16:20:23 +08:00
yhh
4a2362edf2 feat(engine): 添加材质系统和着色器管理 2025-12-03 16:20:13 +08:00
yhh
0c590d7c12 feat(platform-web): 添加BrowserRuntime和资产读取 2025-12-03 16:20:01 +08:00
yhh
c2f8cb5272 feat(editor-app): 重构浏览器预览使用import maps 2025-12-03 16:19:50 +08:00
yhh
55f644a091 feat(editor-core): 添加构建系统和模块管理 2025-12-03 16:19:40 +08:00
yhh
d3dfaa7aac feat(asset-system-editor): 新增编辑器资产管理包 2025-12-03 16:19:29 +08:00
yhh
25e70a1d7b feat(asset-system): 添加运行时资产目录和bundle格式 2025-12-03 16:19:03 +08:00
yhh
e2cca5e490 feat(physics-rapier2d): 添加跨平台WASM加载器 2025-12-03 16:18:48 +08:00
yhh
b3f7676452 feat(rapier2d): 新增Rapier2D WASM绑定包 2025-12-03 16:18:37 +08:00
yhh
e6fb80d0be feat(platform-common): 添加WASM加载器和环境检测API 2025-12-03 16:18:21 +08:00
23 changed files with 27 additions and 5248 deletions

View File

@@ -387,8 +387,8 @@ export class ECSGameManager extends Component {
You've successfully created your first ECS application! Next you can: You've successfully created your first ECS application! Next you can:
- Check the complete [API Documentation](/api/README) - Check the complete [API Documentation](/en/api/README)
- Explore more [practical examples](/examples/) - Explore more [practical examples](/en/examples/)
## FAQ ## FAQ

View File

@@ -4,40 +4,40 @@ Welcome to the ECS Framework Guide. This guide covers the core concepts and usag
## Core Concepts ## Core Concepts
### [Entity](/guide/entity) ### [Entity](./entity.md)
Learn the basics of ECS architecture - how to use entities, lifecycle management, and best practices. Learn the basics of ECS architecture - how to use entities, lifecycle management, and best practices.
### [Component](/guide/component) ### [Component](./component.md)
Learn how to create and use components for modular game feature design. Learn how to create and use components for modular game feature design.
### [System](/guide/system) ### [System](./system.md)
Master system development to implement game logic processing. Master system development to implement game logic processing.
### [Entity Query & Matcher](/guide/entity-query) ### [Entity Query & Matcher](./entity-query.md)
Learn to use Matcher for entity filtering and queries with `all`, `any`, `none`, `nothing` conditions. Learn to use Matcher for entity filtering and queries with `all`, `any`, `none`, `nothing` conditions.
### [Scene](/guide/scene) ### [Scene](./scene.md)
Understand scene lifecycle, system management, and entity container features. Understand scene lifecycle, system management, and entity container features.
### [Event System](/guide/event-system) ### [Event System](./event-system.md)
Master the type-safe event system for component communication and system coordination. Master the type-safe event system for component communication and system coordination.
### [Serialization](/guide/serialization) ### [Serialization](./serialization.md)
Master serialization for scenes, entities, and components. Supports full and incremental serialization for game saves, network sync, and more. Master serialization for scenes, entities, and components. Supports full and incremental serialization for game saves, network sync, and more.
### [Time and Timers](/guide/time-and-timers) ### [Time and Timers](./time-and-timers.md)
Learn time management and timer systems for precise game logic timing control. Learn time management and timer systems for precise game logic timing control.
### [Logging](/guide/logging) ### [Logging](./logging.md)
Master the leveled logging system for debugging, monitoring, and error tracking. Master the leveled logging system for debugging, monitoring, and error tracking.
### [Platform Adapter](/guide/platform-adapter) ### [Platform Adapter](./platform-adapter.md)
Learn how to implement and register platform adapters for browsers, mini-games, Node.js, and more. Learn how to implement and register platform adapters for browsers, mini-games, Node.js, and more.
## Advanced Features ## Advanced Features
### [Service Container](/guide/service-container) ### [Service Container](./service-container.md)
Master dependency injection and service management for loosely-coupled architecture. Master dependency injection and service management for loosely-coupled architecture.
### [Plugin System](/guide/plugin-system) ### [Plugin System](./plugin-system.md)
Learn how to develop and use plugins to extend framework functionality. Learn how to develop and use plugins to extend framework functionality.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@esengine/ecs-framework", "name": "@esengine/ecs-framework",
"version": "2.2.19", "version": "2.2.18",
"description": "用于Laya、Cocos Creator等JavaScript游戏引擎的高性能ECS框架", "description": "用于Laya、Cocos Creator等JavaScript游戏引擎的高性能ECS框架",
"main": "bin/index.js", "main": "bin/index.js",
"types": "bin/index.d.ts", "types": "bin/index.d.ts",

View File

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

View File

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

View File

@@ -1,255 +0,0 @@
/**
* 论坛认证样式 - 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

@@ -1,161 +0,0 @@
/**
* 论坛登录组件 - 使用 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

@@ -1,603 +0,0 @@
/**
* 论坛创建帖子样式
* 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

@@ -1,459 +0,0 @@
/**
* 论坛创建帖子组件 - 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

@@ -1,170 +0,0 @@
/**
* 论坛面板样式
* 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

@@ -1,184 +0,0 @@
/**
* 论坛面板主组件 - 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

@@ -1,560 +0,0 @@
/**
* 论坛帖子详情样式
* 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

@@ -1,270 +0,0 @@
/**
* 论坛帖子详情组件 - 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

@@ -1,590 +0,0 @@
/**
* 论坛帖子列表样式
* 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

@@ -1,341 +0,0 @@
/**
* 帖子列表组件 - 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

@@ -1,97 +0,0 @@
/**
* 用户资料样式 - 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

@@ -1,66 +0,0 @@
/**
* 用户资料组件 - 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

@@ -1,9 +0,0 @@
/**
* 论坛组件导出
* 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

@@ -1,138 +0,0 @@
/**
* 论坛工具函数 | 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

@@ -1,254 +0,0 @@
/**
* 论坛 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

@@ -1,904 +0,0 @@
/**
* 论坛服务 - 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

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

View File

@@ -1,146 +0,0 @@
/**
* 论坛类型定义 - 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' };