refactor: repartition server-side and client-side code

This commit is contained in:
LIlGG
2025-10-11 18:26:07 +08:00
parent 7acc4949fb
commit e9b573a276
309 changed files with 631 additions and 962 deletions

View File

@@ -0,0 +1,80 @@
import { atom } from 'nanostores';
import { toast } from 'sonner';
import type { _1PanelStats } from '~/types/1panel';
export interface _1PanelUser {
projectName?: string;
serverUrl?: string;
}
interface _1PanelConnectionState {
isConnect: boolean;
stats?: _1PanelStats;
serverUrl?: string;
}
const storedConnection = typeof window !== 'undefined' ? localStorage.getItem('1panel_connection') : null;
const initialConnection: _1PanelConnectionState = storedConnection
? JSON.parse(storedConnection)
: {
isConnect: false,
serverUrl: '',
};
export const _1PanelConnectionStore = atom<_1PanelConnectionState>(initialConnection);
export const isConnecting = atom<boolean>(initialConnection.isConnect);
export const isFetchingStats = atom<boolean>(false);
export const update1PanelConnection = (updates: Partial<_1PanelConnectionState>) => {
if (updates.serverUrl) {
updates.serverUrl = updates.serverUrl.replace(/\/$/, '');
}
const currentState = _1PanelConnectionStore.get();
const newState = { ...currentState, ...updates };
_1PanelConnectionStore.set(newState);
// Persist to localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('1panel_connection', JSON.stringify(newState));
}
};
export function reset1PanelConfig() {
update1PanelConnection({ isConnect: false, serverUrl: '' });
}
export async function fetch1PanelStats() {
try {
isFetchingStats.set(true);
const response = await fetch('/api/1panel/stats', {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const { data, success, message } = await response.json();
if (!response.ok || !success) {
throw new Error(`获取站点数据失败: ${message || response.status}`);
}
const websites = data.websites ?? [];
const currentState = _1PanelConnectionStore.get();
update1PanelConnection({
...currentState,
isConnect: true,
stats: data,
});
return websites;
} catch (error) {
console.error('1Panel API Error:', error);
toast.error(`获取 1Panel 站点信息失败: ${error instanceof Error ? error.message : '未知错误'}`);
} finally {
isFetchingStats.set(false);
}
}

View File

@@ -0,0 +1,135 @@
import { map } from 'nanostores';
import type { UPageUIMessage } from '~/types/message';
export type ParsedUIMessage = UPageUIMessage & {
content?: string;
};
export type UIState = {
// 是否显示聊天
showChat: boolean;
};
export type AiState = {
// 聊天是否已经开始
chatStarted: boolean;
// 是否正在流式传输
isStreaming: boolean;
// 是否已经初始化
isInitialized: boolean;
// 是否中止聊天
aborted: boolean;
// 当前的聊天 ID
chatId: string | undefined;
// 当前的消息 ID
messageId: string | undefined;
// 当前聊天的消息列表,包含解析后的消息内容,仅用于前端渲染
parseMessages: ParsedUIMessage[];
};
/**
* AI 状态管理存储
* 用于跟踪 AI 相关的状态信息,包括:
* - isStreaming: 是否正在生成内容
* - chatId: 当前的聊天 ID
* - messageId: 当前的消息 ID
* - chatMessages: 当前聊天的消息列表,包含解析后的消息内容,仅用于前端渲染
*/
export const aiState = map<AiState & UIState>({
chatStarted: false,
isStreaming: false,
chatId: undefined,
messageId: undefined,
isInitialized: false,
parseMessages: [],
aborted: false,
showChat: true,
});
/**
* 更新聊天消息列表
* @param messages 原始消息列表
* @param parsedMessages 解析后的消息内容映射
*/
export function updateParseMessages(messages: UPageUIMessage[], parsedMessages: { [key: number]: string }) {
const updatedMessages = messages.map((message, i) => {
if (message.role === 'user') {
return message;
}
return {
...message,
content: parsedMessages[i] || '',
};
});
aiState.setKey('parseMessages', updatedMessages);
}
/**
* 获取当前的聊天消息列表
* @returns 当前的聊天消息列表
*/
export function getParseMessages(): ParsedUIMessage[] {
return aiState.get().parseMessages;
}
export function setChatStarted(chatStarted: boolean) {
aiState.setKey('chatStarted', chatStarted);
}
export function getChatStarted(): boolean {
return aiState.get().chatStarted;
}
/**
* 更新 AI 的流式状态
* @param streaming 是否正在流式传输
*/
export function setStreamingState(streaming: boolean) {
aiState.setKey('isStreaming', streaming);
}
/**
* 获取当前 AI 的流式状态
* @returns 是否正在流式传输
*/
export function getStreamingState(): boolean {
return aiState.get().isStreaming;
}
/**
* 设置当前的聊天 ID
* @param id 聊天 ID
*/
export function setChatId(id: string | undefined) {
aiState.setKey('chatId', id);
}
/**
* 获取当前的聊天 ID
* @returns 当前的聊天 ID
*/
export function getChatId(): string | undefined {
return aiState.get().chatId;
}
export function getMessageId(): string | undefined {
return aiState.get().messageId;
}
export function setShowChat(showChat: boolean) {
aiState.setKey('showChat', showChat);
}
export function getShowChat(): boolean {
return aiState.get().showChat;
}
export function setAborted(aborted: boolean) {
aiState.setKey('aborted', aborted);
}
export function getAborted(): boolean {
return aiState.get().aborted;
}

View File

@@ -0,0 +1,16 @@
import { atom } from 'nanostores';
import type { UPageMessageMetadata } from '~/types/message';
export type SendChatMessageParams = {
messageContent: string;
files: File[];
metadata?: UPageMessageMetadata;
};
export type SendChatMessageFunction = (params: SendChatMessageParams) => Promise<void>;
export const sendChatMessageStore = atom<SendChatMessageFunction | null>(null);
export function setSendChatMessage(fn: SendChatMessageFunction) {
sendChatMessageStore.set(fn);
}

253
app/.client/stores/chat.ts Normal file
View File

@@ -0,0 +1,253 @@
import { atom, type MapStore, map, type WritableAtom } from 'nanostores';
import { editorBridge } from '~/.client/bridge';
import { ActionRunner } from '~/.client/runtime/action-runner';
import type { ActionCallbackData, ArtifactCallbackData } from '~/.client/runtime/message-parser';
import type { PagesStore } from '~/.client/stores/pages';
import type { WebBuilderStore } from '~/.client/stores/web-builder';
import { createSampler } from '~/.client/utils/sampler';
import { unreachable } from '~/.client/utils/unreachable';
import type { ActionAlert } from '~/types/actions';
export interface ArtifactState {
id: string;
name: string;
title: string;
type?: string;
closed: boolean;
runner: ActionRunner;
}
export type ArtifactUpdateState = Pick<ArtifactState, 'title' | 'closed'>;
type ArtifactsByPageName = Map<string, ArtifactState>;
type ArtifactsByMessageId = Map<string, ArtifactsByPageName>;
type Artifacts = MapStore<ArtifactsByMessageId>;
export class ChatStore {
private globalExecutionQueue = Promise.resolve();
private reloadedMessages = new Set<string>();
// 当前消息 id
currentMessageId: WritableAtom<string | undefined> =
import.meta.hot?.data?.currentMessageId ?? atom<string | undefined>(undefined);
currentDescription: WritableAtom<string | undefined> =
import.meta.hot?.data?.currentDescription ?? atom<string | undefined>(undefined);
artifacts: Artifacts = import.meta.hot?.data?.artifacts ?? map(new Map());
artifactIdList: { messageId: string; pageName: string }[] = [];
actionAlert: WritableAtom<ActionAlert | undefined> =
import.meta.hot?.data?.actionAlert ?? atom<ActionAlert | undefined>(undefined);
// 添加对webBuilderStore和pagesStore的引用
readonly webBuilderStore: WebBuilderStore;
readonly pagesStore: PagesStore;
constructor(webBuilderStore: WebBuilderStore, pagesStore: PagesStore) {
this.webBuilderStore = webBuilderStore;
this.pagesStore = pagesStore;
if (import.meta.hot && import.meta.hot.data) {
import.meta.hot.data.artifacts = this.artifacts;
import.meta.hot.data.actionAlert = this.actionAlert;
import.meta.hot.data.currentDescription = this.currentDescription;
}
this.setupCoordination();
}
private setupCoordination() {
this.artifacts.listen(() => {
this.currentDescription.set(this.firstArtifact?.title || '未命名页面');
});
}
get firstArtifact(): ArtifactState | undefined {
if (this.artifactIdList.length === 0) {
return undefined;
}
const { messageId, pageName } = this.artifactIdList[0];
return this.getArtifact(messageId, pageName);
}
get description() {
return this.currentDescription;
}
get alert() {
return this.actionAlert;
}
clearAlert() {
this.actionAlert.set(undefined);
}
abortAllActions() {
const artifacts = this.artifacts.get();
artifacts.values().forEach((artifactByPageNames) => {
artifactByPageNames.values().forEach((artifact) => {
const actions = artifact.runner.actions.get();
Object.values(actions).forEach((action) => {
if (action.status === 'running' || action.status === 'pending') {
action.abort();
}
});
});
});
}
async addArtifact({ messageId, name, title, id }: ArtifactCallbackData) {
const artifact = this.getArtifact(messageId, name);
if (artifact) {
return;
}
if (!this.artifactIdList.includes({ messageId, pageName: name })) {
this.artifactIdList.push({ messageId, pageName: name });
}
const newArtifact = {
id,
name,
title,
closed: false,
runner: new ActionRunner(editorBridge, { id, name, title }, (alert) => {
if (this.reloadedMessages.has(messageId)) {
return;
}
this.actionAlert.set(alert);
}),
};
const artifactsByMessageId = this.artifacts.get();
let artifactsByPageName = artifactsByMessageId.get(messageId);
if (!artifactsByPageName) {
artifactsByPageName = new Map();
artifactsByMessageId.set(messageId, artifactsByPageName);
}
artifactsByPageName.set(name, newArtifact);
this.artifacts.set(artifactsByMessageId);
const bridge = await editorBridge;
bridge.updatePageAttributes(name, { title });
}
updateArtifact({ messageId, name }: ArtifactCallbackData, state: Partial<ArtifactUpdateState>) {
const artifact = this.getArtifact(messageId, name);
if (!artifact) {
return;
}
const artifactsByMessageId = this.artifacts.get();
const artifactsByPageName = artifactsByMessageId.get(messageId);
if (!artifactsByPageName) {
return;
}
artifactsByPageName.set(name, { ...artifact, ...state });
artifactsByMessageId.set(messageId, artifactsByPageName);
this.artifacts.set(artifactsByMessageId);
}
private getArtifact(messageId: string, pageName: string) {
const artifacts = this.artifacts.get();
const artifactsByPageName = artifacts.get(messageId);
if (!artifactsByPageName) {
return undefined;
}
return artifactsByPageName.get(pageName);
}
private getArtifactByArtifactId(messageId: string, artifactId: string) {
const artifacts = this.artifacts.get();
const artifactsByPageName = artifacts.get(messageId);
if (!artifactsByPageName) {
return undefined;
}
return artifactsByPageName.values().find((artifact) => artifact.id === artifactId);
}
setReloadedMessages(messages: string[]) {
this.reloadedMessages = new Set(messages);
}
addAction(data: ActionCallbackData) {
this._addAction(data);
}
private async _addAction(data: ActionCallbackData) {
const { messageId, artifactId } = data;
const artifact = this.getArtifactByArtifactId(messageId, artifactId);
if (!artifact) {
unreachable('Artifact not found');
}
return artifact.runner.addAction(data);
}
runAction(data: ActionCallbackData, isStreaming: boolean = false) {
if (isStreaming) {
this.actionStreamSampler(data, isStreaming);
} else {
this.addToExecutionQueue(() => this._runAction(data, isStreaming));
}
}
async _runAction(data: ActionCallbackData, isRunning: boolean = false) {
const { messageId, artifactId } = data;
const artifact = this.getArtifactByArtifactId(messageId, artifactId);
if (!artifact) {
unreachable('Artifact not found');
}
const action = artifact.runner.actions.get()[data.actionId];
if (!action || action.executed) {
return;
}
const { pageName, id } = data.action;
if (this.pagesStore.activeSection.get() !== id) {
this.pagesStore.setActiveSection(id);
}
if (this.pagesStore.activePage.get() !== pageName) {
this.pagesStore.setActivePage(pageName);
}
if (this.webBuilderStore.currentView.get() !== 'code') {
this.webBuilderStore.currentView.set('code');
}
const actionId = data.action.id;
const section = this.pagesStore.sections.get()[actionId];
if (!section) {
await artifact.runner.runAction(data, isRunning);
}
this.pagesStore.updateSection(actionId, data.action.content);
if (!isRunning) {
await artifact.runner.runAction(data);
this.pagesStore.resetPageModifications();
}
}
actionStreamSampler = createSampler(async (data: ActionCallbackData, isStreaming: boolean = false) => {
return await this._runAction(data, isStreaming);
}, 100); // TODO: remove this magic number to have it configurable
addToExecutionQueue(callback: () => Promise<void>) {
this.globalExecutionQueue = this.globalExecutionQueue.then(() => callback());
}
setCurrentMessageId(id: string | undefined) {
this.currentMessageId.set(id);
}
}

View File

@@ -0,0 +1,195 @@
import { atom, computed, type MapStore, map, type WritableAtom } from 'nanostores';
import type { Section } from '~/types/actions';
import type { DocumentProperties, Editor } from '~/types/editor';
import type { PageMap } from '~/types/pages';
import type { PagesStore } from './pages';
/**
* 编辑器文档,结构为 <pageName, pageProperties>
*/
export type EditorDocuments = Record<string, DocumentProperties>;
type SelectedDocument = WritableAtom<string | undefined>;
export type EditorSection = Record<string, Section>;
// 编辑器命令类型
export type EditorCommandType = 'scrollToElement' | 'exportToZip';
// 编辑器命令接口
export interface EditorCommand {
type: EditorCommandType;
payload: any;
}
// 创建一个用于发送编辑器命令的atom
export const editorCommands = atom<EditorCommand | null>(null);
/**
* 与 Editor 进行对接的 store。
* 其内部保存的数据可以直接由 editor 使用与操作,并且当前数据与 editor 实时同步。
*/
export class EditorStore {
private readonly pagesStore: PagesStore;
editorInstance: WritableAtom<Editor | null> = import.meta.hot?.data?.editorInstance ?? atom<Editor | null>(null);
// 编辑器中当前选中的文档。
selectedDocument: SelectedDocument = import.meta.hot?.data?.selectedPage ?? atom<string | undefined>();
// 编辑器文档数据,始终是与编辑器所保持的最新数据,但此数据不一定执行了保存。
editorDocuments: MapStore<EditorDocuments> = import.meta.hot?.data?.documents ?? map({});
// 当前编辑器文档,基于 editorDocuments 和 selectedDocument 计算而来。始终是与编辑器所保持的最新数据,但此数据不一定执行了保存。
currentDocument = computed([this.editorDocuments, this.selectedDocument], (documents, selectedDocument) => {
if (!selectedDocument) {
return undefined;
}
return documents[selectedDocument];
});
// 当前编辑器未保存的页面
unsavedDocuments: WritableAtom<Set<string>> = import.meta.hot?.data?.unsavedDocuments ?? atom(new Set<string>());
// 编辑器文档最后保存时间
documentLastSaved: WritableAtom<Record<string, number>> =
import.meta.hot?.data?.documentLastSaved ?? atom<Record<string, number>>({});
constructor(pagesStore: PagesStore) {
this.pagesStore = pagesStore;
if (import.meta.hot && import.meta.hot.data) {
import.meta.hot.data.unsavedDocuments = this.unsavedDocuments;
import.meta.hot.data.selectedDocument = this.selectedDocument;
import.meta.hot.data.editorDocuments = this.editorDocuments;
import.meta.hot.data.documentLastSaved = this.documentLastSaved;
}
this.setupCoordination();
}
private setupCoordination() {
// 监听 pagesStore 的 pages 变化
this.pagesStore.pages.listen((pages) => {
this.setDocuments(pages);
});
// 监听 pagesStore 的 activePage 变化
this.pagesStore.activePage.listen((pageName) => {
this.selectedDocument.set(pageName);
});
}
setEditorInstance(editor: Editor) {
this.editorInstance.set(editor);
}
getEditorInstance() {
return this.editorInstance.get();
}
setDocuments(pages: PageMap, updateContent: boolean = false) {
const documents = this.editorDocuments.get();
this.editorDocuments.set(
Object.fromEntries<DocumentProperties>(
Object.entries(pages)
.map(([pageName, page]) => {
if (page === undefined) {
return undefined;
}
const oldDocument = documents[pageName];
if (oldDocument && !updateContent) {
return [pageName, { ...oldDocument, name: pageName, title: page.title }];
}
return [
pageName,
{
name: pageName,
title: page.title,
content: page.content,
},
] as [string, DocumentProperties];
})
.filter(Boolean) as Array<[string, DocumentProperties]>,
),
);
}
updatePageState(pageName: string, page: Omit<DocumentProperties, 'content'>) {
const documents = this.editorDocuments.get();
const oldDocumentState = documents[pageName];
if (!oldDocumentState) {
return;
}
const content = oldDocumentState.content;
this.editorDocuments.setKey(pageName, { ...oldDocumentState, ...page, content });
}
updateDocumentContent(pageName: string, newContent: string) {
const documents = this.editorDocuments.get();
const oldDocumentState = documents[pageName];
if (!oldDocumentState) {
return;
}
const oldContent = oldDocumentState.content;
const contentChanged = oldContent !== newContent;
if (contentChanged) {
this.editorDocuments.setKey(pageName, {
...oldDocumentState,
content: newContent,
});
}
this.updateUnsavedDocuments(pageName, newContent);
}
private updateUnsavedDocuments(pageName: string, newContent: string) {
const savedContent = this.pagesStore.getPage(pageName)?.content;
// 是否存在未保存的更改
const unsavedChanges = savedContent === undefined || savedContent !== newContent;
const currentDocument = this.currentDocument.get();
if (!currentDocument) {
return;
}
// 保存数据至未保存中
const previousUnsavedPages = this.unsavedDocuments.get();
// 如果已经将此页面标记为未保存,则不进行更新。
if (unsavedChanges && previousUnsavedPages.has(pageName)) {
return;
}
const newUnsavedPages = new Set(previousUnsavedPages);
// 如果存在未保存的更改,则将此页面标记为未保存。否则,将此页面从未保存中移除。
if (unsavedChanges) {
newUnsavedPages.add(pageName);
} else {
newUnsavedPages.delete(pageName);
}
this.unsavedDocuments.set(newUnsavedPages);
}
removeUnsavedDocument(pageName: string, saved: boolean = false) {
const newUnsavedPages = new Set(this.unsavedDocuments.get());
newUnsavedPages.delete(pageName);
this.unsavedDocuments.set(newUnsavedPages);
if (!saved) {
return;
}
// 记录保存时间
const currentTime = Date.now();
const lastSavedTimes = this.documentLastSaved.get();
this.documentLastSaved.set({
...lastSavedTimes,
[pageName]: currentTime,
});
}
scrollToElement(domId: string) {
editorCommands.set({
type: 'scrollToElement',
payload: { domId },
});
}
}

View File

@@ -0,0 +1,64 @@
import { atom } from 'nanostores';
import { toast } from 'sonner';
import { logStore } from '~/stores/logs';
import type { NetlifyConnection } from '~/types/netlify';
// Initialize with stored connection or environment variable
const storedConnection = typeof window !== 'undefined' ? localStorage.getItem('netlify_connection') : null;
// If we have an environment token but no stored connection, initialize with the env token
const initialConnection: NetlifyConnection = storedConnection
? JSON.parse(storedConnection)
: {
isConnect: false,
stats: undefined,
};
export const netlifyConnection = atom<NetlifyConnection>(initialConnection);
export const isConnecting = atom<boolean>(initialConnection.isConnect);
export const isFetchingStats = atom<boolean>(false);
export const updateNetlifyConnection = (updates: Partial<NetlifyConnection>) => {
const currentState = netlifyConnection.get();
const newState = { ...currentState, ...updates };
netlifyConnection.set(newState);
// Persist to localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('netlify_connection', JSON.stringify(newState));
}
};
export async function fetchNetlifyStats() {
try {
isFetchingStats.set(true);
const response = await fetch('/api/netlify/stats');
if (!response.ok) {
throw new Error(`获取统计信息失败: ${response.status}`);
}
const { data, success, message } = await response.json();
if (!success) {
throw new Error(message || '获取统计信息失败');
}
const currentState = netlifyConnection.get();
updateNetlifyConnection({
...currentState,
stats: data,
});
toast.success('Netlify 统计信息更新成功');
return data;
} catch (error) {
console.error('Netlify API Error:', error);
logStore.logError('Failed to fetch Netlify stats', { error });
toast.error(`获取 Netlify 统计信息失败: ${error instanceof Error ? error.message : '未知错误'}`);
throw error;
} finally {
isFetchingStats.set(false);
}
}

425
app/.client/stores/pages.ts Normal file
View File

@@ -0,0 +1,425 @@
import { diffLines } from 'diff';
import { atom, computed, type MapStore, map, type WritableAtom } from 'nanostores';
import { type EditorBridge, type EventPayload, editorBridge } from '~/.client/bridge';
import { computePageModifications, diffPages } from '~/.client/utils/diff';
import { isValidContent } from '~/.client/utils/html-parse';
import type { Page, PageHistory } from '~/types/actions';
import type { PageMap, PageSection, SectionMap } from '~/types/pages';
import { createScopedLogger } from '~/utils/logger';
const logger = createScopedLogger('PagesStore');
type ActiveSection = WritableAtom<string | undefined>;
type ActivePage = WritableAtom<string | undefined>;
/**
* 保存与 AI 交互的页面数据, AI 生成的页面数据会保存在此处。
* 当用户在两个消息之间修改 editor 时,也会讲修改的页面和内容保存在此处。
*/
export class PagesStore {
private readonly editorBridge: Promise<EditorBridge> = editorBridge;
/**
* 跟踪页面数量
*/
private size = 0;
/**
* @note 跟踪所有自上次用户消息以来被修改的文件及其原始内容,以便模型感知这些更改。
* 当用户发送另一条消息且所有更改都需要提交时,需要重置。
*/
private modifiedPages: Map<string, string> = import.meta.hot?.data?.modifiedPages ?? new Map();
/**
* 跟踪已删除的页面,防止它们在重新加载时重新出现
*/
private deletedPages: Set<string> = import.meta.hot?.data?.deletedPages ?? new Set();
/**
* 页面映射,与 AI 做交互,基于 artifacts 数据解析而来。
* 因此,此数据表示与数据库通信的底层数据,未保存的数据将不会在此处体现。
* 如果在编辑器中确定保存了数据,则需要实时同步进 #modifiedPages 中。
*/
pages: MapStore<PageMap> = import.meta.hot?.data?.pages ?? map({});
/**
* 页面历史记录,用于 diff 视图。
* 每次页面保存时,会保存上一次的页面内容。
*/
pageHistory: MapStore<Record<string, PageHistory>> = import.meta.hot?.data?.pageHistory ?? map({});
activePage: ActivePage = import.meta.hot?.data?.activePage ?? atom<string | undefined>();
currentPage = computed([this.pages, this.activePage], (pages, activePage) => {
if (!activePage) {
return undefined;
}
return pages[activePage];
});
/**
* 基于 action 的 section 映射,作为与 AI 交互的底层数据,基于 actions 数据解析而来。
*/
sections: MapStore<SectionMap> = import.meta.hot?.data?.sections ?? map({});
/**
* 当前活跃的 section。
*/
activeSection: ActiveSection = import.meta.hot?.data?.activeSection ?? atom<string | undefined>();
currentSection = computed([this.sections, this.activeSection], (sections, activeSection) => {
if (!activeSection) {
return undefined;
}
return sections[activeSection];
});
get pagesCount() {
return this.size;
}
constructor() {
// Load deleted paths from localStorage if available
try {
if (typeof localStorage !== 'undefined') {
const deletedPagesJson = localStorage.getItem('upage-deleted-pages');
if (deletedPagesJson) {
const deletedPages = JSON.parse(deletedPagesJson);
if (Array.isArray(deletedPages)) {
deletedPages.forEach((path) => this.deletedPages.add(path));
}
}
}
} catch (error) {
logger.error('Failed to load deleted paths from localStorage', error);
}
if (import.meta.hot && import.meta.hot.data) {
// Persist our state across hot reloads
import.meta.hot.data.pages = this.pages;
import.meta.hot.data.modifiedPages = this.modifiedPages;
import.meta.hot.data.deletedPages = this.deletedPages;
import.meta.hot.data.sections = this.sections;
import.meta.hot.data.pageHistory = this.pageHistory;
}
this.#init();
this.setupCoordination();
}
private setupCoordination() {
this.sections.listen(() => {
const currentPage = this.activePage.get();
if (currentPage && this.currentSection.get() === undefined) {
const pageActions = this.getPage(currentPage)?.actionIds;
if (pageActions) {
this.setActiveSection(pageActions[pageActions.length - 1]);
}
}
});
}
getPage(pageName: string) {
return this.pages.get()[pageName];
}
getPageModifications() {
return computePageModifications(this.pages.get(), this.modifiedPages);
}
getModifiedPages() {
let modifiedPages: { [pageName: string]: Page } | undefined = undefined;
for (const [pageName, originalContent] of this.modifiedPages) {
const page = this.pages.get()[pageName];
if (!page) {
continue;
}
if (page.content === originalContent) {
continue;
}
if (!modifiedPages) {
modifiedPages = {};
}
modifiedPages[pageName] = page;
}
return modifiedPages;
}
resetPageModifications() {
this.modifiedPages.clear();
}
async savePage(pageName: string, content: string) {
const page = this.getPage(pageName);
if (!page) {
return false;
}
// 保存上一次的页面内容
this.savePageHistory(pageName, content);
try {
this.pages.setKey(pageName, { ...page, content });
logger.info('Page updated');
} catch (error) {
logger.error('Failed to update page content\n\n', error);
throw error;
}
}
async savePageHistory(pageName: string, newContent: string) {
const page = this.getPage(pageName);
if (!page) {
return;
}
const pageHistory = this.pageHistory.get()[pageName];
// 如果不存在历史记录,则创建一个新的历史记录
const normalizedCurrentContent = newContent?.replace(/\r\n/g, '\n').trim();
const originalContent = pageHistory?.originalContent || page.content!;
if (!originalContent) {
return;
}
const normalizedOriginalContent = (pageHistory?.originalContent || page.content!).replace(/\r\n/g, '\n').trim();
if (!pageHistory) {
if (normalizedCurrentContent !== normalizedOriginalContent) {
const newChanges = diffLines(page.content!, newContent);
const newHistory: PageHistory = {
originalContent: page.content!,
lastModified: Date.now(),
changes: newChanges,
versions: [
{
timestamp: Date.now(),
content: newContent,
},
],
changeSource: 'auto-save',
};
this.pageHistory.setKey(pageName, newHistory);
}
return;
}
// 如果存在历史记录,则检查自上次版本以来是否有实际变化
const lastVersion = pageHistory.versions[pageHistory.versions.length - 1];
const normalizedLastContent = lastVersion?.content.replace(/\r\n/g, '\n').trim();
if (normalizedCurrentContent === normalizedLastContent) {
return;
}
const unifiedDiff = diffPages(pageName, pageHistory.originalContent, newContent);
if (!unifiedDiff) {
return;
}
const newChanges = diffLines(pageHistory.originalContent, newContent);
// 检查是否有显著变化
const hasSignificantChanges = newChanges.some(
(change) => (change.added || change.removed) && change.value.trim().length > 0,
);
if (!hasSignificantChanges) {
return;
}
const newHistory: PageHistory = {
originalContent: pageHistory.originalContent,
lastModified: Date.now(),
changes: [...pageHistory.changes, ...newChanges].slice(-100), // Limitar histórico de mudanças
versions: [
...pageHistory.versions,
{
timestamp: Date.now(),
content: newContent,
},
].slice(-10), // 只保留最近的 10 个版本
changeSource: 'auto-save',
};
this.pageHistory.setKey(pageName, newHistory);
}
async #init() {
const grapesBridge = await this.editorBridge;
this.#cleanupDeletedPages();
grapesBridge.watch(({ type, payload }) => this.#processGrapesBridgeEvent(type, payload));
}
/**
* Removes any deleted files/folders from the store
*/
#cleanupDeletedPages() {
if (this.deletedPages.size === 0) {
return;
}
const currentPages = this.pages.get();
for (const deletedPageName of this.deletedPages) {
if (currentPages[deletedPageName]) {
this.pages.setKey(deletedPageName, undefined);
this.size--;
}
for (const [path] of Object.entries(currentPages)) {
if (path.startsWith(deletedPageName + '/')) {
this.pages.setKey(path, undefined);
this.size--;
if (this.modifiedPages.has(path)) {
this.modifiedPages.delete(path);
}
}
}
}
}
#processGrapesBridgeEvent(type: string, payload: EventPayload) {
const { pageName } = payload;
// Skip processing if this page was explicitly deleted
if (this.deletedPages.has(pageName)) {
return;
}
switch (type) {
case 'add_page': {
const { title: pageTitle, actionIds = [] } = payload;
const oldPage = this.pages.get()[pageName];
if (oldPage) {
throw new Error(`Page ${pageName} already exists`);
}
this.pages.setKey(pageName, {
name: pageName,
title: pageTitle,
content: '',
actionIds,
});
this.size++;
logger.info(`Page created: ${pageName}`);
break;
}
case 'upsert_page': {
const { title: pageTitle, actionIds = [] } = payload;
const oldPage = this.pages.get()[pageName];
this.pages.setKey(pageName, {
name: pageName,
title: pageTitle,
actionIds: actionIds || oldPage?.actionIds,
content: oldPage?.content,
});
break;
}
case 'remove_page': {
this.deletedPages.add(pageName);
this.pages.setKey(pageName, undefined);
this.size--;
if (this.modifiedPages.has(pageName)) {
this.modifiedPages.delete(pageName);
}
this.#persistDeletedPages();
logger.info(`Page deleted: ${pageName}`);
break;
}
case 'update_section': {
const { id, section } = payload;
this.sections.setKey(id, { id, type: 'section', ...section });
break;
}
}
}
async createPage(pageName: string, pageTitle: string) {
await this.editorBridge.then((grapesBridge) => grapesBridge.createPage(pageName, { title: pageTitle }));
return true;
}
async deletePage(pageName: string) {
await this.editorBridge.then((grapesBridge) => grapesBridge.removePage(pageName));
return true;
}
// method to persist deleted paths to localStorage
#persistDeletedPages() {
try {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('upage-deleted-pages', JSON.stringify([...this.deletedPages]));
}
} catch (error) {
logger.error('Failed to persist deleted paths to localStorage', error);
}
}
updateSection(actionId: string, sectionContent: string) {
const sections = this.sections.get();
const sectionState = sections[actionId];
if (!sectionState) {
return;
}
const oldContent = sectionState.content;
const contentChanged = oldContent !== sectionContent;
if (contentChanged) {
this.sections.setKey(actionId, { ...sectionState, content: sectionContent });
}
this.updateSectionRootDomId(actionId, sectionState, sectionContent);
}
private updateSectionRootDomId(actionId: string, section: PageSection, sectionContent: string) {
if (section.validRootDomId) {
return;
}
if (section.action === 'remove') {
this.sections.setKey(actionId, { ...section, rootDomId: section.domId, validRootDomId: true });
return;
}
const isValid = isValidContent(sectionContent);
if (!isValid) {
return;
}
const div = document.createElement('div');
div.innerHTML = sectionContent;
const rootDomId = div.firstElementChild?.id;
if (!rootDomId) {
return;
}
const oldRootDomId = section.rootDomId;
if (oldRootDomId && oldRootDomId === rootDomId) {
this.sections.setKey(actionId, { ...section, validRootDomId: true });
} else {
this.sections.setKey(actionId, { ...section, rootDomId });
}
}
setActiveSection(sectionId: string | undefined) {
this.activeSection.set(sectionId);
}
setActivePage(pageName: string | undefined) {
this.activePage.set(pageName);
}
setPage(pageName: string, page: Page) {
const oldPage = this.getPage(pageName);
if (!oldPage) {
return;
}
this.pages.setKey(pageName, { ...oldPage, ...page });
}
}

