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
42 changed files with 60 additions and 6681 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

@@ -321,19 +321,11 @@ export class Entity {
/** /**
* 通知Scene中的QuerySystem实体组件发生变动 * 通知Scene中的QuerySystem实体组件发生变动
*
* Notify the QuerySystem in Scene that entity components have changed
*
* @param changedComponentType 变化的组件类型(可选,用于优化通知) | Changed component type (optional, for optimized notification)
*/ */
private notifyQuerySystems(changedComponentType?: ComponentType): void { private notifyQuerySystems(): void {
if (this.scene && this.scene.querySystem) { if (this.scene && this.scene.querySystem) {
this.scene.querySystem.updateEntity(this); this.scene.querySystem.updateEntity(this);
this.scene.clearSystemEntityCaches(); this.scene.clearSystemEntityCaches();
// 事件驱动:立即通知关心该组件的系统 | Event-driven: notify systems that care about this component
if (this.scene.notifyEntityComponentChanged) {
this.scene.notifyEntityComponentChanged(this, changedComponentType);
}
} }
} }
@@ -389,7 +381,7 @@ export class Entity {
}); });
} }
this.notifyQuerySystems(componentType); this.notifyQuerySystems();
return component; return component;
} }
@@ -522,7 +514,7 @@ export class Entity {
}); });
} }
this.notifyQuerySystems(componentType); this.notifyQuerySystems();
} }
/** /**

View File

@@ -2,7 +2,7 @@ import { Entity } from './Entity';
import { EntityList } from './Utils/EntityList'; import { EntityList } from './Utils/EntityList';
import { IdentifierPool } from './Utils/IdentifierPool'; import { IdentifierPool } from './Utils/IdentifierPool';
import { EntitySystem } from './Systems/EntitySystem'; import { EntitySystem } from './Systems/EntitySystem';
import { ComponentStorageManager, ComponentType } from './Core/ComponentStorage'; import { ComponentStorageManager } from './Core/ComponentStorage';
import { QuerySystem } from './Core/QuerySystem'; import { QuerySystem } from './Core/QuerySystem';
import { TypeSafeEventSystem } from './Core/EventSystem'; import { TypeSafeEventSystem } from './Core/EventSystem';
import type { ReferenceTracker } from './Core/ReferenceTracker'; import type { ReferenceTracker } from './Core/ReferenceTracker';
@@ -120,26 +120,9 @@ export interface IScene {
/** /**
* 清除所有EntitySystem的实体缓存 * 清除所有EntitySystem的实体缓存
* Clear all EntitySystem entity caches
*/ */
clearSystemEntityCaches(): void; clearSystemEntityCaches(): void;
/**
* 通知相关系统实体的组件发生了变化
*
* 当组件被添加或移除时调用,立即通知相关系统检查该实体是否匹配,
* 并触发 onAdded/onRemoved 回调。通过组件ID索引优化只通知关心该组件的系统。
*
* Notify relevant systems that an entity's components have changed.
* Called when a component is added or removed, immediately notifying
* relevant systems to check if the entity matches and trigger onAdded/onRemoved callbacks.
* Optimized via component ID indexing to only notify systems that care about the changed component.
*
* @param entity 组件发生变化的实体 | The entity whose components changed
* @param changedComponentType 变化的组件类型(可选) | The changed component type (optional)
*/
notifyEntityComponentChanged(entity: Entity, changedComponentType?: ComponentType): void;
/** /**
* 添加实体 * 添加实体
*/ */

View File

