Compare commits
32 Commits
fix/event-
...
feat/docs-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34de1e5edf | ||
|
|
94e0979941 | ||
|
|
0a3f2a3e21 | ||
|
|
9c30ab26a6 | ||
|
|
3c50795dee | ||
|
|
5a0d67b3f6 | ||
|
|
d1ba10564a | ||
|
|
cf00e062f7 | ||
|
|
293ac2dca3 | ||
|
|
f7535a2aac | ||
|
|
ca18be32a8 | ||
|
|
025ce89ded | ||
|
|
2311419e71 | ||
|
|
373bdd5d2b | ||
|
|
b58e75d9a4 | ||
|
|
099809a98c | ||
|
|
83aee02540 | ||
|
|
cb1b171216 | ||
|
|
b64b489b89 | ||
|
|
13cb670a16 | ||
|
|
37ab494e4a | ||
|
|
e1d494b415 | ||
|
|
243b929d5e | ||
|
|
4a2362edf2 | ||
|
|
0c590d7c12 | ||
|
|
c2f8cb5272 | ||
|
|
55f644a091 | ||
|
|
d3dfaa7aac | ||
|
|
25e70a1d7b | ||
|
|
e2cca5e490 | ||
|
|
b3f7676452 | ||
|
|
e6fb80d0be |
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
* 添加实体
|
||||
*/
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 会添加 TagComponent,SystemA 应立即收到 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 添加了 TagComponent,RemoveSystem 在 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 方法', () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 本地文件路径
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 = ``;
|
||||
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`);
|
||||
}
|
||||
} 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 \n- > quotes and - lists\n\nDrag & drop or paste images to upload'
|
||||
: '在这里写下你的内容...\n\n支持 Markdown 语法:\n- **粗体** 和 *斜体*\n- `代码` 和 ```代码块```\n- [链接](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>
|
||||
);
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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, '');
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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++'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 || '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* 论坛服务导出 - GitHub Discussions
|
||||
* Forum service exports - GitHub Discussions
|
||||
*/
|
||||
export { ForumService, getForumService } from './ForumService';
|
||||
export type { DeviceCodeResponse } from './ForumService';
|
||||
export * from './types';
|
||||
@@ -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' };
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user