View File

@@ -0,0 +1,39 @@
import { atom } from 'nanostores';
import type { ExportEditorFile } from './web-builder';
export interface PreviewInfo {
content: string;
filename: string;
mimeType: string;
}
// 当页面保存时,由 save 方法主动调用并 setPreviews 来更新 previews 中的数据
export class PreviewsStore {
previews = atom<PreviewInfo[]>([]);
currentPreview = atom<string | null>(null);
setPreviews(files: ExportEditorFile[]) {
this.previews.set(
files.map((file) => ({
content: file.content,
filename: file.filename,
mimeType: file.mimeType,
})),
);
}
setCurrentPreview(filename: string) {
this.currentPreview.set(filename);
}
}
// Create a singleton instance
let previewsStore: PreviewsStore | null = null;
export function usePreviewStore() {
if (!previewsStore) {
previewsStore = new PreviewsStore();
}
return previewsStore;
}

View File

@@ -0,0 +1,28 @@
import { atom } from 'nanostores';
interface Profile {
username: string;
bio: string;
avatar: string;
}
// Initialize with stored profile or defaults
const storedProfile = typeof window !== 'undefined' ? localStorage.getItem('upage_profile') : null;
const initialProfile: Profile = storedProfile
? JSON.parse(storedProfile)
: {
username: '',
bio: '',
avatar: '',
};
export const profileStore = atom<Profile>(initialProfile);
export const updateProfile = (updates: Partial<Profile>) => {
profileStore.set({ ...profileStore.get(), ...updates });
// Persist to localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('upage_profile', JSON.stringify(profileStore.get()));
}
};

