后端:实现jwt,创建和检测 前端:重新定义能力和契约,不使用一键构建的前端框架,约束太多
This commit is contained in:
parent
88bf3b5592
commit
4b06234df8
@ -11,5 +11,8 @@ toml = "0.8.19"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
sqlx = { version = "0.8.2", features = ["runtime-tokio-native-tls", "postgres"] }
|
||||
async-trait = "0.1.83"
|
||||
anyhow = "1.0"
|
||||
once_cell = "1.10.0"
|
||||
jwt-compact = { version = "0.8.0", features = ["ed25519-dalek"] }
|
||||
ed25519-dalek = "2.1.1"
|
||||
rand = "0.8.5"
|
||||
chrono = "0.4"
|
@ -32,6 +32,7 @@ impl Config {
|
||||
/// 读取配置文件
|
||||
pub fn read() -> Result<Self, Box<dyn std::error::Error>> {
|
||||
let path = env::current_dir()?
|
||||
.join("assets")
|
||||
.join("config.toml");
|
||||
Ok(toml::from_str(&fs::read_to_string(path)?)?)
|
||||
}
|
||||
|
@ -9,11 +9,15 @@
|
||||
*/
|
||||
|
||||
mod config; // 配置模块
|
||||
mod database; // 数据库模块
|
||||
mod database;
|
||||
mod secret;
|
||||
use chrono::Duration;
|
||||
// 数据库模块
|
||||
use database::relational; // 引入关系型数据库
|
||||
use once_cell::sync::Lazy; // 用于延迟初始化
|
||||
use rocket::{get, http::Status, launch, response::status, routes, serde::json::Json}; // 引入Rocket框架相关功能
|
||||
use std::sync::Arc; // 引入Arc用于线程安全的引用计数
|
||||
use std::sync::Arc;
|
||||
// 引入Arc用于线程安全的引用计数
|
||||
use tokio::sync::Mutex; // 引入Mutex用于异步锁
|
||||
|
||||
// 全局数据库连接变量
|
||||
@ -86,3 +90,24 @@ async fn rocket() -> _ {
|
||||
.expect("Failed to connect to database"); // 初始化数据库连接
|
||||
rocket::build().mount("/api", routes![install, ssql]) // 挂载API路由
|
||||
}
|
||||
|
||||
|
||||
// fn main(){
|
||||
// // secret::generate_key().expect("msg");
|
||||
// // 创建claims
|
||||
// let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
|
||||
|
||||
// // 创建 Claims
|
||||
// let claims = secret::CustomClaims {
|
||||
// user_id: String::from("lsy"),
|
||||
// device_ua: String::from("lsy"),
|
||||
// };
|
||||
// let t=String::from("eyJhbGciOiJFZERTQSJ9.eyJleHAiOjE3MzE3NTczMDMsIm5iZiI6MTczMTc1NzI4MywiaWF0IjoxNzMxNzU3MjgzLCJ1c2VyX2lkIjoibHN5IiwiZGV2aWNlX3VhIjoibHN5In0.C8t5XZFSKnnDVmc6WkY-gzGNSAP7lNAjP9yBjhdvIRO7r_QjDnfcm0INIqCt5cyvnRlE2rFJIx_axOfLx2QJAw");
|
||||
// // 生成JWT
|
||||
// let token = secret::generate_jwt(claims,Duration::seconds(20)).expect("msg");
|
||||
// println!("{}", token);
|
||||
|
||||
// // 验证JWT
|
||||
// let a=secret::validate_jwt(&t).expect("msg");
|
||||
// println!("\n\n{}",a.user_id)
|
||||
// }
|
119
backend/src/secret.rs
Normal file
119
backend/src/secret.rs
Normal file
@ -0,0 +1,119 @@
|
||||
// File path: src/secret.rs
|
||||
|
||||
/**
|
||||
* 本文件包含JWT的生成和验证功能。
|
||||
* 提供了生成密钥、生成JWT、验证JWT的相关函数。
|
||||
*/
|
||||
|
||||
use jwt_compact::{alg::Ed25519, AlgorithmExt, Header, Token, UntrustedToken, TimeOptions};
|
||||
use serde::{Serialize, Deserialize};
|
||||
use chrono::{Duration, Utc};
|
||||
use ed25519_dalek::{SigningKey, VerifyingKey};
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::{env, fs};
|
||||
use std::error::Error;
|
||||
use rand::{SeedableRng, RngCore};
|
||||
|
||||
// 定义JWT的Claims结构体(有效载荷)
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct CustomClaims {
|
||||
pub user_id: String, // 用户ID
|
||||
pub device_ua: String, // 用户UA
|
||||
}
|
||||
|
||||
pub enum SecretKey {
|
||||
Signing, // 签名密钥
|
||||
Verifying, // 验证密钥
|
||||
}
|
||||
|
||||
impl SecretKey {
|
||||
fn as_string(&self) -> String {
|
||||
match self {
|
||||
Self::Signing => String::from("signing"),
|
||||
Self::Verifying => String::from("verifying"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成签名密钥和验证密钥,并将其保存到文件中。
|
||||
*/
|
||||
pub fn generate_key() -> Result<(), Box<dyn Error>> {
|
||||
let mut csprng = rand::rngs::StdRng::from_entropy(); // 使用系统熵创建随机数生成器
|
||||
|
||||
let mut private_key_bytes = [0u8; 32]; // 存储签名密钥的字节数组
|
||||
csprng.fill_bytes(&mut private_key_bytes); // 生成签名密钥
|
||||
|
||||
let signing_key = SigningKey::from_bytes(&private_key_bytes); // 从签名密钥获取SigningKey
|
||||
let verifying_key = signing_key.verifying_key(); // 获取验证密钥
|
||||
|
||||
let base_path = env::current_dir()?
|
||||
.join("assets")
|
||||
.join("key");
|
||||
|
||||
fs::create_dir_all(&base_path)?; // 创建目录
|
||||
File::create(base_path.join(SecretKey::Signing.as_string()))?
|
||||
.write_all(signing_key.as_bytes())?; // 保存签名密钥
|
||||
File::create(base_path.join(SecretKey::Verifying.as_string()))?
|
||||
.write_all(verifying_key.as_bytes())?; // 保存验证密钥
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/**
|
||||
* 从文件中读取指定类型的密钥。
|
||||
*/
|
||||
pub fn get_key(key_type: SecretKey) -> Result<[u8; 32], Box<dyn Error>> {
|
||||
let path = env::current_dir()?
|
||||
.join("assets")
|
||||
.join("key")
|
||||
.join(key_type.as_string());
|
||||
let key_bytes = fs::read(path)?; // 读取密钥文件
|
||||
let mut key = [0u8; 32]; // 固定长度的数组
|
||||
key.copy_from_slice(&key_bytes[..32]); // 拷贝前32个字节
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成JWT,包含自定义声明和有效期。
|
||||
*/
|
||||
pub fn generate_jwt(claims: CustomClaims, duration: Duration) -> Result<String, Box<dyn Error>> {
|
||||
let key_bytes = get_key(SecretKey::Signing)?; // 从文件读取私钥
|
||||
let signing_key = SigningKey::from_bytes(&key_bytes); // 创建SigningKey
|
||||
|
||||
let time_options = TimeOptions::new(
|
||||
Duration::seconds(0), // 设置时间容差为0
|
||||
Utc::now // 使用当前UTC时间作为时钟函数
|
||||
); // 设置时间容差为); // 默认时间选项
|
||||
let claims = jwt_compact::Claims::new(claims) // 创建JWT的有效载荷
|
||||
.set_duration_and_issuance(&time_options, duration)
|
||||
.set_not_before(Utc::now()); // 设置不早于时间
|
||||
|
||||
let header = Header::empty(); // 创建自定义的头部
|
||||
|
||||
let token = Ed25519.token(&header, &claims, &signing_key)?; // 使用Ed25519签名JWT
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证JWT并返回自定义声明。
|
||||
*/
|
||||
pub fn validate_jwt(token: &str) -> Result<CustomClaims, Box<dyn Error>> {
|
||||
let key_bytes = get_key(SecretKey::Verifying)?; // 从文件读取验证密钥
|
||||
let verifying = VerifyingKey::from_bytes(&key_bytes)?; // 创建VerifyingKey
|
||||
let token = UntrustedToken::new(token)?; // 创建未受信任的Token
|
||||
let token: Token<CustomClaims> = Ed25519.validator(&verifying).validate(&token)?; // 验证Token
|
||||
|
||||
let time_options = TimeOptions::new(
|
||||
Duration::seconds(0), // 设置时间容差为0
|
||||
Utc::now // 使用当前UTC时间作为时钟函数
|
||||
); // 设置时间容差为); // 默认时间选项
|
||||
token.claims()
|
||||
.validate_expiration(&time_options)? // 验证过期时间
|
||||
.validate_maturity(&time_options)?; // 验证成熟时间
|
||||
let claims = token.claims().custom.clone(); // 获取自定义声明
|
||||
|
||||
Ok(claims)
|
||||
}
|
@ -1,2 +1,3 @@
|
||||
VITE_SOME_KEY = 1
|
||||
VITE_APP_SERVER = false
|
||||
VITE_SERVER_STATUS = false
|
||||
VITE_SERVER_ADDRESS = "localhost:8000"
|
||||
VITE_SERVER_TOKEN = ""
|
@ -1,84 +0,0 @@
|
||||
/**
|
||||
* This is intended to be a basic starting point for linting in your app.
|
||||
* It relies on recommended configs out of the box for simplicity, but you can
|
||||
* and should modify this configuration to best suit your team's needs.
|
||||
*/
|
||||
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
root: true,
|
||||
parserOptions: {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
commonjs: true,
|
||||
es6: true,
|
||||
},
|
||||
ignorePatterns: ["!**/.server", "!**/.client"],
|
||||
|
||||
// Base config
|
||||
extends: ["eslint:recommended"],
|
||||
|
||||
overrides: [
|
||||
// React
|
||||
{
|
||||
files: ["**/*.{js,jsx,ts,tsx}"],
|
||||
plugins: ["react", "jsx-a11y"],
|
||||
extends: [
|
||||
"plugin:react/recommended",
|
||||
"plugin:react/jsx-runtime",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:jsx-a11y/recommended",
|
||||
],
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
formComponents: ["Form"],
|
||||
linkComponents: [
|
||||
{ name: "Link", linkAttribute: "to" },
|
||||
{ name: "NavLink", linkAttribute: "to" },
|
||||
],
|
||||
"import/resolver": {
|
||||
typescript: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Typescript
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
plugins: ["@typescript-eslint", "import"],
|
||||
parser: "@typescript-eslint/parser",
|
||||
settings: {
|
||||
"import/internal-regex": "^~/",
|
||||
"import/resolver": {
|
||||
node: {
|
||||
extensions: [".ts", ".tsx"],
|
||||
},
|
||||
typescript: {
|
||||
alwaysTryTypes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
extends: [
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:import/recommended",
|
||||
"plugin:import/typescript",
|
||||
],
|
||||
},
|
||||
|
||||
// Node
|
||||
{
|
||||
files: [".eslintrc.cjs"],
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
@ -1,25 +0,0 @@
|
||||
/**
|
||||
* File path: types/extensionType.ts
|
||||
*
|
||||
* 该文件定义了扩展类型接口 ExtensionType,包含可选的操作、组件和文本生成函数。
|
||||
*
|
||||
* 接口属性说明:
|
||||
* - action: 可选的操作函数,接受任意参数并返回 void。
|
||||
* - component: 可选的组件函数,接受任意参数并返回一个 React 组件。
|
||||
* - text: 可选的文本生成函数,接受任意参数并返回一个字符串。
|
||||
*/
|
||||
|
||||
|
||||
export class ExtensionProps {
|
||||
|
||||
/** 可选的操作函数,接受任意参数并返回 void */
|
||||
action?: (...args: any[]) => void;
|
||||
|
||||
/** 可选的组件函数,接受任意参数并返回一个 React 组件 */
|
||||
component?: (...args: any[]) => React.FC;
|
||||
|
||||
/** 可选的文本生成函数,接受任意参数并返回一个字符串 */
|
||||
text?: (...args: any[]) => string;
|
||||
}
|
||||
|
||||
|
@ -1,34 +0,0 @@
|
||||
// File path: types/templateType.ts
|
||||
/**
|
||||
* 插件配置接口
|
||||
*
|
||||
* 该接口定义了模板的基本配置,包括依赖项、钩子和页面渲染函数。
|
||||
*/
|
||||
import React from "react";
|
||||
import { ExtensionProps } from "types/extensionRequirement";
|
||||
|
||||
export interface TemplateConfig {
|
||||
/**
|
||||
* 依赖项配置
|
||||
*
|
||||
* 记录每个依赖字段的名称、描述和是否必填。
|
||||
*/
|
||||
dependencies: Record<string, {
|
||||
name: string; // 依赖字段的名称
|
||||
description?: string; // 依赖字段的描述信息
|
||||
required?: boolean; // 依赖字段是否必填
|
||||
}>;
|
||||
|
||||
|
||||
extensions?: Record<string, {
|
||||
description?: string;
|
||||
extension: ExtensionProps;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* 页面渲染函数
|
||||
*
|
||||
* 接受参数并返回一个 React 组件。
|
||||
*/
|
||||
page(params: Map<string, string>): React.FC;
|
||||
}
|
@ -1,18 +0,0 @@
|
||||
/**
|
||||
* By default, Remix will handle hydrating your app on the client for you.
|
||||
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
|
||||
* For more information, see https://remix.run/file-conventions/entry.client
|
||||
*/
|
||||
|
||||
import { RemixBrowser } from "@remix-run/react";
|
||||
import { startTransition, StrictMode } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<RemixBrowser />
|
||||
</StrictMode>
|
||||
);
|
||||
});
|
@ -1,140 +0,0 @@
|
||||
/**
|
||||
* By default, Remix will handle generating the HTTP Response for you.
|
||||
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
|
||||
* For more information, see https://remix.run/file-conventions/entry.server
|
||||
*/
|
||||
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
import type { AppLoadContext, EntryContext } from "@remix-run/node";
|
||||
import { createReadableStreamFromReadable } from "@remix-run/node";
|
||||
import { RemixServer } from "@remix-run/react";
|
||||
import { isbot } from "isbot";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
|
||||
const ABORT_DELAY = 5_000;
|
||||
|
||||
export default function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext,
|
||||
// This is ignored so we can keep it in the template for visibility. Feel
|
||||
// free to delete this parameter in your app if you're not using it!
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
loadContext: AppLoadContext
|
||||
) {
|
||||
return isbot(request.headers.get("user-agent") || "")
|
||||
? handleBotRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
remixContext
|
||||
)
|
||||
: handleBrowserRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
remixContext
|
||||
);
|
||||
}
|
||||
|
||||
function handleBotRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<RemixServer
|
||||
context={remixContext}
|
||||
url={request.url}
|
||||
abortDelay={ABORT_DELAY}
|
||||
/>,
|
||||
{
|
||||
onAllReady() {
|
||||
shellRendered = true;
|
||||
const body = new PassThrough();
|
||||
const stream = createReadableStreamFromReadable(body);
|
||||
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
})
|
||||
);
|
||||
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500;
|
||||
// Log streaming rendering errors from inside the shell. Don't log
|
||||
// errors encountered during initial shell rendering since they'll
|
||||
// reject and get logged in handleDocumentRequest.
|
||||
if (shellRendered) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setTimeout(abort, ABORT_DELAY);
|
||||
});
|
||||
}
|
||||
|
||||
function handleBrowserRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<RemixServer
|
||||
context={remixContext}
|
||||
url={request.url}
|
||||
abortDelay={ABORT_DELAY}
|
||||
/>,
|
||||
{
|
||||
onShellReady() {
|
||||
shellRendered = true;
|
||||
const body = new PassThrough();
|
||||
const stream = createReadableStreamFromReadable(body);
|
||||
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
})
|
||||
);
|
||||
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500;
|
||||
// Log streaming rendering errors from inside the shell. Don't log
|
||||
// errors encountered during initial shell rendering since they'll
|
||||
// reject and get logged in handleDocumentRequest.
|
||||
if (shellRendered) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
setTimeout(abort, ABORT_DELAY);
|
||||
});
|
||||
}
|
20
frontend/app/env.d.ts
vendored
20
frontend/app/env.d.ts
vendored
@ -1,20 +0,0 @@
|
||||
// File path: app/end.d.ts
|
||||
|
||||
/**
|
||||
* 配置
|
||||
*/
|
||||
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_API: string; // 用于访问API的基础URL
|
||||
readonly VITE_THEME_PATH: string; // 存储主题文件的目录路径
|
||||
readonly VITE_CONTENT_PATH: string; //mark文章存储的位置
|
||||
readonly VITE_CONTENT_STATIC_PATH: string; //导出文章静态存储的位置
|
||||
readonly VITE_PLUGINS_PATH: string; // 存储插件文件的目录路径
|
||||
readonly VITE_ASSETS_PATH: string; // 存储静态资源的目录路径
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
@ -1,36 +0,0 @@
|
||||
import { Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
|
||||
import type { LoaderFunction } from "@remix-run/node";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import { createContext, useContext, ReactNode } from 'react';
|
||||
import { ServiceProvider } from "hooks/servicesProvider";
|
||||
import "./tailwind.css";
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1"
|
||||
/>
|
||||
<Meta />
|
||||
</head>
|
||||
<body>
|
||||
<ServiceProvider>
|
||||
{children}
|
||||
</ServiceProvider>
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Layout>
|
||||
<Outlet />
|
||||
</Layout>
|
||||
);
|
||||
}
|
@ -1,138 +0,0 @@
|
||||
import type { MetaFunction } from "@remix-run/node";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: "New Remix App" },
|
||||
{ name: "description", content: "Welcome to Remix!" },
|
||||
];
|
||||
};
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-16">
|
||||
<header className="flex flex-col items-center gap-9">
|
||||
<h1 className="leading text-2xl font-bold text-gray-800 dark:text-gray-100">
|
||||
Welcome to <span className="sr-only">Remix</span>
|
||||
</h1>
|
||||
<div className="h-[144px] w-[434px]">
|
||||
<img
|
||||
src="/logo-light.png"
|
||||
alt="Remix"
|
||||
className="block w-full dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src="/logo-dark.png"
|
||||
alt="Remix"
|
||||
className="hidden w-full dark:block"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<nav className="flex flex-col items-center justify-center gap-4 rounded-3xl border border-gray-200 p-6 dark:border-gray-700">
|
||||
<p className="leading-6 text-gray-700 dark:text-gray-200">
|
||||
What's next?
|
||||
</p>
|
||||
<ul>
|
||||
{resources.map(({ href, text, icon }) => (
|
||||
<li key={href}>
|
||||
<a
|
||||
className="group flex items-center gap-3 self-stretch p-3 leading-normal text-blue-700 hover:underline dark:text-blue-500"
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{icon}
|
||||
{text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const resources = [
|
||||
{
|
||||
href: "https://remix.run/start/quickstart",
|
||||
text: "Quick Start (5 min)",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
|
||||
>
|
||||
<path
|
||||
d="M8.51851 12.0741L7.92592 18L15.6296 9.7037L11.4815 7.33333L12.0741 2L4.37036 10.2963L8.51851 12.0741Z"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "https://remix.run/start/tutorial",
|
||||
text: "Tutorial (30 min)",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
|
||||
>
|
||||
<path
|
||||
d="M4.561 12.749L3.15503 14.1549M3.00811 8.99944H1.01978M3.15503 3.84489L4.561 5.2508M8.3107 1.70923L8.3107 3.69749M13.4655 3.84489L12.0595 5.2508M18.1868 17.0974L16.635 18.6491C16.4636 18.8205 16.1858 18.8205 16.0144 18.6491L13.568 16.2028C13.383 16.0178 13.0784 16.0347 12.915 16.239L11.2697 18.2956C11.047 18.5739 10.6029 18.4847 10.505 18.142L7.85215 8.85711C7.75756 8.52603 8.06365 8.21994 8.39472 8.31453L17.6796 10.9673C18.0223 11.0653 18.1115 11.5094 17.8332 11.7321L15.7766 13.3773C15.5723 13.5408 15.5554 13.8454 15.7404 14.0304L18.1868 16.4767C18.3582 16.6481 18.3582 16.926 18.1868 17.0974Z"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "https://remix.run/docs",
|
||||
text: "Remix Docs",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
|
||||
>
|
||||
<path
|
||||
d="M9.99981 10.0751V9.99992M17.4688 17.4688C15.889 19.0485 11.2645 16.9853 7.13958 12.8604C3.01467 8.73546 0.951405 4.11091 2.53116 2.53116C4.11091 0.951405 8.73546 3.01467 12.8604 7.13958C16.9853 11.2645 19.0485 15.889 17.4688 17.4688ZM2.53132 17.4688C0.951566 15.8891 3.01483 11.2645 7.13974 7.13963C11.2647 3.01471 15.8892 0.951453 17.469 2.53121C19.0487 4.11096 16.9854 8.73551 12.8605 12.8604C8.73562 16.9853 4.11107 19.0486 2.53132 17.4688Z"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "https://rmx.as/discord",
|
||||
text: "Join Discord",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="20"
|
||||
viewBox="0 0 24 20"
|
||||
fill="none"
|
||||
className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
|
||||
>
|
||||
<path
|
||||
d="M15.0686 1.25995L14.5477 1.17423L14.2913 1.63578C14.1754 1.84439 14.0545 2.08275 13.9422 2.31963C12.6461 2.16488 11.3406 2.16505 10.0445 2.32014C9.92822 2.08178 9.80478 1.84975 9.67412 1.62413L9.41449 1.17584L8.90333 1.25995C7.33547 1.51794 5.80717 1.99419 4.37748 2.66939L4.19 2.75793L4.07461 2.93019C1.23864 7.16437 0.46302 11.3053 0.838165 15.3924L0.868838 15.7266L1.13844 15.9264C2.81818 17.1714 4.68053 18.1233 6.68582 18.719L7.18892 18.8684L7.50166 18.4469C7.96179 17.8268 8.36504 17.1824 8.709 16.4944L8.71099 16.4904C10.8645 17.0471 13.128 17.0485 15.2821 16.4947C15.6261 17.1826 16.0293 17.8269 16.4892 18.4469L16.805 18.8725L17.3116 18.717C19.3056 18.105 21.1876 17.1751 22.8559 15.9238L23.1224 15.724L23.1528 15.3923C23.5873 10.6524 22.3579 6.53306 19.8947 2.90714L19.7759 2.73227L19.5833 2.64518C18.1437 1.99439 16.6386 1.51826 15.0686 1.25995ZM16.6074 10.7755L16.6074 10.7756C16.5934 11.6409 16.0212 12.1444 15.4783 12.1444C14.9297 12.1444 14.3493 11.6173 14.3493 10.7877C14.3493 9.94885 14.9378 9.41192 15.4783 9.41192C16.0471 9.41192 16.6209 9.93851 16.6074 10.7755ZM8.49373 12.1444C7.94513 12.1444 7.36471 11.6173 7.36471 10.7877C7.36471 9.94885 7.95323 9.41192 8.49373 9.41192C9.06038 9.41192 9.63892 9.93712 9.6417 10.7815C9.62517 11.6239 9.05462 12.1444 8.49373 12.1444Z"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
@ -1,12 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply bg-white dark:bg-gray-950;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
29
frontend/contracts/capabilityContract.ts
Normal file
29
frontend/contracts/capabilityContract.ts
Normal file
@ -0,0 +1,29 @@
|
||||
// src/contracts/CapabilityContract.ts
|
||||
/**
|
||||
* 能力契约接口
|
||||
*/
|
||||
export interface CapabilityProps {
|
||||
// 能力名称
|
||||
name: string;
|
||||
// 能力描述
|
||||
description?: string;
|
||||
// 能力版本
|
||||
version: string;
|
||||
// 能力参数定义
|
||||
parameters?: {
|
||||
type: 'object';
|
||||
properties: Record<string, {
|
||||
type: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
}>;
|
||||
};
|
||||
// 能力返回值定义
|
||||
returns?: {
|
||||
type: string;
|
||||
description?: string;
|
||||
};
|
||||
// 能力执行函数
|
||||
execute: (...args: any[]) => Promise<any>;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
// File path: /d:/data/echoes/frontend/Requirements/generalRequirement.ts
|
||||
// File path: contracts\generalContract.ts
|
||||
/**
|
||||
* 表示可以序列化的类型。
|
||||
* 可以是以下类型之一:
|
@ -1,4 +1,4 @@
|
||||
// File path: ../Requirements/pluginRequirement.ts
|
||||
// File path: contracts\pluginContract.ts
|
||||
|
||||
/**
|
||||
* 插件配置接口
|
||||
@ -6,10 +6,9 @@
|
||||
* 该接口定义了插件的基本配置,包括插件的名称、版本、描述、作者等信息。
|
||||
* 还包括插件的生命周期钩子和依赖项的配置。
|
||||
*/
|
||||
import { SerializeType } from "./generalRequirement";
|
||||
import { ExtensionProps } from "types/extensionRequirement";
|
||||
import { ExtensionService } from "service/extensionService";
|
||||
import { useExtension } from "hooks/servicesProvider";
|
||||
import { SerializeType } from "contracts/generalContract";
|
||||
import { CapabilityProps } from "contracts/capabilityContract";
|
||||
|
||||
|
||||
export interface PluginConfig {
|
||||
name: string; // 插件名称
|
||||
@ -21,16 +20,16 @@ export interface PluginConfig {
|
||||
icon?: string; // 插件图标URL(可选)
|
||||
managePath?: string; // 插件管理页面路径(可选)
|
||||
configuration?: PluginConfiguration; // 插件配置
|
||||
hooks?: {
|
||||
onInstall?: (context: any) => {}; // 安装时调用的钩子(可选)
|
||||
onUninstall?: (context: any) => {}; // 卸载时调用的钩子(可选)
|
||||
onEnable?: (context: any) => {}; // 启用时调用的钩子(可选)
|
||||
onDisable?: (context: any) => {}; // 禁用时调用的钩子(可选)
|
||||
};
|
||||
/** 能力 */
|
||||
capabilities?: Set<CapabilityProps>;
|
||||
routs: Set<{
|
||||
description?: string; // 路由描述(可选)
|
||||
path: string; // 路由路径
|
||||
}>;
|
||||
// 模块初始化函数
|
||||
initialize?: () => Promise<void>;
|
||||
// 模块销毁函数
|
||||
destroy?: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
@ -46,14 +45,3 @@ export interface PluginConfiguration {
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 插件属性接口
|
||||
*
|
||||
* 该接口定义了插件的属性和行为。
|
||||
*/
|
||||
export class usePluginProps extends ExtensionProps {
|
||||
|
||||
}
|
||||
|
||||
|
35
frontend/contracts/templateContract.ts
Normal file
35
frontend/contracts/templateContract.ts
Normal file
@ -0,0 +1,35 @@
|
||||
export interface TemplateContract {
|
||||
// 模板名称
|
||||
name: string;
|
||||
// 模板描述
|
||||
description?: string;
|
||||
// 模板配置
|
||||
config: {
|
||||
// 模板布局
|
||||
layout?: string;
|
||||
// 模板样式
|
||||
styles?: string[];
|
||||
// 模板脚本
|
||||
scripts?: string[];
|
||||
// 模板区域定义
|
||||
zones?: Record<string, {
|
||||
name: string;
|
||||
description?: string;
|
||||
allowedComponents?: string[];
|
||||
}>;
|
||||
};
|
||||
// 模板数据契约
|
||||
dataContract?: {
|
||||
// 必需的数据字段
|
||||
required: string[];
|
||||
// 可选的数据字段
|
||||
optional?: string[];
|
||||
// 数据验证规则
|
||||
validation?: Record<string, {
|
||||
type: string;
|
||||
rules: any[];
|
||||
}>;
|
||||
};
|
||||
// 渲染函数
|
||||
render: (props: any) => React.ReactNode;
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
// File path: types/themeType.ts
|
||||
// File path: contracts\themeTypeContract.ts
|
||||
/**
|
||||
* 主题配置和模板接口定义文件
|
||||
* 该文件包含主题配置接口和主题模板接口的定义,用于主题管理和渲染。
|
||||
@ -8,7 +8,8 @@
|
||||
* 主题配置接口
|
||||
* 定义主题的基本信息、模板、全局配置、依赖、钩子和路由。
|
||||
*/
|
||||
import { SerializeType } from "./generalRequirement";
|
||||
import { CapabilityProps } from "contracts/capabilityContract";
|
||||
import { SerializeType } from "contracts/generalContract";
|
||||
export interface ThemeConfig {
|
||||
name: string; // 主题的名称
|
||||
displayName: string; // 主题的显示名称
|
||||
@ -33,11 +34,9 @@ export interface ThemeConfig {
|
||||
plugins?: string[]; // 主题所依赖的插件列表
|
||||
assets?: string[]; // 主题所依赖的资源列表
|
||||
};
|
||||
/** 钩子 */
|
||||
hooks?: {
|
||||
onActivate?: () => {}; // 主题激活时执行的钩子
|
||||
onDeactivate?: () => {}; // 主题停用时执行的钩子
|
||||
};
|
||||
/** 能力 */
|
||||
capabilities?: Set<CapabilityProps>;
|
||||
|
||||
/** 路由 */
|
||||
routes: {
|
||||
index: string; // 首页使用的模板
|
@ -1,5 +1,5 @@
|
||||
import { ExtensionService } from 'service/extensionService';
|
||||
import { ThemeService } from 'service/themeService';
|
||||
import { ExtensionService } from 'services/extensionService';
|
||||
import { ThemeService } from 'services/themeService';
|
||||
import { createServiceContext } from './createServiceContext';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
|
@ -1,43 +1,23 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "remix vite:build",
|
||||
"dev": "remix vite:dev",
|
||||
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
|
||||
"start": "remix-serve ./build/server/index.js",
|
||||
"typecheck": "tsc"
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"@remix-run/node": "^2.14.0",
|
||||
"@remix-run/react": "^2.14.0",
|
||||
"@remix-run/serve": "^2.14.0",
|
||||
"isbot": "^4.1.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@remix-run/dev": "^2.14.0",
|
||||
"@types/react": "^18.2.20",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
||||
"@typescript-eslint/parser": "^6.7.4",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.38.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.1",
|
||||
"eslint-plugin-import": "^2.28.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"typescript": "^5.1.6",
|
||||
"vite": "^5.1.0",
|
||||
"vite-tsconfig-paths": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
Binary file not shown.
Before Width: | Height: | Size: 17 KiB |
Binary file not shown.
Before Width: | Height: | Size: 78 KiB |
Binary file not shown.
Before Width: | Height: | Size: 5.8 KiB |
@ -1,34 +0,0 @@
|
||||
// File path: /service/pluginService.ts
|
||||
|
||||
/**
|
||||
* 插件服务类,采用单例模式管理插件组件。
|
||||
* 提供获取插件实例的方法,并缓存插件组件信息。
|
||||
*/
|
||||
|
||||
import { PluginConfiguration } from "types/pluginRequirement";
|
||||
export class PluginService {
|
||||
/** 单例实例 */
|
||||
private static pluginInstance: PluginService | null = null;
|
||||
/** 插件组件缓存 */
|
||||
private pluginComponents: Map<string, Set<{
|
||||
name: string, // 插件名称
|
||||
configuration?: PluginConfiguration, // 插件配置
|
||||
managePath?: string, // 管理路径
|
||||
}>> = new Map();
|
||||
|
||||
/**
|
||||
* 私有构造函数,防止外部实例化。
|
||||
*/
|
||||
private constructor() {};
|
||||
|
||||
/**
|
||||
* 获取插件服务的单例实例。
|
||||
* @returns {PluginService} 插件服务实例
|
||||
*/
|
||||
public static getInstance(): PluginService {
|
||||
if (!this.pluginInstance) {
|
||||
this.pluginInstance = new PluginService();
|
||||
}
|
||||
return this.pluginInstance;
|
||||
}
|
||||
}
|
64
frontend/services/pluginService.ts
Normal file
64
frontend/services/pluginService.ts
Normal file
@ -0,0 +1,64 @@
|
||||
// src/core/PluginManager.ts
|
||||
import { PluginConfiguration } from 'types/pluginRequirement';
|
||||
import { Contracts } from 'contracts/capabilityContract';
|
||||
|
||||
export class PluginManager {
|
||||
private plugins: Map<string, PluginProps> = new Map();
|
||||
private configurations: Map<string, PluginConfiguration> = new Map();
|
||||
private extensions: Map<string, ExtensionProps> = 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<PluginConfiguration | 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 [];
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
// service/theme/themeService.ts
|
||||
import type { ThemeConfig } from 'types/themeTypeRequirement';
|
||||
import { ThemeConfig } from "contracts/themeContract"
|
||||
|
||||
export class ThemeService {
|
||||
private static themeInstance: ThemeService; // 单例实例
|
0
frontend/src/a.ts
Normal file
0
frontend/src/a.ts
Normal file
@ -1,22 +0,0 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
content: ["./app/**/{**,.client,.server}/**/*.{js,jsx,ts,tsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: [
|
||||
"Inter",
|
||||
"ui-sans-serif",
|
||||
"system-ui",
|
||||
"sans-serif",
|
||||
"Apple Color Emoji",
|
||||
"Segoe UI Emoji",
|
||||
"Segoe UI Symbol",
|
||||
"Noto Color Emoji",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
} satisfies Config;
|
@ -1,35 +1,19 @@
|
||||
{
|
||||
"include": [
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"**/.server/**/*.ts",
|
||||
"**/.server/**/*.tsx",
|
||||
"**/.client/**/*.ts",
|
||||
"**/.client/**/*.tsx"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"types": ["@remix-run/node", "vite/client"],
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"target": "ES2022",
|
||||
"strict": true,
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"theme/*":["./theme/*"],
|
||||
"types/*":["Requirements/*"],
|
||||
"service/*":["./service/*"],
|
||||
"hooks/*":["./hooks/*"],
|
||||
},
|
||||
|
||||
// Vite takes care of building everything, not tsc.
|
||||
"noEmit": true
|
||||
}
|
||||
"target": "es6", // 编译到 ES5
|
||||
"lib": ["dom", "dom.iterable", "esnext"], // 指定要编译的库
|
||||
"allowJs": true, // 允许 JavaScript 文件
|
||||
"skipLibCheck": true, // 跳过库检查
|
||||
"esModuleInterop": true, // 支持 CommonJS 模块
|
||||
"allowSyntheticDefaultImports": true, // 允许导入默认值
|
||||
"strict": true, // 启用严格模式
|
||||
"forceConsistentCasingInFileNames": true, // 强制文件名大小写一致
|
||||
"module": "esnext", // 使用 ES 模块
|
||||
"moduleResolution": "node", // 模块解析策略
|
||||
"resolveJsonModule": true, // 允许导入 JSON 模块
|
||||
"isolatedModules": true, // 每个文件单独编译
|
||||
"noEmit": true // 不输出任何文件,只检查类型
|
||||
},
|
||||
"include": ["src/**/*"], // 包含 src 文件夹中的所有 TypeScript 文件
|
||||
"exclude": ["node_modules"] // 排除 node_modules 文件夹
|
||||
}
|
||||
|
@ -1,24 +0,0 @@
|
||||
import { vitePlugin as remix } from "@remix-run/dev";
|
||||
import { defineConfig } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
declare module "@remix-run/node" {
|
||||
interface Future {
|
||||
v3_singleFetch: true;
|
||||
}
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
remix({
|
||||
future: {
|
||||
v3_fetcherPersist: true,
|
||||
v3_relativeSplatPath: true,
|
||||
v3_throwAbortReason: true,
|
||||
v3_singleFetch: true,
|
||||
v3_lazyRouteDiscovery: true,
|
||||
},
|
||||
}),
|
||||
tsconfigPaths(),
|
||||
],
|
||||
});
|
Loading…
Reference in New Issue
Block a user