🎉 first commit
This commit is contained in:
80
app/lib/stores/1panel.ts
Normal file
80
app/lib/stores/1panel.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
135
app/lib/stores/ai-state.ts
Normal file
135
app/lib/stores/ai-state.ts
Normal 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;
|
||||
}
|
||||
16
app/lib/stores/chat-message.ts
Normal file
16
app/lib/stores/chat-message.ts
Normal 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);
|
||||
}
|
||||
211
app/lib/stores/chat.ts
Normal file
211
app/lib/stores/chat.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { atom, type MapStore, map, type WritableAtom } from 'nanostores';
|
||||
import type { ActionAlert } from '~/types/actions';
|
||||
import { createSampler } from '~/utils/sampler';
|
||||
import { unreachable } from '~/utils/unreachable';
|
||||
import { editorBridge } from '../bridge';
|
||||
import { ActionRunner } from '../runtime/action-runner';
|
||||
import type { ActionCallbackData, ArtifactCallbackData } from '../runtime/message-parser';
|
||||
import type { PagesStore } from './pages';
|
||||
import type { WebBuilderStore } from './web-builder';
|
||||
|
||||
export interface ArtifactState {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
type?: string;
|
||||
closed: boolean;
|
||||
runner: ActionRunner;
|
||||
}
|
||||
|
||||
export type ArtifactUpdateState = Pick<ArtifactState, 'title' | 'closed'>;
|
||||
|
||||
type Artifacts = MapStore<Record<string, ArtifactState>>;
|
||||
|
||||
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({});
|
||||
artifactIdList: 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.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 {
|
||||
return this.getArtifact(this.artifactIdList[0]);
|
||||
}
|
||||
|
||||
get description() {
|
||||
return this.currentDescription;
|
||||
}
|
||||
|
||||
get alert() {
|
||||
return this.actionAlert;
|
||||
}
|
||||
|
||||
clearAlert() {
|
||||
this.actionAlert.set(undefined);
|
||||
}
|
||||
|
||||
abortAllActions() {
|
||||
// TODO: what do we wanna do and how do we wanna recover from this?
|
||||
const artifacts = this.artifacts.get();
|
||||
|
||||
Object.values(artifacts).forEach((artifact) => {
|
||||
const actions = artifact.runner.actions.get();
|
||||
|
||||
Object.values(actions).forEach((action) => {
|
||||
if (action.status === 'running' || action.status === 'pending') {
|
||||
action.abort();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
addArtifact({ messageId, name, title, id }: ArtifactCallbackData) {
|
||||
const artifact = this.getArtifact(messageId);
|
||||
if (artifact) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.artifactIdList.includes(messageId)) {
|
||||
this.artifactIdList.push(messageId);
|
||||
}
|
||||
|
||||
this.artifacts.setKey(messageId, {
|
||||
id,
|
||||
name,
|
||||
title,
|
||||
closed: false,
|
||||
runner: new ActionRunner(editorBridge, { id, name, title }, (alert) => {
|
||||
if (this.reloadedMessages.has(messageId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.actionAlert.set(alert);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
updateArtifact({ messageId }: ArtifactCallbackData, state: Partial<ArtifactUpdateState>) {
|
||||
const artifact = this.getArtifact(messageId);
|
||||
if (!artifact) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.artifacts.setKey(messageId, { ...artifact, ...state });
|
||||
}
|
||||
|
||||
private getArtifact(id: string) {
|
||||
const artifacts = this.artifacts.get();
|
||||
return artifacts[id];
|
||||
}
|
||||
|
||||
setReloadedMessages(messages: string[]) {
|
||||
this.reloadedMessages = new Set(messages);
|
||||
}
|
||||
|
||||
addAction(data: ActionCallbackData) {
|
||||
this._addAction(data);
|
||||
}
|
||||
|
||||
private async _addAction(data: ActionCallbackData) {
|
||||
const { messageId } = data;
|
||||
const artifact = this.getArtifact(messageId);
|
||||
|
||||
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 } = data;
|
||||
|
||||
const artifact = this.getArtifact(messageId);
|
||||
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);
|
||||
}
|
||||
}
|
||||
194
app/lib/stores/editor.ts
Normal file
194
app/lib/stores/editor.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
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, 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.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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
519
app/lib/stores/logs.ts
Normal file
519
app/lib/stores/logs.ts
Normal file
@@ -0,0 +1,519 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { atom, map } from 'nanostores';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
|
||||
const logger = createScopedLogger('LogStore');
|
||||
|
||||
export interface LogEntry {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
level: 'info' | 'warning' | 'error' | 'debug';
|
||||
message: string;
|
||||
details?: Record<string, any>;
|
||||
category:
|
||||
| 'system'
|
||||
| 'provider'
|
||||
| 'user'
|
||||
| 'error'
|
||||
| 'api'
|
||||
| 'auth'
|
||||
| 'database'
|
||||
| 'network'
|
||||
| 'performance'
|
||||
| 'settings'
|
||||
| 'task'
|
||||
| 'update'
|
||||
| 'feature';
|
||||
subCategory?: string;
|
||||
duration?: number;
|
||||
statusCode?: number;
|
||||
source?: string;
|
||||
stack?: string;
|
||||
metadata?: {
|
||||
component?: string;
|
||||
action?: string;
|
||||
userId?: string;
|
||||
sessionId?: string;
|
||||
previousValue?: any;
|
||||
newValue?: any;
|
||||
};
|
||||
}
|
||||
|
||||
interface LogDetails extends Record<string, any> {
|
||||
type: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const MAX_LOGS = 1000; // Maximum number of logs to keep in memory
|
||||
|
||||
class LogStore {
|
||||
private _logs = map<Record<string, LogEntry>>({});
|
||||
showLogs = atom(true);
|
||||
private _readLogs = new Set<string>();
|
||||
|
||||
constructor() {
|
||||
// Load saved logs from cookies on initialization
|
||||
this._loadLogs();
|
||||
|
||||
// Only load read logs in browser environment
|
||||
if (typeof window !== 'undefined') {
|
||||
this._loadReadLogs();
|
||||
}
|
||||
}
|
||||
|
||||
// Expose the logs store for subscription
|
||||
get logs() {
|
||||
return this._logs;
|
||||
}
|
||||
|
||||
private _loadLogs() {
|
||||
const savedLogs = Cookies.get('eventLogs');
|
||||
|
||||
if (savedLogs) {
|
||||
try {
|
||||
const parsedLogs = JSON.parse(savedLogs);
|
||||
this._logs.set(parsedLogs);
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse logs from cookies:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _loadReadLogs() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const savedReadLogs = localStorage.getItem('upage_read_logs');
|
||||
|
||||
if (savedReadLogs) {
|
||||
try {
|
||||
const parsedReadLogs = JSON.parse(savedReadLogs);
|
||||
this._readLogs = new Set(parsedReadLogs);
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse read logs:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _saveLogs() {
|
||||
const currentLogs = this._logs.get();
|
||||
Cookies.set('eventLogs', JSON.stringify(currentLogs));
|
||||
}
|
||||
|
||||
private _saveReadLogs() {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('upage_read_logs', JSON.stringify(Array.from(this._readLogs)));
|
||||
}
|
||||
|
||||
private _generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
private _trimLogs() {
|
||||
const currentLogs = Object.entries(this._logs.get());
|
||||
|
||||
if (currentLogs.length > MAX_LOGS) {
|
||||
const sortedLogs = currentLogs.sort(
|
||||
([, a], [, b]) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
|
||||
);
|
||||
const newLogs = Object.fromEntries(sortedLogs.slice(0, MAX_LOGS));
|
||||
this._logs.set(newLogs);
|
||||
}
|
||||
}
|
||||
|
||||
// Base log method for general logging
|
||||
private _addLog(
|
||||
message: string,
|
||||
level: LogEntry['level'],
|
||||
category: LogEntry['category'],
|
||||
details?: Record<string, any>,
|
||||
metadata?: LogEntry['metadata'],
|
||||
) {
|
||||
const id = this._generateId();
|
||||
const entry: LogEntry = {
|
||||
id,
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message,
|
||||
details,
|
||||
category,
|
||||
metadata,
|
||||
};
|
||||
|
||||
this._logs.setKey(id, entry);
|
||||
this._trimLogs();
|
||||
this._saveLogs();
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
// Specialized method for API logging
|
||||
private _addApiLog(
|
||||
message: string,
|
||||
method: string,
|
||||
_url: string,
|
||||
details: {
|
||||
method: string;
|
||||
url: string;
|
||||
statusCode: number;
|
||||
duration: number;
|
||||
request: any;
|
||||
response: any;
|
||||
},
|
||||
) {
|
||||
const statusCode = details.statusCode;
|
||||
return this._addLog(message, statusCode >= 400 ? 'error' : 'info', 'api', details, {
|
||||
component: 'api',
|
||||
action: method,
|
||||
});
|
||||
}
|
||||
|
||||
// System events
|
||||
logSystem(message: string, details?: Record<string, any>) {
|
||||
return this._addLog(message, 'info', 'system', details);
|
||||
}
|
||||
|
||||
// Provider events
|
||||
logProvider(message: string, details?: Record<string, any>) {
|
||||
return this._addLog(message, 'info', 'provider', details);
|
||||
}
|
||||
|
||||
// User actions
|
||||
logUserAction(message: string, details?: Record<string, any>) {
|
||||
return this._addLog(message, 'info', 'user', details);
|
||||
}
|
||||
|
||||
// API Connection Logging
|
||||
logAPIRequest(endpoint: string, method: string, duration: number, statusCode: number, details?: Record<string, any>) {
|
||||
const message = `${method} ${endpoint} - ${statusCode} (${duration}ms)`;
|
||||
const level = statusCode >= 400 ? 'error' : statusCode >= 300 ? 'warning' : 'info';
|
||||
|
||||
return this._addLog(message, level, 'api', {
|
||||
...details,
|
||||
endpoint,
|
||||
method,
|
||||
duration,
|
||||
statusCode,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Authentication Logging
|
||||
logAuth(
|
||||
action: 'login' | 'logout' | 'token_refresh' | 'key_validation',
|
||||
success: boolean,
|
||||
details?: Record<string, any>,
|
||||
) {
|
||||
const message = `Auth ${action} - ${success ? 'Success' : 'Failed'}`;
|
||||
const level = success ? 'info' : 'error';
|
||||
|
||||
return this._addLog(message, level, 'auth', {
|
||||
...details,
|
||||
action,
|
||||
success,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Network Status Logging
|
||||
logNetworkStatus(status: 'online' | 'offline' | 'reconnecting' | 'connected', details?: Record<string, any>) {
|
||||
const message = `Network ${status}`;
|
||||
const level = status === 'offline' ? 'error' : status === 'reconnecting' ? 'warning' : 'info';
|
||||
|
||||
return this._addLog(message, level, 'network', {
|
||||
...details,
|
||||
status,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Database Operations Logging
|
||||
logDatabase(operation: string, success: boolean, duration: number, details?: Record<string, any>) {
|
||||
const message = `DB ${operation} - ${success ? 'Success' : 'Failed'} (${duration}ms)`;
|
||||
const level = success ? 'info' : 'error';
|
||||
|
||||
return this._addLog(message, level, 'database', {
|
||||
...details,
|
||||
operation,
|
||||
success,
|
||||
duration,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// Error events
|
||||
logError(message: string, error?: Error | unknown, details?: Record<string, any>) {
|
||||
const errorDetails =
|
||||
error instanceof Error
|
||||
? {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
...details,
|
||||
}
|
||||
: { error, ...details };
|
||||
|
||||
return this._addLog(message, 'error', 'error', errorDetails);
|
||||
}
|
||||
|
||||
// Warning events
|
||||
logWarning(message: string, details?: Record<string, any>) {
|
||||
return this._addLog(message, 'warning', 'system', details);
|
||||
}
|
||||
|
||||
// Debug events
|
||||
logDebug(message: string, details?: Record<string, any>) {
|
||||
return this._addLog(message, 'debug', 'system', details);
|
||||
}
|
||||
|
||||
clearLogs() {
|
||||
this._logs.set({});
|
||||
this._saveLogs();
|
||||
}
|
||||
|
||||
getLogs() {
|
||||
return Object.values(this._logs.get()).sort(
|
||||
(a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime(),
|
||||
);
|
||||
}
|
||||
|
||||
getFilteredLogs(level?: LogEntry['level'], category?: LogEntry['category'], searchQuery?: string) {
|
||||
return this.getLogs().filter((log) => {
|
||||
const matchesLevel = !level || level === 'debug' || log.level === level;
|
||||
const matchesCategory = !category || log.category === category;
|
||||
const matchesSearch =
|
||||
!searchQuery ||
|
||||
log.message.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
JSON.stringify(log.details).toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
return matchesLevel && matchesCategory && matchesSearch;
|
||||
});
|
||||
}
|
||||
|
||||
markAsRead(logId: string) {
|
||||
this._readLogs.add(logId);
|
||||
this._saveReadLogs();
|
||||
}
|
||||
|
||||
isRead(logId: string): boolean {
|
||||
return this._readLogs.has(logId);
|
||||
}
|
||||
|
||||
clearReadLogs() {
|
||||
this._readLogs.clear();
|
||||
this._saveReadLogs();
|
||||
}
|
||||
|
||||
// API interactions
|
||||
logApiCall(
|
||||
method: string,
|
||||
endpoint: string,
|
||||
statusCode: number,
|
||||
duration: number,
|
||||
requestData?: any,
|
||||
responseData?: any,
|
||||
) {
|
||||
return this._addLog(
|
||||
`API ${method} ${endpoint}`,
|
||||
statusCode >= 400 ? 'error' : 'info',
|
||||
'api',
|
||||
{
|
||||
method,
|
||||
endpoint,
|
||||
statusCode,
|
||||
duration,
|
||||
request: requestData,
|
||||
response: responseData,
|
||||
},
|
||||
{
|
||||
component: 'api',
|
||||
action: method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Network operations
|
||||
logNetworkRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
statusCode: number,
|
||||
duration: number,
|
||||
requestData?: any,
|
||||
responseData?: any,
|
||||
) {
|
||||
return this._addLog(
|
||||
`${method} ${url}`,
|
||||
statusCode >= 400 ? 'error' : 'info',
|
||||
'network',
|
||||
{
|
||||
method,
|
||||
url,
|
||||
statusCode,
|
||||
duration,
|
||||
request: requestData,
|
||||
response: responseData,
|
||||
},
|
||||
{
|
||||
component: 'network',
|
||||
action: method,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Authentication events
|
||||
logAuthEvent(event: string, success: boolean, details?: Record<string, any>) {
|
||||
return this._addLog(
|
||||
`Auth ${event} ${success ? 'succeeded' : 'failed'}`,
|
||||
success ? 'info' : 'error',
|
||||
'auth',
|
||||
details,
|
||||
{
|
||||
component: 'auth',
|
||||
action: event,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Performance tracking
|
||||
logPerformance(operation: string, duration: number, details?: Record<string, any>) {
|
||||
return this._addLog(
|
||||
`Performance: ${operation}`,
|
||||
duration > 1000 ? 'warning' : 'info',
|
||||
'performance',
|
||||
{
|
||||
operation,
|
||||
duration,
|
||||
...details,
|
||||
},
|
||||
{
|
||||
component: 'performance',
|
||||
action: 'metric',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Error handling
|
||||
logErrorWithStack(error: Error, category: LogEntry['category'] = 'error', details?: Record<string, any>) {
|
||||
return this._addLog(
|
||||
error.message,
|
||||
'error',
|
||||
category,
|
||||
{
|
||||
...details,
|
||||
name: error.name,
|
||||
stack: error.stack,
|
||||
},
|
||||
{
|
||||
component: category,
|
||||
action: 'error',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Refresh logs (useful for real-time updates)
|
||||
refreshLogs() {
|
||||
const currentLogs = this._logs.get();
|
||||
this._logs.set({ ...currentLogs });
|
||||
}
|
||||
|
||||
// Enhanced logging methods
|
||||
logInfo(message: string, details: LogDetails) {
|
||||
return this._addLog(message, 'info', 'system', details);
|
||||
}
|
||||
|
||||
logSuccess(message: string, details: LogDetails) {
|
||||
return this._addLog(message, 'info', 'system', { ...details, success: true });
|
||||
}
|
||||
|
||||
logApiRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
details: {
|
||||
method: string;
|
||||
url: string;
|
||||
statusCode: number;
|
||||
duration: number;
|
||||
request: any;
|
||||
response: any;
|
||||
},
|
||||
) {
|
||||
return this._addApiLog(`API ${method} ${url}`, method, url, details);
|
||||
}
|
||||
|
||||
logSettingsChange(component: string, setting: string, oldValue: any, newValue: any) {
|
||||
return this._addLog(
|
||||
`Settings changed in ${component}: ${setting}`,
|
||||
'info',
|
||||
'settings',
|
||||
{
|
||||
setting,
|
||||
previousValue: oldValue,
|
||||
newValue,
|
||||
},
|
||||
{
|
||||
component,
|
||||
action: 'settings_change',
|
||||
previousValue: oldValue,
|
||||
newValue,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
logFeatureToggle(featureId: string, enabled: boolean) {
|
||||
return this._addLog(
|
||||
`Feature ${featureId} ${enabled ? 'enabled' : 'disabled'}`,
|
||||
'info',
|
||||
'feature',
|
||||
{ featureId, enabled },
|
||||
{
|
||||
component: 'features',
|
||||
action: 'feature_toggle',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
logTaskOperation(taskId: string, operation: string, status: string, details?: any) {
|
||||
return this._addLog(
|
||||
`Task ${taskId}: ${operation} - ${status}`,
|
||||
'info',
|
||||
'task',
|
||||
{ taskId, operation, status, ...details },
|
||||
{
|
||||
component: 'task-manager',
|
||||
action: 'task_operation',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
logProviderAction(provider: string, action: string, success: boolean, details?: any) {
|
||||
return this._addLog(
|
||||
`Provider ${provider}: ${action} - ${success ? 'Success' : 'Failed'}`,
|
||||
success ? 'info' : 'error',
|
||||
'provider',
|
||||
{ provider, action, success, ...details },
|
||||
{
|
||||
component: 'providers',
|
||||
action: 'provider_action',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
logPerformanceMetric(component: string, operation: string, duration: number, details?: any) {
|
||||
return this._addLog(
|
||||
`Performance: ${component} - ${operation} took ${duration}ms`,
|
||||
duration > 1000 ? 'warning' : 'info',
|
||||
'performance',
|
||||
{ component, operation, duration, ...details },
|
||||
{
|
||||
component,
|
||||
action: 'performance_metric',
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const logStore = new LogStore();
|
||||
64
app/lib/stores/netlify.ts
Normal file
64
app/lib/stores/netlify.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { atom } from 'nanostores';
|
||||
import { toast } from 'sonner';
|
||||
import type { NetlifyConnection } from '~/types/netlify';
|
||||
import { logStore } from './logs';
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
423
app/lib/stores/pages.ts
Normal file
423
app/lib/stores/pages.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
import { diffLines } from 'diff';
|
||||
import { atom, computed, type MapStore, map, type WritableAtom } from 'nanostores';
|
||||
import { type EditorBridge, type EventPayload, editorBridge } from '~/lib/bridge';
|
||||
import type { Page, PageHistory, Section } from '~/types/actions';
|
||||
import { computePageModifications, diffPages } from '~/utils/diff';
|
||||
import { isValidContent } from '~/utils/html-parse';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
|
||||
const logger = createScopedLogger('PagesStore');
|
||||
|
||||
export type PageSection = Section & {
|
||||
validRootDomId?: boolean;
|
||||
};
|
||||
|
||||
export type PageMap = Record<string, Page | undefined>;
|
||||
type ActiveSection = WritableAtom<string | undefined>;
|
||||
type ActivePage = WritableAtom<string | undefined>;
|
||||
export type SectionMap = Record<string, PageSection | 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) {
|
||||
// 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;
|
||||
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;
|
||||
this.pages.setKey(pageName, {
|
||||
name: pageName,
|
||||
title: pageTitle,
|
||||
actionIds,
|
||||
});
|
||||
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 });
|
||||
}
|
||||
}
|
||||
39
app/lib/stores/previews.ts
Normal file
39
app/lib/stores/previews.ts
Normal 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;
|
||||
}
|
||||
28
app/lib/stores/profile.ts
Normal file
28
app/lib/stores/profile.ts
Normal 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()));
|
||||
}
|
||||
};
|
||||
217
app/lib/stores/settings.ts
Normal file
217
app/lib/stores/settings.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { atom, map } from 'nanostores';
|
||||
import { DEFAULT_TAB_CONFIG } from '~/components/@settings/core/constants';
|
||||
import type {
|
||||
DevTabConfig,
|
||||
TabVisibilityConfig,
|
||||
TabWindowConfig,
|
||||
UserTabConfig,
|
||||
} from '~/components/@settings/core/types';
|
||||
import { toggleTheme } from './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;
|
||||
}
|
||||
|
||||
export const URL_CONFIGURABLE_PROVIDERS = ['Ollama', 'LMStudio', 'OpenAILike'];
|
||||
export const LOCAL_PROVIDERS = ['OpenAILike', 'LMStudio', 'Ollama'];
|
||||
|
||||
// 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);
|
||||
};
|
||||
15
app/lib/stores/sidebar.ts
Normal file
15
app/lib/stores/sidebar.ts
Normal 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);
|
||||
}
|
||||
52
app/lib/stores/tab-configuration.ts
Normal file
52
app/lib/stores/tab-configuration.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
53
app/lib/stores/theme.ts
Normal file
53
app/lib/stores/theme.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { atom } from 'nanostores';
|
||||
import type { Theme } from '~/types/theme';
|
||||
import { logStore } from './logs';
|
||||
|
||||
export const kTheme = 'upage_theme';
|
||||
|
||||
export function themeIsDark() {
|
||||
return themeStore.get() === 'dark';
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME = 'light';
|
||||
|
||||
export const themeStore = atom<Theme>(initStore());
|
||||
|
||||
function initStore() {
|
||||
if (!import.meta.env.SSR) {
|
||||
const persistedTheme = localStorage.getItem(kTheme) as Theme | undefined;
|
||||
const themeAttribute = document.querySelector('html')?.getAttribute('data-theme');
|
||||
|
||||
return persistedTheme ?? (themeAttribute as Theme) ?? DEFAULT_THEME;
|
||||
}
|
||||
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
|
||||
export function toggleTheme() {
|
||||
const currentTheme = themeStore.get();
|
||||
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
||||
|
||||
// Update the theme store
|
||||
themeStore.set(newTheme);
|
||||
|
||||
// Update localStorage
|
||||
localStorage.setItem(kTheme, newTheme);
|
||||
|
||||
// Update the HTML attribute
|
||||
document.querySelector('html')?.setAttribute('data-theme', newTheme);
|
||||
|
||||
// TODO:Update user profile if it exists
|
||||
try {
|
||||
const userProfile = localStorage.getItem('upage_user_profile');
|
||||
|
||||
if (userProfile) {
|
||||
const profile = JSON.parse(userProfile);
|
||||
profile.theme = newTheme;
|
||||
localStorage.setItem('upage_user_profile', JSON.stringify(profile));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating user profile theme:', error);
|
||||
}
|
||||
|
||||
logStore.logSystem(`Theme changed to ${newTheme} mode`);
|
||||
}
|
||||
63
app/lib/stores/vercel.ts
Normal file
63
app/lib/stores/vercel.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { atom } from 'nanostores';
|
||||
import { toast } from 'sonner';
|
||||
import type { VercelConnection } from '~/types/vercel';
|
||||
import { logStore } from './logs';
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
596
app/lib/stores/web-builder.ts
Normal file
596
app/lib/stores/web-builder.ts
Normal file
@@ -0,0 +1,596 @@
|
||||
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 type { Page } from '~/types/actions';
|
||||
import { base64ToBinary, getContentType, getExtensionFromMimeType, getFileName } from '~/utils/file-utils';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
import { formatFile } from '~/utils/prettier';
|
||||
import { ChatStore } from './chat';
|
||||
import { EditorStore } from './editor';
|
||||
import { type PageMap, 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.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);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedPage(pageName: string | undefined) {
|
||||
this.pagesStore.setActivePage(pageName);
|
||||
|
||||
if (pageName) {
|
||||
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) {
|
||||
// 更新内容,但不会触发保存,不会保存至 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 {
|
||||
// 只需更新 pagesStore,Document 将会监听 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();
|
||||
Reference in New Issue
Block a user