@@ -151,30 +151,6 @@ export class Scene implements IScene {
*/ */
private _systemAddCounter: number = 0; private _systemAddCounter: number = 0;
/**
* 组件ID到系统的索引映射
*
* 用于快速查找关心特定组件的系统,避免遍历所有系统。
* 使用组件ID数字而非ComponentType作为key避免类引用问题。
*
* Component ID to systems index map.
* Used for fast lookup of systems that care about specific components.
* Uses component ID (number) instead of ComponentType as key to avoid class reference issues.
*/
private _componentIdToSystems: Map<number, Set<EntitySystem>> = new Map();
/**
* 需要接收所有组件变化通知的系统集合
*
* 包括使用 none 条件、tag/name 查询、或空匹配器的系统。
* 这些系统无法通过组件ID索引优化需要在每次组件变化时都检查。
*
* Systems that need to receive all component change notifications.
* Includes systems using none conditions, tag/name queries, or empty matchers.
* These systems cannot be optimized via component ID indexing.
*/
private _globalNotifySystems: Set<EntitySystem> = new Set();
/** /**
* 获取场景中所有已注册的EntitySystem * 获取场景中所有已注册的EntitySystem
* *
@@ -368,10 +344,6 @@ export class Scene implements IScene {
// 清空系统缓存 // 清空系统缓存
this._cachedSystems = null; this._cachedSystems = null;
this._systemsOrderDirty = true; this._systemsOrderDirty = true;
// 清空组件索引 | Clear component indices
this._componentIdToSystems.clear();
this._globalNotifySystems.clear();
} }
/** /**
@@ -481,146 +453,6 @@ export class Scene implements IScene {
} }
} }
/**
* 通知相关系统实体的组件发生了变化
*
* 这是事件驱动设计的核心:当组件被添加或移除时,立即通知相关系统检查该实体是否匹配,
* 并触发 onAdded/onRemoved 回调。通过组件ID索引优化只通知关心该组件的系统。
*
* This is the core of event-driven design: when a component is added or removed,
* immediately notify relevant systems to check if the entity matches and trigger
* onAdded/onRemoved callbacks. Optimized via component ID indexing to only notify
* systems that care about the changed component.
*
* @param entity 组件发生变化的实体 | The entity whose components changed
* @param changedComponentType 变化的组件类型(可选) | The changed component type (optional)
*/
public notifyEntityComponentChanged(entity: Entity, changedComponentType?: ComponentType): void {
// 已通知的系统集合,避免重复通知 | Set of notified systems to avoid duplicates
const notifiedSystems = new Set<EntitySystem>();
// 如果提供了组件类型,使用索引优化 | If component type provided, use index optimization
if (changedComponentType && ComponentRegistry.isRegistered(changedComponentType)) {
const componentId = ComponentRegistry.getBitIndex(changedComponentType);
const interestedSystems = this._componentIdToSystems.get(componentId);
if (interestedSystems) {
for (const system of interestedSystems) {
system.handleEntityComponentChanged(entity);
notifiedSystems.add(system);
}
}
}
// 通知全局监听系统none条件、tag/name查询等 | Notify global listener systems
for (const system of this._globalNotifySystems) {
if (!notifiedSystems.has(system)) {
system.handleEntityComponentChanged(entity);
notifiedSystems.add(system);
}
}
// 如果没有提供组件类型,回退到遍历所有系统 | Fallback to all systems if no component type
if (!changedComponentType) {
for (const system of this.systems) {
if (!notifiedSystems.has(system)) {
system.handleEntityComponentChanged(entity);
}
}
}
}
/**
* 将系统添加到组件索引
*
* 根据系统的 Matcher 条件将系统注册到相应的组件ID索引中。
*
* Index a system by its interested component types.
* Registers the system to component ID indices based on its Matcher conditions.
*
* @param system 要索引的系统 | The system to index
*/
private indexSystemByComponents(system: EntitySystem): void {
const matcher = system.matcher;
if (!matcher) {
return;
}
// nothing 匹配器不需要索引 | Nothing matcher doesn't need indexing
if (matcher.isNothing()) {
return;
}
const condition = matcher.getCondition();
// 有 none/tag/name 条件的系统加入全局通知 | Systems with none/tag/name go to global
if (condition.none.length > 0 || condition.tag !== undefined || condition.name !== undefined) {
this._globalNotifySystems.add(system);
}
// 空匹配器(匹配所有实体)加入全局通知 | Empty matcher (matches all) goes to global
if (matcher.isEmpty()) {
this._globalNotifySystems.add(system);
return;
}
// 索引 all 条件中的组件 | Index components in all condition
for (const componentType of condition.all) {
this.addSystemToComponentIndex(componentType, system);
}
// 索引 any 条件中的组件 | Index components in any condition
for (const componentType of condition.any) {
this.addSystemToComponentIndex(componentType, system);
}
// 索引单组件查询 | Index single component query
if (condition.component) {
this.addSystemToComponentIndex(condition.component, system);
}
}
/**
* 将系统添加到指定组件的索引
*
* Add system to the index for a specific component type.
*
* @param componentType 组件类型 | Component type
* @param system 系统 | System
*/
private addSystemToComponentIndex(componentType: ComponentType, system: EntitySystem): void {
if (!ComponentRegistry.isRegistered(componentType)) {
ComponentRegistry.register(componentType);
}
const componentId = ComponentRegistry.getBitIndex(componentType);
let systems = this._componentIdToSystems.get(componentId);
if (!systems) {
systems = new Set();
this._componentIdToSystems.set(componentId, systems);
}
systems.add(system);
}
/**
* 从组件索引中移除系统
*
* Remove a system from all component indices.
*
* @param system 要移除的系统 | The system to remove
*/
private removeSystemFromIndex(system: EntitySystem): void {
// 从全局通知列表移除 | Remove from global notify list
this._globalNotifySystems.delete(system);
// 从所有组件索引中移除 | Remove from all component indices
for (const systems of this._componentIdToSystems.values()) {
systems.delete(system);
}
}
/** /**
* 在场景的实体列表中添加一个实体 * 在场景的实体列表中添加一个实体
* @param entity 要添加的实体 * @param entity 要添加的实体
@@ -906,9 +738,6 @@ export class Scene implements IScene {
// 标记系统列表已变化 // 标记系统列表已变化
this.markSystemsOrderDirty(); this.markSystemsOrderDirty();
// 建立组件类型到系统的索引 | Build component type to system index
this.indexSystemByComponents(system);
injectProperties(system, this._services); injectProperties(system, this._services);
// 调试模式下自动包装系统方法以收集性能数据ProfilerSDK 启用时表示调试模式) // 调试模式下自动包装系统方法以收集性能数据ProfilerSDK 启用时表示调试模式)
@@ -993,9 +822,6 @@ export class Scene implements IScene {
// 标记系统列表已变化 // 标记系统列表已变化
this.markSystemsOrderDirty(); this.markSystemsOrderDirty();
// 从组件类型索引中移除 | Remove from component type index
this.removeSystemFromIndex(processor);
// 重置System状态 // 重置System状态
processor.reset(); processor.reset();
} }

View File

@@ -235,7 +235,7 @@ export abstract class EntitySystem implements ISystemBase, IService {
* 在系统创建时调用。框架内部使用,用户不应直接调用。 * 在系统创建时调用。框架内部使用,用户不应直接调用。
*/ */
public initialize(): void { public initialize(): void {
// 防止重复初始化 | Prevent re-initialization // 防止重复初始化
if (this._initialized) { if (this._initialized) {
return; return;
} }
@@ -243,20 +243,13 @@ export abstract class EntitySystem implements ISystemBase, IService {
this._initialized = true; this._initialized = true;
// 框架内部初始化:触发一次实体查询,以便正确跟踪现有实体 // 框架内部初始化:触发一次实体查询,以便正确跟踪现有实体
// Framework initialization: query entities once to track existing entities
if (this.scene) { if (this.scene) {
// 清理缓存确保初始化时重新查询 | Clear cache to ensure fresh query // 清理缓存确保初始化时重新查询
this._entityCache.invalidate(); this._entityCache.invalidate();
const entities = this.queryEntities(); this.queryEntities();
// 初始化时对已存在的匹配实体触发 onAdded
// Trigger onAdded for existing matching entities during initialization
for (const entity of entities) {
this.onAdded(entity);
}
} }
// 调用用户可重写的初始化方法 | Call user-overridable initialization method // 调用用户可重写的初始化方法
this.onInitialize(); this.onInitialize();
} }
@@ -725,151 +718,32 @@ export abstract class EntitySystem implements ISystemBase, IService {
return `${this._systemName}[${entityCount} entities]${perfInfo}`; return `${this._systemName}[${entityCount} entities]${perfInfo}`;
} }
/**
* 检查实体是否匹配当前系统的查询条件
* Check if an entity matches this system's query condition
*
* @param entity 要检查的实体 / The entity to check
* @returns 是否匹配 / Whether the entity matches
*/
public matchesEntity(entity: Entity): boolean {
if (!this._matcher) {
return false;
}
// nothing 匹配器不匹配任何实体
if (this._matcher.isNothing()) {
return false;
}
// 空匹配器匹配所有实体
if (this._matcher.isEmpty()) {
return true;
}
const condition = this._matcher.getCondition();
// 检查 all 条件
for (const componentType of condition.all) {
if (!entity.hasComponent(componentType)) {
return false;
}
}
// 检查 any 条件
if (condition.any.length > 0) {
let hasAny = false;
for (const componentType of condition.any) {
if (entity.hasComponent(componentType)) {
hasAny = true;
break;
}
}
if (!hasAny) {
return false;
}
}
// 检查 none 条件
for (const componentType of condition.none) {
if (entity.hasComponent(componentType)) {
return false;
}
}
// 检查 tag 条件
if (condition.tag !== undefined && entity.tag !== condition.tag) {
return false;
}
// 检查 name 条件
if (condition.name !== undefined && entity.name !== condition.name) {
return false;
}
// 检查单组件条件
if (condition.component !== undefined && !entity.hasComponent(condition.component)) {
return false;
}
return true;
}
/**
* 检查实体是否正在被此系统跟踪
* Check if an entity is being tracked by this system
*
* @param entity 要检查的实体 / The entity to check
* @returns 是否正在跟踪 / Whether the entity is being tracked
*/
public isTracking(entity: Entity): boolean {
return this._entityCache.isTracked(entity);
}
/**
* 当实体的组件发生变化时由 Scene 调用
*
* 立即检查实体是否匹配并触发 onAdded/onRemoved 回调。
* 这是事件驱动设计的核心:组件变化时立即通知相关系统。
*
* Called by Scene when an entity's components change.
* Immediately checks if the entity matches and triggers onAdded/onRemoved callbacks.
* This is the core of event-driven design: notify relevant systems immediately when components change.
*
* @param entity 组件发生变化的实体 / The entity whose components changed
* @internal 由 Scene.notifyEntityComponentChanged 调用 / Called by Scene.notifyEntityComponentChanged
*/
public handleEntityComponentChanged(entity: Entity): void {
if (!this._matcher || !this._enabled) {
return;
}
const wasTracked = this._entityCache.isTracked(entity);
const nowMatches = this.matchesEntity(entity);
if (!wasTracked && nowMatches) {
// 新匹配:添加跟踪并触发 onAdded | New match: add tracking and trigger onAdded
this._entityCache.addTracked(entity);
this._entityCache.invalidate();
this.onAdded(entity);
} else if (wasTracked && !nowMatches) {
// 不再匹配:移除跟踪并触发 onRemoved | No longer matches: remove tracking and trigger onRemoved
this._entityCache.removeTracked(entity);
this._entityCache.invalidate();
this.onRemoved(entity);
}
}
/** /**
* 更新实体跟踪,检查新增和移除的实体 * 更新实体跟踪,检查新增和移除的实体
*
* 由于采用了事件驱动设计,运行时的 onAdded/onRemoved 已在 handleEntityComponentChanged 中
* 立即触发。此方法不再触发回调,只同步跟踪状态。
*
* With event-driven design, runtime onAdded/onRemoved are triggered immediately in
* handleEntityComponentChanged. This method no longer triggers callbacks, only syncs tracking state.
*/ */
private updateEntityTracking(currentEntities: readonly Entity[]): void { private updateEntityTracking(currentEntities: readonly Entity[]): void {
const currentSet = new Set(currentEntities); const currentSet = new Set(currentEntities);
let hasChanged = false; let hasChanged = false;
// 检查新增的实体 | Check for newly added entities // 检查新增的实体
for (const entity of currentEntities) { for (const entity of currentEntities) {
if (!this._entityCache.isTracked(entity)) { if (!this._entityCache.isTracked(entity)) {
this._entityCache.addTracked(entity); this._entityCache.addTracked(entity);
this.onAdded(entity);
hasChanged = true; hasChanged = true;
} }
} }
// 检查移除的实体 | Check for removed entities // 检查移除的实体
for (const entity of this._entityCache.getTracked()) { for (const entity of this._entityCache.getTracked()) {
if (!currentSet.has(entity)) { if (!currentSet.has(entity)) {
this._entityCache.removeTracked(entity); this._entityCache.removeTracked(entity);
this.onRemoved(entity);
hasChanged = true; hasChanged = true;
} }
} }
// 如果实体发生了变化,使缓存失效 | If entities changed, invalidate cache // 如果实体发生了变化,使缓存失效
if (hasChanged) { if (hasChanged) {
this._entityCache.invalidate(); this._entityCache.invalidate();
} }

View File

@@ -439,215 +439,6 @@ describe('EntitySystem', () => {
scene.removeSystem(trackingSystem); scene.removeSystem(trackingSystem);
}); });
it('在系统 process 中添加组件时应立即触发其他系统的 onAdded', () => {
// 使用独立的场景,避免 beforeEach 创建的实体干扰
// Use independent scene to avoid interference from beforeEach entities
const testScene = new Scene();
// 组件定义
class TagComponent extends TestComponent {}
// SystemA: 匹配 TestComponent + TagComponent
class SystemA extends EntitySystem {
public onAddedEntities: Entity[] = [];
constructor() {
super(Matcher.all(TestComponent, TagComponent));
}
protected override onAdded(entity: Entity): void {
this.onAddedEntities.push(entity);
}
}
// TriggerSystem: 在 process 中添加 TagComponent
class TriggerSystem extends EntitySystem {
constructor() {
super(Matcher.all(TestComponent));
}
protected override process(entities: readonly Entity[]): void {
for (const entity of entities) {
if (!entity.hasComponent(TagComponent)) {
entity.addComponent(new TagComponent());
}
}
}
}
const systemA = new SystemA();
const triggerSystem = new TriggerSystem();
// 注意SystemA 先注册TriggerSystem 后注册
// 事件驱动设计确保即使 SystemA 已执行完毕,也能收到 onAdded 通知
testScene.addSystem(systemA);
testScene.addSystem(triggerSystem);
// 创建实体(已有 TestComponent
const testEntity = testScene.createEntity('test');
testEntity.addComponent(new TestComponent());
// 执行一帧TriggerSystem 会添加 TagComponentSystemA 应立即收到 onAdded
testScene.update();
expect(systemA.onAddedEntities.length).toBe(1);
expect(systemA.onAddedEntities[0]).toBe(testEntity);
testScene.removeSystem(systemA);
testScene.removeSystem(triggerSystem);
});
it('同一帧内添加后移除组件onAdded 和 onRemoved 都应触发', () => {
// 使用独立的场景,避免 beforeEach 创建的实体干扰
// Use independent scene to avoid interference from beforeEach entities
const testScene = new Scene();
class TagComponent extends TestComponent {}
class TrackingSystemWithTag extends EntitySystem {
public onAddedEntities: Entity[] = [];
public onRemovedEntities: Entity[] = [];
constructor() {
super(Matcher.all(TestComponent, TagComponent));
}
protected override onAdded(entity: Entity): void {
this.onAddedEntities.push(entity);
}
protected override onRemoved(entity: Entity): void {
this.onRemovedEntities.push(entity);
}
}
// AddSystem: 在 process 中添加 TagComponent
class AddSystem extends EntitySystem {
constructor() {
super(Matcher.all(TestComponent));
}
protected override process(entities: readonly Entity[]): void {
for (const entity of entities) {
if (!entity.hasComponent(TagComponent)) {
entity.addComponent(new TagComponent());
}
}
}
}
// RemoveSystem: 在 lateProcess 中移除 TagComponent
class RemoveSystem extends EntitySystem {
constructor() {
super(Matcher.all(TagComponent));
}
protected override lateProcess(entities: readonly Entity[]): void {
for (const entity of entities) {
const tag = entity.getComponent(TagComponent);
if (tag) {
entity.removeComponent(tag);
}
}
}
}
const trackingSystem = new TrackingSystemWithTag();
const addSystem = new AddSystem();
const removeSystem = new RemoveSystem();
testScene.addSystem(trackingSystem);
testScene.addSystem(addSystem);
testScene.addSystem(removeSystem);
const testEntity = testScene.createEntity('test');
testEntity.addComponent(new TestComponent());
// 执行一帧
testScene.update();
// AddSystem 添加了 TagComponentRemoveSystem 在 lateProcess 中移除
expect(testEntity.hasComponent(TagComponent)).toBe(false);
// 事件驱动onAdded 应该在组件添加时立即触发
expect(trackingSystem.onAddedEntities.length).toBe(1);
// onRemoved 应该在组件移除时立即触发
expect(trackingSystem.onRemovedEntities.length).toBe(1);
testScene.removeSystem(trackingSystem);
testScene.removeSystem(addSystem);
testScene.removeSystem(removeSystem);
});
it('多个系统监听同一组件变化时都应收到 onAdded 通知', () => {
// 使用独立的场景,避免 beforeEach 创建的实体干扰
// Use independent scene to avoid interference from beforeEach entities
const testScene = new Scene();
// 使用独立的组件类,避免继承带来的问题
// Use independent component class to avoid inheritance issues
class TagComponent2 extends Component {}
class SystemA extends EntitySystem {
public onAddedEntities: Entity[] = [];
constructor() {
super(Matcher.all(TestComponent, TagComponent2));
}
protected override onAdded(entity: Entity): void {
this.onAddedEntities.push(entity);
}
}
class SystemB extends EntitySystem {
public onAddedEntities: Entity[] = [];
constructor() {
super(Matcher.all(TestComponent, TagComponent2));
}
protected override onAdded(entity: Entity): void {
this.onAddedEntities.push(entity);
}
}
class TriggerSystem extends EntitySystem {
constructor() {
super(Matcher.all(TestComponent));
}
protected override process(entities: readonly Entity[]): void {
for (const entity of entities) {
if (!entity.hasComponent(TagComponent2)) {
entity.addComponent(new TagComponent2());
}
}
}
}
const systemA = new SystemA();
const systemB = new SystemB();
const triggerSystem = new TriggerSystem();
testScene.addSystem(systemA);
testScene.addSystem(systemB);
testScene.addSystem(triggerSystem);
const testEntity = testScene.createEntity('test');
testEntity.addComponent(new TestComponent());
testScene.update();
// 两个系统都应收到 onAdded 通知
expect(systemA.onAddedEntities.length).toBe(1);
expect(systemB.onAddedEntities.length).toBe(1);
testScene.removeSystem(systemA);
testScene.removeSystem(systemB);
testScene.removeSystem(triggerSystem);
});
}); });
describe('reset 方法', () => { describe('reset 方法', () => {

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

@@ -37,25 +37,6 @@ const filesToBundle = [
} }
]; ];
// Type definition files for IDE intellisense
// 用于 IDE 智能感知的类型定义文件
const typesDir = path.join(bundleDir, 'types');
if (!fs.existsSync(typesDir)) {
fs.mkdirSync(typesDir, { recursive: true });
console.log(`Created types directory: ${typesDir}`);
}
const typeFilesToBundle = [
{
src: path.join(rootPath, 'packages/core/dist/index.d.ts'),
dst: path.join(typesDir, 'ecs-framework.d.ts')
},
{
src: path.join(rootPath, 'packages/engine-core/dist/index.d.ts'),
dst: path.join(typesDir, 'engine-core.d.ts')
}
];
// Copy files // Copy files
let success = true; let success = true;
for (const { src, dst } of filesToBundle) { for (const { src, dst } of filesToBundle) {
@@ -78,24 +59,6 @@ for (const { src, dst } of filesToBundle) {
} }
} }
// Copy type definition files (optional - don't fail if not found)
// 复制类型定义文件(可选 - 找不到不报错)
for (const { src, dst } of typeFilesToBundle) {
try {
if (!fs.existsSync(src)) {
console.warn(`Type definition not found: ${src}`);
console.log(' Build packages first: pnpm --filter @esengine/core build');
continue;
}
fs.copyFileSync(src, dst);
const stats = fs.statSync(dst);
console.log(`✓ Bundled type definition ${path.basename(dst)} (${(stats.size / 1024).toFixed(2)} KB)`);
} catch (error) {
console.warn(`Failed to bundle type definition ${path.basename(src)}: ${error.message}`);
}
}
// Update tauri.conf.json to include runtime directory // Update tauri.conf.json to include runtime directory
if (success) { if (success) {
const tauriConfigPath = path.join(editorPath, 'src-tauri', 'tauri.conf.json'); const tauriConfigPath = path.join(editorPath, 'src-tauri', 'tauri.conf.json');

View File

@@ -75,35 +75,10 @@ pub fn open_file_with_default_app(file_path: String) -> Result<(), String> {
/// Show file in system file explorer /// Show file in system file explorer
#[tauri::command] #[tauri::command]
pub fn show_in_folder(file_path: String) -> Result<(), String> { pub fn show_in_folder(file_path: String) -> Result<(), String> {
println!("[show_in_folder] Received path: {}", file_path);
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
use std::path::Path;
// Normalize path separators for Windows
// 规范化路径分隔符
let normalized_path = file_path.replace('/', "\\");
println!("[show_in_folder] Normalized path: {}", normalized_path);
// Verify the path exists before trying to show it
// 验证路径存在
let path = Path::new(&normalized_path);
let exists = path.exists();
println!("[show_in_folder] Path exists: {}", exists);
if !exists {
return Err(format!("Path does not exist: {}", normalized_path));
}
// Windows explorer requires /select, to be concatenated with the path
// without spaces. Use a single argument to avoid shell parsing issues.
// Windows 资源管理器要求 /select, 与路径连接在一起,中间没有空格
let select_arg = format!("/select,{}", normalized_path);
println!("[show_in_folder] Explorer arg: {}", select_arg);
Command::new("explorer") Command::new("explorer")
.arg(&select_arg) .args(["/select,", &file_path])
.spawn() .spawn()
.map_err(|e| format!("Failed to show in folder: {}", e))?; .map_err(|e| format!("Failed to show in folder: {}", e))?;
} }
@@ -142,55 +117,6 @@ pub fn get_temp_dir() -> Result<String, String> {
.ok_or_else(|| "Failed to get temp directory".to_string()) .ok_or_else(|| "Failed to get temp directory".to_string())
} }
/// Open project folder with specified editor
/// 使用指定编辑器打开项目文件夹
///
/// @param project_path - Project folder path | 项目文件夹路径
/// @param editor_command - Editor command (e.g., "code", "cursor") | 编辑器命令
/// @param file_path - Optional file to open (will be opened in the editor) | 可选的要打开的文件
#[tauri::command]
pub fn open_with_editor(
project_path: String,
editor_command: String,
file_path: Option<String>,
) -> Result<(), String> {
use std::path::Path;
// Normalize paths
let normalized_project = project_path.replace('/', "\\");
let normalized_file = file_path.map(|f| f.replace('/', "\\"));
// Verify project path exists
let project = Path::new(&normalized_project);
if !project.exists() {
return Err(format!("Project path does not exist: {}", normalized_project));
}
println!(
"[open_with_editor] editor: {}, project: {}, file: {:?}",
editor_command, normalized_project, normalized_file
);
let mut cmd = Command::new(&editor_command);
// Add project folder as first argument
cmd.arg(&normalized_project);
// If a specific file is provided, add it as a second argument
// Most editors support: editor <folder> <file>
if let Some(ref file) = normalized_file {
let file_path = Path::new(file);
if file_path.exists() {
cmd.arg(file);
}
}
cmd.spawn()
.map_err(|e| format!("Failed to open with editor '{}': {}", editor_command, e))?;
Ok(())
}
/// Get application resource directory /// Get application resource directory
#[tauri::command] #[tauri::command]
pub fn get_app_resource_dir(app: AppHandle) -> Result<String, String> { pub fn get_app_resource_dir(app: AppHandle) -> Result<String, String> {
@@ -212,97 +138,6 @@ pub fn get_current_dir() -> Result<String, String> {
.map_err(|e| format!("Failed to get current directory: {}", e)) .map_err(|e| format!("Failed to get current directory: {}", e))
} }
/// Copy type definitions to project for IDE intellisense
/// 复制类型定义文件到项目以支持 IDE 智能感知
#[tauri::command]
pub fn copy_type_definitions(app: AppHandle, project_path: String) -> Result<(), String> {
use std::fs;
use std::path::Path;
let project = Path::new(&project_path);
if !project.exists() {
return Err(format!("Project path does not exist: {}", project_path));
}
// Create types directory in project
// 在项目中创建 types 目录
let types_dir = project.join("types");
if !types_dir.exists() {
fs::create_dir_all(&types_dir)
.map_err(|e| format!("Failed to create types directory: {}", e))?;
}
// Get resource directory (where bundled files are)
// 获取资源目录(打包文件所在位置)
let resource_dir = app.path()
.resource_dir()
.map_err(|e| format!("Failed to get resource directory: {}", e))?;
// Type definition files to copy
// 要复制的类型定义文件
// Format: (resource_path, workspace_path, dest_name)
// 格式:(资源路径,工作区路径,目标文件名)
// Note: resource_path is relative to Tauri resource dir (runtime/ is mapped to .)
// 注意resource_path 相对于 Tauri 资源目录runtime/ 映射到 .
let type_files = [
("types/ecs-framework.d.ts", "packages/core/dist/index.d.ts", "ecs-framework.d.ts"),
("types/engine-core.d.ts", "packages/engine-core/dist/index.d.ts", "engine-core.d.ts"),
];
// Try to find workspace root (for development mode)
// 尝试查找工作区根目录(用于开发模式)
let workspace_root = std::env::current_dir()
.ok()
.and_then(|cwd| {
// Look for pnpm-workspace.yaml or package.json in parent directories
// 在父目录中查找 pnpm-workspace.yaml 或 package.json
let mut dir = cwd.as_path();
loop {
if dir.join("pnpm-workspace.yaml").exists() {
return Some(dir.to_path_buf());
}
match dir.parent() {
Some(parent) => dir = parent,
None => return None,
}
}
});
let mut copied_count = 0;
for (resource_relative, workspace_relative, dest_name) in type_files {
let dest_path = types_dir.join(dest_name);
// Try resource directory first (production mode)
// 首先尝试资源目录(生产模式)
let src_path = resource_dir.join(resource_relative);
if src_path.exists() {
fs::copy(&src_path, &dest_path)
.map_err(|e| format!("Failed to copy {}: {}", resource_relative, e))?;
println!("[copy_type_definitions] Copied {} to {}", src_path.display(), dest_path.display());
copied_count += 1;
continue;
}
// Try workspace directory (development mode)
// 尝试工作区目录(开发模式)
if let Some(ref ws_root) = workspace_root {
let ws_src_path = ws_root.join(workspace_relative);
if ws_src_path.exists() {
fs::copy(&ws_src_path, &dest_path)
.map_err(|e| format!("Failed to copy {}: {}", workspace_relative, e))?;
println!("[copy_type_definitions] Copied {} to {} (dev mode)", ws_src_path.display(), dest_path.display());
copied_count += 1;
continue;
}
}
println!("[copy_type_definitions] {} not found, skipping", dest_name);
}
println!("[copy_type_definitions] Copied {} type definition files to {}", copied_count, types_dir.display());
Ok(())
}
/// Start a local HTTP server for runtime preview /// Start a local HTTP server for runtime preview
#[tauri::command] #[tauri::command]
pub fn start_local_server(root_path: String, port: u16) -> Result<String, String> { pub fn start_local_server(root_path: String, port: u16) -> Result<String, String> {

View File

@@ -81,8 +81,6 @@ fn main() {
commands::open_file_with_default_app, commands::open_file_with_default_app,
commands::show_in_folder, commands::show_in_folder,
commands::get_temp_dir, commands::get_temp_dir,
commands::open_with_editor,
commands::copy_type_definitions,
commands::get_app_resource_dir, commands::get_app_resource_dir,
commands::get_current_dir, commands::get_current_dir,
commands::start_local_server, commands::start_local_server,

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';
@@ -381,14 +380,6 @@ function App() {
// 设置 Tauri project:// 协议的基础路径(用于加载插件等项目文件) // 设置 Tauri project:// 协议的基础路径(用于加载插件等项目文件)
await TauriAPI.setProjectBasePath(projectPath); await TauriAPI.setProjectBasePath(projectPath);
// 复制类型定义到项目,用于 IDE 智能感知
// Copy type definitions to project for IDE intellisense
try {
await TauriAPI.copyTypeDefinitions(projectPath);
} catch (e) {
console.warn('[App] Failed to copy type definitions:', e);
}
const settings = SettingsService.getInstance(); const settings = SettingsService.getInstance();
settings.addRecentProject(projectPath); settings.addRecentProject(projectPath);
@@ -473,9 +464,7 @@ function App() {
}; };
const handleCreateProjectFromWizard = async (projectName: string, projectPath: string, _templateId: string) => { const handleCreateProjectFromWizard = async (projectName: string, projectPath: string, _templateId: string) => {
// 使用与 projectPath 相同的路径分隔符 | Use same separator as projectPath const fullProjectPath = `${projectPath}\\${projectName}`;
const sep = projectPath.includes('/') ? '/' : '\\';
const fullProjectPath = `${projectPath}${sep}${projectName}`;
try { try {
setIsLoading(true); setIsLoading(true);
@@ -641,13 +630,6 @@ function App() {
await pluginLoader.unloadProjectPlugins(pluginManager); await pluginLoader.unloadProjectPlugins(pluginManager);
} }
// 清理场景(会清理所有实体和系统)
// Clear scene (clears all entities and systems)
const scene = Core.scene;
if (scene) {
scene.end();
}
// 清理模块系统 // 清理模块系统
const engineService = EngineService.getInstance(); const engineService = EngineService.getInstance();
engineService.clearModuleSystems(); engineService.clearModuleSystems();
@@ -751,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
} }
]; ];
@@ -834,28 +810,6 @@ function App() {
onOpenProject={handleOpenProject} onOpenProject={handleOpenProject}
onCreateProject={handleCreateProject} onCreateProject={handleCreateProject}
onOpenRecentProject={handleOpenRecentProject} onOpenRecentProject={handleOpenRecentProject}
onRemoveRecentProject={(projectPath) => {
settings.removeRecentProject(projectPath);
// 强制重新渲染 | Force re-render
setStatus(t('header.status.ready'));
}}
onDeleteProject={async (projectPath) => {
try {
await TauriAPI.deleteFolder(projectPath);
// 删除成功后从列表中移除并触发重新渲染
// Remove from list and trigger re-render after successful deletion
settings.removeRecentProject(projectPath);
setStatus(t('header.status.ready'));
} catch (error) {
console.error('Failed to delete project:', error);
setErrorDialog({
title: locale === 'zh' ? '删除项目失败' : 'Failed to Delete Project',
message: locale === 'zh'
? `无法删除项目:\n${error instanceof Error ? error.message : String(error)}`
: `Failed to delete project:\n${error instanceof Error ? error.message : String(error)}`
});
}
}}
onLocaleChange={handleLocaleChange} onLocaleChange={handleLocaleChange}
recentProjects={recentProjects} recentProjects={recentProjects}
locale={locale} locale={locale}

View File

@@ -168,26 +168,6 @@ export class TauriAPI {
await invoke('show_in_folder', { filePath: path }); await invoke('show_in_folder', { filePath: path });
} }
/**
* 使用指定编辑器打开项目
* Open project with specified editor
*
* @param projectPath 项目文件夹路径 | Project folder path
* @param editorCommand 编辑器命令(如 "code", "cursor"| Editor command
* @param filePath 可选的要打开的文件路径 | Optional file path to open
*/
static async openWithEditor(
projectPath: string,
editorCommand: string,
filePath?: string
): Promise<void> {
await invoke('open_with_editor', {
projectPath,
editorCommand,
filePath: filePath || null
});
}
/** /**
* 打开行为树文件选择对话框 * 打开行为树文件选择对话框
* @returns 用户选择的文件路径,取消则返回 null * @returns 用户选择的文件路径,取消则返回 null
@@ -331,16 +311,6 @@ export class TauriAPI {
return await invoke<string>('generate_qrcode', { text }); return await invoke<string>('generate_qrcode', { text });
} }
/**
* 复制类型定义文件到项目
* Copy type definition files to project for IDE intellisense
*
* @param projectPath 项目路径 | Project path
*/
static async copyTypeDefinitions(projectPath: string): Promise<void> {
return await invoke<void>('copy_type_definitions', { projectPath });
}
/** /**
* 将本地文件路径转换为 Tauri 可访问的 asset URL * 将本地文件路径转换为 Tauri 可访问的 asset URL
* @param filePath 本地文件路径 * @param filePath 本地文件路径

View File

@@ -40,7 +40,6 @@ import {
import { Core } from '@esengine/ecs-framework'; import { Core } from '@esengine/ecs-framework';
import { MessageHub, FileActionRegistry, type FileCreationTemplate } from '@esengine/editor-core'; import { MessageHub, FileActionRegistry, type FileCreationTemplate } from '@esengine/editor-core';
import { TauriAPI, DirectoryEntry } from '../api/tauri'; import { TauriAPI, DirectoryEntry } from '../api/tauri';
import { SettingsService } from '../services/SettingsService';
import { ContextMenu, ContextMenuItem } from './ContextMenu'; import { ContextMenu, ContextMenuItem } from './ContextMenu';
import { PromptDialog } from './PromptDialog'; import { PromptDialog } from './PromptDialog';
import '../styles/ContentBrowser.css'; import '../styles/ContentBrowser.css';
@@ -211,124 +210,8 @@ export function ContentBrowser({
'Shader': { en: 'Shader', zh: '着色器' }, 'Shader': { en: 'Shader', zh: '着色器' },
'Tilemap': { en: 'Tilemap', zh: '瓦片地图' }, 'Tilemap': { en: 'Tilemap', zh: '瓦片地图' },
'Tileset': { en: 'Tileset', zh: '瓦片集' }, 'Tileset': { en: 'Tileset', zh: '瓦片集' },
'Component': { en: 'Component', zh: '组件' },
'System': { en: 'System', zh: '系统' },
'TypeScript': { en: 'TypeScript', zh: 'TypeScript' },
}; };
// 注册内置的 TypeScript 文件创建模板
// Register built-in TypeScript file creation templates
useEffect(() => {
if (!fileActionRegistry) return;
const builtinTemplates: FileCreationTemplate[] = [
{
id: 'ts-component',
label: 'Component',
extension: '.ts',
icon: 'FileCode',
category: 'Script',
getContent: (fileName: string) => {
const className = fileName.replace(/\.ts$/, '');
return `import { Component, ECSComponent, Property, Serialize, Serializable } from '@esengine/ecs-framework';
/**
* ${className}
*/
@ECSComponent('${className}')
@Serializable({ version: 1, typeId: '${className}' })
export class ${className} extends Component {
// 在这里添加组件属性
// Add component properties here
@Serialize()
@Property({ type: 'number', label: 'Example Property' })
public exampleProperty: number = 0;
onInitialize(): void {
// 组件初始化时调用
// Called when component is initialized
}
onDestroy(): void {
// 组件销毁时调用
// Called when component is destroyed
}
}
`;
}
},
{
id: 'ts-system',
label: 'System',
extension: '.ts',
icon: 'FileCode',
category: 'Script',
getContent: (fileName: string) => {
const className = fileName.replace(/\.ts$/, '');
return `import { EntitySystem, Matcher, type Entity } from '@esengine/ecs-framework';
/**
* ${className}
*/
export class ${className} extends EntitySystem {
// 定义系统处理的组件类型
// Define component types this system processes
protected getMatcher(): Matcher {
// 返回匹配器,指定需要哪些组件
// Return matcher specifying required components
// return Matcher.all(SomeComponent);
return Matcher.empty();
}
protected updateEntity(entity: Entity, deltaTime: number): void {
// 处理每个实体
// Process each entity
}
// 可选:系统初始化
// Optional: System initialization
// onInitialize(): void {
// super.onInitialize();
// }
}
`;
}
},
{
id: 'ts-script',
label: 'TypeScript',
extension: '.ts',
icon: 'FileCode',
category: 'Script',
getContent: (fileName: string) => {
const name = fileName.replace(/\.ts$/, '');
return `/**
* ${name}
*/
export function ${name.charAt(0).toLowerCase() + name.slice(1)}(): void {
// 在这里编写代码
// Write your code here
}
`;
}
}
];
// 注册模板
for (const template of builtinTemplates) {
fileActionRegistry.registerCreationTemplate(template);
}
// 清理函数
return () => {
for (const template of builtinTemplates) {
fileActionRegistry.unregisterCreationTemplate(template);
}
};
}, [fileActionRegistry]);
const getTemplateLabel = (label: string): string => { const getTemplateLabel = (label: string): string => {
const mapping = templateLabels[label]; const mapping = templateLabels[label];
if (mapping) { if (mapping) {
@@ -556,24 +439,6 @@ export function ${name.charAt(0).toLowerCase() + name.slice(1)}(): void {
return; return;
} }
// 脚本文件使用配置的编辑器打开
// Open script files with configured editor
if (ext === 'ts' || ext === 'tsx' || ext === 'js' || ext === 'jsx') {
const settings = SettingsService.getInstance();
const editorCommand = settings.getScriptEditorCommand();
if (editorCommand && projectPath) {
try {
await TauriAPI.openWithEditor(projectPath, editorCommand, asset.path);
return;
} catch (error) {
console.error('Failed to open with editor:', error);
// 如果失败,回退到系统默认应用
// Fall back to system default app if failed
}
}
}
if (fileActionRegistry) { if (fileActionRegistry) {
const handled = await fileActionRegistry.handleDoubleClick(asset.path); const handled = await fileActionRegistry.handleDoubleClick(asset.path);
if (handled) return; if (handled) return;
@@ -585,7 +450,7 @@ export function ${name.charAt(0).toLowerCase() + name.slice(1)}(): void {
console.error('Failed to open file:', error); console.error('Failed to open file:', error);
} }
} }
}, [loadAssets, onOpenScene, fileActionRegistry, projectPath]); }, [loadAssets, onOpenScene, fileActionRegistry]);
// Handle context menu // Handle context menu
const handleContextMenu = useCallback((e: React.MouseEvent, asset?: AssetItem) => { const handleContextMenu = useCallback((e: React.MouseEvent, asset?: AssetItem) => {
@@ -934,10 +799,9 @@ export function ${name.charAt(0).toLowerCase() + name.slice(1)}(): void {
icon: <ExternalLink size={16} />, icon: <ExternalLink size={16} />,
onClick: async () => { onClick: async () => {
try { try {
console.log('[ContentBrowser] showInFolder path:', asset.path);
await TauriAPI.showInFolder(asset.path); await TauriAPI.showInFolder(asset.path);
} catch (error) { } catch (error) {
console.error('Failed to show in folder:', error, 'Path:', asset.path); console.error('Failed to show in folder:', error);
} }
} }
}); });
@@ -1262,16 +1126,10 @@ export function ${name.charAt(0).toLowerCase() + name.slice(1)}(): void {
)} )}
{/* Create File Dialog */} {/* Create File Dialog */}
{createFileDialog && (() => { {createFileDialog && (
// 规范化扩展名(确保有点号前缀)
// Normalize extension (ensure dot prefix)
const ext = createFileDialog.template.extension.startsWith('.')
? createFileDialog.template.extension
: `.${createFileDialog.template.extension}`;
return (
<PromptDialog <PromptDialog
title={locale === 'zh' ? `新建 ${getTemplateLabel(createFileDialog.template.label)}` : `New ${createFileDialog.template.label}`} title={`New ${createFileDialog.template.label}`}
message={locale === 'zh' ? `输入文件名(将添加 ${ext}:` : `Enter file name (${ext} will be added):`} message={`Enter file name (.${createFileDialog.template.extension} will be added):`}
placeholder="filename" placeholder="filename"
confirmText={locale === 'zh' ? '创建' : 'Create'} confirmText={locale === 'zh' ? '创建' : 'Create'}
cancelText={locale === 'zh' ? '取消' : 'Cancel'} cancelText={locale === 'zh' ? '取消' : 'Cancel'}
@@ -1280,8 +1138,8 @@ export function ${name.charAt(0).toLowerCase() + name.slice(1)}(): void {
setCreateFileDialog(null); setCreateFileDialog(null);
let fileName = value; let fileName = value;
if (!fileName.endsWith(ext)) { if (!fileName.endsWith(`.${template.extension}`)) {
fileName = `${fileName}${ext}`; fileName = `${fileName}.${template.extension}`;
} }
const filePath = `${parentPath}/${fileName}`; const filePath = `${parentPath}/${fileName}`;
@@ -1297,8 +1155,7 @@ export function ${name.charAt(0).toLowerCase() + name.slice(1)}(): void {
}} }}
onCancel={() => setCreateFileDialog(null)} onCancel={() => setCreateFileDialog(null)}
/> />
); )}
})()}
</div> </div>
); );
} }

View File

@@ -833,10 +833,9 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
icon: <FolderOpen size={16} />, icon: <FolderOpen size={16} />,
onClick: async () => { onClick: async () => {
try { try {
console.log('[FileTree] showInFolder path:', node.path);
await TauriAPI.showInFolder(node.path); await TauriAPI.showInFolder(node.path);
} catch (error) { } catch (error) {
console.error('Failed to show in folder:', error, 'Path:', node.path); console.error('Failed to show in folder:', error);
} }
} }
}); });

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { getVersion } from '@tauri-apps/api/app'; import { getVersion } from '@tauri-apps/api/app';
import { Globe, ChevronDown, Download, X, Loader2, Trash2 } from 'lucide-react'; import { Globe, ChevronDown, Download, X, Loader2 } from 'lucide-react';
import { checkForUpdatesOnStartup, installUpdate, type UpdateCheckResult } from '../utils/updater'; import { checkForUpdatesOnStartup, installUpdate, type UpdateCheckResult } from '../utils/updater';
import { StartupLogo } from './StartupLogo'; import { StartupLogo } from './StartupLogo';
import '../styles/StartupPage.css'; import '../styles/StartupPage.css';
@@ -11,8 +11,6 @@ interface StartupPageProps {
onOpenProject: () => void; onOpenProject: () => void;
onCreateProject: () => void; onCreateProject: () => void;
onOpenRecentProject?: (projectPath: string) => void; onOpenRecentProject?: (projectPath: string) => void;
onRemoveRecentProject?: (projectPath: string) => void;
onDeleteProject?: (projectPath: string) => Promise<void>;
onLocaleChange?: (locale: Locale) => void; onLocaleChange?: (locale: Locale) => void;
recentProjects?: string[]; recentProjects?: string[];
locale: string; locale: string;
@@ -23,13 +21,11 @@ const LANGUAGES = [
{ code: 'zh', name: '中文' } { code: 'zh', name: '中文' }
]; ];
export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProject, onRemoveRecentProject, onDeleteProject, onLocaleChange, recentProjects = [], locale }: StartupPageProps) { export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProject, onLocaleChange, recentProjects = [], locale }: StartupPageProps) {
const [showLogo, setShowLogo] = useState(true); const [showLogo, setShowLogo] = useState(true);
const [hoveredProject, setHoveredProject] = useState<string | null>(null); const [hoveredProject, setHoveredProject] = useState<string | null>(null);
const [appVersion, setAppVersion] = useState<string>(''); const [appVersion, setAppVersion] = useState<string>('');
const [showLangMenu, setShowLangMenu] = useState(false); const [showLangMenu, setShowLangMenu] = useState(false);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; project: string } | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [updateInfo, setUpdateInfo] = useState<UpdateCheckResult | null>(null); const [updateInfo, setUpdateInfo] = useState<UpdateCheckResult | null>(null);
const [showUpdateBanner, setShowUpdateBanner] = useState(false); const [showUpdateBanner, setShowUpdateBanner] = useState(false);
const [isInstalling, setIsInstalling] = useState(false); const [isInstalling, setIsInstalling] = useState(false);
@@ -70,13 +66,7 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
updateAvailable: 'New version available', updateAvailable: 'New version available',
updateNow: 'Update Now', updateNow: 'Update Now',
installing: 'Installing...', installing: 'Installing...',
later: 'Later', later: 'Later'
removeFromList: 'Remove from List',
deleteProject: 'Delete Project',
deleteConfirmTitle: 'Delete Project',
deleteConfirmMessage: 'Are you sure you want to permanently delete this project? This action cannot be undone.',
cancel: 'Cancel',
delete: 'Delete'
}, },
zh: { zh: {
title: 'ESEngine 编辑器', title: 'ESEngine 编辑器',
@@ -88,13 +78,7 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
updateAvailable: '发现新版本', updateAvailable: '发现新版本',
updateNow: '立即更新', updateNow: '立即更新',
installing: '正在安装...', installing: '正在安装...',
later: '稍后', later: '稍后'
removeFromList: '从列表中移除',
deleteProject: '删除项目',
deleteConfirmTitle: '删除项目',
deleteConfirmMessage: '确定要永久删除此项目吗?此操作无法撤销。',
cancel: '取消',
delete: '删除'
} }
}; };
@@ -152,10 +136,6 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
onMouseEnter={() => setHoveredProject(project)} onMouseEnter={() => setHoveredProject(project)}
onMouseLeave={() => setHoveredProject(null)} onMouseLeave={() => setHoveredProject(null)}
onClick={() => onOpenRecentProject?.(project)} onClick={() => onOpenRecentProject?.(project)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, project });
}}
style={{ cursor: onOpenRecentProject ? 'pointer' : 'default' }} style={{ cursor: onOpenRecentProject ? 'pointer' : 'default' }}
> >
<svg className="recent-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor"> <svg className="recent-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor">
@@ -165,18 +145,6 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
<div className="recent-name">{project.split(/[\\/]/).pop()}</div> <div className="recent-name">{project.split(/[\\/]/).pop()}</div>
<div className="recent-path">{project}</div> <div className="recent-path">{project}</div>
</div> </div>
{onRemoveRecentProject && (
<button
className="recent-remove-btn"
onClick={(e) => {
e.stopPropagation();
onRemoveRecentProject(project);
}}
title={t.removeFromList}
>
<Trash2 size={14} />
</button>
)}
</li> </li>
))} ))}
</ul> </ul>
@@ -249,78 +217,6 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
</div> </div>
)} )}
</div> </div>
{/* 右键菜单 | Context Menu */}
{contextMenu && (
<div
className="startup-context-menu-overlay"
onClick={() => setContextMenu(null)}
>
<div
className="startup-context-menu"
style={{ left: contextMenu.x, top: contextMenu.y }}
onClick={(e) => e.stopPropagation()}
>
<button
className="startup-context-menu-item"
onClick={() => {
onRemoveRecentProject?.(contextMenu.project);
setContextMenu(null);
}}
>
<X size={14} />
<span>{t.removeFromList}</span>
</button>
{onDeleteProject && (
<button
className="startup-context-menu-item danger"
onClick={() => {
setDeleteConfirm(contextMenu.project);
setContextMenu(null);
}}
>
<Trash2 size={14} />
<span>{t.deleteProject}</span>
</button>
)}
</div>
</div>
)}
{/* 删除确认对话框 | Delete Confirmation Dialog */}
{deleteConfirm && (
<div className="startup-dialog-overlay">
<div className="startup-dialog">
<div className="startup-dialog-header">
<Trash2 size={20} className="dialog-icon-danger" />
<h3>{t.deleteConfirmTitle}</h3>
</div>
<div className="startup-dialog-body">
<p>{t.deleteConfirmMessage}</p>
<p className="startup-dialog-path">{deleteConfirm}</p>
</div>
<div className="startup-dialog-footer">
<button
className="startup-dialog-btn"
onClick={() => setDeleteConfirm(null)}
>
{t.cancel}
</button>
<button
className="startup-dialog-btn danger"
onClick={async () => {
if (deleteConfirm && onDeleteProject) {
await onDeleteProject(deleteConfirm);
}
setDeleteConfirm(null);
}}
>
{t.delete}
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

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

@@ -14,15 +14,6 @@ import { EditorEngineSync } from '../services/EditorEngineSync';
let engineInitialized = false; let engineInitialized = false;
let engineInitializing = false; let engineInitializing = false;
/**
* 重置引擎初始化状态(在项目关闭时调用)
* Reset engine initialization state (called when project is closed)
*/
export function resetEngineState(): void {
engineInitialized = false;
engineInitializing = false;
}
export interface EngineState { export interface EngineState {
initialized: boolean; initialized: boolean;
running: boolean; running: boolean;

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

@@ -56,32 +56,6 @@ class EditorAppearanceEditorModule implements IEditorModuleLoader {
step: 1 step: 1
} }
] ]
},
{
id: 'scriptEditor',
title: '脚本编辑器',
description: '配置用于打开脚本文件的外部编辑器',
settings: [
{
key: 'editor.scriptEditor',
label: '脚本编辑器',
type: 'select',
defaultValue: 'system',
description: '双击脚本文件时使用的编辑器',
options: SettingsService.SCRIPT_EDITORS.map(editor => ({
value: editor.id,
label: editor.name
}))
},
{
key: 'editor.customScriptEditorCommand',
label: '自定义编辑器命令',
type: 'string',
defaultValue: '',
description: '当选择"自定义"时,填写编辑器的命令行命令(如 notepad++',
placeholder: '例如notepad++'
}
]
} }
] ]
}); });

View File

@@ -28,7 +28,6 @@ import {
type GameRuntimeConfig type GameRuntimeConfig
} from '@esengine/runtime-core'; } from '@esengine/runtime-core';
import { getMaterialManager } from '@esengine/material-system'; import { getMaterialManager } from '@esengine/material-system';
import { resetEngineState } from '../hooks/useEngine';
import { convertFileSrc } from '@tauri-apps/api/core'; import { convertFileSrc } from '@tauri-apps/api/core';
import { IdGenerator } from '../utils/idGenerator'; import { IdGenerator } from '../utils/idGenerator';
import { TauriAssetReader } from './TauriAssetReader'; import { TauriAssetReader } from './TauriAssetReader';
@@ -246,14 +245,7 @@ export class EngineService {
ctx.uiInputSystem.unbind?.(); ctx.uiInputSystem.unbind?.();
} }
// 清理 viewport | Clear viewport
this.unregisterViewport('editor-viewport');
// 重置 useEngine 的模块级状态 | Reset useEngine module-level state
resetEngineState();
this._modulesInitialized = false; this._modulesInitialized = false;
this._initialized = false;
} }
/** /**

View File

@@ -70,11 +70,9 @@ export class SettingsService {
} }
public addRecentProject(projectPath: string): void { public addRecentProject(projectPath: string): void {
// 规范化路径,防止双重转义 | Normalize path to prevent double escaping
const normalizedPath = projectPath.replace(/\\\\/g, '\\');
const recentProjects = this.getRecentProjects(); const recentProjects = this.getRecentProjects();
const filtered = recentProjects.filter((p) => p !== normalizedPath); const filtered = recentProjects.filter((p) => p !== projectPath);
const updated = [normalizedPath, ...filtered].slice(0, 10); const updated = [projectPath, ...filtered].slice(0, 10);
this.set('recentProjects', updated); this.set('recentProjects', updated);
} }
@@ -87,64 +85,4 @@ export class SettingsService {
public clearRecentProjects(): void { public clearRecentProjects(): void {
this.set('recentProjects', []); this.set('recentProjects', []);
} }
// ==================== Script Editor Settings ====================
/**
* 支持的脚本编辑器类型
* Supported script editor types
*/
public static readonly SCRIPT_EDITORS = [
{ id: 'system', name: 'System Default', nameZh: '系统默认', command: '' },
{ id: 'vscode', name: 'Visual Studio Code', nameZh: 'Visual Studio Code', command: 'code' },
{ id: 'cursor', name: 'Cursor', nameZh: 'Cursor', command: 'cursor' },
{ id: 'webstorm', name: 'WebStorm', nameZh: 'WebStorm', command: 'webstorm' },
{ id: 'sublime', name: 'Sublime Text', nameZh: 'Sublime Text', command: 'subl' },
{ id: 'custom', name: 'Custom', nameZh: '自定义', command: '' }
];
/**
* 获取脚本编辑器设置
* Get script editor setting
*/
public getScriptEditor(): string {
return this.get<string>('editor.scriptEditor', 'system');
}
/**
* 设置脚本编辑器
* Set script editor
*/
public setScriptEditor(editorId: string): void {
this.set('editor.scriptEditor', editorId);
}
/**
* 获取自定义脚本编辑器命令
* Get custom script editor command
*/
public getCustomScriptEditorCommand(): string {
return this.get<string>('editor.customScriptEditorCommand', '');
}
/**
* 设置自定义脚本编辑器命令
* Set custom script editor command
*/
public setCustomScriptEditorCommand(command: string): void {
this.set('editor.customScriptEditorCommand', command);
}
/**
* 获取当前脚本编辑器的命令
* Get current script editor command
*/
public getScriptEditorCommand(): string {
const editorId = this.getScriptEditor();
if (editorId === 'custom') {
return this.getCustomScriptEditorCommand();
}
const editor = SettingsService.SCRIPT_EDITORS.find(e => e.id === editorId);
return editor?.command || '';
}
} }

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' };

View File

@@ -174,32 +174,6 @@
white-space: nowrap; white-space: nowrap;
} }
.recent-remove-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: none;
border-radius: 4px;
color: #6e6e6e;
cursor: pointer;
opacity: 0;
transition: all 0.15s;
flex-shrink: 0;
}
.recent-item:hover .recent-remove-btn {
opacity: 1;
}
.recent-remove-btn:hover {
background: rgba(255, 80, 80, 0.15);
color: #f87171;
}
.startup-footer { .startup-footer {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -372,157 +346,3 @@
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
/* 右键菜单样式 | Context Menu Styles */
.startup-context-menu-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 1000;
}
.startup-context-menu {
position: fixed;
min-width: 180px;
background: #252529;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
padding: 4px 0;
z-index: 1001;
}
.startup-context-menu-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 14px;
background: transparent;
border: none;
color: #cccccc;
font-size: 13px;
text-align: left;
cursor: pointer;
transition: all 0.1s;
}
.startup-context-menu-item:hover {
background: #3b82f6;
color: #ffffff;
}
.startup-context-menu-item.danger {
color: #f87171;
}
.startup-context-menu-item.danger:hover {
background: #dc2626;
color: #ffffff;
}
/* 对话框样式 | Dialog Styles */
.startup-dialog-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1002;
}
.startup-dialog {
width: 400px;
background: #2d2d30;
border: 1px solid #3e3e42;
border-radius: 8px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.startup-dialog-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 20px;
background: #252526;
border-bottom: 1px solid #3e3e42;
}
.startup-dialog-header h3 {
margin: 0;
font-size: 15px;
font-weight: 500;
color: #ffffff;
}
.dialog-icon-danger {
color: #f87171;
}
.startup-dialog-body {
padding: 20px;
}
.startup-dialog-body p {
margin: 0 0 12px 0;
font-size: 13px;
color: #cccccc;
line-height: 1.5;
}
.startup-dialog-body p:last-child {
margin-bottom: 0;
}
.startup-dialog-path {
padding: 10px 12px;
background: #1e1e1e;
border-radius: 4px;
font-family: monospace;
font-size: 12px;
color: #858585;
word-break: break-all;
}
.startup-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 20px;
background: #252526;
border-top: 1px solid #3e3e42;
}
.startup-dialog-btn {
padding: 8px 16px;
border: 1px solid #3e3e42;
border-radius: 4px;
background: #2d2d30;
color: #cccccc;
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
}
.startup-dialog-btn:hover {
background: #37373d;
border-color: #555;
}
.startup-dialog-btn.danger {
background: #dc2626;
border-color: #dc2626;
color: #ffffff;
}
.startup-dialog-btn.danger:hover {
background: #b91c1c;
border-color: #b91c1c;
}

View File

@@ -1159,11 +1159,6 @@ export class PluginManager implements IService {
} }
} }
} }
// 重置初始化状态,允许下次重新初始化运行时
// Reset initialized flag to allow re-initialization
this.initialized = false;
logger.debug('Scene systems cleared, runtime can be re-initialized');
} }
/** /**

View File

@@ -94,15 +94,11 @@ export class ProjectService implements IService {
scriptsPath: 'scripts', scriptsPath: 'scripts',
buildOutput: '.esengine/compiled', buildOutput: '.esengine/compiled',
scenesPath: 'scenes', scenesPath: 'scenes',
defaultScene: 'main.ecs', defaultScene: 'main.ecs'
plugins: { enabledPlugins: [] },
modules: { disabledModules: [] }
}; };
await this.fileAPI.writeFileContent(configPath, JSON.stringify(config, null, 2)); await this.fileAPI.writeFileContent(configPath, JSON.stringify(config, null, 2));
// Create scenes folder and default scene
// 创建场景文件夹和默认场景
const scenesPath = `${projectPath}${sep}${config.scenesPath}`; const scenesPath = `${projectPath}${sep}${config.scenesPath}`;
await this.fileAPI.createDirectory(scenesPath); await this.fileAPI.createDirectory(scenesPath);
@@ -115,55 +111,6 @@ export class ProjectService implements IService {
}) as string; }) as string;
await this.fileAPI.writeFileContent(defaultScenePath, sceneData); await this.fileAPI.writeFileContent(defaultScenePath, sceneData);
// Create scripts folder for user scripts
// 创建用户脚本文件夹
const scriptsPath = `${projectPath}${sep}${config.scriptsPath}`;
await this.fileAPI.createDirectory(scriptsPath);
// Create scripts/editor folder for editor extension scripts
// 创建编辑器扩展脚本文件夹
const editorScriptsPath = `${scriptsPath}${sep}editor`;
await this.fileAPI.createDirectory(editorScriptsPath);
// Create assets folder for project assets (textures, audio, etc.)
// 创建资源文件夹(纹理、音频等)
const assetsPath = `${projectPath}${sep}assets`;
await this.fileAPI.createDirectory(assetsPath);
// Create types folder for type definitions
// 创建类型定义文件夹
const typesPath = `${projectPath}${sep}types`;
await this.fileAPI.createDirectory(typesPath);
// Create tsconfig.json for TypeScript support
// 创建 tsconfig.json 用于 TypeScript 支持
const tsConfig = {
compilerOptions: {
target: 'ES2020',
module: 'ESNext',
moduleResolution: 'bundler',
lib: ['ES2020', 'DOM'],
strict: true,
esModuleInterop: true,
skipLibCheck: true,
forceConsistentCasingInFileNames: true,
experimentalDecorators: true,
emitDecoratorMetadata: true,
noEmit: true,
// Reference local type definitions
// 引用本地类型定义文件
typeRoots: ['./types'],
paths: {
'@esengine/ecs-framework': ['./types/ecs-framework.d.ts'],
'@esengine/engine-core': ['./types/engine-core.d.ts']
}
},
include: ['scripts/**/*.ts'],
exclude: ['.esengine']
};
const tsConfigPath = `${projectPath}${sep}tsconfig.json`;
await this.fileAPI.writeFileContent(tsConfigPath, JSON.stringify(tsConfig, null, 2));
await this.messageHub.publish('project:created', { await this.messageHub.publish('project:created', {
path: projectPath path: projectPath
}); });
@@ -311,10 +258,8 @@ export class ProjectService implements IService {
scenesPath: config.scenesPath || 'scenes', scenesPath: config.scenesPath || 'scenes',
defaultScene: config.defaultScene || 'main.ecs', defaultScene: config.defaultScene || 'main.ecs',
uiDesignResolution: config.uiDesignResolution, uiDesignResolution: config.uiDesignResolution,
// Provide default empty plugins config for legacy projects plugins: config.plugins,
// 为旧项目提供默认的空插件配置 modules: config.modules
plugins: config.plugins || { enabledPlugins: [] },
modules: config.modules || { disabledModules: [] }
}; };
logger.debug('Loaded config result:', result); logger.debug('Loaded config result:', result);
return result; return result;