View File

@@ -0,0 +1,214 @@
import Cookies from 'js-cookie';
import { atom, map } from 'nanostores';
import { DEFAULT_TAB_CONFIG } from '~/.client/components/@settings/core/constants';
import type {
DevTabConfig,
TabVisibilityConfig,
TabWindowConfig,
UserTabConfig,
} from '~/.client/components/@settings/core/types';
import { toggleTheme } from '~/stores/theme';
export interface Shortcut {
key: string;
ctrlKey?: boolean;
shiftKey?: boolean;
altKey?: boolean;
metaKey?: boolean;
ctrlOrMetaKey?: boolean;
action: () => void;
description?: string; // Description of what the shortcut does
isPreventDefault?: boolean; // Whether to prevent default browser behavior
}
export interface Shortcuts {
toggleTheme: Shortcut;
}
// Simplified shortcuts store with only theme toggle
export const shortcutsStore = map<Shortcuts>({
toggleTheme: {
key: 'd',
metaKey: true,
altKey: true,
shiftKey: true,
action: () => toggleTheme(),
description: 'Toggle theme',
isPreventDefault: true,
},
});
// Add this helper function at the top of the file
const isBrowser = typeof window !== 'undefined';
export const isDebugMode = atom(false);
export const isProduction = atom(
isBrowser ? window.ENV.OPERATING_ENV === 'production' : process.env.OPERATING_ENV === 'production',
);
export const isDevelopment = atom(
isBrowser ? window.ENV.OPERATING_ENV === 'development' : process.env.OPERATING_ENV === 'development',
);
export const isTest = atom(isBrowser ? window.ENV.OPERATING_ENV === 'test' : process.env.OPERATING_ENV === 'test');
// Define keys for localStorage
const SETTINGS_KEYS = {
LATEST_BRANCH: 'isLatestBranch',
EVENT_LOGS: 'isEventLogsEnabled',
PROMPT_ID: 'promptId',
DEVELOPER_MODE: 'isDeveloperMode',
} as const;
// Initialize settings from localStorage or defaults
const getInitialSettings = () => {
const getStoredBoolean = (key: string, defaultValue: boolean): boolean => {
if (!isBrowser) {
return defaultValue;
}
const stored = localStorage.getItem(key);
if (stored === null) {
return defaultValue;
}
try {
return JSON.parse(stored);
} catch {
return defaultValue;
}
};
return {
latestBranch: getStoredBoolean(SETTINGS_KEYS.LATEST_BRANCH, false),
eventLogs: getStoredBoolean(SETTINGS_KEYS.EVENT_LOGS, true),
promptId: isBrowser ? localStorage.getItem(SETTINGS_KEYS.PROMPT_ID) || 'default' : 'default',
developerMode: getStoredBoolean(SETTINGS_KEYS.DEVELOPER_MODE, false),
};
};
// Initialize stores with persisted values
const initialSettings = getInitialSettings();
export const latestBranchStore = atom<boolean>(initialSettings.latestBranch);
export const isEventLogsEnabled = atom<boolean>(initialSettings.eventLogs);
export const promptStore = atom<string>(initialSettings.promptId);
// Helper functions to update settings with persistence
export const updateLatestBranch = (enabled: boolean) => {
latestBranchStore.set(enabled);
localStorage.setItem(SETTINGS_KEYS.LATEST_BRANCH, JSON.stringify(enabled));
};
export const updateEventLogs = (enabled: boolean) => {
isEventLogsEnabled.set(enabled);
localStorage.setItem(SETTINGS_KEYS.EVENT_LOGS, JSON.stringify(enabled));
};
export const updatePromptId = (id: string) => {
promptStore.set(id);
localStorage.setItem(SETTINGS_KEYS.PROMPT_ID, id);
};
// Initialize tab configuration from localStorage or defaults
const getInitialTabConfiguration = (): TabWindowConfig => {
const defaultConfig: TabWindowConfig = {
userTabs: DEFAULT_TAB_CONFIG.filter((tab): tab is UserTabConfig => tab.window === 'user'),
developerTabs: DEFAULT_TAB_CONFIG.filter((tab): tab is DevTabConfig => tab.window === 'developer'),
};
if (!isBrowser) {
return defaultConfig;
}
try {
const saved = localStorage.getItem('upage_tab_configuration');
if (!saved) {
return defaultConfig;
}
const parsed = JSON.parse(saved);
if (!parsed?.userTabs || !parsed?.developerTabs) {
return defaultConfig;
}
// Ensure proper typing of loaded configuration
return {
userTabs: parsed.userTabs.filter((tab: TabVisibilityConfig): tab is UserTabConfig => tab.window === 'user'),
developerTabs: parsed.developerTabs.filter(
(tab: TabVisibilityConfig): tab is DevTabConfig => tab.window === 'developer',
),
};
} catch (error) {
console.warn('Failed to parse tab configuration:', error);
return defaultConfig;
}
};
export const tabConfigurationStore = map<TabWindowConfig>(getInitialTabConfiguration());
// Helper function to update tab configuration
export const updateTabConfiguration = (config: TabVisibilityConfig) => {
const currentConfig = tabConfigurationStore.get();
console.log('Current tab configuration before update:', currentConfig);
const isUserTab = config.window === 'user';
const targetArray = isUserTab ? 'userTabs' : 'developerTabs';
// Only update the tab in its respective window
const updatedTabs = currentConfig[targetArray].map((tab) => (tab.id === config.id ? { ...config } : tab));
// If tab doesn't exist in this window yet, add it
if (!updatedTabs.find((tab) => tab.id === config.id)) {
updatedTabs.push(config);
}
// Create new config, only updating the target window's tabs
const newConfig: TabWindowConfig = {
...currentConfig,
[targetArray]: updatedTabs,
};
console.log('New tab configuration after update:', newConfig);
tabConfigurationStore.set(newConfig);
Cookies.set('tabConfiguration', JSON.stringify(newConfig), {
expires: 365, // Set cookie to expire in 1 year
path: '/',
sameSite: 'strict',
});
};
// Helper function to reset tab configuration
export const resetTabConfiguration = () => {
const defaultConfig: TabWindowConfig = {
userTabs: DEFAULT_TAB_CONFIG.filter((tab): tab is UserTabConfig => tab.window === 'user'),
developerTabs: DEFAULT_TAB_CONFIG.filter((tab): tab is DevTabConfig => tab.window === 'developer'),
};
tabConfigurationStore.set(defaultConfig);
localStorage.setItem('upage_tab_configuration', JSON.stringify(defaultConfig));
};
// Settings panel state
export const settingsStore = map({
isOpen: false,
selectedTab: 'user', // Default tab
});
// Settings panel actions
export const openSettings = () => {
settingsStore.setKey('isOpen', true);
settingsStore.setKey('selectedTab', 'user'); // Always open to user tab
};
export const closeSettings = () => {
settingsStore.setKey('isOpen', false);
settingsStore.setKey('selectedTab', 'user'); // Reset to user tab when closing
};
export const setSelectedTab = (tab: string) => {
settingsStore.setKey('selectedTab', tab);
};

