前端:更新依赖项以支持Radix UI,重构主题切换和消息提示逻辑,增加echoes动态文字
This commit is contained in:
parent
6440c0a719
commit
79c66bbe69
@ -5,6 +5,7 @@ export interface EnvConfig {
|
|||||||
VITE_API_BASE_URL: string;
|
VITE_API_BASE_URL: string;
|
||||||
VITE_API_USERNAME: string;
|
VITE_API_USERNAME: string;
|
||||||
VITE_API_PASSWORD: string;
|
VITE_API_PASSWORD: string;
|
||||||
|
VITE_PATTERN: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_CONFIG: EnvConfig = {
|
export const DEFAULT_CONFIG: EnvConfig = {
|
||||||
@ -14,11 +15,12 @@ export const DEFAULT_CONFIG: EnvConfig = {
|
|||||||
VITE_API_BASE_URL: "http://127.0.0.1:22000",
|
VITE_API_BASE_URL: "http://127.0.0.1:22000",
|
||||||
VITE_API_USERNAME: "",
|
VITE_API_USERNAME: "",
|
||||||
VITE_API_PASSWORD: "",
|
VITE_API_PASSWORD: "",
|
||||||
|
VITE_PATTERN: "true"
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// 扩展 ImportMeta 接口
|
// 扩展 ImportMeta 接口
|
||||||
declare global {
|
declare global {
|
||||||
interface ImportMetaEnv extends EnvConfig {}
|
interface ImportMetaEnv extends EnvConfig { }
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
readonly env: ImportMetaEnv;
|
readonly env: ImportMetaEnv;
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,117 @@
|
|||||||
|
@import "@radix-ui/themes/styles.css";
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--transition-duration: 150ms;
|
||||||
|
--transition-easing: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--logo-path-length: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 基础过渡效果 */
|
||||||
|
.radix-themes {
|
||||||
|
transition:
|
||||||
|
background-color var(--transition-duration) var(--transition-easing),
|
||||||
|
color var(--transition-duration) var(--transition-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 主题过渡效果 */
|
||||||
|
.dark body,
|
||||||
|
body {
|
||||||
|
transition: background-color var(--transition-duration) var(--transition-easing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 基础布局样式 */
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式调整 */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
html {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Logo 动画 */
|
||||||
|
@keyframes logo-anim {
|
||||||
|
0% {
|
||||||
|
stroke-dashoffset: var(--logo-path-length);
|
||||||
|
stroke-dasharray: var(--logo-path-length) var(--logo-path-length);
|
||||||
|
opacity: 0;
|
||||||
|
fill: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
5% {
|
||||||
|
opacity: 1;
|
||||||
|
stroke-dashoffset: var(--logo-path-length);
|
||||||
|
stroke-dasharray: var(--logo-path-length) var(--logo-path-length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 慢速绘画过程 */
|
||||||
|
45% {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
stroke-dasharray: var(--logo-path-length) var(--logo-path-length);
|
||||||
|
fill: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 慢慢填充效果 */
|
||||||
|
50% {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
stroke-dasharray: var(--logo-path-length) var(--logo-path-length);
|
||||||
|
fill: currentColor;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 保持填充状态 */
|
||||||
|
75% {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
stroke-dasharray: var(--logo-path-length) var(--logo-path-length);
|
||||||
|
fill: currentColor;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 变回线条 */
|
||||||
|
85% {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
stroke-dasharray: var(--logo-path-length) var(--logo-path-length);
|
||||||
|
fill: transparent;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 线条消失 */
|
||||||
|
95% {
|
||||||
|
stroke-dashoffset: var(--logo-path-length);
|
||||||
|
stroke-dasharray: var(--logo-path-length) var(--logo-path-length);
|
||||||
|
fill: transparent;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
stroke-dashoffset: var(--logo-path-length);
|
||||||
|
stroke-dasharray: var(--logo-path-length) var(--logo-path-length);
|
||||||
|
fill: transparent;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#logo-anim,
|
||||||
|
#logo-anim path {
|
||||||
|
fill: transparent;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-dashoffset: var(--logo-path-length);
|
||||||
|
stroke-dasharray: var(--logo-path-length) var(--logo-path-length);
|
||||||
|
animation: logo-anim 15s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||||
|
transform-origin: center;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 确保 Logo 在暗色模式下的颜色正确 */
|
||||||
|
.dark #logo-anim,
|
||||||
|
.dark #logo-anim path {
|
||||||
|
stroke: currentColor;
|
||||||
|
}
|
||||||
|
@ -1,8 +1,10 @@
|
|||||||
import React, { createContext, useState, useEffect } from "react";
|
import React, { createContext, useState, useContext, useEffect } from "react";
|
||||||
import { message } from "hooks/message";
|
|
||||||
import {DEFAULT_CONFIG} from "app/env"
|
import {DEFAULT_CONFIG} from "app/env"
|
||||||
import { useHub } from "core/hub";
|
import { HttpClient } from "core/http";
|
||||||
|
import { ThemeModeToggle } from "hooks/themeMode";
|
||||||
|
import { Theme, Button, Select, Flex, Container, Heading, Text, Box } from '@radix-ui/themes';
|
||||||
|
import { toast } from 'hooks/notification';
|
||||||
|
import { Echoes } from "hooks/echo";
|
||||||
|
|
||||||
interface SetupContextType {
|
interface SetupContextType {
|
||||||
currentStep: number;
|
currentStep: number;
|
||||||
@ -23,14 +25,12 @@ const StepContainer: React.FC<{ title: string; children: React.ReactNode }> = ({
|
|||||||
title,
|
title,
|
||||||
children,
|
children,
|
||||||
}) => (
|
}) => (
|
||||||
<div className="mx-auto max-w-3xl">
|
<Container size="2" className="w-full max-w-[90%] md:max-w-[600px] mx-auto px-4">
|
||||||
<h2 className="text-xl font-medium text-custom-title-light dark:text-custom-title-dark mb-6 px-4">
|
<Heading size="5" mb="4">{title}</Heading>
|
||||||
{title}
|
<Flex direction="column" gap="4">
|
||||||
</h2>
|
|
||||||
<div className="space-y-6 px-4">
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</Flex>
|
||||||
</div>
|
</Container>
|
||||||
);
|
);
|
||||||
|
|
||||||
// 通用的导航按钮组件
|
// 通用的导航按钮组件
|
||||||
@ -39,45 +39,37 @@ const NavigationButtons: React.FC<StepProps & { loading?: boolean; disabled?: bo
|
|||||||
loading = false,
|
loading = false,
|
||||||
disabled = false
|
disabled = false
|
||||||
}) => (
|
}) => (
|
||||||
<div className="flex justify-end mt-4">
|
<Flex justify="end" mt="4" className="w-full">
|
||||||
<button
|
<Button
|
||||||
onClick={onNext}
|
onClick={onNext}
|
||||||
disabled={loading || disabled}
|
disabled={loading || disabled}
|
||||||
className={`px-6 py-2 rounded-lg transition-colors font-medium text-sm
|
className="w-full md:w-auto"
|
||||||
${loading || disabled
|
|
||||||
? 'bg-gray-400 cursor-not-allowed'
|
|
||||||
: 'bg-blue-500 hover:bg-blue-600 text-white'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{loading ? '处理中...' : '下一步'}
|
{loading ? '处理中...' : '下一步'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
|
||||||
// 输入框组件
|
// 修改输入框组件
|
||||||
const InputField: React.FC<{
|
const InputField: React.FC<{
|
||||||
label: string;
|
label: string;
|
||||||
name: string;
|
name: string;
|
||||||
defaultValue?: string | number;
|
defaultValue?: string | number;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
}> = ({ label, name, defaultValue, hint, required = true }) => (
|
}> = ({ label, name, defaultValue, hint, required = true }) => (
|
||||||
<div className="mb-6">
|
<Box mb="6" className="w-full">
|
||||||
<h3 className="text-base font-medium text-custom-title-light dark:text-custom-title-dark mb-2">
|
<Text as="label" size="2" weight="medium" mb="2" className="block">
|
||||||
{label} {required && <span className="text-red-500">*</span>}
|
{label} {required && <Text color="red">*</Text>}
|
||||||
</h3>
|
</Text>
|
||||||
<input
|
<input
|
||||||
name={name}
|
name={name}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue?.toString()}
|
||||||
required={required}
|
required={required}
|
||||||
className="w-full p-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
|
className="w-full h-[40px] px-3 rounded-md border border-gray-200 dark:border-gray-700 text-sm"
|
||||||
/>
|
/>
|
||||||
{hint && (
|
{hint && <Text size="1" color="gray" mt="1" className="text-xs">{hint}</Text>}
|
||||||
<p className="text-xs text-custom-p-light dark:text-custom-p-dark mt-1.5">
|
</Box>
|
||||||
{hint}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const Introduction: React.FC<StepProps> = ({ onNext }) => (
|
const Introduction: React.FC<StepProps> = ({ onNext }) => (
|
||||||
@ -94,7 +86,7 @@ const Introduction: React.FC<StepProps> = ({ onNext }) => (
|
|||||||
const DatabaseConfig: React.FC<StepProps> = ({ onNext }) => {
|
const DatabaseConfig: React.FC<StepProps> = ({ onNext }) => {
|
||||||
const [dbType, setDbType] = useState("postgresql");
|
const [dbType, setDbType] = useState("postgresql");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const http = useHub().http;
|
const http = HttpClient.getInstance();
|
||||||
|
|
||||||
const validateForm = () => {
|
const validateForm = () => {
|
||||||
const getRequiredFields = () => {
|
const getRequiredFields = () => {
|
||||||
@ -131,14 +123,15 @@ const DatabaseConfig: React.FC<StepProps> = ({ onNext }) => {
|
|||||||
default: return field;
|
default: return field;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
message.error(`请填写以下必填项:${fieldNames.join('、')}`);
|
toast.error(`请填写以下必填项:${fieldNames.join('、')}`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
if (!validateForm()) {
|
const validation = validateForm();
|
||||||
|
if (validation !== true) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,11 +169,12 @@ const DatabaseConfig: React.FC<StepProps> = ({ onNext }) => {
|
|||||||
|
|
||||||
Object.assign(import.meta.env, newEnv);
|
Object.assign(import.meta.env, newEnv);
|
||||||
|
|
||||||
message.success('数据库配置成功!');
|
toast.success('数据库配置成功!');
|
||||||
|
|
||||||
setTimeout(() => onNext(), 1000);
|
setTimeout(() => onNext(), 1000);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error( error);
|
console.error(error);
|
||||||
message.error(error.message,error.title);
|
toast.error(error.message, error.title);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -189,20 +183,21 @@ const DatabaseConfig: React.FC<StepProps> = ({ onNext }) => {
|
|||||||
return (
|
return (
|
||||||
<StepContainer title="数据库配置">
|
<StepContainer title="数据库配置">
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6">
|
<Box mb="6">
|
||||||
<h3 className="text-base font-medium text-custom-title-light dark:text-custom-title-dark mb-1.5">
|
<Text as="label" size="2" weight="medium" mb="2" className="block">
|
||||||
数据库类型
|
数据库类型
|
||||||
</h3>
|
</Text>
|
||||||
<select
|
<Select.Root value={dbType} onValueChange={setDbType}>
|
||||||
value={dbType}
|
<Select.Trigger />
|
||||||
onChange={(e) => setDbType(e.target.value)}
|
<Select.Content>
|
||||||
className="w-full p-2.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-all"
|
<Select.Group>
|
||||||
>
|
<Select.Item value="postgresql">PostgreSQL</Select.Item>
|
||||||
<option value="postgresql">PostgreSQL</option>
|
<Select.Item value="mysql">MySQL</Select.Item>
|
||||||
<option value="mysql">MySQL</option>
|
<Select.Item value="sqllite">SQLite</Select.Item>
|
||||||
<option value="sqllite">SQLite</option>
|
</Select.Group>
|
||||||
</select>
|
</Select.Content>
|
||||||
</div>
|
</Select.Root>
|
||||||
|
</Box>
|
||||||
|
|
||||||
{dbType === "postgresql" && (
|
{dbType === "postgresql" && (
|
||||||
<>
|
<>
|
||||||
@ -327,7 +322,7 @@ interface InstallReplyData {
|
|||||||
|
|
||||||
const AdminConfig: React.FC<StepProps> = ({ onNext }) => {
|
const AdminConfig: React.FC<StepProps> = ({ onNext }) => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const http = useHub().http;
|
const http = HttpClient.getInstance();
|
||||||
|
|
||||||
const handleNext = async () => {
|
const handleNext = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -365,11 +360,11 @@ const AdminConfig: React.FC<StepProps> = ({ onNext }) => {
|
|||||||
|
|
||||||
Object.assign(import.meta.env, newEnv);
|
Object.assign(import.meta.env, newEnv);
|
||||||
|
|
||||||
message.success('管理员账号创建成功!');
|
toast.success('管理员账号创建成功!');
|
||||||
onNext();
|
onNext();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
message.error(error.message,error.title);
|
toast.error(error.message, error.title);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -388,9 +383,6 @@ const AdminConfig: React.FC<StepProps> = ({ onNext }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const SetupComplete: React.FC = () => {
|
const SetupComplete: React.FC = () => {
|
||||||
const http = useHub().http;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StepContainer title="安装完成">
|
<StepContainer title="安装完成">
|
||||||
@ -409,76 +401,64 @@ const SetupComplete: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 修改主题切换按钮组件
|
|
||||||
const ThemeToggle: React.FC = () => {
|
|
||||||
const [isDark, setIsDark] = useState(false);
|
|
||||||
const [isVisible, setIsVisible] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
|
||||||
setIsDark(isDarkMode);
|
|
||||||
|
|
||||||
const handleScroll = () => {
|
|
||||||
const currentScrollPos = window.scrollY;
|
|
||||||
setIsVisible(currentScrollPos < 100); // 滚动超过100px就隐藏
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('scroll', handleScroll);
|
|
||||||
return () => window.removeEventListener('scroll', handleScroll);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleTheme = () => {
|
|
||||||
const newIsDark = !isDark;
|
|
||||||
setIsDark(newIsDark);
|
|
||||||
document.documentElement.classList.toggle('dark');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
onClick={toggleTheme}
|
|
||||||
className={`absolute top-4 right-4 p-2.5 rounded-lg bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-all duration-300 ${
|
|
||||||
isVisible ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isDark ? (
|
|
||||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SetupPage() {
|
export default function SetupPage() {
|
||||||
const [currentStep, setCurrentStep] = useState(() => {
|
const [currentStep, setCurrentStep] = useState(() => {
|
||||||
return Number(import.meta.env.VITE_INIT_STATUS ?? 0) + 1;
|
return Number(import.meta.env.VITE_INIT_STATUS ?? 0) + 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [appearance, setAppearance] = useState<'light' | 'dark'>('light');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 在客户端运行时检查主题
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
setAppearance(isDark ? 'dark' : 'light');
|
||||||
|
|
||||||
|
// 监听主题变化
|
||||||
|
const handleThemeChange = (event: CustomEvent<{ theme: 'light' | 'dark' }>) => {
|
||||||
|
setAppearance(event.detail.theme);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('theme-change', handleThemeChange as EventListener);
|
||||||
|
return () => window.removeEventListener('theme-change', handleThemeChange as EventListener);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen w-full bg-custom-bg-light dark:bg-custom-bg-dark">
|
<Theme
|
||||||
<ThemeToggle />
|
accentColor="blue"
|
||||||
<div className="container mx-auto py-8">
|
grayColor="slate"
|
||||||
<SetupContext.Provider value={{ currentStep, setCurrentStep }}>
|
radius="medium"
|
||||||
{currentStep === 1 && (
|
appearance={appearance}
|
||||||
<Introduction onNext={() => setCurrentStep(currentStep + 1)} />
|
>
|
||||||
)}
|
<div className="min-h-screen w-full">
|
||||||
{currentStep === 2 && (
|
<div className="fixed top-2 right-4 z-10">
|
||||||
<DatabaseConfig
|
<ThemeModeToggle />
|
||||||
onNext={() => setCurrentStep(currentStep + 1)}
|
</div>
|
||||||
/>
|
<div className="flex justify-center pt-2">
|
||||||
)}
|
<div className="w-20 h-20 md:w-24 md:h-24">
|
||||||
{currentStep === 3 && (
|
<Echoes />
|
||||||
<AdminConfig
|
</div>
|
||||||
onNext={() => setCurrentStep(currentStep + 1)}
|
</div>
|
||||||
/>
|
<Flex direction="column" className="min-h-screen w-full pb-4">
|
||||||
)}
|
<Container className="w-full">
|
||||||
{currentStep === 4 && <SetupComplete />}
|
<SetupContext.Provider value={{ currentStep, setCurrentStep }}>
|
||||||
</SetupContext.Provider>
|
{currentStep === 1 && (
|
||||||
|
<Introduction onNext={() => setCurrentStep(currentStep + 1)} />
|
||||||
|
)}
|
||||||
|
{currentStep === 2 && (
|
||||||
|
<DatabaseConfig
|
||||||
|
onNext={() => setCurrentStep(currentStep + 1)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentStep === 3 && (
|
||||||
|
<AdminConfig
|
||||||
|
onNext={() => setCurrentStep(currentStep + 1)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentStep === 4 && <SetupComplete />}
|
||||||
|
</SetupContext.Provider>
|
||||||
|
</Container>
|
||||||
|
</Flex>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Theme>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -5,15 +5,35 @@ import {
|
|||||||
Scripts,
|
Scripts,
|
||||||
ScrollRestoration,
|
ScrollRestoration,
|
||||||
} from "@remix-run/react";
|
} from "@remix-run/react";
|
||||||
|
import { NotificationProvider } from "hooks/notification";
|
||||||
import { HubProvider } from "core/hub";
|
import { Theme } from '@radix-ui/themes';
|
||||||
import { MessageProvider, MessageContainer } from "hooks/message";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import "~/index.css";
|
import "~/index.css";
|
||||||
|
|
||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
export function Layout() {
|
||||||
|
const [theme, setTheme] = useState<'light' | 'dark'>('light');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 初始化主题
|
||||||
|
const isDark = document.documentElement.classList.contains('dark');
|
||||||
|
setTheme(isDark ? 'dark' : 'light');
|
||||||
|
|
||||||
|
// 监听主题变化
|
||||||
|
const handleThemeChange = (event: CustomEvent<{ theme: 'light' | 'dark' }>) => {
|
||||||
|
setTheme(event.detail.theme);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('theme-change', handleThemeChange as EventListener);
|
||||||
|
return () => window.removeEventListener('theme-change', handleThemeChange as EventListener);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html
|
||||||
|
lang="en"
|
||||||
|
className="h-full"
|
||||||
|
suppressHydrationWarning={true}
|
||||||
|
>
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<meta
|
<meta
|
||||||
@ -26,54 +46,45 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
/>
|
/>
|
||||||
<Meta />
|
<Meta />
|
||||||
<Links />
|
<Links />
|
||||||
</head>
|
|
||||||
<body suppressHydrationWarning={true}>
|
|
||||||
<HubProvider>
|
|
||||||
<MessageProvider>
|
|
||||||
<MessageContainer />
|
|
||||||
<Outlet />
|
|
||||||
</MessageProvider>
|
|
||||||
</HubProvider>
|
|
||||||
|
|
||||||
<ScrollRestoration />
|
|
||||||
<script
|
<script
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: `
|
__html: `
|
||||||
(function() {
|
(function() {
|
||||||
// 立即应用系统主题
|
document.documentElement.classList.remove('dark');
|
||||||
function applyTheme(isDark) {
|
const savedTheme = localStorage.getItem('theme-preference');
|
||||||
document.documentElement.classList.toggle('dark', isDark);
|
if (savedTheme) {
|
||||||
}
|
document.documentElement.classList.toggle('dark', savedTheme === 'dark');
|
||||||
|
} else {
|
||||||
// 获取系统主题并立即应用
|
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
document.documentElement.classList.toggle('dark', darkModeMediaQuery.matches);
|
||||||
applyTheme(darkModeMediaQuery.matches);
|
}
|
||||||
|
})()
|
||||||
// 添加主题变化监听
|
`,
|
||||||
try {
|
|
||||||
// 现代浏览器的方式
|
|
||||||
darkModeMediaQuery.addEventListener('change', (e) => {
|
|
||||||
applyTheme(e.matches);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
// 兼容旧版浏览器
|
|
||||||
darkModeMediaQuery.addListener((e) => {
|
|
||||||
applyTheme(e.matches);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
`,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
className="h-full"
|
||||||
|
suppressHydrationWarning={true}
|
||||||
|
>
|
||||||
|
<Theme
|
||||||
|
appearance={theme}
|
||||||
|
accentColor="blue"
|
||||||
|
grayColor="slate"
|
||||||
|
radius="medium"
|
||||||
|
scaling="100%"
|
||||||
|
>
|
||||||
|
<NotificationProvider>
|
||||||
|
<Outlet />
|
||||||
|
</NotificationProvider>
|
||||||
|
</Theme>
|
||||||
|
<ScrollRestoration />
|
||||||
<Scripts />
|
<Scripts />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return <Layout />;
|
||||||
<Layout>
|
|
||||||
<Outlet />
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
@ -1,16 +1,12 @@
|
|||||||
import ErrorPage from 'hooks/error';
|
import ErrorPage from 'hooks/error';
|
||||||
import { useEffect } from 'react';
|
import Layout from 'themes/echoes/layout';
|
||||||
|
|
||||||
const MyComponent = () => {
|
|
||||||
return <div>Hello, World!</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function Routes() {
|
export default function Routes() {
|
||||||
|
return Layout.render({
|
||||||
|
children: <></>,
|
||||||
return (
|
args: {
|
||||||
<div>
|
title: "我的页面",
|
||||||
<ErrorPage />
|
theme: "dark"
|
||||||
</div>
|
}
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
@ -15,7 +15,7 @@ export class CapabilityService {
|
|||||||
|
|
||||||
private static instance: CapabilityService;
|
private static instance: CapabilityService;
|
||||||
|
|
||||||
private constructor() {}
|
private constructor() { }
|
||||||
|
|
||||||
public static getInstance(): CapabilityService {
|
public static getInstance(): CapabilityService {
|
||||||
if (!this.instance) {
|
if (!this.instance) {
|
||||||
|
@ -105,6 +105,8 @@ export class HttpClient {
|
|||||||
if ((error as ErrorResponse).title && (error as ErrorResponse).message) {
|
if ((error as ErrorResponse).title && (error as ErrorResponse).message) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
console.log(error);
|
||||||
|
|
||||||
const errorResponse: ErrorResponse = {
|
const errorResponse: ErrorResponse = {
|
||||||
title: "未知错误",
|
title: "未知错误",
|
||||||
message: error.message || "发生未知错误"
|
message: error.message || "发生未知错误"
|
||||||
|
@ -1,34 +0,0 @@
|
|||||||
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(),
|
|
||||||
);
|
|
@ -1,34 +0,0 @@
|
|||||||
import { ReactNode } from "react"; // Import React
|
|
||||||
import { LoaderFunction } from "react-router-dom";
|
|
||||||
|
|
||||||
interface RouteElement {
|
|
||||||
element: ReactNode;
|
|
||||||
loader?: LoaderFunction;
|
|
||||||
children?: RouteElement[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class RouteManager {
|
|
||||||
private static instance: RouteManager;
|
|
||||||
private routes = new Map<string, RouteElement>();
|
|
||||||
private routesCache = new Map<string, string>();
|
|
||||||
|
|
||||||
private constructor() {}
|
|
||||||
|
|
||||||
public static getInstance(): RouteManager {
|
|
||||||
if (!RouteManager.instance) {
|
|
||||||
RouteManager.instance = new RouteManager();
|
|
||||||
}
|
|
||||||
return RouteManager.instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
private createRouteElement(path: string, element: RouteElement) {
|
|
||||||
this.routes.set(path, element);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getRoutes(path: string): RouteElement | undefined {
|
|
||||||
return this.routes.get(path);
|
|
||||||
}
|
|
||||||
private getRoutesCache(path: string): string | undefined {
|
|
||||||
return this.routesCache.get(path);
|
|
||||||
}
|
|
||||||
}
|
|
20
frontend/core/template.ts
Normal file
20
frontend/core/template.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { ReactNode } from "react"; // Import React
|
||||||
|
import { LoaderFunction } from "react-router-dom";
|
||||||
|
import { Template } from "interface/template";
|
||||||
|
|
||||||
|
export class TemplateManager {
|
||||||
|
private static instance: TemplateManager;
|
||||||
|
private templates = new Map<string, Template>();
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
public static getInstance(): TemplateManager {
|
||||||
|
if (!TemplateManager.instance) {
|
||||||
|
TemplateManager.instance = new TemplateManager();
|
||||||
|
}
|
||||||
|
return TemplateManager.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取主题和模板中的模板
|
||||||
|
|
||||||
|
}
|
@ -1,35 +0,0 @@
|
|||||||
import { createContext, useContext, ReactNode, FC } from "react";
|
|
||||||
|
|
||||||
type ServiceContextReturn<N extends string, T> = {
|
|
||||||
[K in `${N}Provider`]: FC<{ children: ReactNode }>;
|
|
||||||
} & {
|
|
||||||
[K in `use${N}`]: () => T;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createServiceContext<T, N extends string>(
|
|
||||||
serviceName: N,
|
|
||||||
getServiceInstance: () => T,
|
|
||||||
): ServiceContextReturn<N, T> {
|
|
||||||
const ServiceContext = createContext<T | undefined>(undefined);
|
|
||||||
|
|
||||||
const Provider: FC<{ children: ReactNode }> = ({ children }) => (
|
|
||||||
<ServiceContext.Provider value={getServiceInstance()}>
|
|
||||||
{children}
|
|
||||||
</ServiceContext.Provider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const useService = (): T => {
|
|
||||||
const context = useContext(ServiceContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error(
|
|
||||||
`use${serviceName} must be used within a ${serviceName}Provider`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
[`${serviceName}Provider`]: Provider,
|
|
||||||
[`use${serviceName}`]: useService,
|
|
||||||
} as ServiceContextReturn<N, T>;
|
|
||||||
}
|
|
44
frontend/hooks/echo.tsx
Normal file
44
frontend/hooks/echo.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export const Echoes: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 233 62"
|
||||||
|
className="w-full h-full"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
id="logo-anim"
|
||||||
|
style={{
|
||||||
|
strokeLinecap: "round",
|
||||||
|
strokeLinejoin: "round",
|
||||||
|
fillRule: "evenodd",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M0 0 C4.55555556 0.55555556 4.55555556 0.55555556 6 2 C7.13201767 10.83574853 4.5537396 19.48771208 2.5 28 C1.99804478 30.12284226 1.49918528 32.24637956 1.0020752 34.37036133 C0.55430717 36.27682919 0.09865509 38.18144027 -0.35742188 40.0859375 C-1.07177874 42.82816851 -1.07177874 42.82816851 -1 45 C-0.48050781 44.20335937 0.03898437 43.40671875 0.57421875 42.5859375 C11.10252879 26.5891337 11.10252879 26.5891337 17 24 C20.125 23.5 20.125 23.5 23 24 C26.83465936 28.76741433 27.00477728 34.3725597 27.72851562 40.23046875 C28.50735742 45.71462102 30.11315491 49.28422199 33 54 C32.75 56.4375 32.75 56.4375 32 58 C28.7049067 58.29955394 26.64500567 58.37625331 23.75 56.6875 C21.00094229 52.46573281 20.72390713 47.9186904 20 43 C19.63700368 41.10183175 19.26280756 39.20575496 18.875 37.3125 C18.70742187 36.48363281 18.53984375 35.65476563 18.3671875 34.80078125 C18.24601562 34.20652344 18.12484375 33.61226562 18 33 C14.4685318 34.44016491 12.92200112 36.10397858 10.8125 39.25 C10.21695313 40.13171875 9.62140625 41.0134375 9.0078125 41.921875 C6.31698552 46.04711169 3.6455625 50.18491771 1.02075195 54.35253906 C-0.52125135 56.78724519 -1.95491094 58.95491094 -4 61 C-6.5625 61.1875 -6.5625 61.1875 -9 61 C-10 60 -10 60 -10.14013672 56.47949219 C-10.08148221 52.01604551 -9.48822252 47.72494726 -8.7578125 43.328125 C-8.56516312 42.12261993 -8.56516312 42.12261993 -8.36862183 40.89276123 C-7.95990355 38.34419537 -7.54258548 35.79712628 -7.125 33.25 C-6.85230051 31.55738245 -6.58016064 29.86467464 -6.30859375 28.171875 C-5.78582 24.9187608 -5.2557761 21.66696722 -4.71972656 18.41601562 C-4.26905092 15.67719239 -3.84070507 12.9385784 -3.42871094 10.19335938 C-3.2640332 9.11376953 -3.09935547 8.03417969 -2.9296875 6.921875 C-2.79546387 6.0050293 -2.66124023 5.08818359 -2.52294922 4.14355469 C-2 2 -2 2 0 0 Z "
|
||||||
|
transform="translate(87,0)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M0 0 C2.125 1.5 2.125 1.5 3 4 C3.16869423 9.06082702 2.39778026 12.02220803 -0.75 16 C-4.92335377 20.45332718 -10.07929122 23.19240511 -15.40234375 26.04296875 C-18.24112837 27.73840784 -18.24112837 27.73840784 -18.56640625 30.61328125 C-18.2014155 33.35790574 -18.2014155 33.35790574 -15 35 C-7.97649839 35 -4.17909427 32.58337686 1 28 C1.66 27.34 2.32 26.68 3 26 C5.125 26.375 5.125 26.375 7 27 C5.47373298 34.12257944 0.95607598 36.98449365 -4.75 40.875 C-10.84193697 42.98374741 -16.67866319 43.05885539 -22.5625 40.375 C-26.24444983 36.78745914 -26.83021954 33.76571676 -27.3125 28.75 C-26.44034596 18.28415152 -22.13508157 9.50933046 -14.23828125 2.41796875 C-9.87287645 -0.46177381 -5.0404331 -0.96390753 0 0 Z M-13.0859375 11.05078125 C-15.46908589 13.47770175 -16.88035317 15.79034576 -18 19 C-17.67 19.66 -17.34 20.32 -17 21 C-15.20386168 19.73549459 -13.41322966 18.46316512 -11.625 17.1875 C-10.62726563 16.47980469 -9.62953125 15.77210937 -8.6015625 15.04296875 C-5.83319352 13.08757661 -5.83319352 13.08757661 -4 10 C-4.30840599 7.84725052 -4.30840599 7.84725052 -5 6 C-8.48529312 6 -10.5885008 8.81216981 -13.0859375 11.05078125 Z "
|
||||||
|
transform="translate(194,19)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M0 0 C2.125 1.5 2.125 1.5 3 4 C3.16869423 9.06082702 2.39778026 12.02220803 -0.75 16 C-4.92335377 20.45332718 -10.07929122 23.19240511 -15.40234375 26.04296875 C-18.24112837 27.73840784 -18.24112837 27.73840784 -18.56640625 30.61328125 C-18.2014155 33.35790574 -18.2014155 33.35790574 -15 35 C-7.97649839 35 -4.17909427 32.58337686 1 28 C1.66 27.34 2.32 26.68 3 26 C5.125 26.375 5.125 26.375 7 27 C5.47373298 34.12257944 0.95607598 36.98449365 -4.75 40.875 C-10.84193697 42.98374741 -16.67866319 43.05885539 -22.5625 40.375 C-26.24444983 36.78745914 -26.83021954 33.76571676 -27.3125 28.75 C-26.44034596 18.28415152 -22.13508157 9.50933046 -14.23828125 2.41796875 C-9.87287645 -0.46177381 -5.0404331 -0.96390753 0 0 Z M-13.0859375 11.05078125 C-15.46908589 13.47770175 -16.88035317 15.79034576 -18 19 C-17.67 19.66 -17.34 20.32 -17 21 C-15.20386168 19.73549459 -13.41322966 18.46316512 -11.625 17.1875 C-10.62726562 16.47980469 -9.62953125 15.77210937 -8.6015625 15.04296875 C-5.83319352 13.08757661 -5.83319352 13.08757661 -4 10 C-4.30840599 7.84725052 -4.30840599 7.84725052 -5 6 C-8.48529312 6 -10.5885008 8.81216981 -13.0859375 11.05078125 Z "
|
||||||
|
transform="translate(27,19)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M0 0 C3.69527184 1.74685578 5.47717274 3.28546941 7.875 6.5625 C10.114188 13.40446332 9.97220755 19.67980807 6.9375 26.25 C3.26647723 32.77280599 -1.74359246 38.58119749 -9 41 C-13.16700248 41.60697745 -17.12242085 41.98601239 -20.94140625 39.98046875 C-24.51875377 37.12721085 -25.79629249 34.79211037 -26.30859375 30.28515625 C-26.65858689 21.53532781 -25.02308905 15.22860505 -20 8 C-13.78847728 1.31686166 -9.14425293 -0.99729947 0 0 Z M-13 10 C-17.99946227 16.38820179 -19.51248523 22.05647893 -19 30 C-18.49166762 32.44162567 -18.49166762 32.44162567 -17 34 C-14.78705637 35.19488739 -14.78705637 35.19488739 -12 35 C-5.3983905 32.0528529 -0.86192365 27.77490776 2 21 C2.58594139 16.74621767 2.38401583 13.0240422 0.875 9 C-0.9152847 6.79591315 -0.9152847 6.79591315 -3.8125 5.9375 C-8.0777564 6.02113248 -9.76655628 7.25860206 -13 10 Z "
|
||||||
|
transform="translate(151,21)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M0 0 C3.08616928 3.08616928 4.90831127 5.56354329 5.1875 9.9375 C5 13 5 13 3 16 C1.1875 15.9375 1.1875 15.9375 -1 15 C-2.75 11.4375 -2.75 11.4375 -4 8 C-10.71628357 11.50067752 -13.45658865 17.42209188 -16.375 24.1875 C-17.13192649 27.5936692 -17.3342212 30.52409948 -17 34 C-13.63485541 36.24342972 -12.10349913 36.20088223 -8.1875 35.625 C-4.98478661 34.70993903 -2.55692219 32.94846434 0.16796875 31.078125 C2.52392749 29.69167593 4.31957201 29.58118313 7 30 C6.52159393 34.30565461 4.20810194 36.26970047 1 39 C-4.98635827 43.19452314 -10.80628431 43.92458415 -18 43 C-21.31141732 41.71598104 -22.4693361 40.85917012 -24.3125 37.875 C-26.28793198 29.61410261 -24.71328334 22.56854516 -21 15 C-16.30491914 7.59711261 -9.74998405 -2.0235816 0 0 Z "
|
||||||
|
transform="translate(64,19)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M0 0 C0.77601563 -0.00515625 1.55203125 -0.0103125 2.3515625 -0.015625 C4.375 0.25 4.375 0.25 6.375 2.25 C6 4.875 6 4.875 5.375 7.25 C4.80652344 7.27578125 4.23804688 7.3015625 3.65234375 7.328125 C-2.13318736 7.75310558 -5.86965905 8.88163349 -10.625 12.25 C-10.955 13.24 -11.285 14.23 -11.625 15.25 C-8.06916346 19.26465416 -4.48770953 21.60991904 0.2890625 24.0390625 C3.33050964 25.8046966 5.02772758 27.30740343 6.25 30.625 C6.41854678 34.16448233 5.57116559 35.487082 3.375 38.25 C-4.08131256 44.18115772 -12.33738592 45.20835775 -21.625 44.25 C-22.285 43.59 -22.945 42.93 -23.625 42.25 C-23.08928571 38.71428571 -23.08928571 38.71428571 -21.625 37.25 C-20.12443322 37.25595463 -18.62397304 37.3056031 -17.125 37.375 C-11.80066201 37.45171957 -7.52448551 36.23819702 -2.625 34.25 C-2.625 33.26 -2.625 32.27 -2.625 31.25 C-5.18015457 29.45648905 -5.18015457 29.45648905 -8.375 27.8125 C-17.82201749 22.71247969 -17.82201749 22.71247969 -19.5625 17.375 C-19.64363503 13.31824856 -18.93117065 11.60443004 -16.625 8.25 C-11.72056199 3.88588143 -6.7658008 -0.04525619 0 0 Z "
|
||||||
|
transform="translate(226.625,17.75)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
@ -1,236 +0,0 @@
|
|||||||
import React, { createContext, useState, useContext, useEffect } from "react";
|
|
||||||
|
|
||||||
interface Message {
|
|
||||||
id: number;
|
|
||||||
type: "success" | "error" | "info" | "warning";
|
|
||||||
content: string;
|
|
||||||
title?: string;
|
|
||||||
duration?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MessageContextType {
|
|
||||||
messages: Message[];
|
|
||||||
addMessage: (
|
|
||||||
type: Message["type"],
|
|
||||||
content: string,
|
|
||||||
title?: string,
|
|
||||||
duration?: number,
|
|
||||||
) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MessageContext = createContext<MessageContextType>({
|
|
||||||
messages: [],
|
|
||||||
addMessage: () => {},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const MessageProvider: React.FC<{ children: React.ReactNode }> = ({
|
|
||||||
children,
|
|
||||||
}) => {
|
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
|
||||||
|
|
||||||
const removeMessage = (id: number) => {
|
|
||||||
setMessages((prev) => prev.filter((msg) => msg.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const addMessage = (
|
|
||||||
type: Message["type"],
|
|
||||||
content: string,
|
|
||||||
title?: string,
|
|
||||||
duration = 3000,
|
|
||||||
) => {
|
|
||||||
const id = Date.now();
|
|
||||||
|
|
||||||
setMessages((prevMessages) => {
|
|
||||||
const newMessages = [...prevMessages, { id, type, content, title }];
|
|
||||||
return newMessages;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (duration > 0) {
|
|
||||||
setTimeout(() => removeMessage(id), duration);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<MessageContext.Provider value={{ messages, addMessage }}>
|
|
||||||
{children}
|
|
||||||
</MessageContext.Provider>
|
|
||||||
<div
|
|
||||||
id="message-container"
|
|
||||||
style={{
|
|
||||||
position: "fixed",
|
|
||||||
top: "16px",
|
|
||||||
right: "16px",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: "8px",
|
|
||||||
pointerEvents: "none",
|
|
||||||
zIndex: 999999,
|
|
||||||
maxWidth: "90vw",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{messages.map((msg) => (
|
|
||||||
<div
|
|
||||||
key={msg.id}
|
|
||||||
style={{
|
|
||||||
backgroundColor:
|
|
||||||
msg.type === "success"
|
|
||||||
? "rgba(34, 197, 94, 0.95)"
|
|
||||||
: msg.type === "error"
|
|
||||||
? "rgba(239, 68, 68, 0.95)"
|
|
||||||
: msg.type === "warning"
|
|
||||||
? "rgba(234, 179, 8, 0.95)"
|
|
||||||
: "rgba(59, 130, 246, 0.95)",
|
|
||||||
color: "white",
|
|
||||||
width: "320px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
|
|
||||||
overflow: "hidden",
|
|
||||||
animation: "slideInRight 0.3s ease-out forwards",
|
|
||||||
pointerEvents: "auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
padding: "12px 16px",
|
|
||||||
display: "flex",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
{msg.title && (
|
|
||||||
<div style={{
|
|
||||||
fontSize: "14px",
|
|
||||||
fontWeight: "bold",
|
|
||||||
marginBottom: "4px",
|
|
||||||
}}>
|
|
||||||
{msg.title}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
fontSize: "14px",
|
|
||||||
lineHeight: "1.5",
|
|
||||||
wordBreak: "break-word",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{msg.content}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => removeMessage(msg.id)}
|
|
||||||
style={{
|
|
||||||
background: "none",
|
|
||||||
border: "none",
|
|
||||||
color: "rgba(255, 255, 255, 0.8)",
|
|
||||||
cursor: "pointer",
|
|
||||||
padding: "4px",
|
|
||||||
fontSize: "16px",
|
|
||||||
lineHeight: 1,
|
|
||||||
transition: "color 0.2s",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.color = "white")}
|
|
||||||
onMouseLeave={(e) =>
|
|
||||||
(e.currentTarget.style.color = "rgba(255, 255, 255, 0.8)")
|
|
||||||
}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: "2px",
|
|
||||||
backgroundColor: "rgba(255, 255, 255, 0.2)",
|
|
||||||
position: "relative",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
bottom: 0,
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
backgroundColor: "rgba(255, 255, 255, 0.4)",
|
|
||||||
animation: `progress ${msg.duration || 3000}ms linear`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<style>{`
|
|
||||||
@keyframes slideInRight {
|
|
||||||
from {
|
|
||||||
transform: translateX(100%);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
transform: translateX(0);
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@keyframes progress {
|
|
||||||
from {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
width: 0%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 修改全局消息实例的实现
|
|
||||||
let globalAddMessage:
|
|
||||||
| ((type: Message["type"], content: string, title?: string, duration?: number) => void)
|
|
||||||
| null = null;
|
|
||||||
|
|
||||||
export const MessageContainer: React.FC = () => {
|
|
||||||
const { addMessage } = useContext(MessageContext);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
globalAddMessage = addMessage;
|
|
||||||
return () => {
|
|
||||||
globalAddMessage = null;
|
|
||||||
};
|
|
||||||
}, [addMessage]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 修改消息方法的实现
|
|
||||||
export const message = {
|
|
||||||
success: (content: string, title?: string) => {
|
|
||||||
if (!globalAddMessage) {
|
|
||||||
console.warn("Message system not initialized");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
globalAddMessage("success", content, title);
|
|
||||||
},
|
|
||||||
error: (content: string, title?: string) => {
|
|
||||||
if (!globalAddMessage) {
|
|
||||||
console.warn("Message system not initialized");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
globalAddMessage("error", content, title);
|
|
||||||
},
|
|
||||||
warning: (content: string, title?: string) => {
|
|
||||||
if (!globalAddMessage) {
|
|
||||||
console.warn("Message system not initialized");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
globalAddMessage("warning", content, title);
|
|
||||||
},
|
|
||||||
info: (content: string, title?: string) => {
|
|
||||||
if (!globalAddMessage) {
|
|
||||||
console.warn("Message system not initialized");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
globalAddMessage("info", content, title);
|
|
||||||
},
|
|
||||||
};
|
|
170
frontend/hooks/notification.tsx
Normal file
170
frontend/hooks/notification.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
import React, { createContext, useState, useContext } from 'react';
|
||||||
|
import { Button, Flex, Card, Text, Box } from '@radix-ui/themes';
|
||||||
|
import { CheckCircledIcon, CrossCircledIcon, InfoCircledIcon } from '@radix-ui/react-icons';
|
||||||
|
|
||||||
|
// 定义通知类型枚举
|
||||||
|
export enum NotificationType {
|
||||||
|
SUCCESS = 'success',
|
||||||
|
ERROR = 'error',
|
||||||
|
INFO = 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通知类型定义
|
||||||
|
type Notification = {
|
||||||
|
id: string;
|
||||||
|
type: NotificationType;
|
||||||
|
title: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 通知配置类型定义
|
||||||
|
type NotificationConfig = {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
bgColor: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 通知配置映射
|
||||||
|
const notificationConfigs: Record<NotificationType, NotificationConfig> = {
|
||||||
|
[NotificationType.SUCCESS]: {
|
||||||
|
icon: <CheckCircledIcon className="w-5 h-5 text-white" />,
|
||||||
|
bgColor: 'bg-[rgba(0,168,91,0.85)]'
|
||||||
|
},
|
||||||
|
[NotificationType.ERROR]: {
|
||||||
|
icon: <CrossCircledIcon className="w-5 h-5 text-white" />,
|
||||||
|
bgColor: 'bg-[rgba(225,45,57,0.85)]'
|
||||||
|
},
|
||||||
|
[NotificationType.INFO]: {
|
||||||
|
icon: <InfoCircledIcon className="w-5 h-5 text-white" />,
|
||||||
|
bgColor: 'bg-[rgba(38,131,255,0.85)]'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 修改通知上下文类型定义
|
||||||
|
type NotificationContextType = {
|
||||||
|
show: (type: NotificationType, title: string, message?: string) => void;
|
||||||
|
success: (title: string, message?: string) => void;
|
||||||
|
error: (title: string, message?: string) => void;
|
||||||
|
info: (title: string, message?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NotificationContext = createContext<NotificationContextType>({
|
||||||
|
show: () => {},
|
||||||
|
success: () => {},
|
||||||
|
error: () => {},
|
||||||
|
info: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 简化全局 toast 对象定义
|
||||||
|
export const toast: NotificationContextType = {
|
||||||
|
show: () => {},
|
||||||
|
success: () => {},
|
||||||
|
error: () => {},
|
||||||
|
info: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NotificationProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
|
|
||||||
|
// 统一参数顺序:title 在前,message 在后
|
||||||
|
const show = (type: NotificationType, title: string, message?: string) => {
|
||||||
|
const id = Math.random().toString(36).substring(2, 9);
|
||||||
|
const newNotification = { id, type, title, message };
|
||||||
|
|
||||||
|
setNotifications(prev => [...prev, newNotification]);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
setNotifications(prev => prev.filter(notification => notification.id !== id));
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 简化快捷方法定义
|
||||||
|
const contextValue = {
|
||||||
|
show,
|
||||||
|
success: (title: string, message?: string) => show(NotificationType.SUCCESS, title, message),
|
||||||
|
error: (title: string, message?: string) => show(NotificationType.ERROR, title, message),
|
||||||
|
info: (title: string, message?: string) => show(NotificationType.INFO, title, message),
|
||||||
|
};
|
||||||
|
|
||||||
|
// 初始化全局方法
|
||||||
|
React.useEffect(() => {
|
||||||
|
Object.assign(toast, contextValue);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeNotification = (id: string) => {
|
||||||
|
setNotifications(prev => prev.filter(notification => notification.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationContext.Provider value={contextValue}>
|
||||||
|
{notifications.length > 0 && (
|
||||||
|
<Box
|
||||||
|
position="fixed"
|
||||||
|
top="4"
|
||||||
|
className="fixed top-4 right-4 z-[1000] flex flex-col gap-2 w-full max-w-[360px] px-4 md:px-0 md:right-6"
|
||||||
|
>
|
||||||
|
{notifications.map(notification => (
|
||||||
|
<Card
|
||||||
|
key={notification.id}
|
||||||
|
className="p-0 overflow-hidden shadow-lg w-full"
|
||||||
|
>
|
||||||
|
<Flex
|
||||||
|
direction="column"
|
||||||
|
gap="2"
|
||||||
|
className={`relative min-h-[52px] p-4 ${notificationConfigs[notification.type].bgColor}`}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => closeNotification(notification.id)}
|
||||||
|
className="absolute right-2 top-2 p-1 min-w-0 h-auto text-white opacity-70 cursor-pointer bg-transparent border-none text-sm hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</Button>
|
||||||
|
<Flex direction="column" gap="1.5" className="pr-6">
|
||||||
|
<Flex align="center" gap="2">
|
||||||
|
<span className="flex items-center justify-center">
|
||||||
|
{notificationConfigs[notification.type].icon}
|
||||||
|
</span>
|
||||||
|
{notification.title && (
|
||||||
|
<Text
|
||||||
|
weight="bold"
|
||||||
|
size="2"
|
||||||
|
className="text-white leading-tight"
|
||||||
|
>
|
||||||
|
{notification.title}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
<Text
|
||||||
|
size="2"
|
||||||
|
className="text-white/80 leading-normal"
|
||||||
|
>
|
||||||
|
{notification.message}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
<div className="h-0.5 w-full bg-white/10 mt-1">
|
||||||
|
<div
|
||||||
|
className="h-full bg-white/20 animate-[progress_3s_linear]"
|
||||||
|
style={{
|
||||||
|
transformOrigin: 'left'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Flex>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</NotificationContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 导出hook
|
||||||
|
export const useNotification = () => {
|
||||||
|
const context = useContext(NotificationContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useNotification must be used within a NotificationProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
66
frontend/hooks/themeMode.tsx
Normal file
66
frontend/hooks/themeMode.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { MoonIcon, SunIcon } from "@radix-ui/react-icons"
|
||||||
|
import { Button } from "@radix-ui/themes";
|
||||||
|
|
||||||
|
const THEME_KEY = "theme-preference";
|
||||||
|
|
||||||
|
export const ThemeModeToggle: React.FC = () => {
|
||||||
|
const [isDark, setIsDark] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const [visible, setVisible] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
const saved = localStorage.getItem(THEME_KEY);
|
||||||
|
if (saved) {
|
||||||
|
setIsDark(saved === "dark");
|
||||||
|
document.documentElement.classList.toggle("dark", saved === "dark");
|
||||||
|
} else {
|
||||||
|
const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
setIsDark(systemDark);
|
||||||
|
document.documentElement.classList.toggle("dark", systemDark);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加滚动监听
|
||||||
|
let lastScroll = 0;
|
||||||
|
const handleScroll = () => {
|
||||||
|
const currentScroll = window.scrollY;
|
||||||
|
setVisible(currentScroll <= lastScroll || currentScroll < 50);
|
||||||
|
lastScroll = currentScroll;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('scroll', handleScroll);
|
||||||
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const newIsDark = !isDark;
|
||||||
|
setIsDark(newIsDark);
|
||||||
|
localStorage.setItem(THEME_KEY, newIsDark ? "dark" : "light");
|
||||||
|
document.documentElement.classList.toggle("dark", newIsDark);
|
||||||
|
|
||||||
|
const event = new CustomEvent("theme-change", {
|
||||||
|
detail: { theme: newIsDark ? "dark" : "light" },
|
||||||
|
});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className={`p-2 rounded-lg transition-all duration-300 transform ${
|
||||||
|
visible ? 'translate-y-0 opacity-100' : '-translate-y-full opacity-0'
|
||||||
|
}`}
|
||||||
|
aria-label="Toggle theme"
|
||||||
|
>
|
||||||
|
{isDark ? (
|
||||||
|
<SunIcon width="24" height="24" className="text-yellow-400" />
|
||||||
|
) : (
|
||||||
|
<MoonIcon width="24" height="24" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
29
frontend/interface/layout.ts
Normal file
29
frontend/interface/layout.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { HttpClient } from 'core/http';
|
||||||
|
import { CapabilityService } from 'core/capability';
|
||||||
|
import { Serializable } from 'interface/serializableType';
|
||||||
|
|
||||||
|
export class Layout {
|
||||||
|
private http: HttpClient;
|
||||||
|
private capability: CapabilityService;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public element: (props: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
args?: Serializable;
|
||||||
|
}) => React.ReactNode,
|
||||||
|
services?: {
|
||||||
|
http?: HttpClient;
|
||||||
|
capability?: CapabilityService;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
this.http = services?.http || HttpClient.getInstance();
|
||||||
|
this.capability = services?.capability || CapabilityService.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
render(props: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
args?: Serializable;
|
||||||
|
}) {
|
||||||
|
return this.element(props);
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { Configuration, PathDescription } from "commons/serializableType";
|
import { Configuration, PathDescription } from "interface/serializableType";
|
||||||
|
|
||||||
export interface PluginConfig {
|
export interface PluginConfig {
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -1,24 +1,26 @@
|
|||||||
import { HttpClient } from 'core/http';
|
import { HttpClient } from 'core/http';
|
||||||
import { CapabilityService } from 'core/capability';
|
import { CapabilityService } from 'core/capability';
|
||||||
|
import { Serializable } from 'interface/serializableType';
|
||||||
|
|
||||||
export class Template {
|
export class Template {
|
||||||
constructor(
|
constructor(
|
||||||
public name: string,
|
|
||||||
public config: {
|
public config: {
|
||||||
layout?: string;
|
layout?: string;
|
||||||
styles?: string[];
|
styles?: string[];
|
||||||
scripts?: string[];
|
scripts?: string[];
|
||||||
|
description?: string;
|
||||||
},
|
},
|
||||||
public element: (services: {
|
public element: (services: {
|
||||||
http: HttpClient;
|
http: HttpClient;
|
||||||
capability: CapabilityService;
|
capability: CapabilityService;
|
||||||
}) => React.ReactNode,
|
args: Serializable;
|
||||||
public description?: string,
|
}) => React.ReactNode
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
render(services: {
|
render(services: {
|
||||||
http: HttpClient;
|
http: HttpClient;
|
||||||
capability: CapabilityService;
|
capability: CapabilityService;
|
||||||
|
args: Serializable;
|
||||||
}) {
|
}) {
|
||||||
return this.element(services);
|
return this.element(services);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Configuration, PathDescription } from "commons/serializableType";
|
import { Configuration, PathDescription } from "interface/serializableType";
|
||||||
|
|
||||||
export interface ThemeConfig {
|
export interface ThemeConfig {
|
||||||
name: string;
|
name: string;
|
||||||
|
@ -12,11 +12,14 @@
|
|||||||
"typecheck": "tsc"
|
"typecheck": "tsc"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
|
"@radix-ui/themes": "^3.1.6",
|
||||||
"@remix-run/node": "^2.14.0",
|
"@remix-run/node": "^2.14.0",
|
||||||
"@remix-run/react": "^2.14.0",
|
"@remix-run/react": "^2.14.0",
|
||||||
"@remix-run/serve": "^2.14.0",
|
"@remix-run/serve": "^2.14.0",
|
||||||
"@types/axios": "^0.14.4",
|
"@types/axios": "^0.14.4",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.7.7",
|
||||||
|
"bootstrap-icons": "^1.11.3",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.21.1",
|
"express": "^4.21.1",
|
||||||
"isbot": "^4.1.0",
|
"isbot": "^4.1.0",
|
||||||
|
@ -7,37 +7,21 @@ export default {
|
|||||||
"./core/**/*.{js,jsx,ts,tsx}",
|
"./core/**/*.{js,jsx,ts,tsx}",
|
||||||
"./hooks/**/*.{js,jsx,ts,tsx}",
|
"./hooks/**/*.{js,jsx,ts,tsx}",
|
||||||
],
|
],
|
||||||
|
darkMode: 'class',
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
sans: ["Inter", "system-ui", "sans-serif"],
|
sans: ["Inter", "system-ui", "sans-serif"],
|
||||||
},
|
},
|
||||||
colors: {
|
keyframes: {
|
||||||
custom: {
|
progress: {
|
||||||
bg: {
|
from: { transform: 'scaleX(1)' },
|
||||||
light: "#FAFAFA", // 更柔和的背景色
|
to: { transform: 'scaleX(0)' }
|
||||||
dark: "#111827", // 更深邃的暗色背景
|
}
|
||||||
},
|
|
||||||
box: {
|
|
||||||
light: "#FFFFFF",
|
|
||||||
dark: "#1E293B",
|
|
||||||
},
|
|
||||||
p: {
|
|
||||||
light: "#374151", // 更清晰的文本颜色
|
|
||||||
dark: "#D1D5DB", // 更亮的暗色文本,提高可读性
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
light: "#111827",
|
|
||||||
dark: "#F8FAFC", // 更亮的标题颜色
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
hide: 'hide 100ms ease-in',
|
progress: 'progress 3s linear'
|
||||||
slideIn: 'slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1)',
|
}
|
||||||
swipeOut: 'swipeOut 100ms ease-out',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
darkMode: "class",
|
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
import { Template } from 'core/template';
|
import { Template } from 'interface/template';
|
||||||
|
|
||||||
export default new Template(
|
export default new Template(
|
||||||
"page",
|
|
||||||
{
|
{
|
||||||
layout: "default",
|
layout: "default",
|
||||||
},
|
},
|
||||||
({ http }) => {
|
({ http,args }) => {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
Hello World
|
Hello World
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
|
||||||
);
|
);
|
86
frontend/themes/echoes/layout.tsx
Normal file
86
frontend/themes/echoes/layout.tsx
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { Layout } from "interface/layout";
|
||||||
|
import { ThemeModeToggle } from "hooks/themeMode";
|
||||||
|
import { Echoes } from "hooks/echo";
|
||||||
|
import { Container, Flex, Box, Link } from "@radix-ui/themes";
|
||||||
|
|
||||||
|
export default new Layout(({ children, args }) => {
|
||||||
|
return (
|
||||||
|
<Box className="min-h-screen flex flex-col">
|
||||||
|
{/* 导航栏 */}
|
||||||
|
<Box asChild className="fixed top-0 w-full border-b backdrop-blur-sm z-50">
|
||||||
|
<nav>
|
||||||
|
<Container size="4" className="mx-auto">
|
||||||
|
<Flex justify="between" align="center" className="h-16">
|
||||||
|
{/* Logo 区域 */}
|
||||||
|
<Flex align="center" gap="4">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-xl font-bold flex items-center hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
<Echoes/>
|
||||||
|
</Link>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
{/* 导航链接 */}
|
||||||
|
<Flex align="center" gap="6">
|
||||||
|
<Flex gap="4">
|
||||||
|
<Link
|
||||||
|
href="/posts"
|
||||||
|
className="hover:opacity-80 transition-opacity font-medium"
|
||||||
|
>
|
||||||
|
文章
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/about"
|
||||||
|
className="hover:opacity-80 transition-opacity font-medium"
|
||||||
|
>
|
||||||
|
关于
|
||||||
|
</Link>
|
||||||
|
</Flex>
|
||||||
|
<Box>
|
||||||
|
<ThemeModeToggle />
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Container>
|
||||||
|
</nav>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 主要内容区域 */}
|
||||||
|
<Box className="flex-1 w-full mt-16">
|
||||||
|
<Container size="4" className="py-8">
|
||||||
|
<main>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 页脚 */}
|
||||||
|
<Box asChild className="w-full border-t mt-auto">
|
||||||
|
<footer>
|
||||||
|
<Container size="4" className="py-8">
|
||||||
|
<Flex direction="column" align="center" gap="4">
|
||||||
|
<Flex gap="6" className="text-sm">
|
||||||
|
<Link href="/terms" className="hover:opacity-80 transition-opacity">
|
||||||
|
使用条款
|
||||||
|
</Link>
|
||||||
|
<Link href="/privacy" className="hover:opacity-80 transition-opacity">
|
||||||
|
隐私政策
|
||||||
|
</Link>
|
||||||
|
<Link href="/contact" className="hover:opacity-80 transition-opacity">
|
||||||
|
联系我们
|
||||||
|
</Link>
|
||||||
|
</Flex>
|
||||||
|
<Box className="text-sm text-center opacity-85">
|
||||||
|
<p>© {new Date().getFullYear()} Echoes. All rights reserved.</p>
|
||||||
|
<p className="mt-1 text-xs opacity-75">
|
||||||
|
Powered by Echoes Framework
|
||||||
|
</p>
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
</Container>
|
||||||
|
</footer>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
Loading…
Reference in New Issue
Block a user