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:
- Check the complete [API Documentation](/api/README)
- Explore more [practical examples](/examples/)
- Check the complete [API Documentation](/en/api/README)
- Explore more [practical examples](/en/examples/)
## FAQ

View File

@@ -4,40 +4,40 @@ Welcome to the ECS Framework Guide. This guide covers the core concepts and usag
## Core Concepts
### [Entity](/guide/entity)
### [Entity](./entity.md)
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.
### [System](/guide/system)
### [System](./system.md)
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.
### [Scene](/guide/scene)
### [Scene](./scene.md)
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.
### [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.
### [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.
### [Logging](/guide/logging)
### [Logging](./logging.md)
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.
## Advanced Features
### [Service Container](/guide/service-container)
### [Service Container](./service-container.md)
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.

View File

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

View File

@@ -321,19 +321,11 @@ export class Entity {
/**
* 通知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) {
this.scene.querySystem.updateEntity(this);
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;
}
@@ -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 { IdentifierPool } from './Utils/IdentifierPool';
import { EntitySystem } from './Systems/EntitySystem';
import { ComponentStorageManager, ComponentType } from './Core/ComponentStorage';
import { ComponentStorageManager } from './Core/ComponentStorage';
import { QuerySystem } from './Core/QuerySystem';
import { TypeSafeEventSystem } from './Core/EventSystem';
import type { ReferenceTracker } from './Core/ReferenceTracker';
@@ -120,26 +120,9 @@ export interface IScene {
/**
* 清除所有EntitySystem的实体缓存
* Clear all EntitySystem entity caches
*/
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;
/**
* 组件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
*
@@ -368,10 +344,6 @@ export class Scene implements IScene {
// 清空系统缓存
this._cachedSystems = null;
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 要添加的实体
@@ -906,9 +738,6 @@ export class Scene implements IScene {
// 标记系统列表已变化
this.markSystemsOrderDirty();
// 建立组件类型到系统的索引 | Build component type to system index
this.indexSystemByComponents(system);
injectProperties(system, this._services);
// 调试模式下自动包装系统方法以收集性能数据ProfilerSDK 启用时表示调试模式)
@@ -993,9 +822,6 @@ export class Scene implements IScene {
// 标记系统列表已变化
this.markSystemsOrderDirty();
// 从组件类型索引中移除 | Remove from component type index
this.removeSystemFromIndex(processor);
// 重置System状态
processor.reset();
}

View File

@@ -235,7 +235,7 @@ export abstract class EntitySystem implements ISystemBase, IService {
* 在系统创建时调用。框架内部使用,用户不应直接调用。
*/
public initialize(): void {
// 防止重复初始化 | Prevent re-initialization
// 防止重复初始化
if (this._initialized) {
return;
}
@@ -243,20 +243,13 @@ export abstract class EntitySystem implements ISystemBase, IService {
this._initialized = true;
// 框架内部初始化:触发一次实体查询,以便正确跟踪现有实体
// Framework initialization: query entities once to track existing entities
if (this.scene) {
// 清理缓存确保初始化时重新查询 | Clear cache to ensure fresh query
// 清理缓存确保初始化时重新查询
this._entityCache.invalidate();
const entities = this.queryEntities();
// 初始化时对已存在的匹配实体触发 onAdded
// Trigger onAdded for existing matching entities during initialization
for (const entity of entities) {
this.onAdded(entity);
}
this.queryEntities();
}
// 调用用户可重写的初始化方法 | Call user-overridable initialization method
// 调用用户可重写的初始化方法
this.onInitialize();
}
@@ -725,151 +718,32 @@ export abstract class EntitySystem implements ISystemBase, IService {
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 {
const currentSet = new Set(currentEntities);
let hasChanged = false;
// 检查新增的实体 | Check for newly added entities
// 检查新增的实体
for (const entity of currentEntities) {
if (!this._entityCache.isTracked(entity)) {
this._entityCache.addTracked(entity);
this.onAdded(entity);
hasChanged = true;
}
}
// 检查移除的实体 | Check for removed entities
// 检查移除的实体
for (const entity of this._entityCache.getTracked()) {
if (!currentSet.has(entity)) {
this._entityCache.removeTracked(entity);
this.onRemoved(entity);
hasChanged = true;
}
}
// 如果实体发生了变化,使缓存失效 | If entities changed, invalidate cache
// 如果实体发生了变化,使缓存失效
if (hasChanged) {
this._entityCache.invalidate();
}

View File

@@ -439,215 +439,6 @@ describe('EntitySystem', () => {
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 方法', () => {

View File

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

View File

@@ -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
let success = true;
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
if (success) {
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
#[tauri::command]
pub fn show_in_folder(file_path: String) -> Result<(), String> {
println!("[show_in_folder] Received path: {}", file_path);
#[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")
.arg(&select_arg)
.args(["/select,", &file_path])
.spawn()
.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())
}
/// 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
#[tauri::command]
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))
}
/// 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
#[tauri::command]
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::show_in_folder,
commands::get_temp_dir,
commands::open_with_editor,
commands::copy_type_definitions,
commands::get_app_resource_dir,
commands::get_current_dir,
commands::start_local_server,

View File

@@ -44,7 +44,6 @@ import { ErrorDialog } from './components/ErrorDialog';
import { ConfirmDialog } from './components/ConfirmDialog';
import { PluginGeneratorWindow } from './components/PluginGeneratorWindow';
import { BuildSettingsWindow } from './components/BuildSettingsWindow';
import { ForumPanel } from './components/forum';
import { ToastProvider, useToast } from './components/Toast';
import { TitleBar } from './components/TitleBar';
import { MainToolbar } from './components/MainToolbar';
@@ -381,14 +380,6 @@ function App() {
// 设置 Tauri project:// 协议的基础路径(用于加载插件等项目文件)
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();
settings.addRecentProject(projectPath);
@@ -473,9 +464,7 @@ function App() {
};
const handleCreateProjectFromWizard = async (projectName: string, projectPath: string, _templateId: string) => {
// 使用与 projectPath 相同的路径分隔符 | Use same separator as projectPath
const sep = projectPath.includes('/') ? '/' : '\\';
const fullProjectPath = `${projectPath}${sep}${projectName}`;
const fullProjectPath = `${projectPath}\\${projectName}`;
try {
setIsLoading(true);
@@ -641,13 +630,6 @@ function App() {
await pluginLoader.unloadProjectPlugins(pluginManager);
}
// 清理场景(会清理所有实体和系统)
// Clear scene (clears all entities and systems)
const scene = Core.scene;
if (scene) {
scene.end();
}
// 清理模块系统
const engineService = EngineService.getInstance();
engineService.clearModuleSystems();
@@ -751,12 +733,6 @@ function App() {
title: locale === 'zh' ? '检视器' : 'Inspector',
content: <Inspector entityStore={entityStore} messageHub={messageHub} inspectorRegistry={inspectorRegistry!} projectPath={currentProjectPath} commandManager={commandManager} />,
closable: false
},
{
id: 'forum',
title: locale === 'zh' ? '社区论坛' : 'Forum',
content: <ForumPanel />,
closable: true
}
];
@@ -834,28 +810,6 @@ function App() {
onOpenProject={handleOpenProject}
onCreateProject={handleCreateProject}
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}
recentProjects={recentProjects}
locale={locale}

View File

@@ -168,26 +168,6 @@ export class TauriAPI {
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
@@ -331,16 +311,6 @@ export class TauriAPI {
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
* @param filePath 本地文件路径

View File

@@ -40,7 +40,6 @@ import {
import { Core } from '@esengine/ecs-framework';
import { MessageHub, FileActionRegistry, type FileCreationTemplate } from '@esengine/editor-core';
import { TauriAPI, DirectoryEntry } from '../api/tauri';
import { SettingsService } from '../services/SettingsService';
import { ContextMenu, ContextMenuItem } from './ContextMenu';
import { PromptDialog } from './PromptDialog';
import '../styles/ContentBrowser.css';
@@ -211,124 +210,8 @@ export function ContentBrowser({
'Shader': { en: 'Shader', zh: '着色器' },
'Tilemap': { en: 'Tilemap', 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 mapping = templateLabels[label];
if (mapping) {
@@ -556,24 +439,6 @@ export function ${name.charAt(0).toLowerCase() + name.slice(1)}(): void {
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) {
const handled = await fileActionRegistry.handleDoubleClick(asset.path);
if (handled) return;
@@ -585,7 +450,7 @@ export function ${name.charAt(0).toLowerCase() + name.slice(1)}(): void {
console.error('Failed to open file:', error);
}
}
}, [loadAssets, onOpenScene, fileActionRegistry, projectPath]);
}, [loadAssets, onOpenScene, fileActionRegistry]);
// Handle context menu
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} />,
onClick: async () => {
try {
console.log('[ContentBrowser] showInFolder path:', asset.path);
await TauriAPI.showInFolder(asset.path);
} 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 */}
{createFileDialog && (() => {
// 规范化扩展名(确保有点号前缀)
// Normalize extension (ensure dot prefix)
const ext = createFileDialog.template.extension.startsWith('.')
? createFileDialog.template.extension
: `.${createFileDialog.template.extension}`;
return (
{createFileDialog && (
<PromptDialog
title={locale === 'zh' ? `新建 ${getTemplateLabel(createFileDialog.template.label)}` : `New ${createFileDialog.template.label}`}
message={locale === 'zh' ? `输入文件名(将添加 ${ext}:` : `Enter file name (${ext} will be added):`}
title={`New ${createFileDialog.template.label}`}
message={`Enter file name (.${createFileDialog.template.extension} will be added):`}
placeholder="filename"
confirmText={locale === 'zh' ? '创建' : 'Create'}
cancelText={locale === 'zh' ? '取消' : 'Cancel'}
@@ -1280,8 +1138,8 @@ export function ${name.charAt(0).toLowerCase() + name.slice(1)}(): void {
setCreateFileDialog(null);
let fileName = value;
if (!fileName.endsWith(ext)) {
fileName = `${fileName}${ext}`;
if (!fileName.endsWith(`.${template.extension}`)) {
fileName = `${fileName}.${template.extension}`;
}
const filePath = `${parentPath}/${fileName}`;
@@ -1297,8 +1155,7 @@ export function ${name.charAt(0).toLowerCase() + name.slice(1)}(): void {
}}
onCancel={() => setCreateFileDialog(null)}
/>
);
})()}
)}
</div>
);
}

View File

@@ -833,10 +833,9 @@ export const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(({ rootPath, o
icon: <FolderOpen size={16} />,
onClick: async () => {
try {
console.log('[FileTree] showInFolder path:', node.path);
await TauriAPI.showInFolder(node.path);
} 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 { 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 { StartupLogo } from './StartupLogo';
import '../styles/StartupPage.css';
@@ -11,8 +11,6 @@ interface StartupPageProps {
onOpenProject: () => void;
onCreateProject: () => void;
onOpenRecentProject?: (projectPath: string) => void;
onRemoveRecentProject?: (projectPath: string) => void;
onDeleteProject?: (projectPath: string) => Promise<void>;
onLocaleChange?: (locale: Locale) => void;
recentProjects?: string[];
locale: string;
@@ -23,13 +21,11 @@ const LANGUAGES = [
{ 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 [hoveredProject, setHoveredProject] = useState<string | null>(null);
const [appVersion, setAppVersion] = useState<string>('');
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 [showUpdateBanner, setShowUpdateBanner] = useState(false);
const [isInstalling, setIsInstalling] = useState(false);
@@ -70,13 +66,7 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
updateAvailable: 'New version available',
updateNow: 'Update Now',
installing: 'Installing...',
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'
later: 'Later'
},
zh: {
title: 'ESEngine 编辑器',
@@ -88,13 +78,7 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
updateAvailable: '发现新版本',
updateNow: '立即更新',
installing: '正在安装...',
later: '稍后',
removeFromList: '从列表中移除',
deleteProject: '删除项目',
deleteConfirmTitle: '删除项目',
deleteConfirmMessage: '确定要永久删除此项目吗?此操作无法撤销。',
cancel: '取消',
delete: '删除'
later: '稍后'
}
};
@@ -152,10 +136,6 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
onMouseEnter={() => setHoveredProject(project)}
onMouseLeave={() => setHoveredProject(null)}
onClick={() => onOpenRecentProject?.(project)}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, project });
}}
style={{ cursor: onOpenRecentProject ? 'pointer' : 'default' }}
>
<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-path">{project}</div>
</div>
{onRemoveRecentProject && (
<button
className="recent-remove-btn"
onClick={(e) => {
e.stopPropagation();
onRemoveRecentProject(project);
}}
title={t.removeFromList}
>
<Trash2 size={14} />
</button>
)}
</li>
))}
</ul>
@@ -249,78 +217,6 @@ export function StartupPage({ onOpenProject, onCreateProject, onOpenRecentProjec
</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>
);
}

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 engineInitializing = false;
/**
* 重置引擎初始化状态(在项目关闭时调用)
* Reset engine initialization state (called when project is closed)
*/
export function resetEngineState(): void {
engineInitialized = false;
engineInitializing = false;
}
export interface EngineState {
initialized: 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
}
]
},
{
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
} from '@esengine/runtime-core';
import { getMaterialManager } from '@esengine/material-system';
import { resetEngineState } from '../hooks/useEngine';
import { convertFileSrc } from '@tauri-apps/api/core';
import { IdGenerator } from '../utils/idGenerator';
import { TauriAssetReader } from './TauriAssetReader';
@@ -246,14 +245,7 @@ export class EngineService {
ctx.uiInputSystem.unbind?.();
}
// 清理 viewport | Clear viewport
this.unregisterViewport('editor-viewport');
// 重置 useEngine 的模块级状态 | Reset useEngine module-level state
resetEngineState();
this._modulesInitialized = false;
this._initialized = false;
}
/**

View File

@@ -70,11 +70,9 @@ export class SettingsService {
}
public addRecentProject(projectPath: string): void {
// 规范化路径,防止双重转义 | Normalize path to prevent double escaping
const normalizedPath = projectPath.replace(/\\\\/g, '\\');
const recentProjects = this.getRecentProjects();
const filtered = recentProjects.filter((p) => p !== normalizedPath);
const updated = [normalizedPath, ...filtered].slice(0, 10);
const filtered = recentProjects.filter((p) => p !== projectPath);
const updated = [projectPath, ...filtered].slice(0, 10);
this.set('recentProjects', updated);
}
@@ -87,64 +85,4 @@ export class SettingsService {
public clearRecentProjects(): void {
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;
}
.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 {
display: flex;
align-items: center;
@@ -372,157 +346,3 @@
opacity: 0.5;
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',
buildOutput: '.esengine/compiled',
scenesPath: 'scenes',
defaultScene: 'main.ecs',
plugins: { enabledPlugins: [] },
modules: { disabledModules: [] }
defaultScene: 'main.ecs'
};
await this.fileAPI.writeFileContent(configPath, JSON.stringify(config, null, 2));
// Create scenes folder and default scene
// 创建场景文件夹和默认场景
const scenesPath = `${projectPath}${sep}${config.scenesPath}`;
await this.fileAPI.createDirectory(scenesPath);
@@ -115,55 +111,6 @@ export class ProjectService implements IService {
}) as string;
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', {
path: projectPath
});
@@ -311,10 +258,8 @@ export class ProjectService implements IService {
scenesPath: config.scenesPath || 'scenes',
defaultScene: config.defaultScene || 'main.ecs',
uiDesignResolution: config.uiDesignResolution,
// Provide default empty plugins config for legacy projects
// 为旧项目提供默认的空插件配置
plugins: config.plugins || { enabledPlugins: [] },
modules: config.modules || { disabledModules: [] }
plugins: config.plugins,
modules: config.modules
};
logger.debug('Loaded config result:', result);
return result;