View File

@@ -0,0 +1,15 @@
import { atom } from 'nanostores';
export const kSidebar = 'upage_sidebar';
export const DEFAULT_SIDEBAR_STATE = false;
export const sidebarStore = atom<boolean>(DEFAULT_SIDEBAR_STATE);
export function toggleSidebar() {
const currentSidebar = sidebarStore.get();
const newSidebar = !currentSidebar;
// Update the theme store
sidebarStore.set(newSidebar);
}

View File

@@ -0,0 +1,52 @@
import { atom, map } from 'nanostores';
export interface TabConfig {
id: string;
visible: boolean;
window: 'developer' | 'user';
order: number;
locked?: boolean;
}
const DEFAULT_CONFIG = {
userTabs: [],
developerTabs: [],
};
export const userTabsStore = atom<TabConfig[]>(DEFAULT_CONFIG.userTabs);
export const developerTabsStore = atom<TabConfig[]>(DEFAULT_CONFIG.developerTabs);
export const tabConfiguration = map({
userTabs: DEFAULT_CONFIG.userTabs,
developerTabs: DEFAULT_CONFIG.developerTabs,
});
tabConfiguration.set({
userTabs: DEFAULT_CONFIG.userTabs,
developerTabs: DEFAULT_CONFIG.developerTabs,
});
userTabsStore.listen((userTabs) => {
tabConfiguration.setKey('userTabs', userTabs as never[]);
});
developerTabsStore.listen((developerTabs) => {
tabConfiguration.setKey('developerTabs', developerTabs as never[]);
});
export const tabConfigurationStore = {
get: () => ({
userTabs: userTabsStore.get(),
developerTabs: developerTabsStore.get(),
}),
set: (config: { userTabs: TabConfig[]; developerTabs: TabConfig[] }) => {
userTabsStore.set(config.userTabs);
developerTabsStore.set(config.developerTabs);
},
reset: () => {
userTabsStore.set(DEFAULT_CONFIG.userTabs);
developerTabsStore.set(DEFAULT_CONFIG.developerTabs);
},
};

