更新系统配置,修改默认主题为"echoes";前端:新增默认错误,以模板和能力为核心,移除主题和插件服务,上下文服务统一方便实现互相调用的服务,

This commit is contained in:
lsy 2024-11-30 22:24:35 +08:00
parent a43a9bac36
commit 610b3b5422
20 changed files with 355 additions and 286 deletions

View File

@ -25,7 +25,7 @@ impl Default for SystemConfigure {
fn default() -> Self {
Self {
author_name: "lsy".to_string(),
current_theme: "default".to_string(),
current_theme: "echoes".to_string(),
site_keyword: "echoes".to_string(),
site_description: "echoes是一个高效、可扩展的博客平台".to_string(),
admin_path: "admin".to_string(),

View File

@ -1,7 +1,7 @@
import React, { createContext, useState, useEffect } from "react";
import {useHttp} from 'hooks/servicesProvider'
import { message} from "hooks/message";
import { message } from "hooks/message";
import {DEFAULT_CONFIG} from "app/env"
import { useHub } from "core/hub";
interface SetupContextType {
@ -94,7 +94,7 @@ const Introduction: React.FC<StepProps> = ({ onNext }) => (
const DatabaseConfig: React.FC<StepProps> = ({ onNext }) => {
const [dbType, setDbType] = useState("postgresql");
const [loading, setLoading] = useState(false);
const api = useHttp();
const http = useHub().http;
const validateForm = () => {
const getRequiredFields = () => {
@ -131,7 +131,7 @@ const DatabaseConfig: React.FC<StepProps> = ({ onNext }) => {
default: return field;
}
});
message.error(`请填写以下必填项:${fieldNames.join('、')}`, '验证失败');
message.error(`请填写以下必填项:${fieldNames.join('、')}`);
return false;
}
return true;
@ -154,37 +154,33 @@ const DatabaseConfig: React.FC<StepProps> = ({ onNext }) => {
db_name: (document.querySelector('[name="db_name"]') as HTMLInputElement)?.value?.trim()??"",
};
await api.post('/sql', formData);
let oldEnv = import.meta.env?? DEFAULT_CONFIG
await http.post('/sql', formData);
let oldEnv = import.meta.env ?? DEFAULT_CONFIG;
const viteEnv = Object.entries(oldEnv).reduce((acc, [key, value]) => {
if (key.startsWith('VITE_')) {
acc[key] = value;
}
return acc;
}, {} as Record<string, any>);
if (key.startsWith('VITE_')) {
acc[key] = value;
}
return acc;
}, {} as Record<string, any>);
const newEnv = {
...viteEnv,
VITE_INIT_STATUS: '2'
};
await api.dev("/env", {
await http.dev("/env", {
method: "POST",
body: JSON.stringify(newEnv),
});
Object.assign( newEnv)
Object.assign(import.meta.env, newEnv);
message.success('数据库配置已保存', '配置成功');
message.success('数据库配置成功');
setTimeout(() => onNext(), 1000);
} catch (error: any) {
console.error( error);
message.error(error.message , error.title || '配置失败');
message.error(error.message );
} finally {
setLoading(false);
}
@ -331,7 +327,7 @@ interface InstallReplyData {
const AdminConfig: React.FC<StepProps> = ({ onNext }) => {
const [loading, setLoading] = useState(false);
const api = useHttp();
const http = useHub().http;
const handleNext = async () => {
setLoading(true);
@ -342,7 +338,7 @@ const AdminConfig: React.FC<StepProps> = ({ onNext }) => {
email: (document.querySelector('[name="admin_email"]') as HTMLInputElement)?.value,
};
const response = await api.post('/administrator', formData) as InstallReplyData;
const response = await http.post('/administrator', formData) as InstallReplyData;
const data = response;
localStorage.setItem('token', data.token);
@ -358,22 +354,22 @@ const AdminConfig: React.FC<StepProps> = ({ onNext }) => {
const newEnv = {
...viteEnv,
VITE_INIT_STATUS: '3',
VITE_API_USERNAME:data.username,
VITE_API_PASSWORD:data.password
VITE_API_USERNAME: data.username,
VITE_API_PASSWORD: data.password
};
await api.dev("/env", {
await http.dev("/env", {
method: "POST",
body: JSON.stringify(newEnv),
});
message.success('管理员账号已创建,即将进入下一步', '创建成功');
Object.assign(import.meta.env, newEnv);
message.success('管理员账号创建成功!');
onNext();
} catch (error: any) {
console.error(error);
message.error(error.message, error.title || '创建失败');
message.error(error.message);
} finally {
setLoading(false);
}
@ -392,7 +388,7 @@ const AdminConfig: React.FC<StepProps> = ({ onNext }) => {
};
const SetupComplete: React.FC = () => {
const api = useHttp();
const http = useHub().http;
@ -458,9 +454,9 @@ const ThemeToggle: React.FC = () => {
};
export default function SetupPage() {
let step = Number(import.meta.env.VITE_INIT_STATUS)+1;
const [currentStep, setCurrentStep] = useState(step);
const [currentStep, setCurrentStep] = useState(() => {
return Number(import.meta.env.VITE_INIT_STATUS ?? 0) + 1;
});
return (
<div className="relative min-h-screen w-full bg-custom-bg-light dark:bg-custom-bg-dark">

View File

@ -6,8 +6,8 @@ import {
ScrollRestoration,
} from "@remix-run/react";
import { BaseProvider } from "hooks/servicesProvider";
import { HubProvider } from "core/hub";
import { MessageProvider, MessageContainer } from "hooks/message";
import "~/index.css";
@ -16,47 +16,56 @@ export function Layout({ children }: { children: React.ReactNode }) {
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="generator" content="echoes" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<meta
name="generator"
content="echoes"
/>
<Meta />
<Links />
</head>
<body suppressHydrationWarning={true}>
<BaseProvider>
<HubProvider>
<MessageProvider>
<MessageContainer />
<Outlet />
</BaseProvider>
</MessageProvider>
</HubProvider>
<ScrollRestoration />
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
function getInitialColorMode() {
const persistedColorPreference = window.localStorage.getItem('theme');
const hasPersistedPreference = typeof persistedColorPreference === 'string';
if (hasPersistedPreference) {
return persistedColorPreference;
}
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const hasMediaQueryPreference = typeof mql.matches === 'boolean';
if (hasMediaQueryPreference) {
return mql.matches ? 'dark' : 'light';
}
return 'light';
}
const colorMode = getInitialColorMode();
document.documentElement.classList.toggle('dark', colorMode === 'dark');
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
const newColorMode = e.matches ? 'dark' : 'light';
document.documentElement.classList.toggle('dark', newColorMode === 'dark');
localStorage.setItem('theme', newColorMode);
});
})()
`,
}}
/>
<Scripts />
<ScrollRestoration />
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
// 立即应用系统主题
function applyTheme(isDark) {
document.documentElement.classList.toggle('dark', isDark);
}
// 获取系统主题并立即应用
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
applyTheme(darkModeMediaQuery.matches);
// 添加主题变化监听
try {
// 现代浏览器的方式
darkModeMediaQuery.addEventListener('change', (e) => {
applyTheme(e.matches);
});
} catch (e) {
// 兼容旧版浏览器
darkModeMediaQuery.addListener((e) => {
applyTheme(e.matches);
});
}
})()
`,
}}
/>
<Scripts />
</body>
</html>
);

View File

@ -1,18 +1,16 @@
import { useState } from "react";
import { ErrorResponse } from "core/http";
import ReactDOMServer from "react-dom/server";
import { useHttp } from "hooks/servicesProvider";
import ErrorPage from 'hooks/error';
import { useEffect } from 'react';
const MyComponent = () => {
return <div>Hello, World!</div>;
};
export default function Routes() {
let http=useHttp();
return (<div>
</div>);
return (
<div>
<ErrorPage />
</div>
);
}

34
frontend/core/hub.ts Normal file
View File

@ -0,0 +1,34 @@
import { CapabilityService } from "core/capability";
import { HttpClient } from "core/http";
import { RouteManager } from "core/route";
import { createServiceContext } from "hooks/createServiceContext";
export default class ServerHub{
private static instance: ServerHub;
public http:HttpClient;
public route:RouteManager;
public capability:CapabilityService;
private constructor(http:HttpClient,route:RouteManager,capability:CapabilityService){
this.http=http;
this.route=route;
this.capability=capability;
}
public static getInstance(): ServerHub {
if (!this.instance) {
this.instance = new ServerHub(
HttpClient.getInstance(),
RouteManager.getInstance(),
CapabilityService.getInstance(),
);
}
return this.instance;
}
}
export const { HubProvider, useHub } = createServiceContext("Hub", () =>
ServerHub.getInstance(),
);

View File

@ -1,64 +0,0 @@
export interface PluginConfig {
name: string;
version: string;
displayName: string;
description?: string;
author?: string;
enabled: boolean;
icon?: string;
managePath?: string;
configuration?: Configuration;
routes: Set<{
description?: string;
path: string;
}>;
}
export class PluginManager {
private configurations: Map<string, PluginConfig> = new Map();
async loadPlugins() {
const pluginDirs = await this.scanPluginDirectory();
for (const dir of pluginDirs) {
try {
const config = await import(`@/plugins/${dir}/plugin.config.ts`);
const plugin: PluginProps = config.default;
this.plugins.set(plugin.name, plugin);
if (plugin.settingsSchema) {
this.configurations.set(plugin.name, plugin.settingsSchema);
}
if (plugin.extensions) {
Object.entries(plugin.extensions).forEach(([key, value]) => {
this.extensions.set(`${plugin.name}.${key}`, value.extension);
});
}
if (plugin.hooks?.onInstall) {
await plugin.hooks.onInstall({});
}
} catch (error) {
console.error(`Failed to load plugin from directory ${dir}:`, error);
}
}
}
async getPluginConfig(pluginName: string): Promise<PluginConfig | undefined> {
const dbConfig = await this.fetchConfigFromDB(pluginName);
if (dbConfig) {
return dbConfig;
}
return this.configurations.get(pluginName);
}
private async fetchConfigFromDB(pluginName: string) {
return null;
}
private async scanPluginDirectory(): Promise<string[]> {
return [];
}
}

View File

@ -1,11 +0,0 @@
export interface Template {
name: string;
description?: string;
config: {
layout?: string;
styles?: string[];
scripts?: string[];
};
loader: () => Promise<void>;
element: () => React.ReactNode;
}

View File

@ -1,79 +0,0 @@
import { Configuration, PathDescription } from "commons/serializableType";
import { HttpClient } from "core/http";
export interface ThemeConfig {
name: string;
displayName: string;
icon?: string;
version: string;
description?: string;
author?: string;
templates: Map<string, PathDescription>;
globalSettings?: {
layout?: string;
css?: string;
};
configuration: Configuration;
routes: {
index: string;
post: string;
tag: string;
category: string;
error: string;
loading: string;
page: Map<string, string>;
};
}
export class ThemeService {
private static instance: ThemeService;
private currentTheme?: ThemeConfig;
private http: HttpClient;
private constructor(api: HttpClient) {
this.http = api;
}
public static getInstance(api?: HttpClient): ThemeService {
if (!ThemeService.instance && api) {
ThemeService.instance = new ThemeService(api);
}
return ThemeService.instance;
}
public async getCurrentTheme(): Promise<void> {
try {
const themeConfig = await this.http.api<ThemeConfig>("/theme", {
method: "GET",
});
this.currentTheme = themeConfig;
} catch (error) {
console.error("Failed to initialize theme:", error);
throw error;
}
}
public getThemeConfig(): ThemeConfig | undefined {
return this.currentTheme;
}
public async updateThemeConfig(
config: Partial<ThemeConfig>,
name: string,
): Promise<void> {
try {
const updatedConfig = await this.http.api<ThemeConfig>(`/theme/`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(config),
});
await this.loadTheme(updatedConfig);
} catch (error) {
console.error("Failed to update theme configuration:", error);
throw error;
}
}
}

43
frontend/hooks/error.tsx Normal file
View File

@ -0,0 +1,43 @@
import React, { useState, useEffect } from "react";
const ErrorPage = () => {
const [text, setText] = useState("");
const fullText = "404 - 页面不见了 :(";
const typingSpeed = 100;
useEffect(() => {
let currentIndex = 0;
const typingEffect = setInterval(() => {
if (currentIndex < fullText.length) {
setText(fullText.slice(0, currentIndex + 1));
currentIndex++;
} else {
clearInterval(typingEffect);
}
}, typingSpeed);
return () => clearInterval(typingEffect);
}, []);
return (
<div className="min-h-screen flex items-center justify-center bg-custom-bg-light dark:bg-custom-bg-dark transition-colors duration-300">
<div className="text-center">
<h1 className="text-6xl font-bold text-custom-title-light dark:text-custom-title-dark mb-4">
{text}
<span className="animate-pulse">|</span>
</h1>
<p className="text-custom-p-light dark:text-custom-p-dark text-xl">
访
</p>
<button
onClick={() => window.location.href = '/'}
className="mt-8 px-6 py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors duration-300"
>
</button>
</div>
</div>
);
};
export default ErrorPage;

101
frontend/hooks/loading.tsx Normal file
View File

@ -0,0 +1,101 @@
import React, { createContext, useState, useContext } from "react";
interface LoadingContextType {
isLoading: boolean;
showLoading: () => void;
hideLoading: () => void;
}
const LoadingContext = createContext<LoadingContextType>({
isLoading: false,
showLoading: () => {},
hideLoading: () => {},
});
export const LoadingProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [isLoading, setIsLoading] = useState(false);
const showLoading = () => setIsLoading(true);
const hideLoading = () => setIsLoading(false);
return (
<LoadingContext.Provider value={{ isLoading, showLoading, hideLoading }}>
{children}
{isLoading && (
<div className="fixed inset-0 flex flex-col items-center justify-center bg-black/25 dark:bg-black/40 z-[999999]">
<div className="loading-spinner mb-2" />
<div className="text-custom-p-light dark:text-custom-p-dark text-sm">...</div>
</div>
)}
<style>{`
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-spinner {
width: 30px;
height: 30px;
border: 3px solid rgba(59, 130, 246, 0.2);
border-radius: 50%;
border-top-color: #3B82F6;
animation: spin 0.8s linear infinite;
}
.dark .loading-spinner {
border: 3px solid rgba(96, 165, 250, 0.2);
border-top-color: #60A5FA;
}
`}</style>
</LoadingContext.Provider>
);
};
// 全局loading实例
let globalShowLoading: (() => void) | null = null;
let globalHideLoading: (() => void) | null = null;
export const LoadingContainer: React.FC = () => {
const { showLoading, hideLoading } = useContext(LoadingContext);
React.useEffect(() => {
globalShowLoading = showLoading;
globalHideLoading = hideLoading;
return () => {
globalShowLoading = null;
globalHideLoading = null;
};
}, [showLoading, hideLoading]);
return null;
};
// 导出loading方法
export const loading = {
show: () => {
if (!globalShowLoading) {
console.warn("Loading system not initialized");
return;
}
globalShowLoading();
},
hide: () => {
if (!globalHideLoading) {
console.warn("Loading system not initialized");
return;
}
globalHideLoading();
},
};

View File

@ -8,11 +8,6 @@ interface Message {
duration?: number;
}
interface MessageOptions {
content: string;
duration?: number;
}
interface MessageContextType {
messages: Message[];
addMessage: (

View File

@ -1,29 +0,0 @@
import { CapabilityService } from "core/capability";
import { HttpClient } from "core/http";
import { RouteManager } from "core/route";
import { createServiceContext } from "hooks/createServiceContext";
import { ReactNode } from "react";
import { StyleProvider } from "hooks/stylesProvider";
export const { CapabilityProvider, useCapability } = createServiceContext(
"Capability",
() => CapabilityService.getInstance(),
);
export const { RouteProvider, useRoute } = createServiceContext("Route", () =>
RouteManager.getInstance(),
);
export const { HttpProvider, useHttp } = createServiceContext("Http", () =>
HttpClient.getInstance(),
);
export const BaseProvider = ({ children }: { children: ReactNode }) => (
<HttpProvider>
<CapabilityProvider>
<StyleProvider>
<RouteProvider>{children}</RouteProvider>
</StyleProvider>
</CapabilityProvider>
</HttpProvider>
);

View File

@ -1,10 +0,0 @@
import { ReactNode } from "react";
import { MessageProvider, MessageContainer } from "hooks/message";
export const StyleProvider = ({ children }: { children: ReactNode }) => (
<MessageProvider>
<MessageContainer />
{children}
</MessageProvider>
);

View File

@ -0,0 +1,14 @@
import { Configuration, PathDescription } from "commons/serializableType";
export interface PluginConfig {
name: string;
version: string;
displayName: string;
description?: string;
author?: string;
enabled: boolean;
icon?: string;
managePath?: string;
configuration?: Configuration;
routes: Set<PathDescription>;
}

View File

@ -0,0 +1,25 @@
import { HttpClient } from 'core/http';
import { CapabilityService } from 'core/capability';
export class Template {
constructor(
public name: string,
public config: {
layout?: string;
styles?: string[];
scripts?: string[];
},
public element: (services: {
http: HttpClient;
capability: CapabilityService;
}) => React.ReactNode,
public description?: string,
) {}
render(services: {
http: HttpClient;
capability: CapabilityService;
}) {
return this.element(services);
}
}

View File

@ -0,0 +1,24 @@
import { Configuration, PathDescription } from "commons/serializableType";
export interface ThemeConfig {
name: string;
displayName: string;
icon?: string;
version: string;
description?: string;
author?: string;
templates: Map<string, PathDescription>;
globalSettings?: {
layout?: string;
css?: string;
};
configuration: Configuration;
routes: {
article:string;
post: string;
tag: string;
category: string;
error: string;
page: Map<string, string>;
};
}

View File

@ -12,9 +12,6 @@
"typecheck": "tsc"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.2",
"@radix-ui/react-dropdown-menu": "^2.1.2",
"@radix-ui/react-toast": "^1.2.2",
"@remix-run/node": "^2.14.0",
"@remix-run/react": "^2.14.0",
"@remix-run/serve": "^2.14.0",

View File

@ -29,7 +29,7 @@ export default {
title: {
light: "#111827",
dark: "#F8FAFC", // 更亮的标题颜色
},
}
},
},
animation: {

View File

@ -0,0 +1,15 @@
import { Template } from 'core/template';
export default new Template(
"page",
{
layout: "default",
},
({ http }) => {
return (
<div>
Hello World
</div>
);
}
);

View File

@ -1,12 +1,17 @@
import { ThemeConfig } from "contracts/themeContract";
import { ThemeConfig } from "interface/theme";
export const themeConfig: ThemeConfig = {
name: "default",
const themeConfig: ThemeConfig = {
name: "echoes",
displayName: "默认主题",
version: "1.0.0",
description: "一个简约风格的博客主题",
author: "lsy",
entry: "default",
configuration: {
},
globalSettings:{
layout:"layout.tsx"
},
templates: new Map([
[
"page",
@ -19,9 +24,15 @@ export const themeConfig: ThemeConfig = {
]),
routes: {
article:"",
post: "",
tag: "",
category: "",
page: "",
error: "",
page: new Map<string, string>([
]),
},
};
export default themeConfig;