feat(editor): 添加 GitHub Discussions 社区论坛功能 (#266)

* feat(editor): 添加 GitHub Discussions 社区论坛功能

* chore: 更新 pnpm-lock.yaml
This commit is contained in:
YHH
2025-12-04 09:51:04 +08:00
committed by GitHub
parent 4b8d22ac32
commit 3b56ed17fe
20 changed files with 5249 additions and 13 deletions

View File

@@ -0,0 +1,919 @@
/**
* 论坛服务 - 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
// =====================================================
/**
* 上传图片到 GitHub 仓库
* Upload image to GitHub repository
* @param file 图片文件 | Image file
* @param onProgress 进度回调 | Progress callback
* @returns 图片 URL | Image URL
*/
async uploadImage(
file: File,
onProgress?: (progress: number) => void
): Promise<string> {
const token = this.currentUser?.accessToken;
if (!token) {
throw new Error('Not authenticated');
}
// 验证文件类型 | 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');
}
// 限制文件大小 (5MB) | Limit file size (5MB)
const maxSize = 5 * 1024 * 1024;
if (file.size > maxSize) {
throw new Error('Image size must be less than 5MB');
}
onProgress?.(10);
// 生成唯一文件名 | Generate unique filename
const ext = file.name.split('.').pop() || 'png';
const timestamp = Date.now();
const randomStr = Math.random().toString(36).substring(2, 8);
const fileName = `${timestamp}-${randomStr}.${ext}`;
const filePath = `forum-images/${fileName}`;
onProgress?.(20);
// 读取文件为 base64 | Read file as base64
const base64Content = await this.fileToBase64(file);
onProgress?.(40);
// 使用 GitHub REST API 上传文件 | Upload file using GitHub REST API
const response = await fetch(
`https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/contents/${filePath}`,
{
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/vnd.github.v3+json'
},
body: JSON.stringify({
message: `Upload forum image: ${fileName}`,
content: base64Content,
branch: 'master'
})
}
);
onProgress?.(80);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error('[ForumService] Upload failed:', errorData);
throw new Error(`Failed to upload image: ${response.status}`);
}
const data = await response.json();
onProgress?.(100);
// 返回 raw URL | Return raw URL
// 使用 jsdelivr CDN 加速 | Use jsdelivr CDN for acceleration
const cdnUrl = `https://cdn.jsdelivr.net/gh/${REPO_OWNER}/${REPO_NAME}@master/${filePath}`;
return cdnUrl;
}
/**
* 将文件转换为 base64
* Convert file to base64
*/
private fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
// 移除 data:image/xxx;base64, 前缀 | Remove data:image/xxx;base64, prefix
const base64 = result.split(',')[1] || '';
resolve(base64);
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
}
// 单例实例 | Singleton instance
let forumServiceInstance: ForumService | null = null;
export function getForumService(): ForumService {
if (!forumServiceInstance) {
forumServiceInstance = new ForumService();
}
return forumServiceInstance;
}

View File

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

View File

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