View File

@@ -0,0 +1,63 @@
import { atom } from 'nanostores';
import { toast } from 'sonner';
import { logStore } from '~/stores/logs';
import type { VercelConnection } from '~/types/vercel';
// Initialize with stored connection or defaults
const storedConnection = typeof window !== 'undefined' ? localStorage.getItem('vercel_connection') : null;
const initialConnection: VercelConnection = storedConnection
? JSON.parse(storedConnection)
: {
isConnect: false,
user: null,
stats: undefined,
};
export const vercelConnection = atom<VercelConnection>(initialConnection);
export const isConnect = atom<boolean>(initialConnection.isConnect);
export const isFetchingStats = atom<boolean>(false);
export const updateVercelConnection = (updates: Partial<VercelConnection>) => {
const currentState = vercelConnection.get();
const newState = { ...currentState, ...updates };
vercelConnection.set(newState);
// Persist to localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('vercel_connection', JSON.stringify(newState));
}
};
export async function fetchVercelStats() {
try {
isFetchingStats.set(true);
const response = await fetch('/api/vercel/stats');
if (!response.ok) {
throw new Error(`获取统计信息失败: ${response.status}`);
}
const { data, success, message } = await response.json();
if (!success) {
throw new Error(message || '获取统计信息失败');
}
const currentState = vercelConnection.get();
updateVercelConnection({
...currentState,
stats: data,
});
toast.success('Vercel 统计信息更新成功');
return data;
} catch (error) {
console.error('Vercel API Error:', error);
logStore.logError('Failed to fetch Vercel stats', { error });
toast.error(`获取 Vercel 统计信息失败: ${error instanceof Error ? error.message : '未知错误'}`);
throw error;
} finally {
isFetchingStats.set(false);
}
}

View File

@@ -0,0 +1,601 @@
import { Octokit, type RestEndpointMethodTypes } from '@octokit/rest';
import Cookies from 'js-cookie';
import JSZip from 'jszip';
import { atom, type WritableAtom } from 'nanostores';
import { toast } from 'sonner';
import { formatFile } from '~/.client/utils/prettier';
import type { Page } from '~/types/actions';
import type { PageMap } from '~/types/pages';
import { base64ToBinary, getContentType, getExtensionFromMimeType, getFileName } from '~/utils/file-utils';
import { createScopedLogger } from '~/utils/logger';
import { ChatStore } from './chat';
import { EditorStore } from './editor';
import { PagesStore } from './pages';
import { PreviewsStore } from './previews';
const logger = createScopedLogger('WebBuilderStore');
export type WebBuilderViewType = 'code' | 'diff' | 'preview';
export type GetProjectFilesOptions = {
inline?: boolean;
pathMode?: 'relative' | 'absolute';
};
export type ExportEditorFile = {
filename: string;
content: string;
mimeType: string;
};
/**
* WebBuilderStore 是整个 builder 的 store负责管理、统筹所有与构建器有关的状态。
*/
export class WebBuilderStore {
readonly chatStore: ChatStore;
readonly previewsStore: PreviewsStore;
readonly pagesStore: PagesStore;
readonly editorStore: EditorStore;
// 是否显示 webBuilder
showWorkbench: WritableAtom<boolean> = import.meta.hot?.data?.showWorkbench ?? atom(false);
// 当前 webBuilder 所在的视图
currentView: WritableAtom<WebBuilderViewType> = import.meta.hot?.data?.currentView ?? atom('code');
constructor() {
this.previewsStore = new PreviewsStore();
this.pagesStore = new PagesStore();
this.chatStore = new ChatStore(this, this.pagesStore);
this.editorStore = new EditorStore(this.pagesStore);
if (import.meta.hot && import.meta.hot.data) {
import.meta.hot.data.showWorkbench = this.showWorkbench;
import.meta.hot.data.currentView = this.currentView;
}
this.setupCoordination();
}
private setupCoordination() {
this.currentView.listen((view) => {
if (view === 'preview') {
this.getProjectFiles({ pathMode: 'absolute' }).then((files) => {
this.setPreviews(files);
});
}
});
}
setPages(pages: PageMap) {
this.editorStore.setDocuments(pages, true);
for (const [pageName, page] of Object.entries(pages)) {
if (page) {
this.pagesStore.setPage(pageName, page);
}
}
if (this.pagesStore.pagesCount > 0 && this.editorStore.currentDocument.get() === undefined) {
// 找到第一个页面并选中
for (const [pageName] of Object.entries(pages)) {
this.setSelectedPage(pageName);
this.setActiveSectionByPageName(pageName);
break;
}
}
}
setSelectedPage(pageName: string | undefined) {
this.pagesStore.setActivePage(pageName);
}
setActiveSectionByPageName(pageName: string) {
const page = this.pagesStore.getPage(pageName);
if (page) {
this.setActiveSection(page.actionIds[page.actionIds.length - 1]);
}
}
setActiveSection(sectionId: string | undefined) {
this.pagesStore.setActiveSection(sectionId);
}
setPreviews(files: ExportEditorFile[]) {
this.previewsStore.setPreviews(files);
const currentDocument = this.editorStore.currentDocument.get();
const pageName = currentDocument?.name;
if (pageName) {
this.previewsStore.setCurrentPreview(`${pageName}.html`);
}
}
setShowWorkbench(show: boolean) {
this.showWorkbench.set(show);
}
async setDocumentContent(pageName: string, _html: string) {
if (_html.trim() === '') {
return;
}
// 更新内容,但不会触发保存,不会保存至 pages 中。
this.editorStore.updateDocumentContent(pageName, _html);
}
/**
* 执行保存,将 editorStore 中的当前页面内容同步保存至 PagesStore 中。
* @returns
*/
async saveCurrentDocument() {
const currentPage = this.editorStore.currentDocument.get();
if (!currentPage) {
return;
}
this.saveDocument(currentPage.name as string);
}
async saveDocument(pageName: string) {
const documents = this.editorStore.editorDocuments.get();
const pageProperties = documents[pageName];
if (pageProperties === undefined) {
return;
}
// 触发 page 的保存
this.pagesStore.savePage(pageName, pageProperties.content as string).then(() => {
this.editorStore.removeUnsavedDocument(pageName, true);
});
}
/**
* 将当前页面重置为上次保存的状态。
* @returns
*/
resetCurrentPage() {
const currentPage = this.editorStore.currentDocument.get();
if (currentPage === undefined) {
return;
}
const { name: pageName } = currentPage;
const page = this.pagesStore.getPage(pageName as string);
if (!page) {
return;
}
this.editorStore.updateDocumentContent(pageName as string, page.content as string);
}
async saveAllPages() {
for (const pageName of this.editorStore.unsavedDocuments.get()) {
await this.saveDocument(pageName);
}
}
/**
* 创建一个页面,通常由用户手动创建。
* @param pageName 页面名称
* @param pageTitle 页面标题
* @returns 是否成功
*/
async createPage(pageName: string, pageTitle = '未命名页面') {
try {
// 只需更新 pagesStoreDocument 将会监听 pagesStore 的变化,并更新自身。
return await this.pagesStore.createPage(pageName, pageTitle);
} catch (error) {
console.error('Failed to create page:', error);
throw error;
}
}
/**
* 删除一个页面,
* @param pageName 页面名称
* @returns 是否成功
*/
async deletePage(pageName: string) {
try {
const currentDocument = this.editorStore.currentDocument.get();
const isInCurrentPage = currentDocument?.name === pageName;
const success = await this.pagesStore.deletePage(pageName);
if (success) {
if (isInCurrentPage) {
const pages = this.pagesStore.pages.get();
let nextPage: string | undefined = undefined;
for (const [path] of Object.entries(pages)) {
nextPage = path;
break;
}
this.setSelectedPage(nextPage);
}
}
return success;
} catch (error) {
console.error('Failed to delete page:', error);
throw error;
}
}
async pushToGitHub(repoName: string, commitMessage?: string, githubUsername?: string, ghToken?: string) {
try {
// Use cookies if username and token are not provided
const githubToken = ghToken || Cookies.get('githubToken');
const owner = githubUsername || Cookies.get('githubUsername');
if (!githubToken || !owner) {
throw new Error('GitHub token or username is not set in cookies or provided.');
}
// Initialize Octokit with the auth token
const octokit = new Octokit({ auth: githubToken });
// Check if the repository already exists before creating it
let repo: RestEndpointMethodTypes['repos']['get']['response']['data'];
try {
const resp = await octokit.repos.get({ owner, repo: repoName });
repo = resp.data;
} catch (error) {
if (error instanceof Error && 'status' in error && error.status === 404) {
// Repository doesn't exist, so create a new one
const { data: newRepo } = await octokit.repos.createForAuthenticatedUser({
name: repoName,
private: false,
auto_init: true,
});
repo = newRepo;
} else {
console.log('cannot create repo!');
throw error; // Some other error occurred
}
}
// Get all pages
const pages = await this.getProjectFilesAsMap({
inline: false,
});
if (!pages || Object.keys(pages).length === 0) {
throw new Error('No pages found to push');
}
// Create blobs for each file
const blobs = await Promise.all(
Object.entries(pages).map(async ([path, content]) => {
if (path && content) {
const formatContent = await formatFile(path, content);
const { data: blob } = await octokit.git.createBlob({
owner: repo.owner.login,
repo: repo.name,
content: Buffer.from(formatContent).toString('base64'),
encoding: 'base64',
});
return { path, sha: blob.sha };
}
return null;
}),
);
const validBlobs = blobs.filter(Boolean); // Filter out any undefined blobs
if (validBlobs.length === 0) {
throw new Error('No valid files to push');
}
// Get the latest commit SHA (assuming main branch, update dynamically if needed)
const { data: ref } = await octokit.git.getRef({
owner: repo.owner.login,
repo: repo.name,
ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch
});
const latestCommitSha = ref.object.sha;
// Create a new tree
const { data: newTree } = await octokit.git.createTree({
owner: repo.owner.login,
repo: repo.name,
base_tree: latestCommitSha,
tree: validBlobs.map((blob) => ({
path: blob!.path,
mode: '100644',
type: 'blob',
sha: blob!.sha,
})),
});
// Create a new commit
const { data: newCommit } = await octokit.git.createCommit({
owner: repo.owner.login,
repo: repo.name,
message: commitMessage || 'Initial commit from your app',
tree: newTree.sha,
parents: [latestCommitSha],
});
// Update the reference
await octokit.git.updateRef({
owner: repo.owner.login,
repo: repo.name,
ref: `heads/${repo.default_branch || 'main'}`, // Handle dynamic branch
sha: newCommit.sha,
});
} catch (error) {
console.error('Error pushing to GitHub:', error);
throw error; // Rethrow the error for further handling
}
}
async exportToZip(prefix: string = 'upage_export') {
try {
const projectFiles = await this.getProjectFiles({ inline: false });
if (projectFiles.length === 0) {
toast.error('没有可导出的文件');
return;
}
const zip = new JSZip();
projectFiles.forEach((file: ExportEditorFile) => {
if (file.mimeType?.startsWith('text/') || file.filename.endsWith('.js')) {
zip.file(file.filename, file.content);
} else {
zip.file(file.filename, file.content, { binary: true });
}
});
const zipBlob = await zip.generateAsync({ type: 'blob' });
const url = URL.createObjectURL(zipBlob);
const a = document.createElement('a');
a.href = url;
a.download = `${prefix}_${Date.now()}.zip`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
logger.error('导出 HTML 文件失败', error);
toast.error('导出 HTML 文件失败');
}
}
async getProjectFilesAsMap(options: GetProjectFilesOptions = {}): Promise<Record<string, string>> {
const files = await this.getProjectFiles(options);
return files.reduce(
(acc, file) => {
acc[file.filename] = file.content;
return acc;
},
{} as Record<string, string>,
);
}
/**
* 获取当前编辑器中的文件。
* @param inline 是否内联样式及所有图片。如果为 false则将提取所有本地资源为单独文件。默认为 true。
* @param pathMode HTML 内的文件路径模式,默认为相对路径。
* @returns 文件列表。
*/
async getProjectFiles({
inline = true,
pathMode = 'relative',
}: GetProjectFilesOptions = {}): Promise<ExportEditorFile[]> {
const getFiles = async () => {
const files: ExportEditorFile[] = [];
for (const page of Object.values(this.pagesStore.pages.get())) {
if (!page) {
continue;
}
const doc = this.createProjectHead(page, pathMode);
const pageElement = document.createElement('div');
pageElement.id = page.name;
pageElement.innerHTML = page.content || '';
doc.body.innerHTML = pageElement.innerHTML;
const file = {
filename: `${page.name}.html`,
content: '',
mimeType: 'text/html',
};
if (inline) {
file.content = '<!DOCTYPE html>\n' + doc.documentElement.outerHTML;
files.push(file);
} else {
const extractedFiles = await this.extractResources(pageElement);
doc.body.innerHTML = pageElement.innerHTML;
file.content = '<!DOCTYPE html>\n' + doc.documentElement.outerHTML;
files.push(file);
files.push(...extractedFiles);
}
}
return files;
};
const files: ExportEditorFile[] = await getFiles();
if (!inline) {
const tailwindContent = await fetch('/tailwindcss.js').then((resp) => resp.text());
files.push({
filename: 'tailwindcss.js',
content: tailwindContent,
mimeType: 'application/javascript',
});
const iconifyContent = await fetch('/iconify-icon.min.js').then((resp) => resp.text());
files.push({
filename: 'iconify-icon.min.js',
content: iconifyContent,
mimeType: 'application/javascript',
});
}
return files;
}
private createProjectHead(page: Page, pathMode: 'relative' | 'absolute' = 'relative'): Document {
const basePath = pathMode === 'relative' ? './' : '/';
const doc = document.implementation.createHTMLDocument('');
const head = doc.head;
const meta = doc.createElement('meta');
meta.setAttribute('charset', 'UTF-8');
head.appendChild(meta);
const viewport = doc.createElement('meta');
viewport.setAttribute('name', 'viewport');
viewport.setAttribute('content', 'width=device-width, initial-scale=1.0');
head.appendChild(viewport);
const title = doc.createElement('title');
title.textContent = page.title || 'UPage Generated Page';
head.appendChild(title);
const tailwindScript = doc.createElement('script');
tailwindScript.setAttribute('src', `${basePath}tailwindcss.js`);
head.appendChild(tailwindScript);
const iconifyScript = doc.createElement('script');
iconifyScript.setAttribute('src', `${basePath}iconify-icon.min.js`);
head.appendChild(iconifyScript);
return doc;
}
/**
* 提取资源为单独文件
* @param doc HTML 文档
* @returns 提取的文件列表
*/
private async extractResources(doc: Element, assetsFolder: string = 'assets'): Promise<ExportEditorFile[]> {
const files: ExportEditorFile[] = [];
const resources: {
element: Element;
attribute: string;
value: string;
}[] = [];
doc.querySelectorAll('*').forEach((element) => {
const src = element.getAttribute('src');
if (src) {
resources.push({
element,
attribute: 'src',
value: src,
});
}
const href = element.getAttribute('href');
if (href && element.tagName === 'LINK') {
resources.push({
element,
attribute: 'href',
value: href,
});
}
const background = element.getAttribute('background');
if (background) {
resources.push({
element,
attribute: 'background',
value: background,
});
}
const backgroundImage = element.getAttribute('background-image');
if (backgroundImage) {
resources.push({
element,
attribute: 'background-image',
value: backgroundImage,
});
}
});
for (const resource of resources) {
if (this.isRemoteUrl(resource.value) || this.isAnchor(resource.value)) {
continue;
}
if (resource.value.startsWith('data:')) {
const mimeType = resource.value.split(';')[0].split(':')[1];
const base64Content = resource.value.split(',')[1];
const filename = `${assetsFolder}/${Date.now()}${getExtensionFromMimeType(mimeType)}`;
resource.element.setAttribute(resource.attribute, `./${filename}`);
// 将 base64 转换为二进制字符串
const binaryString = base64ToBinary(base64Content);
files.push({
filename,
content: binaryString,
mimeType,
});
continue;
}
try {
const response = await fetch(resource.value, {
headers: {
'Content-Type': getContentType(resource.value),
},
});
if (!response.ok) {
logger.error(`Failed to fetch resource: ${resource.value} (status: ${response.status})`);
continue;
}
const filename = `${assetsFolder}/${getFileName(resource.value)}`;
resource.element.setAttribute(resource.attribute, `./${filename}`);
const mimeType = getContentType(resource.value);
let content: string;
if (this.isTextMimeType(mimeType)) {
content = await response.text();
} else {
const buffer = await response.arrayBuffer();
const array = new Uint8Array(buffer);
content = Array.from(array)
.map((byte) => String.fromCharCode(byte))
.join('');
}
files.push({
filename,
content,
mimeType,
});
} catch (error) {
logger.error(`Error fetching resource: ${resource.value}`, error);
continue;
}
}
return files;
}
/**
* 检查 URL 是否为远程 URL以 http:// 或 https:// 开头)
* @param url 要检查的 URL
* @returns 是否为远程 URL
*/
private isRemoteUrl(url: string): boolean {
return url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//');
}
private isAnchor(url: string): boolean {
return url.startsWith('#');
}
/**
* 判断是否为文本类型的 MIME 类型
* @param mimeType MIME 类型
* @returns 是否为文本类型
*/
private isTextMimeType(mimeType: string): boolean {
return (
mimeType.startsWith('text/') ||
mimeType === 'application/javascript' ||
mimeType === 'application/json' ||
mimeType === 'application/xml'
);
}
}
export const webBuilderStore = new WebBuilderStore();