更新cxm,ccr以及其他未更新的
This commit is contained in:
parent
014aec6757
commit
639a0fd504
9
rust/blog_os/.cargo/config.toml
Normal file
9
rust/blog_os/.cargo/config.toml
Normal file
@ -0,0 +1,9 @@
|
||||
[unstable]
|
||||
build-std-features = ["compiler-builtins-mem"]
|
||||
build-std = ["core", "compiler_builtins"]
|
||||
|
||||
[build]
|
||||
target = "x86_64-blog_os.json"
|
||||
|
||||
[target.'cfg(target_os = "none")']
|
||||
runner = "bootimage runner"
|
12
rust/blog_os/Cargo.toml
Normal file
12
rust/blog_os/Cargo.toml
Normal file
@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "blog_os"
|
||||
version = "0.1.0"
|
||||
edition = "2018"
|
||||
|
||||
# 使用 `cargo build` 编译时需要的配置
|
||||
[profile.dev]
|
||||
panic = "abort" # 禁用panic时栈展开
|
||||
|
||||
# 使用 `cargo build --release` 编译时需要的配置
|
||||
[profile.release]
|
||||
panic = "abort" # 禁用 panic 时栈展开
|
15
rust/blog_os/src/main.rs
Normal file
15
rust/blog_os/src/main.rs
Normal file
@ -0,0 +1,15 @@
|
||||
// src/main.rs
|
||||
#![no_std] // 不链接Rust标准库
|
||||
#![no_main] // 禁用所有Rust层级的入口点
|
||||
use core::panic::PanicInfo;
|
||||
/// 这个函数将在panic时被调用
|
||||
#[panic_handler]
|
||||
fn panic(_info: &PanicInfo) -> ! {
|
||||
loop {}
|
||||
}
|
||||
#[no_mangle] // 不重整函数名
|
||||
pub extern "C" fn _start() -> ! {
|
||||
// 因为编译器会寻找一个名为`_start`的函数,所以这个函数就是入口点
|
||||
// 默认命名为`_start`
|
||||
loop {}
|
||||
}
|
15
rust/blog_os/x86_64-blog_os.json
Normal file
15
rust/blog_os/x86_64-blog_os.json
Normal file
@ -0,0 +1,15 @@
|
||||
{
|
||||
"llvm-target": "x86_64-unknown-none",
|
||||
"data-layout": "e-m:e-i64:64-f80:128-n8:16:32:64-S128",
|
||||
"arch": "x86_64",
|
||||
"target-endian": "little",
|
||||
"target-pointer-width": "64",
|
||||
"target-c-int-width": "32",
|
||||
"os": "none",
|
||||
"executables": true,
|
||||
"linker-flavor": "ld.lld",
|
||||
"linker": "rust-lld",
|
||||
"panic-strategy": "abort",
|
||||
"disable-redzone": true,
|
||||
"features": "-mmx,-sse,+soft-float"
|
||||
}
|
@ -210,8 +210,8 @@
|
||||
fill: transparent;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: var(--path-length);
|
||||
stroke-dashoffset: var(--path-length);
|
||||
stroke-dasharray: var(--path-length) var(--path-length);
|
||||
animation: logo-anim 15s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||
transform-origin: center;
|
||||
stroke-linecap: round;
|
||||
|
@ -56,7 +56,7 @@ const App: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleDownloadCss = () => {
|
||||
const animationCss = `
|
||||
const animationCss = `/* 注意:每个路径都需要在 SVG 中设置 --path-length 变量 */
|
||||
.animated-text {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
@ -66,8 +66,9 @@ const App: React.FC = () => {
|
||||
fill: transparent;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
/* 使用每个路径自己的长度 */
|
||||
stroke-dasharray: var(--path-length);
|
||||
stroke-dashoffset: var(--path-length);
|
||||
stroke-dasharray: var(--path-length) var(--path-length);
|
||||
animation: logo-anim 15s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||
transform-origin: center;
|
||||
stroke-linecap: round;
|
||||
@ -88,14 +89,12 @@ const App: React.FC = () => {
|
||||
stroke-dasharray: var(--path-length) var(--path-length);
|
||||
}
|
||||
|
||||
/* 慢速绘画过程 */
|
||||
50% {
|
||||
stroke-dashoffset: 0;
|
||||
stroke-dasharray: var(--path-length) var(--path-length);
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
/* 慢慢填充效果 */
|
||||
60% {
|
||||
stroke-dashoffset: 0;
|
||||
stroke-dasharray: var(--path-length) var(--path-length);
|
||||
@ -103,7 +102,6 @@ const App: React.FC = () => {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 保持填充状态 */
|
||||
75% {
|
||||
stroke-dashoffset: 0;
|
||||
stroke-dasharray: var(--path-length) var(--path-length);
|
||||
@ -111,7 +109,6 @@ const App: React.FC = () => {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 变回线条 */
|
||||
85% {
|
||||
stroke-dashoffset: 0;
|
||||
stroke-dasharray: var(--path-length) var(--path-length);
|
||||
@ -119,7 +116,6 @@ const App: React.FC = () => {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 线条消失 */
|
||||
95% {
|
||||
stroke-dashoffset: var(--path-length);
|
||||
stroke-dasharray: var(--path-length) var(--path-length);
|
||||
@ -133,6 +129,13 @@ const App: React.FC = () => {
|
||||
fill: transparent;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 确保在暗色模式下的颜色正确 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.animated-text path {
|
||||
stroke: currentColor;
|
||||
}
|
||||
}`;
|
||||
|
||||
const blob = new Blob([animationCss], { type: 'text/css' });
|
||||
|
@ -210,7 +210,8 @@ impl FontHandler {
|
||||
|
||||
paths.push(Path::new()
|
||||
.set("d", builder.path_data)
|
||||
.set("fill", "currentColor"));
|
||||
.set("fill", "currentColor")
|
||||
.set("style", format!("--path-length: {};", builder.path_length)));
|
||||
}
|
||||
}
|
||||
|
||||
|
1
rust/svg/src/vite-env.d.ts
vendored
Normal file
1
rust/svg/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
6
rust/temp/Cargo.toml
Normal file
6
rust/temp/Cargo.toml
Normal file
@ -0,0 +1,6 @@
|
||||
[package]
|
||||
name = "temp"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
@ -1,84 +0,0 @@
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tokio::io::{AsyncReadExt};
|
||||
use tokio::{net, runtime};
|
||||
use crate::request::{Method,Request};
|
||||
use crate::route::{Route};
|
||||
use crate::respond::{Respond};
|
||||
|
||||
pub struct Server {
|
||||
listener_addr: &'static str,
|
||||
listener_port: i32,
|
||||
status: bool,
|
||||
routes:RwLock<Vec<Route>>,
|
||||
}
|
||||
impl Server {
|
||||
pub fn new(listener_port:i32) -> Arc<Self> {
|
||||
if listener_port < 1 || listener_port > 65535 {
|
||||
panic!("listener port must be between 1 and 65535");
|
||||
}
|
||||
Arc::new(
|
||||
Self {
|
||||
listener_addr: "127.0.0.1",
|
||||
listener_port,
|
||||
status: false,
|
||||
routes: RwLock::new(Vec::new()),
|
||||
}
|
||||
)
|
||||
}
|
||||
pub fn start(self: Arc<Self>) {
|
||||
let address=format!("{}:{}", self.listener_addr, self.listener_port);
|
||||
let rt = runtime::Runtime::new().unwrap();
|
||||
println!("Listening on {}", address);
|
||||
let listener =rt.block_on(async { net::TcpListener::bind(address).await.expect("Failed to bind listener") });
|
||||
rt.block_on(self.receive(listener));
|
||||
}
|
||||
}
|
||||
|
||||
impl Server {
|
||||
async fn receive(self:Arc<Self>,listener:net::TcpListener) {
|
||||
loop {
|
||||
let (mut socket, _) = listener.accept().await.expect("Failed to accept connection");
|
||||
let server = self.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut buf = [0; 1024];
|
||||
if let Err(e) = socket.read(&mut buf).await {
|
||||
println!("Failed to read from socket: {}", e);
|
||||
return;
|
||||
}
|
||||
let content = match String::from_utf8(buf.to_vec()) {
|
||||
Ok(s) => s,
|
||||
Err(e1) => {
|
||||
println!("Failed to convert buffer to string: {}", e1);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let request = match Request::build(content) {
|
||||
Some(req) => req,
|
||||
None => {
|
||||
println!("Request parsing failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let respond=Respond::build(socket);
|
||||
for route in server.routes.read().expect("Unable to read route").iter() {
|
||||
if request.method() == &route.method {
|
||||
(route.function)(request, respond);
|
||||
return;
|
||||
}else {
|
||||
println!("Request parsing failed");
|
||||
}
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn route(self:&Arc<Self>, path: &'static str, method: &'static str, function: impl Fn(Request, Arc<Respond>) + Send + Sync + 'static) {
|
||||
let test_method=method.to_uppercase().as_str().into();
|
||||
if test_method == Method::Uninitialized {panic!("该方法未被定义")}
|
||||
let route = Route { path, method:test_method, function: Box::new(function) };
|
||||
self.routes.write().expect("Route write failed").push(route);
|
||||
}
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
use core::Server;
|
||||
mod core;
|
||||
mod request;
|
||||
mod respond;
|
||||
mod route;
|
||||
|
||||
fn main() {
|
||||
let server = Server::new( 8000);
|
||||
server.route("/","get",|request, respond| {
|
||||
respond.send();
|
||||
println!("{}",request.method())
|
||||
});
|
||||
server.start();
|
||||
}
|
@ -1,102 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Display;
|
||||
pub enum Method {
|
||||
Get,
|
||||
Post,
|
||||
Uninitialized
|
||||
}
|
||||
|
||||
impl From<&str> for Method {
|
||||
fn from(s: &str) -> Method {
|
||||
match s{
|
||||
"GET" => Method::Get,
|
||||
"POST" => Method::Post,
|
||||
_ => Method::Uninitialized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Method {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
let method = match self {
|
||||
Method::Get => {"GET"}
|
||||
Method::Post => {"POST"}
|
||||
Method::Uninitialized => {"Uninitialized"}
|
||||
};
|
||||
write!(f, "{}", method)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for Method {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
match (self, other) {
|
||||
(Method::Get, Method::Get) => true,
|
||||
(Method::Post, Method::Post) => true,
|
||||
(Method::Uninitialized, Method::Uninitialized) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Version {
|
||||
V1_1,
|
||||
Uninitialized
|
||||
}
|
||||
|
||||
impl From<&str> for Version {
|
||||
fn from(s: &str) -> Version {
|
||||
match s {
|
||||
"HTTP/1.1" => Version::V1_1,
|
||||
_ => Version::Uninitialized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Resource{
|
||||
Path(String),
|
||||
}
|
||||
|
||||
|
||||
pub struct Request{
|
||||
method: Method,
|
||||
version: Version,
|
||||
resource: Resource,
|
||||
headers: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl Request{
|
||||
pub fn build(content:String) -> Option<Request>{
|
||||
let mut content =content.lines();
|
||||
|
||||
let request_line =content.next().unwrap_or("");
|
||||
if request_line.is_empty(){return None}
|
||||
let request_line:Vec<_> = request_line.split_whitespace().collect();
|
||||
if request_line.len()<3 {return None}
|
||||
let method=request_line[0];
|
||||
let resource=request_line[1];
|
||||
let version=request_line[2];
|
||||
if method.is_empty()||resource.is_empty()||version.is_empty() {return None}
|
||||
|
||||
let mut headers =HashMap::<String, String>::new();
|
||||
for i in content {
|
||||
if i.len()==0 { break;}
|
||||
let parts:Vec<&str> = i.split(": ").collect();
|
||||
if parts.len() == 2 {
|
||||
headers.insert(parts[0].to_string(), parts[1].to_string());
|
||||
}
|
||||
};
|
||||
|
||||
Some(Request{
|
||||
method: method.into(),
|
||||
version: version.into(),
|
||||
resource: Resource::Path(resource.to_string()),
|
||||
headers,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Request {
|
||||
pub fn method(&self) -> &Method {
|
||||
&self.method
|
||||
}
|
||||
}
|
@ -1,60 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::task;
|
||||
|
||||
pub struct Respond<'a>{
|
||||
version:&'a str,
|
||||
status_code:&'a str,
|
||||
status_message:&'a str,
|
||||
headers:HashMap<&'a str, &'a str>,
|
||||
body:&'a str,
|
||||
socket:Arc<Mutex<TcpStream>>
|
||||
}
|
||||
|
||||
|
||||
impl Respond<'_> {
|
||||
pub fn build(socket: TcpStream) -> Arc<Respond<'static>> {
|
||||
Arc::new(Respond {
|
||||
version: "HTTP/1.1",
|
||||
status_code: "200",
|
||||
status_message: "OK",
|
||||
headers: HashMap::new(),
|
||||
body: "Hello, world!",
|
||||
socket: Arc::new(Mutex::new(socket))
|
||||
})
|
||||
}
|
||||
fn format_message(self:Arc<Self>) -> String {
|
||||
let mut massage =String::new();
|
||||
let status_line= format!("{} {} {}\r\n", self.status_message,self.version,self.status_message);
|
||||
massage.push_str(&status_line);
|
||||
let mut header =String::new();
|
||||
for (key, value) in self.headers.iter() {
|
||||
header += &format!("{}: {}\r\n", key, value);
|
||||
}
|
||||
massage.push_str("\n\n");
|
||||
massage.push_str(self.body);
|
||||
massage
|
||||
}
|
||||
|
||||
pub fn send(self: Arc<Self>) {
|
||||
let socket = self.socket.clone();
|
||||
let message = self.format_message();
|
||||
|
||||
task::spawn(async move {
|
||||
let mut socket = socket.lock().await;
|
||||
if let Ok(_) = socket.write_all(message.as_ref()).await {
|
||||
if let Err(e) = socket.flush().await {
|
||||
println!("Failed to flush: {}", e);
|
||||
}
|
||||
} else {
|
||||
println!("Failed to send content");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -1,10 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use crate::request::{Method, Request};
|
||||
use crate::respond::{Respond};
|
||||
|
||||
pub(crate) struct Route {
|
||||
pub path: &'static str,
|
||||
pub method: Method,
|
||||
pub function: Box<dyn Fn(Request,Arc<Respond>) + Send + Sync>,
|
||||
}
|
||||
|
@ -32,16 +32,12 @@ const renderLoop = () => {
|
||||
const drawGrid = () => {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = GRID_COLOR;
|
||||
|
||||
// 画垂直线
|
||||
for (let i = 0; i <= width; i++) {
|
||||
ctx.moveTo(i * (CELL_SIZE + 1) + 1, 0);
|
||||
ctx.lineTo(i * (CELL_SIZE + 1) + 1, (CELL_SIZE + 1) * height + 1);
|
||||
}
|
||||
|
||||
// 画水平线
|
||||
for (let j = 0; j <= height; j++) {
|
||||
ctx.moveTo(0, j * (CELL_SIZE + 1) + 1);
|
||||
ctx.lineTo(0, j * (CELL_SIZE + 1) + 1);
|
||||
ctx.lineTo((CELL_SIZE + 1) * width + 1, j * (CELL_SIZE + 1) + 1);
|
||||
}
|
||||
|
||||
|
50
web/chess/README.md
Normal file
50
web/chess/README.md
Normal file
@ -0,0 +1,50 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
|
||||
- Optionally add `...tseslint.configs.stylisticTypeChecked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import react from 'eslint-plugin-react'
|
||||
|
||||
export default tseslint.config({
|
||||
// Set the react version
|
||||
settings: { react: { version: '18.3' } },
|
||||
plugins: {
|
||||
// Add the react plugin
|
||||
react,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended rules
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
},
|
||||
})
|
||||
```
|
28
web/chess/eslint.config.js
Normal file
28
web/chess/eslint.config.js
Normal file
@ -0,0 +1,28 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
13
web/chess/index.html
Normal file
13
web/chess/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
@ -189,6 +189,7 @@ const Game: React.FC = () => {
|
||||
|
||||
useEffect(()=>{
|
||||
drawChess(chessGame);
|
||||
console.log(chessGame?.render())
|
||||
},[])
|
||||
function chessBoardClick(e: React.MouseEvent<HTMLCanvasElement>) {
|
||||
const client=e.currentTarget.getBoundingClientRect();
|
||||
|
25
web/chess/tsconfig.app.json
Normal file
25
web/chess/tsconfig.app.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
7
web/chess/tsconfig.json
Normal file
7
web/chess/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
23
web/chess/tsconfig.node.json
Normal file
23
web/chess/tsconfig.node.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
7
web/chess/vite.config.ts
Normal file
7
web/chess/vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()]
|
||||
});
|
50
web/cxm/README.md
Normal file
50
web/cxm/README.md
Normal file
@ -0,0 +1,50 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
|
||||
- Optionally add `...tseslint.configs.stylisticTypeChecked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import react from 'eslint-plugin-react'
|
||||
|
||||
export default tseslint.config({
|
||||
// Set the react version
|
||||
settings: { react: { version: '18.3' } },
|
||||
plugins: {
|
||||
// Add the react plugin
|
||||
react,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended rules
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
},
|
||||
})
|
||||
```
|
28
web/cxm/eslint.config.js
Normal file
28
web/cxm/eslint.config.js
Normal file
@ -0,0 +1,28 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
13
web/cxm/index.html
Normal file
13
web/cxm/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
35
web/cxm/package.json
Normal file
35
web/cxm/package.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "cxm",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@studio-freight/lenis": "^1.0.33",
|
||||
"gsap": "^3.12.5",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/parser": "^7.26.3",
|
||||
"@babel/types": "^7.26.3",
|
||||
"@eslint/js": "^9.15.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"csstype": "^3.1.3",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.14",
|
||||
"globals": "^15.12.0",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.15.0",
|
||||
"vite": "^6.0.1"
|
||||
}
|
||||
}
|
54
web/cxm/src/App.css
Normal file
54
web/cxm/src/App.css
Normal file
@ -0,0 +1,54 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.App {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
3
web/cxm/src/App.d.ts
vendored
Normal file
3
web/cxm/src/App.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
import './App.css';
|
||||
declare function App(): import("react/jsx-runtime").JSX.Element;
|
||||
export default App;
|
7
web/cxm/src/App.js
Normal file
7
web/cxm/src/App.js
Normal file
@ -0,0 +1,7 @@
|
||||
import { jsx as _jsx } from "react/jsx-runtime";
|
||||
import { Parallax } from './components/Parallax';
|
||||
import './App.css';
|
||||
function App() {
|
||||
return (_jsx("div", { className: "App", children: _jsx(Parallax, {}) }));
|
||||
}
|
||||
export default App;
|
12
web/cxm/src/App.tsx
Normal file
12
web/cxm/src/App.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { Parallax } from './components/Parallax';
|
||||
import './App.css';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="App">
|
||||
<Parallax />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
BIN
web/cxm/src/assets/z.woff2
Normal file
BIN
web/cxm/src/assets/z.woff2
Normal file
Binary file not shown.
40
web/cxm/src/components/ImageModal.css
Normal file
40
web/cxm/src/components/ImageModal.css
Normal file
@ -0,0 +1,40 @@
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
position: absolute;
|
||||
top: -2rem;
|
||||
right: -2rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 2rem;
|
||||
cursor: pointer;
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.modal-image {
|
||||
max-width: 100%;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
}
|
7
web/cxm/src/components/ImageModal.d.ts
vendored
Normal file
7
web/cxm/src/components/ImageModal.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
import './ImageModal.css';
|
||||
interface ImageModalProps {
|
||||
image: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
export declare const ImageModal: ({ image, onClose }: ImageModalProps) => import("react/jsx-runtime").JSX.Element;
|
||||
export {};
|
15
web/cxm/src/components/ImageModal.js
Normal file
15
web/cxm/src/components/ImageModal.js
Normal file
@ -0,0 +1,15 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useEffect } from 'react';
|
||||
import './ImageModal.css';
|
||||
export const ImageModal = ({ image, onClose }) => {
|
||||
useEffect(() => {
|
||||
const handleEsc = (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleEsc);
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
}, [onClose]);
|
||||
return (_jsx("div", { className: "modal-overlay", onClick: onClose, children: _jsxs("div", { className: "modal-content", onClick: e => e.stopPropagation(), children: [_jsx("button", { className: "modal-close", onClick: onClose, children: "\u00D7" }), _jsx("img", { src: image, alt: "", className: "modal-image" })] }) }));
|
||||
};
|
28
web/cxm/src/components/ImageModal.tsx
Normal file
28
web/cxm/src/components/ImageModal.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { useEffect } from 'react';
|
||||
import './ImageModal.css';
|
||||
|
||||
interface ImageModalProps {
|
||||
image: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ImageModal = ({ image, onClose }: ImageModalProps) => {
|
||||
useEffect(() => {
|
||||
const handleEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleEsc);
|
||||
return () => window.removeEventListener('keydown', handleEsc);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
||||
<button className="modal-close" onClick={onClose}>×</button>
|
||||
<img src={image} alt="" className="modal-image" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
530
web/cxm/src/components/Parallax.css
Normal file
530
web/cxm/src/components/Parallax.css
Normal file
@ -0,0 +1,530 @@
|
||||
@font-face {
|
||||
font-family: 'PP Neue Corp Wide';
|
||||
src: url('../assets/z.woff2') format('woff2');
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-neutral-900: #0a0a0a;
|
||||
--color-light: #ffffff;
|
||||
--color-black: #0a0a0a;
|
||||
--container-padding: 2rem;
|
||||
--section-padding: 4rem;
|
||||
--color-dark-rgb: 10, 10, 10;
|
||||
--bg-gradient-start: #1a1a2e;
|
||||
--bg-gradient-end: #0a0a0a;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
background: linear-gradient(to bottom, var(--bg-gradient-start), var(--bg-gradient-end));
|
||||
color: var(--color-light);
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-x: hidden;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.parallax {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.parallax__header {
|
||||
z-index: 2;
|
||||
padding: var(--section-padding) var(--container-padding);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.parallax__visuals {
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
height: 120%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.parallax__black-line-overflow {
|
||||
z-index: 20;
|
||||
background-color: var(--color-black);
|
||||
width: 100%;
|
||||
height: 2px;
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.parallax__layers {
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.parallax__layer-img {
|
||||
pointer-events: none;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
height: 117.5%;
|
||||
position: absolute;
|
||||
top: -17.5%;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.parallax__layer-title {
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100svh;
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.parallax__title {
|
||||
pointer-events: auto;
|
||||
text-align: center;
|
||||
text-transform: none;
|
||||
margin-top: 0;
|
||||
margin-bottom: .1em;
|
||||
margin-right: .075em;
|
||||
font-family: 'PP Neue Corp Wide', sans-serif;
|
||||
font-size: 11vw;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.parallax__fade {
|
||||
z-index: 30;
|
||||
background: linear-gradient(to top,
|
||||
rgba(10, 10, 10, 1) 0%,
|
||||
rgba(10, 10, 10, 0.95) 15%,
|
||||
rgba(10, 10, 10, 0.85) 30%,
|
||||
rgba(10, 10, 10, 0.75) 45%,
|
||||
rgba(10, 10, 10, 0.6) 60%,
|
||||
rgba(10, 10, 10, 0.4) 75%,
|
||||
rgba(10, 10, 10, 0.2) 85%,
|
||||
rgba(10, 10, 10, 0.1) 92%,
|
||||
transparent 100%
|
||||
);
|
||||
width: 100%;
|
||||
height: 30%;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.parallax__content {
|
||||
padding: var(--section-padding) var(--container-padding);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
background: linear-gradient(to bottom,
|
||||
rgba(26, 42, 46, 0.95) 0%,
|
||||
rgba(10, 10, 10, 0.98) 85%,
|
||||
var(--color-black) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.osmo-icon-svg {
|
||||
width: 8em;
|
||||
position: relative;
|
||||
color: var(--color-light);
|
||||
}
|
||||
|
||||
.travel-grid {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.travel-item {
|
||||
padding: 2rem;
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(255, 255, 255, 0.07) 0%,
|
||||
rgba(255, 255, 255, 0.02) 100%
|
||||
);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.07);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.15);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.travel-item:hover {
|
||||
transform: translateY(-5px);
|
||||
background: linear-gradient(
|
||||
145deg,
|
||||
rgba(255, 255, 255, 0.1) 0%,
|
||||
rgba(255, 255, 255, 0.04) 100%
|
||||
);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.travel-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.03) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.travel-item h3 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--color-light);
|
||||
font-family: 'PP Neue Corp Wide', sans-serif;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.travel-item p {
|
||||
color: var(--color-light);
|
||||
line-height: 1.8;
|
||||
opacity: 0.9;
|
||||
font-size: 0.95rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.travel-item::after {
|
||||
content: '点击查看图片';
|
||||
position: absolute;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
font-size: 0.8rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.travel-item:hover::after {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.travel-intro h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.travel-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(to bottom,
|
||||
rgba(10, 10, 10, 0.98) 0%,
|
||||
rgba(26, 42, 46, 0.95) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
top: 5vh;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
perspective: 300px;
|
||||
perspective-origin: 50% 50%;
|
||||
}
|
||||
|
||||
.card {
|
||||
position: absolute;
|
||||
top: 35%;
|
||||
left: 50%;
|
||||
width: 50%;
|
||||
height: 400px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
transform: translate3d(-50%, -50%, 0);
|
||||
background-color: #000;
|
||||
}
|
||||
|
||||
.card img {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0.75;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.copy {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h1 span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media(max-width: 768px) {
|
||||
.card {
|
||||
width: 90%;
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.slider-section {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(to bottom,
|
||||
var(--color-black) 0%,
|
||||
rgba(26, 42, 46, 0.95) 15%,
|
||||
rgba(10, 10, 10, 0.98) 100%
|
||||
);
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.slider-section::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255, 255, 255, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
|
||||
background-size: 50px 50px;
|
||||
opacity: 0.5;
|
||||
animation: gridMove 30s linear infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.circle-decoration {
|
||||
position: absolute;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
border: 1px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.circle-left {
|
||||
left: -150px;
|
||||
top: 20%;
|
||||
}
|
||||
|
||||
.circle-right {
|
||||
right: -150px;
|
||||
bottom: 20%;
|
||||
}
|
||||
|
||||
.number-decoration {
|
||||
position: absolute;
|
||||
font-size: 120px;
|
||||
font-weight: 800;
|
||||
color: #ffffff;
|
||||
font-family: 'PP Neue Corp Wide', sans-serif;
|
||||
}
|
||||
|
||||
.number-top {
|
||||
top: 5%;
|
||||
right: 10%;
|
||||
}
|
||||
|
||||
.number-bottom {
|
||||
bottom: 5%;
|
||||
left: 10%;
|
||||
}
|
||||
|
||||
.side-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 1.2rem;
|
||||
color: rgba(255, 255, 255, 0.85) !important;
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
letter-spacing: 0.3em;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
text-shadow: none;
|
||||
z-index: 10;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
.side-text:hover {
|
||||
text-shadow: none;
|
||||
transform: translateY(-50%) scale(1.05);
|
||||
}
|
||||
|
||||
.side-text-left {
|
||||
left: 3rem;
|
||||
}
|
||||
|
||||
.side-text-right {
|
||||
right: 2rem;
|
||||
writing-mode: vertical-lr;
|
||||
}
|
||||
|
||||
.side-text::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 150px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.5),
|
||||
transparent
|
||||
);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: -150px;
|
||||
}
|
||||
|
||||
.side-text::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 150px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.5),
|
||||
transparent
|
||||
);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: -150px;
|
||||
}
|
||||
|
||||
.year-display {
|
||||
position: absolute;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 1.1rem;
|
||||
color: rgba(255, 255, 255, 0.85) !important;
|
||||
letter-spacing: 0.5em;
|
||||
font-weight: 500;
|
||||
text-shadow: none;
|
||||
z-index: 10;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
.year-display:hover {
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.side-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gridMove {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(50px);
|
||||
}
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* 添加旅行卡片的悬停效果 */
|
||||
.travel-item {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: pointer;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.travel-item:hover {
|
||||
transform: translateY(-10px);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.travel-item h3 {
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.travel-item:hover h3 {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.travel-item p {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.travel-item:hover p {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.parallax__content::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 150px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 0%,
|
||||
var(--color-black) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
2
web/cxm/src/components/Parallax.d.ts
vendored
Normal file
2
web/cxm/src/components/Parallax.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
import './Parallax.css';
|
||||
export declare const Parallax: () => import("react/jsx-runtime").JSX.Element;
|
237
web/cxm/src/components/Parallax.js
Normal file
237
web/cxm/src/components/Parallax.js
Normal file
File diff suppressed because one or more lines are too long
394
web/cxm/src/components/Parallax.tsx
Normal file
394
web/cxm/src/components/Parallax.tsx
Normal file
@ -0,0 +1,394 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import gsap from 'gsap';
|
||||
import { CustomEase } from 'gsap/CustomEase';
|
||||
import ScrollTrigger from 'gsap/ScrollTrigger';
|
||||
import Lenis from '@studio-freight/lenis';
|
||||
import './Parallax.css';
|
||||
import layer1 from '../assets/b1.png';
|
||||
import layer2 from '../assets/b2.webp';
|
||||
import layer3 from '../assets/b3.webp';
|
||||
|
||||
import { ImageModal } from './ImageModal';
|
||||
|
||||
// 导入旅行图片
|
||||
import changbaishan from '../assets/a/changbaishan.jpg';
|
||||
import chongqing from '../assets/a/chongqing.jpg';
|
||||
import haerbing from '../assets/a/haerbing.jpg';
|
||||
import qindao from '../assets/a/qindao.jpg';
|
||||
import qinghuangdao from '../assets/a/qinghuangdao.jpg';
|
||||
import shichuan from '../assets/a/shichuan.jpg';
|
||||
import tianjin from '../assets/a/tianjin.jpg';
|
||||
import xizhang from '../assets/a/xizhang.jpg';
|
||||
import taian from '../assets/a/taian.jpg';
|
||||
|
||||
// 导入滑动卡片图片
|
||||
import a1 from '../assets/b/a1.jpg';
|
||||
import a2 from '../assets/b/a2.jpg';
|
||||
import a3 from '../assets/b/a3.jpg';
|
||||
import a4 from '../assets/b/a4.jpg';
|
||||
import a5 from '../assets/b/a5.jpg';
|
||||
import a6 from '../assets/b/a6.jpg';
|
||||
import a7 from '../assets/b/a7.jpg';
|
||||
import a8 from '../assets/b/a8.jpg';
|
||||
import a9 from '../assets/b/a9.jpg';
|
||||
import a10 from '../assets/b/a10.jpg';
|
||||
import a11 from '../assets/b/a11.jpg';
|
||||
import a12 from '../assets/b/a12.jpg';
|
||||
import a13 from '../assets/b/a13.jpg';
|
||||
import a14 from '../assets/b/a14.jpg';
|
||||
import a15 from '../assets/b/a15.jpg';
|
||||
import a16 from '../assets/b/a16.jpg';
|
||||
import a17 from '../assets/b/a17.jpg';
|
||||
import a18 from '../assets/b/a18.jpg';
|
||||
import a19 from '../assets/b/a19.jpg';
|
||||
import a20 from '../assets/b/a20.jpg';
|
||||
import a21 from '../assets/b/a21.jpg';
|
||||
|
||||
gsap.registerPlugin(CustomEase, ScrollTrigger);
|
||||
|
||||
// 添加 CustomEase
|
||||
CustomEase.create("cubic", "0.83, 0, 0.17, 1");
|
||||
|
||||
// 在组件外部创建图片数组
|
||||
const allImages = [
|
||||
a1, a2, a3, a4, a5, a6, a7, a8, a9, a10,
|
||||
a11, a12, a13, a14, a15, a16, a17, a18,
|
||||
a19, a20, a21
|
||||
];
|
||||
|
||||
// 在组件外部添加一个用于跟踪最近使用的图片的数组
|
||||
let recentlyUsedImages: typeof allImages[number][] = [];
|
||||
|
||||
// 修改节流函数,使用闭包来保存状态
|
||||
function throttle(func: Function, limit: number) {
|
||||
let inThrottle: boolean;
|
||||
let lastResult: any;
|
||||
return function(this: any, ...args: any[]) {
|
||||
if (!inThrottle) {
|
||||
lastResult = func.apply(this, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => {
|
||||
inThrottle = false;
|
||||
}, limit);
|
||||
}
|
||||
return lastResult;
|
||||
}
|
||||
}
|
||||
|
||||
// 在组件内部,但在 useEffect 外部定义
|
||||
function initializeCards() {
|
||||
const cards = Array.from(document.querySelectorAll(".card"));
|
||||
gsap.set(cards, {
|
||||
y: i => -15 + (15 * i) + "%",
|
||||
z: i => 15 * i,
|
||||
transformOrigin: "50% 50%"
|
||||
});
|
||||
}
|
||||
|
||||
export const Parallax = () => {
|
||||
const layersRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||
const [displayedCards, setDisplayedCards] = useState<string[]>(() => {
|
||||
const shuffled = [...allImages].sort(() => 0.5 - Math.random());
|
||||
const initial = shuffled.slice(0, 5);
|
||||
return initial;
|
||||
});
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const lenis = new Lenis({
|
||||
duration: 1.2,
|
||||
orientation: 'vertical',
|
||||
gestureOrientation: 'vertical',
|
||||
smoothWheel: true,
|
||||
});
|
||||
|
||||
let rafId: number;
|
||||
let isAnimating = false;
|
||||
let autoPlayInterval: ReturnType<typeof setInterval>;
|
||||
|
||||
function raf(time: number) {
|
||||
lenis.raf(time);
|
||||
rafId = requestAnimationFrame(raf);
|
||||
}
|
||||
rafId = requestAnimationFrame(raf);
|
||||
|
||||
const scrollHandler = () => {
|
||||
ScrollTrigger.update();
|
||||
};
|
||||
|
||||
lenis.on('scroll', scrollHandler);
|
||||
|
||||
if (layersRef.current) {
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: layersRef.current,
|
||||
start: "top top",
|
||||
end: "bottom top",
|
||||
scrub: 1,
|
||||
invalidateOnRefresh: true,
|
||||
}
|
||||
});
|
||||
|
||||
const layers = [
|
||||
{ layer: "1", yPercent: 50 },
|
||||
{ layer: "2", yPercent: 35 },
|
||||
{ layer: "3", yPercent: 25 },
|
||||
{ layer: "4", yPercent: 15 }
|
||||
];
|
||||
|
||||
layers.forEach((layerObj) => {
|
||||
const element = layersRef.current!.querySelector(`[data-parallax-layer="${layerObj.layer}"]`);
|
||||
if (element) {
|
||||
gsap.set(element, {
|
||||
y: 0,
|
||||
force3D: true
|
||||
});
|
||||
|
||||
tl.to(element, {
|
||||
yPercent: layerObj.yPercent,
|
||||
ease: "none",
|
||||
force3D: true,
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 修改 handleClick 函数
|
||||
const handleClick = () => {
|
||||
if (isAnimating) return;
|
||||
isAnimating = true;
|
||||
|
||||
const slider = document.querySelector(".slider");
|
||||
if (!slider) return;
|
||||
|
||||
const cards = Array.from(slider.querySelectorAll(".card"));
|
||||
const lastCard = cards[cards.length - 1];
|
||||
|
||||
if (lastCard) {
|
||||
const currentDisplayed = new Set(displayedCards);
|
||||
const usedImages = new Set(recentlyUsedImages);
|
||||
const availableImages = allImages.filter(img =>
|
||||
!currentDisplayed.has(img) && !usedImages.has(img)
|
||||
);
|
||||
|
||||
let newImage: string;
|
||||
|
||||
if (availableImages.length < 5) {
|
||||
recentlyUsedImages = [];
|
||||
const newAvailableImages = allImages.filter(img => !currentDisplayed.has(img));
|
||||
const randomIndex = Math.floor(Math.random() * newAvailableImages.length);
|
||||
newImage = newAvailableImages[randomIndex];
|
||||
} else {
|
||||
const randomIndex = Math.floor(Math.random() * availableImages.length);
|
||||
newImage = availableImages[randomIndex];
|
||||
}
|
||||
|
||||
recentlyUsedImages.push(newImage);
|
||||
|
||||
// 更新状态,新图片添加到开头
|
||||
const newState = [newImage, ...displayedCards.slice(0, -1)];
|
||||
setDisplayedCards(newState);
|
||||
|
||||
// 先创建新的卡片元素
|
||||
const newCard = lastCard.cloneNode(true) as HTMLElement;
|
||||
const newImg = newCard.querySelector('img');
|
||||
if (newImg) {
|
||||
newImg.src = newImage;
|
||||
}
|
||||
|
||||
// 执行动画
|
||||
gsap.to(lastCard, {
|
||||
y: "+=150%",
|
||||
duration: 0.75,
|
||||
ease: "cubic",
|
||||
onComplete: () => {
|
||||
// 用新卡片替换旧卡片
|
||||
slider.replaceChild(newCard, lastCard);
|
||||
slider.prepend(newCard);
|
||||
|
||||
gsap.set(cards, {
|
||||
clearProps: "all"
|
||||
});
|
||||
initializeCards();
|
||||
isAnimating = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 动播放函数
|
||||
const autoPlay = () => {
|
||||
if (!isAnimating) {
|
||||
handleClick();
|
||||
}
|
||||
};
|
||||
|
||||
// 节流后的点击处理函数
|
||||
const throttledHandleClick = throttle(handleClick, 1000);
|
||||
|
||||
// 开始自动播放
|
||||
autoPlayInterval = setInterval(autoPlay, 2000);
|
||||
|
||||
// 鼠标进入时暂停自动播放
|
||||
const handleMouseEnter = () => {
|
||||
clearInterval(autoPlayInterval);
|
||||
};
|
||||
|
||||
// 鼠标离开时恢复自动播放
|
||||
const handleMouseLeave = () => {
|
||||
autoPlayInterval = setInterval(autoPlay, 2000);
|
||||
};
|
||||
|
||||
// 初始化卡片位置
|
||||
initializeCards();
|
||||
|
||||
const slider = document.querySelector(".slider");
|
||||
if (slider) {
|
||||
slider.addEventListener("click", throttledHandleClick);
|
||||
slider.addEventListener("mouseenter", handleMouseEnter);
|
||||
slider.addEventListener("mouseleave", handleMouseLeave);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (rafId) {
|
||||
cancelAnimationFrame(rafId);
|
||||
}
|
||||
const slider = document.querySelector(".slider");
|
||||
if (slider) {
|
||||
slider.removeEventListener("click", throttledHandleClick);
|
||||
slider.removeEventListener("mouseenter", handleMouseEnter);
|
||||
slider.removeEventListener("mouseleave", handleMouseLeave);
|
||||
}
|
||||
clearInterval(autoPlayInterval);
|
||||
lenis.destroy();
|
||||
ScrollTrigger.getAll().forEach(trigger => trigger.kill());
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 3. 添加始化函数
|
||||
useEffect(() => {
|
||||
// 只保留卡片初始化
|
||||
initializeCards();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="parallax">
|
||||
<section className="parallax__header">
|
||||
<div className="parallax__visuals">
|
||||
<div className="parallax__black-line-overflow"></div>
|
||||
<div ref={layersRef} data-parallax-layers className="parallax__layers">
|
||||
<img
|
||||
src={layer3}
|
||||
loading="eager"
|
||||
width="800"
|
||||
data-parallax-layer="1"
|
||||
alt=""
|
||||
className="parallax__layer-img"
|
||||
/>
|
||||
<img
|
||||
src={layer2}
|
||||
loading="eager"
|
||||
width="800"
|
||||
data-parallax-layer="2"
|
||||
alt=""
|
||||
className="parallax__layer-img"
|
||||
/>
|
||||
<div data-parallax-layer="3" className="parallax__layer-title">
|
||||
<h2 className="parallax__title">旅行故事</h2>
|
||||
</div>
|
||||
<img
|
||||
src={layer1}
|
||||
loading="eager"
|
||||
width="800"
|
||||
data-parallax-layer="4"
|
||||
alt=""
|
||||
className="parallax__layer-img"
|
||||
/>
|
||||
</div>
|
||||
<div className="parallax__fade"></div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="parallax__content">
|
||||
<div className="travel-grid">
|
||||
<div className="travel-item" onClick={() => setSelectedImage(xizhang)}>
|
||||
<h3>西藏</h3>
|
||||
<p>在海拔4000米的高原上,感受着最接近天空的信仰。布达拉宫的庄严肃穆,大昭寺的虔诚香火,以及纳木错圣湖的碧蓝湖水,都让人内心无比平静。高原的星空璀璨得让人屏息。</p>
|
||||
</div>
|
||||
|
||||
<div className="travel-item" onClick={() => setSelectedImage(shichuan)}>
|
||||
<h3>四川</h3>
|
||||
<p>在成都的宽窄巷子,感受悠闲的四川文化。稻城丁的雪山、草与湖泊构成了人间最后的香格里拉。乐山大佛巍峨庄严,都江堰千年智慧,让人不禁感叹古人的匠心。</p>
|
||||
</div>
|
||||
|
||||
<div className="travel-item" onClick={() => setSelectedImage(chongqing)}>
|
||||
<h3>重庆</h3>
|
||||
<p>夜幕降临,这座不夜城开始绽放光。洪崖洞层层叠叠的灯光倒映在江面,两江交汇处的璀璨夜景令人沉醉。子坝轻轨穿楼而过,解放碑的繁华夜色,构成了这座立体城市最迷人的画卷。</p>
|
||||
</div>
|
||||
|
||||
<div className="travel-item" onClick={() => setSelectedImage(haerbing)}>
|
||||
<h3>哈尔滨</h3>
|
||||
<p>冰雪世界的晶莹剔透,中央大街的欧式建筑,松花江的冰雪奇缘。在零下20度的寒冬感受着这座城市独特的俄罗斯风情和冰雪艺术。</p>
|
||||
</div>
|
||||
|
||||
<div className="travel-item" onClick={() => setSelectedImage(changbaishan)}>
|
||||
<h3>长白山</h3>
|
||||
<p>天池的深邃神秘,瀑布的气势磅礴,温泉的温暖治愈。在这座火山与冰雪的天堂,感受大自然的鬼斧神工体验东北原始森林的神秘。</p>
|
||||
</div>
|
||||
|
||||
<div className="travel-item" onClick={() => setSelectedImage(qindao)}>
|
||||
<h3>青岛</h3>
|
||||
<p>的海轻抚,八关的建,酒博物馆金色回。漫步在海边,品尝着新鲜的海鲜,欣赏着这座充满德国风情的海滨城市。</p>
|
||||
</div>
|
||||
|
||||
<div className="travel-item" onClick={() => setSelectedImage(qinghuangdao)}>
|
||||
<h3>秦皇岛</h3>
|
||||
<p>在万里长的起点北戴河,听着渤海湾的浪涛。漫步在白色的沙滩上,日出东方,霞满。这里有最美的海岸线,也有最动人的历史故事。</p>
|
||||
</div>
|
||||
|
||||
<div className="travel-item" onClick={() => setSelectedImage(tianjin)}>
|
||||
<h3>天津</h3>
|
||||
<p>五大洋楼群诉着百年历史,意式风情区的地域情调。古文化街的津味小吃,海河两岸的璨夜景。在这座中西合璧的城市里,感受着独特的海派文化。</p>
|
||||
</div>
|
||||
|
||||
<div className="travel-item" onClick={() => setSelectedImage(taian)}>
|
||||
<h3>泰安</h3>
|
||||
<p>泰山巍峨雄伟,云海日出壮观无比。上南天门,俯瞰众渺小。岱庙的古老建,诉着千历史。这里不仅是五岳之首,更是中华文化的精神象征。</p>
|
||||
</div>
|
||||
</div>
|
||||
{selectedImage && (
|
||||
<ImageModal
|
||||
image={selectedImage}
|
||||
onClose={() => setSelectedImage(null)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="slider-section">
|
||||
<div className="circle-decoration circle-left"></div>
|
||||
<div className="circle-decoration circle-right"></div>
|
||||
<div className="number-decoration number-top">01</div>
|
||||
<div className="number-decoration number-bottom">21</div>
|
||||
<div className="side-text side-text-left">
|
||||
TRAVEL MEMORIES · 旅行记忆
|
||||
</div>
|
||||
<div className="side-text side-text-right">
|
||||
PHOTO COLLECTION · 影像集
|
||||
</div>
|
||||
<div className="year-display">
|
||||
2022 - 2024
|
||||
</div>
|
||||
<div className="container">
|
||||
<div className="slider">
|
||||
{displayedCards.map((img, index) => (
|
||||
<div className="card" key={index}>
|
||||
<img src={img} alt="" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
98
web/cxm/src/index.css
Normal file
98
web/cxm/src/index.css
Normal file
@ -0,0 +1,98 @@
|
||||
:root {
|
||||
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
html.lenis {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.lenis.lenis-smooth {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
|
||||
.lenis.lenis-smooth [data-lenis-prevent] {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.lenis.lenis-stopped {
|
||||
overflow: hidden;
|
||||
}
|
1
web/cxm/src/main.d.ts
vendored
Normal file
1
web/cxm/src/main.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
import './index.css';
|
6
web/cxm/src/main.js
Normal file
6
web/cxm/src/main.js
Normal file
@ -0,0 +1,6 @@
|
||||
import { jsx as _jsx } from "react/jsx-runtime";
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App.tsx';
|
||||
createRoot(document.getElementById('root')).render(_jsx(StrictMode, { children: _jsx(App, {}) }));
|
10
web/cxm/src/main.tsx
Normal file
10
web/cxm/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
1
web/cxm/src/vite-env.d.ts
vendored
Normal file
1
web/cxm/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
13
web/cxm/tsconfig.app.json
Normal file
13
web/cxm/tsconfig.app.json
Normal file
@ -0,0 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@babel/*": ["./node_modules/@babel/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
1
web/cxm/tsconfig.app.tsbuildinfo
Normal file
1
web/cxm/tsconfig.app.tsbuildinfo
Normal file
@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/imagemodal.tsx","./src/components/parallax.tsx"],"errors":true,"version":"5.6.3"}
|
7
web/cxm/tsconfig.json
Normal file
7
web/cxm/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
8
web/cxm/tsconfig.node.json
Normal file
8
web/cxm/tsconfig.node.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"target": "ES2015",
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
1
web/cxm/tsconfig.node.tsbuildinfo
Normal file
1
web/cxm/tsconfig.node.tsbuildinfo
Normal file
@ -0,0 +1 @@
|
||||
{"root":["./vite.config.ts"],"errors":true,"version":"5.6.3"}
|
2
web/cxm/vite.config.d.ts
vendored
Normal file
2
web/cxm/vite.config.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
6
web/cxm/vite.config.js
Normal file
6
web/cxm/vite.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
});
|
7
web/cxm/vite.config.ts
Normal file
7
web/cxm/vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
84
web/czr/.eslintrc.cjs
Normal file
84
web/czr/.eslintrc.cjs
Normal file
@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
36
web/czr/app/components/Carousel.tsx
Normal file
36
web/czr/app/components/Carousel.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
interface CarouselProps {
|
||||
items: {
|
||||
content: React.ReactNode;
|
||||
}[];
|
||||
interval?: number;
|
||||
}
|
||||
|
||||
export function Carousel({
|
||||
items,
|
||||
interval = 5000
|
||||
}: CarouselProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
setCurrentIndex((current) => (current + 1) % items.length);
|
||||
}, [items.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (interval > 0) {
|
||||
const timer = setInterval(goToNext, interval);
|
||||
return () => clearInterval(timer);
|
||||
}
|
||||
}, [interval, goToNext]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="flex justify-center items-center">
|
||||
<div>
|
||||
{items[currentIndex].content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
116
web/czr/app/components/Navigation.tsx
Normal file
116
web/czr/app/components/Navigation.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
import { Link, useLocation } from '@remix-run/react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function Navigation() {
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
const location = useLocation();
|
||||
|
||||
const isActive = (path: string) => location.pathname === path;
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: '首页' },
|
||||
{ path: '/solutions', label: '解决方案' },
|
||||
{ path: '/innovations', label: '创新' },
|
||||
{ path: '/about', label: '关于我们' },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="nav-container">
|
||||
<div className="nav-content">
|
||||
<Link to="/" className="nav-logo">
|
||||
<svg
|
||||
className="animated-text"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="47.4 25.200005 455.2 109.8"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{
|
||||
width: '120px', // 设置固定宽度
|
||||
height: 'auto', // 高度自动调整保持比例
|
||||
color: '#1a1a1a' // 设置logo颜色
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M 119.200005 86.90001 L 120.9 87.70001 L 120.700005 98.600006 Q 120.3 109.600006 120.3 114.00001 Q 120.3 118.00001 119.6 120.850006 Q 118.9 123.700005 117.9 123.700005 Q 117 123.700005 117 124.350006 Q 117 125.00001 115.450005 124.850006 Q 113.9 124.700005 114.25 123.65001 Q 114.6 122.600006 114.3 105.50001 Q 114.200005 94.00001 114 90.90001 Q 113.8 87.8 112.8 86.600006 Q 111.600006 84.8 112 84.40001 Q 112.4 84.00001 114.8 85.00001 L 119.200005 86.90001 ZM 109.95 47.250008 Q 110.600006 47.100006 112.7 47.300003 Q 117 47.90001 118 48.300003 Q 119 48.700005 119 49.800003 Q 119 51.200005 111.5 58.800007 Q 106.7 63.800007 105.350006 65.55 Q 104 67.3 104 68.70001 Q 104 70.90001 104.55 71.25001 Q 105.100006 71.600006 105.55 75.40001 Q 106 79.20001 106.350006 79.20001 Q 106.7 79.20001 114.25 72.50001 Q 121.8 65.8 123.3 64.90001 Q 124.6 64.00001 127.9 63.900005 Q 131.20001 63.800007 132 64.600006 Q 132.8 65.40001 132.3 66.45001 Q 131.8 67.50001 130.70001 67.50001 Q 128.9 67.50001 124.05 71.20001 Q 119.200005 74.90001 114.3 79.90001 Q 107.9 86.40001 106.65 88.65001 Q 105.4 90.90001 105.4 95.40001 Q 105.4 100.3 104.2 101.40001 Q 103.100006 102.50001 102.2 101.950005 Q 101.3 101.40001 99.7 98.40001 Q 97.8 94.700005 96.2 93.40001 L 94.7 92.3 L 97 89.90001 Q 99.3 87.600006 100 84.8 Q 100.3 83.3 100.45 81.100006 Q 100.600006 78.90001 100.45 77.3 Q 100.3 75.70001 100 75.70001 Q 99.4 75.70001 95.4 83.8 L 91.3 91.700005 L 91.3 100.8 L 91.3 109.90001 L 89.100006 110.200005 Q 87.100006 110.700005 84.100006 108.90001 Q 78.3 105.600006 73.8 108.90001 Q 71.9 110.30001 70.25 110.50001 Q 68.6 110.700005 68.1 109.50001 Q 67.6 108.50001 67.6 106.15001 Q 67.6 103.8 69.15 103.200005 Q 70.7 102.600006 71.2 102.90001 Q 71.7 103.100006 77.05 97.3 Q 82.4 91.50001 83.3 89.700005 Q 84.4 87.40001 82.600006 87.70001 Q 81.2 87.8 78.3 89.40001 Q 73.8 91.90001 72.25 91.3 Q 70.7 90.700005 70.2 85.90001 Q 69.8 83.50001 70.4 81.95001 Q 71 80.40001 74.9 73.90001 Q 76.7 70.70001 78.4 67.00001 Q 80.1 63.300007 79.8 63.000008 Q 79.5 62.70001 76.45 64.90001 Q 73.4 67.100006 71.2 69.20001 Q 68.3 71.90001 66.55 75.20001 Q 64.8 78.50001 61.85 79.90001 Q 58.9 81.3 58.15 80.850006 Q 57.4 80.40001 57.4 76.90001 L 57.4 73.3 L 63.4 69.00001 Q 79.5 57.400005 82.4 54.90001 Q 84.2 53.200005 85.2 52.90001 Q 86.2 52.600006 89.5 52.90001 Q 92.8 53.300003 94.25 54.050003 Q 95.7 54.800003 95.7 56.400005 Q 95.7 57.20001 92.7 59.400005 Q 89.7 61.600006 88.5 63.900005 L 87.3 66.100006 L 89.8 68.600006 Q 92.4 71.00001 94.4 70.8 Q 95.9 70.70001 96.850006 69.600006 Q 97.8 68.50001 100.2 63.900005 Q 103.7 57.20001 105.850006 54.100006 Q 108 51.000008 108 50.300003 Q 108 49.600006 108.8 48.300003 Q 109.3 47.40001 109.95 47.250008 ZM 106.350006 58.600006 Q 106.4 58.300007 106.350006 58.100006 Q 106.3 57.900005 106.2 57.900005 Q 105.5 57.900005 104.7 59.70001 Q 104.2 60.900005 104.4 61.000008 Q 104.9 61.20001 105.8 59.70001 Q 105.9 59.500008 106 59.300007 Q 106.3 58.900005 106.350006 58.600006 ZM 84.8 71.600006 Q 84.2 71.600006 83.350006 74.350006 Q 82.5 77.100006 82.9 77.600006 Q 83.9 78.40001 84.9 76.40001 Q 85.5 75.100006 85.5 74.00001 Q 85.5 71.600006 84.8 71.600006 ZM 80.5 35.90001 Q 81.6 36.40001 84.2 37.500008 Q 87.3 39.000008 89.5 42.200005 Q 90.9 44.300003 91.100006 45.15001 Q 91.3 46.000008 90.9 47.500008 Q 90.4 48.800003 89.7 49.40001 Q 89 50.000008 87.4 50.40001 Q 84.7 51.200005 84.4 50.850006 Q 84.100006 50.500008 80.6 51.800003 Q 79.7 52.100006 78.95 52.350006 Q 78.2 52.600006 77.75 52.700005 Q 77.3 52.800003 77.3 52.700005 Q 77.3 52.600006 79.7 49.700005 Q 85.2 43.100006 80.3 38.500008 Q 78.7 37.000008 78.7 36.000008 Q 78.7 35.200005 80.5 35.90001 Z"
|
||||
fill="currentColor"
|
||||
style={{ "--path-length": "1019.60175" } as React.CSSProperties}
|
||||
/>
|
||||
<path
|
||||
d="M 201.5 57.600006 Q 202.7 58.20001 205.5 59.000008 Q 208 59.600006 209.20001 61.150005 Q 210.4 62.70001 209.3 63.900005 Q 208.70001 64.90001 207.6 68.25001 Q 206.5 71.600006 205.1 73.40001 Q 204.20001 74.600006 204.20001 74.90001 Q 204.20001 75.20001 205.3 75.8 Q 206.6 76.50001 206.75 77.75001 Q 206.9 79.00001 205.8 79.70001 L 199.2 83.40001 Q 193.8 86.600006 192.1 87.100006 Q 190.9 87.3 190.35 88.350006 Q 189.8 89.40001 188.9 92.600006 Q 186.5 100.90001 188.1 101.90001 Q 188.6 102.200005 193.9 102.8 Q 199.2 103.40001 206.8 102.200005 Q 214.4 101.00001 216.5 99.40001 Q 217.3 98.700005 217.3 96.00001 Q 217.3 93.3 218.1 91.8 Q 218.70001 90.90001 218.9 90.8 Q 219.1 90.700005 219.6 91.40001 Q 220.20001 92.50001 223.3 94.00001 Q 224.5 94.50001 225.3 95.100006 Q 226.1 95.700005 225.9 96.00001 Q 225.6 96.700005 226.4 99.00001 Q 226.8 100.50001 226.75 101.25001 Q 226.70001 102.00001 226 103.200005 Q 223.5 107.00001 211.1 109.700005 Q 202.4 111.700005 193 110.600006 Q 183.6 109.50001 180.9 106.30001 Q 179.6 104.50001 179.75 101.05 Q 179.9 97.600006 181.5 94.90001 L 183.2 91.8 L 181.6 90.600006 Q 180.1 89.40001 180.1 86.00001 Q 180.1 83.600006 180.45 83.100006 Q 180.8 82.600006 182.9 81.90001 Q 185.7 81.00001 188.1 78.90001 Q 190.1 77.20001 193.1 73.00001 Q 196.1 68.8 196.6 66.8 Q 196.9 65.70001 196.7 65.55 Q 196.5 65.40001 195 66.40001 Q 192.9 67.50001 191.7 69.3 Q 190.7 70.70001 188.4 72.40001 Q 186.1 74.100006 185.1 74.100006 Q 184.2 74.100006 182.35 72.45001 Q 180.5 70.8 179.4 69.100006 Q 178.5 67.70001 178.55 67.50001 Q 178.6 67.3 180 67.3 Q 181.5 67.3 185.4 65.90001 Q 189.3 64.50001 189.3 63.900005 Q 189.3 63.500008 191.65 62.250008 Q 194 61.000008 195.9 59.000008 Q 198.6 56.100006 201.5 57.600006 ZM 170.55 45.550003 Q 170.9 45.300003 172.8 45.500008 Q 176.9 46.200005 177.9 49.800003 Q 178.4 51.200005 173.9 56.600006 Q 169.4 62.000008 169.4 62.650005 Q 169.4 63.300007 165.4 67.50001 Q 158.5 74.90001 158.5 76.600006 Q 158.5 77.3 161.3 77.70001 Q 164.1 78.100006 164.4 78.40001 Q 164.7 78.70001 164 84.8 Q 163.6 89.00001 163.65 90.100006 Q 163.7 91.200005 164.6 91.90001 Q 165.9 92.90001 166.6 94.200005 Q 166.8 94.600006 167.05 94.700005 Q 167.3 94.8 167.8 94.450005 Q 168.3 94.100006 169.05 93.3 Q 169.8 92.50001 171.2 91.00001 Q 175.8 85.70001 175.5 87.100006 Q 175.4 87.600006 173.8 89.90001 Q 170.7 94.40001 167 101.90001 Q 163.3 109.40001 163.3 111.200005 Q 163.3 113.40001 162.05 114.350006 Q 160.8 115.30001 159.3 114.65001 Q 157.8 114.00001 157.5 111.90001 Q 157 110.100006 156.3 107.40001 Q 155.9 105.80001 156 104.80001 Q 156.1 103.8 157.1 101.90001 L 158.7 98.8 L 157.1 94.50001 Q 155.5 90.200005 155.6 87.600006 L 155.6 85.20001 L 153.1 86.3 Q 150.7 87.40001 148.4 89.00001 Q 146.7 90.3 145.4 90.450005 Q 144.1 90.600006 143.8 89.600006 Q 143.6 88.8 143.3 85.100006 Q 143.1 82.50001 143.4 81.75001 Q 143.7 81.00001 145.7 79.70001 Q 147.7 78.40001 150 76.100006 Q 152.3 73.8 157.6 68.00001 Q 161 64.100006 165.7 56.500008 Q 170.4 48.90001 170.2 47.200005 Q 170.2 45.800003 170.55 45.550003 Z"
|
||||
fill="currentColor"
|
||||
style={{ "--path-length": "659.9861" } as React.CSSProperties}
|
||||
/>
|
||||
<path
|
||||
d="M 291.9 67.40001 Q 291.6 68.600006 290.3 69.3 Q 289.1 70.00001 285.05 75.00001 Q 281 80.00001 279.1 81.70001 Q 277.4 83.3 277.35 84.100006 Q 277.3 84.90001 278.5 86.40001 Q 280 88.100006 279.8 90.15001 Q 279.6 92.200005 277.3 98.3 Q 272.7 109.90001 276 110.600006 Q 278.3 111.100006 285.95 111.05001 Q 293.6 111.00001 295.7 110.40001 Q 298.1 109.80001 299.95 107.700005 Q 301.8 105.600006 302.2 103.00001 Q 302.8 100.00001 303.9 96.65001 Q 305 93.3 305.4 93.3 Q 306 93.3 306 95.100006 Q 306 96.90001 308.4 100.200005 Q 309.9 102.200005 310.4 103.50001 Q 310.9 104.80001 310.9 106.80001 Q 311 112.40001 307.35 114.80001 Q 303.7 117.200005 295.3 117.100006 Q 289.2 117.100006 282.6 117.200005 Q 278.5 117.30001 276.75 117.100006 Q 275 116.90001 273.4 116.00001 Q 271.6 115.200005 271 114.30001 Q 270.4 113.40001 269.9 111.100006 Q 269.2 108.50001 269.65 106.350006 Q 270.1 104.200005 271.2 104.200005 Q 271.8 104.200005 271.8 103.200005 Q 271.8 102.200005 273.2 98.00001 Q 274.6 93.8 275.2 93.00001 Q 275.9 91.90001 276.15 89.55 Q 276.4 87.20001 275.9 86.40001 Q 275.4 85.8 275.15 85.90001 Q 274.9 86.00001 274.2 87.00001 Q 273 88.600006 271.1 89.700005 Q 269.5 90.8 263.3 96.75001 Q 257.1 102.700005 252.5 107.700005 Q 249.9 110.700005 246.85 112.700005 Q 243.8 114.700005 241.85 115.00001 Q 239.9 115.30001 239.2 113.80001 Q 238.4 111.100006 244.8 106.200005 Q 248.8 103.200005 258.45 92.15001 Q 268.1 81.100006 267.4 80.50001 Q 267 80.20001 262.7 82.8 Q 259.8 84.600006 255.65 86.55 Q 251.5 88.50001 250.6 88.50001 Q 249.9 88.50001 248.85 86.25001 Q 247.8 84.00001 247.8 82.50001 Q 247.8 81.100006 248.2 80.600006 Q 248.6 80.100006 250.5 79.600006 Q 253.1 78.90001 255.5 76.50001 Q 259.7 72.50001 258.1 75.50001 Q 257.3 77.00001 258.2 76.8 Q 259 76.600006 261.6 75.100006 L 271.4 69.70001 Q 278.3 65.90001 281 63.150005 Q 283.7 60.400005 283.95 60.400005 Q 284.2 60.400005 286.25 62.150005 Q 288.3 63.900005 289.15 63.900005 Q 290 63.900005 291.2 65.100006 Q 292.2 66.20001 291.9 67.40001 ZM 284.45 43.300003 Q 286.6 42.90001 288.3 44.40001 Q 289.3 45.40001 289.7 45.000008 Q 290 44.700005 290.4 45.600006 Q 290.5 45.800003 290.6 46.000008 Q 290.8 47.000008 290.5 47.65001 Q 290.2 48.300003 288.8 49.40001 Q 286.1 51.600006 282.5 53.950005 Q 278.9 56.300007 278.35 56.300007 Q 277.8 56.300007 274.8 58.800007 Q 271.8 61.300007 269.2 63.900005 Q 267.2 66.100006 266.3 65.8 Q 266.1 65.70001 266.7 64.95001 Q 267.3 64.20001 268.45 62.900005 Q 269.6 61.600006 271.1 60.100006 Q 276.5 54.700005 276.5 54.000008 Q 276.6 52.600006 271.5 55.40001 Q 270.7 55.800003 269.8 56.400005 Q 263.1 60.400005 259.8 56.20001 Q 258.2 53.90001 258.2 53.40001 Q 258.2 52.90001 260 52.90001 Q 262 52.90001 265.15 51.700005 Q 268.3 50.500008 275.9 46.90001 Q 282.3 43.700005 284.45 43.300003 Z"
|
||||
fill="currentColor"
|
||||
style={{ "--path-length": "663.6744" } as React.CSSProperties}
|
||||
/>
|
||||
<path
|
||||
d="M 358.65 38.750008 Q 358.9 39.300003 358.9 41.000008 Q 358.9 43.000008 357.75 45.950005 Q 356.6 48.90001 355.6 49.300003 Q 355 49.500008 351.85 53.250008 Q 348.7 57.000008 348.7 57.400005 Q 348.7 57.900005 351.4 59.100006 Q 355 60.70001 355.7 62.300007 Q 356.4 63.900005 355.9 69.90001 L 355.6 76.90001 L 359.2 72.90001 Q 363 68.70001 364.8 66.20001 Q 366.1 64.3 366.15 63.750008 Q 366.2 63.20001 365.4 62.000008 Q 364.4 60.500008 364.45 59.95001 Q 364.5 59.400005 365.8 59.400005 Q 367.2 59.400005 370.6 57.70001 Q 375.7 55.000008 380.3 57.000008 Q 381.2 57.400005 381.4 56.20001 Q 381.5 55.100006 381.5 51.600006 Q 381.5 45.700005 380.7 44.250008 Q 379.9 42.800003 381.2 41.90001 Q 384 39.800003 385.8 41.90001 Q 387.6 44.000008 387.6 49.000008 Q 387.6 53.100006 389.8 57.100006 Q 391.7 60.70001 394.9 64.15001 Q 398.1 67.600006 400.7 68.90001 Q 402.9 70.100006 403.15 71.70001 Q 403.4 73.3 401.4 73.90001 Q 395.8 75.600006 390.6 78.90001 L 386.5 81.600006 L 386 92.3 Q 385.3 107.600006 383.9 114.55001 Q 382.5 121.50001 379.7 122.200005 Q 378 122.600006 377.7 121.950005 Q 377.4 121.30001 378.1 118.30001 Q 378.8 114.700005 379.2 100.8 Q 379.2 100.50001 379.2 100.00001 Q 379.7 88.100006 378.9 87.20001 Q 378.2 86.40001 375.3 88.50001 Q 375.2 88.600006 375 88.700005 L 374.9 88.8 L 374.6 89.00001 Q 371.8 91.00001 369.65 92.00001 Q 367.5 93.00001 364.1 95.40001 Q 361.8 97.00001 361.1 97.25001 Q 360.4 97.50001 359.8 96.700005 Q 358.9 95.50001 358.9 92.50001 Q 358.9 90.700005 359.25 90.00001 Q 359.6 89.3 360.5 89.00001 Q 362 88.40001 368.15 81.350006 Q 374.3 74.3 373.6 74.55 Q 372.9 74.8 368.95 76.70001 Q 365 78.600006 364.35 78.25001 Q 363.7 77.90001 363.7 75.45001 Q 363.7 73.00001 366.8 69.100006 Q 370.3 64.600006 369.7 64.3 Q 369.5 64.20001 369.2 64.20001 Q 368.5 64.20001 366.1 67.8 L 361 75.20001 Q 356.5 81.100006 355.7 84.00001 Q 354.9 86.90001 355.1 95.90001 Q 355.4 106.30001 355.05 107.15001 Q 354.7 108.00001 352.1 108.00001 Q 349.5 108.00001 345.9 105.200005 Q 342.3 102.40001 338.8 100.850006 Q 335.3 99.3 333.05 96.200005 Q 330.8 93.100006 330.8 92.700005 Q 331.1 91.600006 334.6 93.3 Q 337.7 94.90001 337.7 93.600006 Q 337.7 92.90001 339.05 91.05 Q 340.4 89.200005 342.15 84.75001 Q 343.9 80.3 344.6 79.20001 Q 346 76.600006 344.5 76.600006 Q 343.1 76.8 338.2 78.90001 Q 333.4 81.20001 330.25 81.350006 Q 327.1 81.50001 326.9 79.40001 Q 326.8 78.600006 327.05 78.25001 Q 327.3 77.90001 328.2 77.90001 Q 329.6 77.90001 334.75 74.90001 Q 339.9 71.90001 343.9 70.70001 L 348 69.40001 L 348 65.100006 Q 348 60.20001 346.5 60.20001 Q 345.7 60.100006 344.6 61.100006 Q 342.9 62.600006 342 62.20001 Q 341.9 62.000008 341.9 61.800007 Q 341.9 61.500008 344.6 58.050007 Q 347.3 54.600006 347.3 53.90001 Q 347.3 53.200005 350.3 48.65001 Q 353.3 44.100006 354.75 41.15001 Q 356.2 38.200005 357.6 38.200005 Q 358.4 38.200005 358.65 38.750008 ZM 388.6 59.70001 Q 387.8 59.600006 386.9 66.40001 Q 385.8 76.50001 387.1 76.50001 Q 388.1 76.50001 390.95 75.100006 Q 393.8 73.70001 394.5 72.90001 Q 395.1 72.00001 394.9 71.350006 Q 394.7 70.70001 393.4 68.8 Q 391.3 66.00001 390.4 63.20001 Q 389.2 59.70001 388.6 59.70001 ZM 378.1 61.500008 Q 377.6 61.70001 377.2 62.20001 Q 375.3 63.800007 373.15 66.70001 Q 371 69.600006 371.3 70.100006 Q 371.7 70.40001 376.3 68.8 Q 379.9 67.70001 380.4 66.95001 Q 380.9 66.20001 381.1 61.70001 Q 381.2 59.100006 378.1 61.500008 ZM 379.55 75.20001 Q 378.9 75.20001 375.45 78.90001 Q 372 82.600006 372 83.3 Q 372 84.100006 376 82.100006 Q 378.9 80.600006 379.55 79.95001 Q 380.2 79.3 380.2 77.600006 Q 380.2 75.20001 379.55 75.20001 ZM 346.8 84.00001 Q 346.2 83.40001 345.8 84.90001 Q 345.8 85.00001 345.8 85.3 Q 345.3 87.600006 345.8 87.3 Q 346 87.20001 346.6 86.20001 Q 347.4 84.70001 346.8 84.00001 Z"
|
||||
fill="currentColor"
|
||||
style={{ "--path-length": "1026.061" } as React.CSSProperties}
|
||||
/>
|
||||
<path
|
||||
d="M 471.4 45.40001 Q 471.6 45.700005 471.8 45.800003 Q 473.7 47.40001 474.1 48.40001 Q 474.5 49.40001 474.5 52.500008 L 474.5 56.800007 L 478.5 57.20001 Q 482.6 57.500008 483.5 58.900005 Q 484.2 59.900005 484.05 60.400005 Q 483.9 60.900005 482.8 61.800007 Q 481.1 63.000008 477.6 63.500008 Q 474.5 64.00001 473.35 65.350006 Q 472.2 66.70001 471.8 70.50001 Q 471.3 74.50001 471 75.8 Q 471 76.100006 471 76.350006 Q 471 76.600006 471.5 76.8 Q 472 77.00001 472.8 77.00001 Q 473.6 77.00001 475.3 77.00001 Q 479 77.00001 480.15 77.40001 Q 481.3 77.8 481.8 79.100006 Q 482.1 79.90001 480.25 84.75001 Q 478.4 89.600006 476.2 94.00001 L 475.1 96.3 L 477.5 98.200005 Q 479.9 100.3 485.2 103.700005 Q 489.7 106.700005 491.15 108.50001 Q 492.6 110.30001 492.6 112.80001 Q 492.6 117.50001 487.5 117.200005 Q 485.7 117.100006 485.25 116.80001 Q 484.8 116.50001 484.9 115.40001 Q 485.1 113.80001 484.1 112.30001 Q 483.4 111.100006 478.05 107.15001 Q 472.7 103.200005 471.1 102.600006 Q 470.3 102.3 468.2 103.90001 Q 466.5 105.40001 462.4 106.950005 Q 458.3 108.50001 456.2 108.50001 Q 451.3 108.50001 447.45 106.450005 Q 443.6 104.40001 442.1 101.00001 L 440.9 98.600006 L 440.4 102.700005 Q 439.8 106.700005 439.8 110.200005 Q 439.9 113.80001 439.2 114.65001 Q 438.5 115.50001 436.3 114.600006 Q 435 114.100006 434 113.00001 Q 433 111.90001 431.7 109.40001 Q 429.2 104.80001 427.65 103.100006 Q 426.1 101.40001 424.6 101.850006 Q 423.1 102.3 420.75 101.65001 Q 418.4 101.00001 417.8 100.100006 Q 416.9 98.600006 417.8 97.40001 Q 418.7 96.200005 422.3 93.90001 Q 427.3 90.8 431.2 87.600006 L 435.1 84.50001 L 435.1 78.20001 Q 435.1 71.90001 435.7 69.3 Q 436.3 66.70001 435.8 66.40001 Q 435.3 66.100006 430.4 69.50001 Q 422.2 75.00001 419 71.70001 Q 418.5 71.20001 418.35 70.850006 Q 418.2 70.50001 418.65 70.100006 Q 419.1 69.70001 420 69.25001 Q 420.9 68.8 422.8 68.00001 Q 426.6 66.50001 431.9 63.400005 Q 436.5 60.70001 437.35 59.050007 Q 438.2 57.400005 438.2 51.100006 Q 438.2 45.40001 438.95 45.40001 Q 439.7 45.40001 442.55 48.550003 Q 445.4 51.700005 445.4 54.100006 Q 445.4 55.40001 445.75 56.050007 Q 446.1 56.70001 447.1 57.20001 Q 448.9 57.900005 451.15 57.900005 Q 453.4 57.900005 454.2 58.70001 Q 455.9 60.400005 453.1 61.100006 Q 452.1 61.300007 450.3 61.500008 Q 445.5 62.000008 444.45 62.800007 Q 443.4 63.600006 442.55 69.95001 Q 441.7 76.3 442.2 76.75001 Q 442.7 77.20001 444 75.90001 Q 445.9 74.3 445.8 75.350006 Q 445.7 76.40001 443.8 79.40001 Q 442.1 82.00001 441.7 83.40001 Q 441.3 84.8 441.3 88.200005 Q 441.3 90.100006 441.3 90.950005 Q 441.3 91.8 441.45 92.450005 Q 441.6 93.100006 441.85 93.00001 Q 442.1 92.90001 442.4 92.90001 Q 443.1 92.8 443.55 93.40001 Q 444 94.00001 444.6 95.8 Q 445.9 100.200005 449.7 101.3 Q 453.5 102.40001 457.8 99.600006 Q 459.3 98.700005 459.95 98.3 Q 460.6 97.90001 461.05 97.3 Q 461.5 96.700005 461.45 96.450005 Q 461.4 96.200005 460.7 95.55 Q 460 94.90001 459.25 94.450005 Q 458.5 94.00001 456.7 92.90001 Q 447.4 87.8 447.4 87.100006 Q 447.4 86.600006 448.35 86.55 Q 449.3 86.50001 450.75 86.8 Q 452.2 87.100006 453.7 87.600006 Q 455.7 88.50001 456.25 88.50001 Q 456.8 88.50001 457.5 87.50001 Q 458.4 86.3 458.05 84.90001 Q 457.7 83.50001 459.3 81.8 Q 460.5 80.50001 462.4 75.40001 Q 464.3 70.3 463.7 69.70001 Q 463.4 69.40001 460.3 70.50001 Q 457.6 71.40001 456.15 70.90001 Q 454.7 70.40001 453.1 68.20001 Q 452.4 67.20001 452.5 66.90001 Q 452.6 66.600006 453.4 66.3 Q 455.4 65.600006 459.9 63.550007 Q 464.4 61.500008 465 61.20001 Q 466.1 60.20001 467 46.500008 Q 467.2 44.500008 467.2 43.90001 Q 467.5 42.200005 471.4 45.40001 ZM 469.2 84.90001 Q 468.2 85.20001 467.5 86.00001 Q 466.5 87.20001 465.5 88.8 Q 464.5 90.40001 464.5 91.00001 Q 464.5 91.3 466.1 91.3 Q 467.7 91.3 469.55 88.600006 Q 471.4 85.90001 471.4 85.20001 Q 471.4 83.90001 469.2 84.90001 ZM 433.1 94.850006 Q 432.5 94.8 431 97.00001 Q 429.8 98.600006 429.9 99.8 Q 430 101.00001 431.3 101.00001 Q 432.2 101.00001 432.55 100.3 Q 432.9 99.600006 433.3 97.00001 Q 433.7 94.90001 433.1 94.850006 Z"
|
||||
fill="currentColor"
|
||||
style={{ "--path-length": "768.58575" } as React.CSSProperties}
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
|
||||
{/* 桌面端导航链接 */}
|
||||
<div className="nav-links">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`nav-link ${isActive(item.path) ? 'active' : ''}`}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 移动端菜单按钮 */}
|
||||
<button
|
||||
className="mobile-menu-button"
|
||||
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
{isMobileMenuOpen ? (
|
||||
<path d="M6 18L18 6M6 6l12 12" />
|
||||
) : (
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 移动端导航菜单 */}
|
||||
{isMobileMenuOpen && (
|
||||
<div className="mobile-nav">
|
||||
<div className="mobile-nav-links">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`mobile-nav-link ${isActive(item.path) ? 'active' : ''}`}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
18
web/czr/app/entry.client.tsx
Normal file
18
web/czr/app/entry.client.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
});
|
89
web/czr/app/entry.server.tsx
Normal file
89
web/czr/app/entry.server.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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,
|
||||
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) => {
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
|
||||
{
|
||||
onAllReady() {
|
||||
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);
|
||||
},
|
||||
}
|
||||
);
|
||||
setTimeout(abort, ABORT_DELAY);
|
||||
});
|
||||
}
|
||||
|
||||
function handleBrowserRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<RemixServer context={remixContext} url={request.url} abortDelay={ABORT_DELAY} />,
|
||||
{
|
||||
onShellReady() {
|
||||
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);
|
||||
},
|
||||
}
|
||||
);
|
||||
setTimeout(abort, ABORT_DELAY);
|
||||
});
|
||||
}
|
27
web/czr/app/env.ts
Normal file
27
web/czr/app/env.ts
Normal file
@ -0,0 +1,27 @@
|
||||
export interface EnvConfig {
|
||||
VITE_PORT: string;
|
||||
VITE_ADDRESS: string;
|
||||
VITE_INIT_STATUS: string;
|
||||
VITE_API_BASE_URL: string;
|
||||
VITE_API_USERNAME: string;
|
||||
VITE_API_PASSWORD: string;
|
||||
VITE_PATTERN: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: EnvConfig = {
|
||||
VITE_PORT: "22100",
|
||||
VITE_ADDRESS: "localhost",
|
||||
VITE_INIT_STATUS: "0",
|
||||
VITE_API_BASE_URL: "http://127.0.0.1:22000",
|
||||
VITE_API_USERNAME: "",
|
||||
VITE_API_PASSWORD: "",
|
||||
VITE_PATTERN: "true",
|
||||
} as const;
|
||||
|
||||
// 扩展 ImportMeta 接口
|
||||
declare global {
|
||||
interface ImportMetaEnv extends EnvConfig {}
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
}
|
3
web/czr/app/hooks/ParticleImage.tsx
Normal file
3
web/czr/app/hooks/ParticleImage.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
import React from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
// ... 其他导入保持不变
|
196
web/czr/app/index.css
Normal file
196
web/czr/app/index.css
Normal file
@ -0,0 +1,196 @@
|
||||
@import "@radix-ui/themes/styles.css";
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--transition-duration: 150ms;
|
||||
--transition-easing: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--hljs-theme: 'github';
|
||||
}
|
||||
|
||||
:root[class~="dark"] {
|
||||
--hljs-theme: 'github-dark';
|
||||
}
|
||||
|
||||
/* 确保 Radix UI 主题类包裹整个应用 */
|
||||
.radix-themes {
|
||||
transition:
|
||||
background-color var(--transition-duration) var(--transition-easing),
|
||||
color var(--transition-duration) var(--transition-easing);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* 基础布局样式 */
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 添加暗色模式支持 */
|
||||
.radix-themes-dark {
|
||||
@apply dark;
|
||||
}
|
||||
|
||||
/* 隐藏不活跃的主题样式 */
|
||||
[data-theme="light"] .hljs-dark {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .hljs-light {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 导航栏基础样式 */
|
||||
.nav-container {
|
||||
@apply fixed top-0 left-0 right-0 z-50;
|
||||
@apply bg-white/80 dark:bg-gray-900/80;
|
||||
@apply backdrop-blur-sm;
|
||||
@apply border-b border-gray-200 dark:border-gray-700;
|
||||
}
|
||||
|
||||
.nav-content {
|
||||
@apply container mx-auto px-4;
|
||||
@apply flex items-center justify-between;
|
||||
@apply h-16;
|
||||
}
|
||||
|
||||
.nav-logo {
|
||||
@apply text-xl font-bold;
|
||||
@apply text-gray-800 dark:text-white;
|
||||
@apply hover:text-gray-600 dark:hover:text-gray-300;
|
||||
@apply transition-colors duration-200;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
@apply hidden md:flex items-center space-x-8;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
@apply text-gray-600 dark:text-gray-300;
|
||||
@apply hover:text-gray-900 dark:hover:text-white;
|
||||
@apply transition-colors duration-200;
|
||||
@apply font-medium;
|
||||
}
|
||||
|
||||
/* 移动端菜单按钮 */
|
||||
.mobile-menu-button {
|
||||
@apply md:hidden;
|
||||
@apply p-2 rounded-md;
|
||||
@apply text-gray-600 dark:text-gray-300;
|
||||
@apply hover:bg-gray-100 dark:hover:bg-gray-800;
|
||||
@apply transition-colors duration-200;
|
||||
}
|
||||
|
||||
/* 移动端导航菜单 */
|
||||
.mobile-nav {
|
||||
@apply md:hidden;
|
||||
@apply fixed top-16 left-0 right-0;
|
||||
@apply bg-white dark:bg-gray-900;
|
||||
@apply border-b border-gray-200 dark:border-gray-700;
|
||||
@apply shadow-lg;
|
||||
}
|
||||
|
||||
.mobile-nav-links {
|
||||
@apply flex flex-col space-y-4;
|
||||
@apply p-4;
|
||||
}
|
||||
|
||||
.mobile-nav-link {
|
||||
@apply text-gray-600 dark:text-gray-300;
|
||||
@apply hover:text-gray-900 dark:hover:text-white;
|
||||
@apply transition-colors duration-200;
|
||||
@apply font-medium;
|
||||
@apply block;
|
||||
@apply py-2;
|
||||
}
|
||||
|
||||
/* 激活状态的导航链接 */
|
||||
.nav-link.active,
|
||||
.mobile-nav-link.active {
|
||||
@apply text-blue-600 dark:text-blue-400;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 注意:每个路径都需要在 SVG 中设置 --path-length 变量 */
|
||||
.animated-text {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.animated-text path {
|
||||
fill: transparent;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
/* 使用每个路径自己的长度 */
|
||||
stroke-dasharray: var(--path-length);
|
||||
stroke-dashoffset: var(--path-length);
|
||||
animation: logo-anim 15s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||
transform-origin: center;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
@keyframes logo-anim {
|
||||
0% {
|
||||
stroke-dashoffset: var(--path-length);
|
||||
stroke-dasharray: var(--path-length) var(--path-length);
|
||||
opacity: 0;
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
5% {
|
||||
opacity: 1;
|
||||
stroke-dashoffset: var(--path-length);
|
||||
stroke-dasharray: var(--path-length) var(--path-length);
|
||||
}
|
||||
|
||||
50% {
|
||||
stroke-dashoffset: 0;
|
||||
stroke-dasharray: var(--path-length) var(--path-length);
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
60% {
|
||||
stroke-dashoffset: 0;
|
||||
stroke-dasharray: var(--path-length) var(--path-length);
|
||||
fill: currentColor;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
75% {
|
||||
stroke-dashoffset: 0;
|
||||
stroke-dasharray: var(--path-length) var(--path-length);
|
||||
fill: currentColor;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
85% {
|
||||
stroke-dashoffset: 0;
|
||||
stroke-dasharray: var(--path-length) var(--path-length);
|
||||
fill: transparent;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
95% {
|
||||
stroke-dashoffset: var(--path-length);
|
||||
stroke-dasharray: var(--path-length) var(--path-length);
|
||||
fill: transparent;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
stroke-dashoffset: var(--path-length);
|
||||
stroke-dasharray: var(--path-length) var(--path-length);
|
||||
fill: transparent;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 确保在暗色模式下的颜色正确 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.animated-text path {
|
||||
stroke: currentColor;
|
||||
}
|
||||
}
|
508
web/czr/app/init.tsx
Normal file
508
web/czr/app/init.tsx
Normal file
@ -0,0 +1,508 @@
|
||||
import React, { createContext, useState } from "react";
|
||||
import { DEFAULT_CONFIG } from "app/env";
|
||||
import { HttpClient } from "core/http";
|
||||
import { ThemeModeToggle } from "hooks/themeMode";
|
||||
import {
|
||||
Theme,
|
||||
Button,
|
||||
Select,
|
||||
Flex,
|
||||
Container,
|
||||
Heading,
|
||||
Text,
|
||||
Box,
|
||||
TextField,
|
||||
} from "@radix-ui/themes";
|
||||
import { toast } from "hooks/notification";
|
||||
import { Echoes } from "hooks/echoes";
|
||||
|
||||
interface SetupContextType {
|
||||
currentStep: number;
|
||||
setCurrentStep: (step: number) => void;
|
||||
}
|
||||
|
||||
const SetupContext = createContext<SetupContextType>({
|
||||
currentStep: 1,
|
||||
setCurrentStep: () => {},
|
||||
});
|
||||
|
||||
// 步骤组件的通用属性接口
|
||||
interface StepProps {
|
||||
onNext: () => void;
|
||||
}
|
||||
|
||||
const StepContainer: React.FC<{ title: string; children: React.ReactNode }> = ({
|
||||
title,
|
||||
children,
|
||||
}) => (
|
||||
<Box style={{ width: "90%", maxWidth: "600px", margin: "0 auto" }}>
|
||||
<Heading size="5" mb="4" weight="bold">
|
||||
{title}
|
||||
</Heading>
|
||||
<Flex direction="column" gap="4">
|
||||
{children}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
|
||||
// 通用的导航按钮组件
|
||||
const NavigationButtons: React.FC<
|
||||
StepProps & { loading?: boolean; disabled?: boolean }
|
||||
> = ({ onNext, loading = false, disabled = false }) => (
|
||||
<Flex justify="end" mt="4">
|
||||
<Button
|
||||
size="3"
|
||||
disabled={loading || disabled}
|
||||
onClick={onNext}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{loading ? "处理中..." : "下一步"}
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
// 修改输入框组件
|
||||
const InputField: React.FC<{
|
||||
label: string;
|
||||
name: string;
|
||||
defaultValue?: string | number;
|
||||
hint?: string;
|
||||
required?: boolean;
|
||||
}> = ({ label, name, defaultValue, hint, required = true }) => (
|
||||
<Box mb="4">
|
||||
<Text as="label" size="2" weight="medium" className="block mb-2">
|
||||
{label} {required && <Text color="red">*</Text>}
|
||||
</Text>
|
||||
<TextField.Root
|
||||
name={name}
|
||||
defaultValue={defaultValue?.toString()}
|
||||
required={required}
|
||||
>
|
||||
<TextField.Slot></TextField.Slot>
|
||||
</TextField.Root>
|
||||
{hint && (
|
||||
<Text color="gray" size="1" mt="1">
|
||||
{hint}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const Introduction: React.FC<StepProps> = ({ onNext }) => (
|
||||
<StepContainer title="安装说明">
|
||||
<Text size="3" style={{ lineHeight: 1.6 }}>
|
||||
欢迎使用 Echoes
|
||||
</Text>
|
||||
<NavigationButtons onNext={onNext} />
|
||||
</StepContainer>
|
||||
);
|
||||
|
||||
const DatabaseConfig: React.FC<StepProps> = ({ onNext }) => {
|
||||
const [dbType, setDbType] = useState("postgresql");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const http = HttpClient.getInstance();
|
||||
|
||||
const validateForm = () => {
|
||||
const getRequiredFields = () => {
|
||||
switch (dbType) {
|
||||
case "sqllite":
|
||||
return ["db_prefix", "db_name"];
|
||||
case "postgresql":
|
||||
case "mysql":
|
||||
return [
|
||||
"db_host",
|
||||
"db_prefix",
|
||||
"db_port",
|
||||
"db_user",
|
||||
"db_password",
|
||||
"db_name",
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const requiredFields = getRequiredFields();
|
||||
const emptyFields: string[] = [];
|
||||
|
||||
requiredFields.forEach((field) => {
|
||||
const input = document.querySelector(
|
||||
`[name="${field}"]`,
|
||||
) as HTMLInputElement;
|
||||
if (input && (!input.value || input.value.trim() === "")) {
|
||||
emptyFields.push(field);
|
||||
}
|
||||
});
|
||||
|
||||
if (emptyFields.length > 0) {
|
||||
const fieldNames = emptyFields.map((field) => {
|
||||
switch (field) {
|
||||
case "db_host":
|
||||
return "数据库地址";
|
||||
case "db_prefix":
|
||||
return "数据库前缀";
|
||||
case "db_port":
|
||||
return "端口";
|
||||
case "db_user":
|
||||
return "用户名";
|
||||
case "db_password":
|
||||
return "密码";
|
||||
case "db_name":
|
||||
return "数据库名";
|
||||
default:
|
||||
return field;
|
||||
}
|
||||
});
|
||||
toast.error(`请填写以下必填项:${fieldNames.join("、")}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleNext = async () => {
|
||||
const validation = validateForm();
|
||||
if (validation !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const formData = {
|
||||
db_type: dbType,
|
||||
host:
|
||||
(
|
||||
document.querySelector('[name="db_host"]') as HTMLInputElement
|
||||
)?.value?.trim() ?? "",
|
||||
db_prefix:
|
||||
(
|
||||
document.querySelector('[name="db_prefix"]') as HTMLInputElement
|
||||
)?.value?.trim() ?? "",
|
||||
port: Number(
|
||||
(
|
||||
document.querySelector('[name="db_port"]') as HTMLInputElement
|
||||
)?.value?.trim() ?? 0,
|
||||
),
|
||||
user:
|
||||
(
|
||||
document.querySelector('[name="db_user"]') as HTMLInputElement
|
||||
)?.value?.trim() ?? "",
|
||||
password:
|
||||
(
|
||||
document.querySelector('[name="db_password"]') as HTMLInputElement
|
||||
)?.value?.trim() ?? "",
|
||||
db_name:
|
||||
(
|
||||
document.querySelector('[name="db_name"]') as HTMLInputElement
|
||||
)?.value?.trim() ?? "",
|
||||
};
|
||||
|
||||
await http.post("/sql", formData);
|
||||
|
||||
let oldEnv = import.meta.env ?? DEFAULT_CONFIG;
|
||||
const viteEnv = Object.entries(oldEnv).reduce(
|
||||
(acc, [key, value]) => {
|
||||
if (key.startsWith("VITE_")) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
|
||||
const newEnv = {
|
||||
...viteEnv,
|
||||
VITE_INIT_STATUS: "2",
|
||||
};
|
||||
|
||||
await http.dev("/env", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(newEnv),
|
||||
});
|
||||
|
||||
Object.assign(import.meta.env, newEnv);
|
||||
|
||||
toast.success("数据库配置成功!");
|
||||
|
||||
setTimeout(() => onNext(), 1000);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.message, error.title);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StepContainer title="数据库配置">
|
||||
<div>
|
||||
<Box mb="6">
|
||||
<Text as="label" size="2" weight="medium" mb="2" className="block">
|
||||
数据库类型
|
||||
</Text>
|
||||
<Select.Root value={dbType} onValueChange={setDbType}>
|
||||
<Select.Trigger />
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
<Select.Item value="postgresql">PostgreSQL</Select.Item>
|
||||
<Select.Item value="mysql">MySQL</Select.Item>
|
||||
<Select.Item value="sqllite">SQLite</Select.Item>
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Box>
|
||||
|
||||
{dbType === "postgresql" && (
|
||||
<>
|
||||
<InputField
|
||||
label="数据库地址"
|
||||
name="db_host"
|
||||
defaultValue="localhost"
|
||||
hint="通常使 localhost"
|
||||
required
|
||||
/>
|
||||
<InputField
|
||||
label="数据库前缀"
|
||||
name="db_prefix"
|
||||
defaultValue="echoec_"
|
||||
hint="通常使用 echoec_"
|
||||
required
|
||||
/>
|
||||
<InputField
|
||||
label="端口"
|
||||
name="db_port"
|
||||
defaultValue={5432}
|
||||
hint="PostgreSQL 默认端口为 5432"
|
||||
required
|
||||
/>
|
||||
<InputField
|
||||
label="用户名"
|
||||
name="db_user"
|
||||
defaultValue="postgres"
|
||||
required
|
||||
/>
|
||||
<InputField
|
||||
label="密码"
|
||||
name="db_password"
|
||||
defaultValue="postgres"
|
||||
required
|
||||
/>
|
||||
<InputField
|
||||
label="数据库名"
|
||||
name="db_name"
|
||||
defaultValue="echoes"
|
||||
required
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{dbType === "mysql" && (
|
||||
<>
|
||||
<InputField
|
||||
label="数据库地址"
|
||||
name="db_host"
|
||||
defaultValue="localhost"
|
||||
hint="通常使用 localhost"
|
||||
required
|
||||
/>
|
||||
<InputField
|
||||
label="数据库前缀"
|
||||
name="db_prefix"
|
||||
defaultValue="echoec_"
|
||||
hint="通常使用 echoec_"
|
||||
required
|
||||
/>
|
||||
<InputField
|
||||
label="端口"
|
||||
name="db_port"
|
||||
defaultValue={3306}
|
||||
hint="mysql 默认端口为 3306"
|
||||
required
|
||||
/>
|
||||
<InputField
|
||||
label="用户名"
|
||||
name="db_user"
|
||||
defaultValue="root"
|
||||
required
|
||||
/>
|
||||
<InputField
|
||||
label="密码"
|
||||
name="db_password"
|
||||
defaultValue="mysql"
|
||||
required
|
||||
/>
|
||||
<InputField
|
||||
label="数据库名"
|
||||
name="db_name"
|
||||
defaultValue="echoes"
|
||||
required
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{dbType === "sqllite" && (
|
||||
<>
|
||||
<InputField
|
||||
label="数据库前缀"
|
||||
name="db_prefix"
|
||||
defaultValue="echoec_"
|
||||
hint="通常使用 echoec_"
|
||||
required
|
||||
/>
|
||||
<InputField
|
||||
label="数据库名"
|
||||
name="db_name"
|
||||
defaultValue="echoes.db"
|
||||
required
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<NavigationButtons
|
||||
onNext={handleNext}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</StepContainer>
|
||||
);
|
||||
};
|
||||
|
||||
interface InstallReplyData {
|
||||
token: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const AdminConfig: React.FC<StepProps> = ({ onNext }) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const http = HttpClient.getInstance();
|
||||
|
||||
const handleNext = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const formData = {
|
||||
username: (
|
||||
document.querySelector('[name="admin_username"]') as HTMLInputElement
|
||||
)?.value,
|
||||
password: (
|
||||
document.querySelector('[name="admin_password"]') as HTMLInputElement
|
||||
)?.value,
|
||||
email: (
|
||||
document.querySelector('[name="admin_email"]') as HTMLInputElement
|
||||
)?.value,
|
||||
};
|
||||
|
||||
const response = (await http.post(
|
||||
"/administrator",
|
||||
formData,
|
||||
)) as InstallReplyData;
|
||||
const data = response;
|
||||
|
||||
localStorage.setItem("token", data.token);
|
||||
|
||||
let oldEnv = import.meta.env ?? DEFAULT_CONFIG;
|
||||
const viteEnv = Object.entries(oldEnv).reduce(
|
||||
(acc, [key, value]) => {
|
||||
if (key.startsWith("VITE_")) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
|
||||
const newEnv = {
|
||||
...viteEnv,
|
||||
VITE_INIT_STATUS: "3",
|
||||
VITE_API_USERNAME: data.username,
|
||||
VITE_API_PASSWORD: data.password,
|
||||
};
|
||||
|
||||
await http.dev("/env", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(newEnv),
|
||||
});
|
||||
|
||||
Object.assign(import.meta.env, newEnv);
|
||||
|
||||
toast.success("管理员账号创建成功!");
|
||||
onNext();
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error(error.message, error.title);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StepContainer title="创建管理员账号">
|
||||
<div className="space-y-6">
|
||||
<InputField label="用户名" name="admin_username" />
|
||||
<InputField label="密码" name="admin_password" />
|
||||
<InputField label="邮箱" name="admin_email" />
|
||||
<NavigationButtons onNext={handleNext} loading={loading} />
|
||||
</div>
|
||||
</StepContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const SetupComplete: React.FC = () => (
|
||||
<StepContainer title="安装完成">
|
||||
<Flex direction="column" align="center" gap="4">
|
||||
<Text size="5" weight="medium">
|
||||
恭喜!安装已完成
|
||||
</Text>
|
||||
<Text size="3">系统正在重启中,请稍候...</Text>
|
||||
<Box mt="4">
|
||||
<Flex justify="center">
|
||||
<Box className="animate-spin rounded-full h-8 w-8 border-b-2 border-current"></Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Flex>
|
||||
</StepContainer>
|
||||
);
|
||||
|
||||
export default function SetupPage() {
|
||||
const [currentStep, setCurrentStep] = useState(() => {
|
||||
return Number(import.meta.env.VITE_INIT_STATUS ?? 0) + 1;
|
||||
});
|
||||
|
||||
return (
|
||||
<Theme
|
||||
grayColor="gray"
|
||||
accentColor="gray"
|
||||
radius="medium"
|
||||
panelBackground="solid"
|
||||
appearance="inherit"
|
||||
>
|
||||
<Box className="min-h-screen w-full">
|
||||
<Box position="fixed" top="2" right="4">
|
||||
<ThemeModeToggle />
|
||||
</Box>
|
||||
|
||||
<Flex justify="center" pt="2">
|
||||
<Box className="w-20 h-20">
|
||||
<Echoes />
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
<Flex direction="column" className="min-h-screen w-full pb-4">
|
||||
<Container className="w-full">
|
||||
<SetupContext.Provider value={{ currentStep, setCurrentStep }}>
|
||||
{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>
|
||||
</Box>
|
||||
</Theme>
|
||||
);
|
||||
}
|
31
web/czr/app/root.tsx
Normal file
31
web/czr/app/root.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import {
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
} from "@remix-run/react";
|
||||
|
||||
import "./index.css"
|
||||
import Navigation from '~/components/Navigation';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body suppressHydrationWarning={true}>
|
||||
<Navigation />
|
||||
<div className="pt-16">
|
||||
<Outlet />
|
||||
</div>
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
46
web/czr/app/routes.tsx
Normal file
46
web/czr/app/routes.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import ErrorPage from "hooks/error";
|
||||
import layout from "themes/echoes/layout";
|
||||
import article from "themes/echoes/article";
|
||||
import about from "themes/echoes/about";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import post from "themes/echoes/post";
|
||||
|
||||
export default function Routes() {
|
||||
const location = useLocation();
|
||||
let path = location.pathname;
|
||||
|
||||
const args = {
|
||||
title: "我的页面",
|
||||
theme: "dark",
|
||||
nav: '<a href="/">index</a><a href="/error">error</a><a href="/about">about</a><a href="/post">post</a>',
|
||||
};
|
||||
|
||||
console.log(path);
|
||||
path = path.split("/")[1];
|
||||
|
||||
if (path === "error") {
|
||||
return layout.render({
|
||||
children: ErrorPage.render(args),
|
||||
args,
|
||||
});
|
||||
}
|
||||
|
||||
if (path === "about") {
|
||||
return layout.render({
|
||||
children: about.render(args),
|
||||
args,
|
||||
});
|
||||
}
|
||||
|
||||
if (path === "post") {
|
||||
return layout.render({
|
||||
children: post.render(args),
|
||||
args,
|
||||
});
|
||||
}
|
||||
|
||||
return layout.render({
|
||||
children: article.render(args),
|
||||
args,
|
||||
});
|
||||
}
|
146
web/czr/app/routes/_index.tsx
Normal file
146
web/czr/app/routes/_index.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
import type { MetaFunction } from "@remix-run/node";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: "新纪元科技 - 引领创新未来" },
|
||||
{ name: "description", content: "专注于环保科技创新,为可持续发展提供解决方案" },
|
||||
];
|
||||
};
|
||||
|
||||
export default function Index() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-white text-gray-800">
|
||||
|
||||
{/* Hero区域 */}
|
||||
<div className="relative overflow-hidden bg-gradient-to-r from-green-50 to-blue-50">
|
||||
<div className="max-w-7xl mx-auto px-4 py-24">
|
||||
<div className="text-center">
|
||||
<h1 className="text-5xl font-bold mb-6 bg-gradient-to-r from-green-600 to-blue-600 bg-clip-text text-transparent">
|
||||
创新科技,守护地球
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 max-w-2xl mx-auto mb-10">
|
||||
致力于开发环保科技解决方案,用创新力量推动可持续发展
|
||||
</p>
|
||||
<button className="bg-gradient-to-r from-green-600 to-blue-600 text-white px-8 py-3 rounded-full font-semibold
|
||||
hover:opacity-90 transition-all duration-300 hover:scale-105 hover:shadow-lg">
|
||||
了解更多
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 核心技术 */}
|
||||
<section className="py-20 bg-white" id="innovations">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<h2 className="text-3xl font-bold text-center mb-16">核心创新技术</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-12">
|
||||
<div className="text-center group transform transition-all duration-300 hover:-translate-y-2 hover:shadow-xl rounded-xl p-6">
|
||||
<div className="bg-green-50 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6
|
||||
group-hover:bg-green-100 transition-colors duration-300 group-hover:scale-110">
|
||||
<svg className="w-10 h-10 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-4">智能环保系统</h3>
|
||||
<p className="text-gray-600">采用AI技术优化资源利用,提高能源使用效率</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center group transform transition-all duration-300 hover:-translate-y-2 hover:shadow-xl rounded-xl p-6">
|
||||
<div className="bg-blue-50 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6
|
||||
group-hover:bg-blue-100 transition-colors duration-300 group-hover:scale-110">
|
||||
<svg className="w-10 h-10 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-4">新能源转换</h3>
|
||||
<p className="text-gray-600">创新能源转换技术,实现清洁能源的高效利用</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center group transform transition-all duration-300 hover:-translate-y-2 hover:shadow-xl rounded-xl p-6">
|
||||
<div className="bg-cyan-50 w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6
|
||||
group-hover:bg-cyan-100 transition-colors duration-300 group-hover:scale-110">
|
||||
<svg className="w-10 h-10 text-cyan-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold mb-4">生态监测</h3>
|
||||
<p className="text-gray-600">全方位环境监测系统,保护生态平衡</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 解决方案 */}
|
||||
<section className="py-20 bg-gradient-to-r from-green-50 to-blue-50" id="solutions">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<h2 className="text-3xl font-bold text-center mb-16">创新解决方案</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div className="bg-white rounded-lg p-8 shadow-sm hover:shadow-xl transition-all duration-300 hover:-translate-y-1">
|
||||
<h3 className="text-xl font-semibold mb-4">智慧城市环保系统</h3>
|
||||
<p className="text-gray-600 mb-4">整合城市环境数据,提供智能化环保解决方案</p>
|
||||
<ul className="text-gray-600 space-y-2">
|
||||
<li>• 空气质量实时监测</li>
|
||||
<li>• 智能垃圾分类系统</li>
|
||||
<li>• 城市能源管理优化</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-8 shadow-sm hover:shadow-xl transition-all duration-300 hover:-translate-y-1">
|
||||
<h3 className="text-xl font-semibold mb-4">工业节能方案</h3>
|
||||
<p className="text-gray-600 mb-4">为工业企业提供全方位的节能减排解决方案</p>
|
||||
<ul className="text-gray-600 space-y-2">
|
||||
<li className="transition-all duration-200 hover:text-green-600 hover:translate-x-2">• 能源使用效率优化</li>
|
||||
<li className="transition-all duration-200 hover:text-green-600 hover:translate-x-2">• 废物循环利用系统</li>
|
||||
<li className="transition-all duration-200 hover:text-green-600 hover:translate-x-2">• 清洁生产技术改造</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 页脚 */}
|
||||
<footer className="bg-gray-900 text-white py-12">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-4">关于我们</h4>
|
||||
<p className="text-gray-400">新纪元科技致力于环保科技创新,为地球可持续发展贡献力量</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-4">联系方式</h4>
|
||||
<p className="text-gray-400">电话:400-888-8888</p>
|
||||
<p className="text-gray-400">邮箱:contact@xingjiyuan.com</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-4">解决方案</h4>
|
||||
<ul className="text-gray-400 space-y-2">
|
||||
<li>
|
||||
<a href="#" className="hover:text-white transition-colors duration-200 hover:translate-x-2 inline-block">
|
||||
智慧城市
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" className="hover:text-white transition-colors duration-200 hover:translate-x-2 inline-block">
|
||||
工业节能
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" className="hover:text-white transition-colors duration-200 hover:translate-x-2 inline-block">
|
||||
生态监测
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold mb-4">公司地址</h4>
|
||||
<p className="text-gray-400">中国上海市浦东新区科技创新大道888号</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-gray-800 mt-8 pt-8 text-center text-gray-400">
|
||||
<p>© 2024 新纪元科技 版权所有</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
67
web/czr/app/routes/about.tsx
Normal file
67
web/czr/app/routes/about.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import type { MetaFunction } from "@remix-run/node";
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: "关于我们 - 新纪元科技" },
|
||||
{ name: "description", content: "了解新纪元科技的使命与愿景" },
|
||||
];
|
||||
};
|
||||
|
||||
export default function About() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-white text-gray-800">
|
||||
|
||||
{/* 页面标题 */}
|
||||
<div className="bg-gradient-to-r from-green-50 to-blue-50 py-20">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<h1 className="text-4xl font-bold text-center bg-gradient-to-r from-green-600 to-blue-600 bg-clip-text text-transparent">
|
||||
关于我们
|
||||
</h1>
|
||||
<p className="text-center text-gray-600 mt-4 max-w-2xl mx-auto">
|
||||
致力于用科技创新推动环保事业发展
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 公司介绍 */}
|
||||
<div className="py-20">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
|
||||
<div className="transform transition-all duration-500 hover:scale-105">
|
||||
<h2 className="text-3xl font-bold mb-6 relative after:content-[''] after:absolute after:bottom-0
|
||||
after:left-0 after:w-20 after:h-1 after:bg-gradient-to-r after:from-green-500 after:to-blue-500">
|
||||
公司简介
|
||||
</h2>
|
||||
<p className="text-gray-600 mb-4">
|
||||
新纪元科技成立于2020年,是一家专注于环保科技创新的高新技术企业。我们致力于通过技术创新解决环境问题,推动可持续发展。
|
||||
</p>
|
||||
<p className="text-gray-600">
|
||||
公司拥有一支专业的研发团队,在环保技术领域具有深厚的积累和创新能力。
|
||||
</p>
|
||||
</div>
|
||||
<div className="transform transition-all duration-500 hover:scale-105">
|
||||
<h2 className="text-3xl font-bold mb-6 relative after:content-[''] after:absolute after:bottom-0
|
||||
after:left-0 after:w-20 after:h-1 after:bg-gradient-to-r after:from-green-500 after:to-blue-500">
|
||||
愿景使命
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">愿景</h3>
|
||||
<p className="text-gray-600">成为全球领先的环保科技创新企业</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2">使命</h3>
|
||||
<p className="text-gray-600">用科技创新守护地球家园</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 页脚 */}
|
||||
<footer className="bg-gray-900 text-white py-12">
|
||||
{/* 同首页页脚内容 */}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
157
web/czr/app/routes/innovations.tsx
Normal file
157
web/czr/app/routes/innovations.tsx
Normal file
@ -0,0 +1,157 @@
|
||||
import type { MetaFunction } from "@remix-run/node";
|
||||
import { ImageLoader } from "hooks/ParticleImage";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
import { Carousel } from "~/components/Carousel";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: "创新技术 - 新纪元科技" },
|
||||
{ name: "description", content: "新纪元科技的创新技术" },
|
||||
];
|
||||
};
|
||||
|
||||
export const loader = () => {
|
||||
return {
|
||||
isClient: true,
|
||||
innovations: [
|
||||
{
|
||||
title: "智能环境监测",
|
||||
image: "/a1.jpg",
|
||||
},
|
||||
{
|
||||
title: "清洁能源技术",
|
||||
image: "/a2.jpg",
|
||||
},
|
||||
{
|
||||
title: "废物处理创新",
|
||||
image: "/a3.jpg",
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
|
||||
export default function Innovations() {
|
||||
const { isClient, innovations } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-white text-gray-800">
|
||||
{/* 头部区域:标题 + 轮播图 */}
|
||||
<div className="relative bg-gradient-to-b from-green-50 to-blue-50/30 pt-16 pb-32 overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
{/* 标题部分 */}
|
||||
<div className="text-center mb-16">
|
||||
<h1 className="text-5xl font-bold bg-gradient-to-r from-green-600 to-blue-600 bg-clip-text text-transparent">
|
||||
创新技术
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-6 text-lg max-w-2xl mx-auto">
|
||||
引领环保科技发展,推动行业技术革新
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 轮播图部分 */}
|
||||
{isClient ? (
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-white/10 pointer-events-none" />
|
||||
<div className="flex justify-center">
|
||||
<div className="w-[600px]">
|
||||
<Carousel
|
||||
items={innovations.map((innovation) => ({
|
||||
content: (
|
||||
<div className="w-[600px] h-[400px] relative rounded-xl overflow-hidden">
|
||||
<ImageLoader
|
||||
src={innovation.image}
|
||||
alt={innovation.title}
|
||||
className="relative z-[1]"
|
||||
containerClassName="w-full h-full"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent z-[2]" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-8 z-[3]">
|
||||
<h3 className="text-white text-3xl font-bold mb-2">{innovation.title}</h3>
|
||||
<p className="text-white/80 text-lg">探索环保科技的无限可能</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
interval={5000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
<div className="w-[600px] h-[400px] bg-gray-100 rounded-xl" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 技术详情部分 */}
|
||||
<div className="py-24 bg-white">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<h2 className="text-4xl font-bold text-center mb-20">
|
||||
<span className="bg-gradient-to-r from-green-600 to-blue-600 bg-clip-text text-transparent">
|
||||
核心技术详解
|
||||
</span>
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div className="bg-white rounded-lg p-8 shadow-lg transform transition-all duration-300
|
||||
hover:-translate-y-2 hover:shadow-xl">
|
||||
<div className="bg-green-50 w-16 h-16 rounded-full flex items-center justify-center mb-6
|
||||
transform transition-all duration-300 hover:scale-110 hover:rotate-12">
|
||||
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-4 relative">
|
||||
<span className="relative z-10">AI环境优化</span>
|
||||
<span className="absolute bottom-0 left-0 w-full h-2 bg-green-100 transform -skew-x-12"></span>
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
运用人工智能技术,实现环境数据的智能分析和决策优化,提供精准的环境治理方案。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-8 shadow-lg transform transition-all duration-300
|
||||
hover:-translate-y-2 hover:shadow-xl">
|
||||
<div className="bg-blue-50 w-16 h-16 rounded-full flex items-center justify-center mb-6
|
||||
transform transition-all duration-300 hover:scale-110 hover:rotate-12">
|
||||
<svg className="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-4 relative">
|
||||
<span className="relative z-10">清洁能源转换</span>
|
||||
<span className="absolute bottom-0 left-0 w-full h-2 bg-blue-100 transform -skew-x-12"></span>
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
创新的能源转换技术,提高清洁能源利用效率,推动色能源革命。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-8 shadow-lg transform transition-all duration-300
|
||||
hover:-translate-y-2 hover:shadow-xl">
|
||||
<div className="bg-purple-50 w-16 h-16 rounded-full flex items-center justify-center mb-6
|
||||
transform transition-all duration-300 hover:scale-110 hover:rotate-12">
|
||||
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold mb-4 relative">
|
||||
<span className="relative z-10">生态修复系统</span>
|
||||
<span className="absolute bottom-0 left-0 w-full h-2 bg-purple-100 transform -skew-x-12"></span>
|
||||
</h3>
|
||||
<p className="text-gray-600">
|
||||
综合性生态环境修复解决方案,助力自然生态系统恢复与保护
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 页脚 */}
|
||||
<footer className="bg-gray-900 text-white py-12">
|
||||
{/* 同首页页脚内容 */}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
105
web/czr/app/routes/solutions.tsx
Normal file
105
web/czr/app/routes/solutions.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import type { MetaFunction } from "@remix-run/node";
|
||||
import { ImageLoader } from "hooks/ParticleImage";
|
||||
import { useLoaderData } from "@remix-run/react";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: "解决方案 - 新纪元科技" },
|
||||
{ name: "description", content: "新纪元科技提供的环保科技解决方案" },
|
||||
];
|
||||
};
|
||||
|
||||
export const loader = () => {
|
||||
return { isClient: true };
|
||||
};
|
||||
|
||||
export default function Solutions() {
|
||||
const { isClient } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-white text-gray-800">
|
||||
{/* 页面标题和轮播图 */}
|
||||
<div className="bg-gradient-to-r from-green-50 to-blue-50 py-20">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex flex-col items-center">
|
||||
{isClient ? (
|
||||
<div className="w-[60px] md:w-[70px] h-[60px] md:h-[70px]">
|
||||
{/* 轮播图代码从这里开始 */}
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-[60px] md:w-[70px] h-[60px] md:h-[70px] bg-gray-100 rounded-lg" />
|
||||
)}
|
||||
<h1 className="text-4xl font-bold text-center mt-8 bg-gradient-to-r from-green-600 to-blue-600 bg-clip-text text-transparent">
|
||||
解决方案
|
||||
</h1>
|
||||
<p className="text-center text-gray-600 mt-4 max-w-2xl mx-auto">
|
||||
为不同行业提供定制化的环保科技解决方案,助力企业实现可持续发展
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 解决方案详情 */}
|
||||
<div className="py-20">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-12">
|
||||
<div className="bg-white rounded-lg p-8 shadow-lg transform transition-all duration-300
|
||||
hover:-translate-y-2 hover:shadow-xl border border-transparent hover:border-green-100">
|
||||
<h2 className="text-2xl font-bold mb-6 relative inline-block">
|
||||
<span className="relative z-10">智慧城市解决方案</span>
|
||||
<span className="absolute bottom-0 left-0 w-full h-2 bg-green-100 transform -skew-x-12"></span>
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600">通过智能技术优化城市环境管理</p>
|
||||
<ul className="space-y-2 text-gray-600">
|
||||
<li className="transition-all duration-200 hover:text-green-600 hover:translate-x-2">
|
||||
• 智能环境监测系统
|
||||
</li>
|
||||
<li className="transition-all duration-200 hover:text-green-600 hover:translate-x-2">
|
||||
• 城市垃圾分类管理
|
||||
</li>
|
||||
<li className="transition-all duration-200 hover:text-green-600 hover:translate-x-2">
|
||||
• 智慧能源管理平台
|
||||
</li>
|
||||
<li className="transition-all duration-200 hover:text-green-600 hover:translate-x-2">
|
||||
• 城市空气质量优化
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg p-8 shadow-lg transform transition-all duration-300
|
||||
hover:-translate-y-2 hover:shadow-xl border border-transparent hover:border-green-100">
|
||||
<h2 className="text-2xl font-bold mb-6 relative inline-block">
|
||||
<span className="relative z-10">工业节能方案</span>
|
||||
<span className="absolute bottom-0 left-0 w-full h-2 bg-green-100 transform -skew-x-12"></span>
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<p className="text-gray-600">帮助工业企业实现节能减排</p>
|
||||
<ul className="space-y-2 text-gray-600">
|
||||
<li className="transition-all duration-200 hover:text-green-600 hover:translate-x-2">
|
||||
• 工业能源审计
|
||||
</li>
|
||||
<li className="transition-all duration-200 hover:text-green-600 hover:translate-x-2">
|
||||
• 节能改造方案
|
||||
</li>
|
||||
<li className="transition-all duration-200 hover:text-green-600 hover:translate-x-2">
|
||||
• 废物循环利用
|
||||
</li>
|
||||
<li className="transition-all duration-200 hover:text-green-600 hover:translate-x-2">
|
||||
• 清洁生产技术
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 页脚 - 可以提取为共享组件 */}
|
||||
<footer className="bg-gray-900 text-white py-12">
|
||||
{/* 同首页页脚内容 */}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
5
web/czr/app/styles/navigation.css
Normal file
5
web/czr/app/styles/navigation.css
Normal file
@ -0,0 +1,5 @@
|
||||
.nav-logo svg {
|
||||
width: 120px;
|
||||
height: auto;
|
||||
color: #1a1a1a;
|
||||
}
|
711
web/czr/hooks/ParticleImage.tsx
Normal file
711
web/czr/hooks/ParticleImage.tsx
Normal file
@ -0,0 +1,711 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { gsap } from 'gsap';
|
||||
import throttle from 'lodash/throttle';
|
||||
|
||||
interface HSL {
|
||||
h: number;
|
||||
s: number;
|
||||
l: number;
|
||||
}
|
||||
|
||||
interface Particle {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
originalX: number;
|
||||
originalY: number;
|
||||
originalColor: THREE.Color;
|
||||
delay: number;
|
||||
}
|
||||
|
||||
const createErrorParticles = (width: number, height: number) => {
|
||||
const particles: Particle[] = [];
|
||||
const positionArray: number[] = [];
|
||||
const colorArray: number[] = [];
|
||||
|
||||
const errorColor = new THREE.Color(0.8, 0, 0);
|
||||
const size = Math.min(width, height);
|
||||
const scaleFactor = size * 0.3;
|
||||
const particlesPerLine = 50;
|
||||
|
||||
// X 形状的两条线
|
||||
const lines = [
|
||||
// 左上到右下的线
|
||||
{ start: [-1, 1], end: [1, -1] },
|
||||
// 右上到左下的线
|
||||
{ start: [1, 1], end: [-1, -1] }
|
||||
];
|
||||
|
||||
lines.forEach(line => {
|
||||
for (let i = 0; i < particlesPerLine; i++) {
|
||||
const t = i / (particlesPerLine - 1);
|
||||
const x = line.start[0] + (line.end[0] - line.start[0]) * t;
|
||||
const y = line.start[1] + (line.end[1] - line.start[1]) * t;
|
||||
|
||||
// 添加一些随机偏移
|
||||
const randomOffset = 0.1;
|
||||
const randomX = x + (Math.random() - 0.5) * randomOffset;
|
||||
const randomY = y + (Math.random() - 0.5) * randomOffset;
|
||||
|
||||
const scaledX = randomX * scaleFactor;
|
||||
const scaledY = randomY * scaleFactor;
|
||||
|
||||
particles.push({
|
||||
x: scaledX,
|
||||
y: scaledY,
|
||||
z: 0,
|
||||
originalX: scaledX,
|
||||
originalY: scaledY,
|
||||
originalColor: errorColor,
|
||||
delay: 0
|
||||
});
|
||||
|
||||
// 修改初始位置生成方式
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const distance = size * 2;
|
||||
positionArray.push(
|
||||
Math.cos(angle) * distance,
|
||||
Math.sin(angle) * distance,
|
||||
0
|
||||
);
|
||||
|
||||
// 初始颜色设置为最终颜色的一半亮度
|
||||
colorArray.push(errorColor.r * 0.5, errorColor.g * 0.5, errorColor.b * 0.5);
|
||||
}
|
||||
});
|
||||
|
||||
const particleSize = Math.max(1.2, (size / 200) * 1.2);
|
||||
|
||||
return { particles, positionArray, colorArray, particleSize };
|
||||
};
|
||||
|
||||
// 修改 createSmileParticles 函数
|
||||
const createSmileParticles = (width: number, height: number) => {
|
||||
const particles: Particle[] = [];
|
||||
const positionArray: number[] = [];
|
||||
const colorArray: number[] = [];
|
||||
|
||||
const size = Math.min(width, height);
|
||||
const scale = size / 200;
|
||||
const radius = size * 0.35;
|
||||
const particleSize = Math.max(1.2, scale * 1.2);
|
||||
const particleColor = new THREE.Color(0.8, 0.6, 0);
|
||||
|
||||
// 预先计算所有需要的粒子位置
|
||||
const allPoints: { x: number; y: number }[] = [];
|
||||
|
||||
// 计算脸部轮廓的点
|
||||
const outlinePoints = Math.floor(60 * scale);
|
||||
for (let i = 0; i < outlinePoints; i++) {
|
||||
const angle = (i / outlinePoints) * Math.PI * 2;
|
||||
allPoints.push({
|
||||
x: Math.cos(angle) * radius,
|
||||
y: Math.sin(angle) * radius
|
||||
});
|
||||
}
|
||||
|
||||
// 修改眼睛的生成方式
|
||||
const eyeOffset = radius * 0.3;
|
||||
const eyeY = radius * 0.15;
|
||||
const eyeSize = radius * 0.1; // 稍微减小眼睛尺寸
|
||||
const eyePoints = Math.floor(20 * scale);
|
||||
|
||||
[-1, 1].forEach(side => {
|
||||
// 使用同心圆的方式生成眼睛
|
||||
const eyeCenterX = side * eyeOffset;
|
||||
const rings = 3; // 同心圆的数量
|
||||
|
||||
for (let ring = 0; ring < rings; ring++) {
|
||||
const ringRadius = eyeSize * (1 - ring / rings); // 从外到内递减半径
|
||||
const pointsInRing = Math.floor(eyePoints / rings);
|
||||
|
||||
for (let i = 0; i < pointsInRing; i++) {
|
||||
const angle = (i / pointsInRing) * Math.PI * 2;
|
||||
allPoints.push({
|
||||
x: eyeCenterX + Math.cos(angle) * ringRadius,
|
||||
y: eyeY + Math.sin(angle) * ringRadius
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 添加中心点
|
||||
allPoints.push({
|
||||
x: eyeCenterX,
|
||||
y: eyeY
|
||||
});
|
||||
});
|
||||
|
||||
// 计算嘴巴的点
|
||||
const smileWidth = radius * 0.6;
|
||||
const smileY = -radius * 0.35;
|
||||
const smilePoints = Math.floor(25 * scale);
|
||||
|
||||
for (let i = 0; i < smilePoints; i++) {
|
||||
const t = i / (smilePoints - 1);
|
||||
const x = (t * 2 - 1) * smileWidth;
|
||||
const y = smileY + Math.pow(x / smileWidth, 2) * radius * 0.2;
|
||||
allPoints.push({ x, y });
|
||||
}
|
||||
|
||||
// 为所有点创建粒子
|
||||
allPoints.forEach(point => {
|
||||
particles.push({
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
z: 0,
|
||||
originalX: point.x,
|
||||
originalY: point.y,
|
||||
originalColor: particleColor,
|
||||
delay: 0
|
||||
});
|
||||
|
||||
// 生成初始位置(从外围圆形区域开始)
|
||||
const initAngle = Math.random() * Math.PI * 2;
|
||||
const distance = size * 2;
|
||||
positionArray.push(
|
||||
Math.cos(initAngle) * distance,
|
||||
Math.sin(initAngle) * distance,
|
||||
0
|
||||
);
|
||||
|
||||
// 初始颜色设置为最终颜色的一半亮度
|
||||
colorArray.push(
|
||||
particleColor.r * 0.5,
|
||||
particleColor.g * 0.5,
|
||||
particleColor.b * 0.5
|
||||
);
|
||||
});
|
||||
|
||||
return { particles, positionArray, colorArray, particleSize };
|
||||
};
|
||||
|
||||
// 在文件开头添加新的 helper 函数
|
||||
const easeOutCubic = (t: number) => {
|
||||
return 1 - Math.pow(1 - t, 3);
|
||||
};
|
||||
|
||||
const customEase = (t: number) => {
|
||||
return t < 0.5
|
||||
? 4 * t * t * t
|
||||
: 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
};
|
||||
|
||||
// 在文件开头添加新的 LoaderStatus 接口
|
||||
interface LoaderStatus {
|
||||
isLoading: boolean;
|
||||
hasError: boolean;
|
||||
timeoutError: boolean;
|
||||
animationPhase: 'assembling' | 'image' | 'dissolving' | 'transitioning';
|
||||
}
|
||||
|
||||
// 修改 ParticleImage 组件的 props 接口
|
||||
interface ParticleImageProps {
|
||||
src?: string;
|
||||
status?: LoaderStatus;
|
||||
onLoad?: () => void;
|
||||
onAnimationComplete?: () => void;
|
||||
transitionType?: 'in' | 'out' | 'none';
|
||||
previousParticles?: Particle[];
|
||||
onParticlesCreated?: (particles: Particle[]) => void;
|
||||
onAnimationPhaseChange?: (phase: 'assembling' | 'image' | 'dissolving' | 'transitioning') => void;
|
||||
}
|
||||
|
||||
// 修改 BG_CONFIG
|
||||
const BG_CONFIG = {
|
||||
colors: {
|
||||
from: 'rgb(10,37,77)',
|
||||
via: 'rgb(8,27,57)',
|
||||
to: 'rgb(2,8,23)'
|
||||
},
|
||||
className: 'bg-gradient-to-br from-[rgb(248,250,252)] via-[rgb(241,245,249)] to-[rgb(236,241,247)] dark:from-[rgb(10,37,77)] dark:via-[rgb(8,27,57)] dark:to-[rgb(2,8,23)]',
|
||||
size: {
|
||||
container: ''
|
||||
}
|
||||
};
|
||||
|
||||
// 修改图像采样函数
|
||||
const createParticlesFromImage = (imageData: ImageData, width: number, height: number) => {
|
||||
const particles: Particle[] = [];
|
||||
const positionArray: number[] = [];
|
||||
const colorArray: number[] = [];
|
||||
|
||||
// 根据容器尺寸计算缩放因子
|
||||
const aspectRatio = width / height;
|
||||
const scaleFactor = width / 2; // 使用容器宽度的一半作为基准
|
||||
|
||||
// 固定粒子数量以保持一致的视觉效果
|
||||
const particlesPerSide = Math.floor(Math.min(150, Math.max(80, Math.min(width, height) / 4)));
|
||||
const stepX = width / particlesPerSide;
|
||||
const stepY = height / particlesPerSide;
|
||||
|
||||
for (let y = 0; y < height; y += stepY) {
|
||||
for (let x = 0; x < width; x += stepX) {
|
||||
const pixelX = Math.floor(x);
|
||||
const pixelY = Math.floor(y);
|
||||
const i = (pixelY * width + pixelX) * 4;
|
||||
|
||||
const r = imageData.data[i] / 255;
|
||||
const g = imageData.data[i + 1] / 255;
|
||||
const b = imageData.data[i + 2] / 255;
|
||||
|
||||
// 计算亮度并设置最小值
|
||||
const brightness = Math.max(0.1, (r + g + b) / 3);
|
||||
|
||||
// 将坐标映射到容器范围
|
||||
const px = ((x / width) * 2 - 1) * scaleFactor;
|
||||
const py = ((1 - y / height) * 2 - 1) * (scaleFactor / aspectRatio);
|
||||
|
||||
// 创建粒子
|
||||
const finalColor = new THREE.Color(
|
||||
Math.max(0.1, r),
|
||||
Math.max(0.1, g),
|
||||
Math.max(0.1, b)
|
||||
);
|
||||
|
||||
particles.push({
|
||||
x: px,
|
||||
y: py,
|
||||
z: 0,
|
||||
originalX: px,
|
||||
originalY: py,
|
||||
originalColor: finalColor,
|
||||
delay: Math.random() * 0.3
|
||||
});
|
||||
|
||||
// 设置初始位置
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const distance = Math.max(width, height);
|
||||
positionArray.push(
|
||||
Math.cos(angle) * distance,
|
||||
Math.sin(angle) * distance,
|
||||
0
|
||||
);
|
||||
|
||||
// 设置初始颜色
|
||||
colorArray.push(
|
||||
finalColor.r * 0.3,
|
||||
finalColor.g * 0.3,
|
||||
finalColor.b * 0.3
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 调整粒子大小
|
||||
const particleSize = Math.max(2, Math.min(width, height) / 150);
|
||||
|
||||
return { particles, positionArray, colorArray, particleSize };
|
||||
};
|
||||
|
||||
// 优化动画效果
|
||||
const animateParticles = (
|
||||
particles: Particle[],
|
||||
geometry: THREE.BufferGeometry,
|
||||
onComplete?: () => void
|
||||
) => {
|
||||
const positionAttribute = geometry.attributes.position;
|
||||
const colorAttribute = geometry.attributes.color;
|
||||
|
||||
particles.forEach((particle, i) => {
|
||||
const i3 = i * 3;
|
||||
|
||||
// 位置动画
|
||||
gsap.to(positionAttribute.array, {
|
||||
duration: 1 + Math.random() * 0.5,
|
||||
delay: particle.delay,
|
||||
[i3]: particle.originalX,
|
||||
[i3 + 1]: particle.originalY,
|
||||
[i3 + 2]: 0,
|
||||
ease: "power2.out",
|
||||
onUpdate: () => void (positionAttribute.needsUpdate = true)
|
||||
});
|
||||
|
||||
// 颜色动画
|
||||
gsap.to(colorAttribute.array, {
|
||||
duration: 0.8,
|
||||
delay: particle.delay,
|
||||
[i3]: particle.originalColor.r,
|
||||
[i3 + 1]: particle.originalColor.g,
|
||||
[i3 + 2]: particle.originalColor.b,
|
||||
ease: "power2.inOut",
|
||||
onUpdate: () => {
|
||||
colorAttribute.needsUpdate = true;
|
||||
return;
|
||||
},
|
||||
onComplete: i === particles.length - 1 ? onComplete : undefined
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const ParticleImage = ({
|
||||
src,
|
||||
status,
|
||||
onLoad,
|
||||
onAnimationComplete,
|
||||
transitionType = 'in',
|
||||
previousParticles,
|
||||
onParticlesCreated,
|
||||
onAnimationPhaseChange
|
||||
}: ParticleImageProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const sceneRef = useRef<THREE.Scene>();
|
||||
const cameraRef = useRef<THREE.OrthographicCamera>();
|
||||
const rendererRef = useRef<THREE.WebGLRenderer>();
|
||||
const animationFrameRef = useRef<number>();
|
||||
const geometryRef = useRef<THREE.BufferGeometry>();
|
||||
const materialRef = useRef<THREE.PointsMaterial>();
|
||||
const pointsRef = useRef<THREE.Points>();
|
||||
|
||||
// 清理函数
|
||||
const cleanup = useCallback(() => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
|
||||
// 清理 Three.js 资源
|
||||
if (geometryRef.current) {
|
||||
geometryRef.current.dispose();
|
||||
}
|
||||
|
||||
if (materialRef.current) {
|
||||
materialRef.current.dispose();
|
||||
}
|
||||
|
||||
if (pointsRef.current) {
|
||||
if (pointsRef.current.geometry) {
|
||||
pointsRef.current.geometry.dispose();
|
||||
}
|
||||
if (pointsRef.current.material instanceof THREE.Material) {
|
||||
pointsRef.current.material.dispose();
|
||||
}
|
||||
sceneRef.current?.remove(pointsRef.current);
|
||||
}
|
||||
|
||||
if (rendererRef.current) {
|
||||
rendererRef.current.dispose();
|
||||
if (containerRef.current?.contains(rendererRef.current.domElement)) {
|
||||
containerRef.current.removeChild(rendererRef.current.domElement);
|
||||
}
|
||||
}
|
||||
|
||||
// 清理 GSAP 动画
|
||||
gsap.killTweensOf('*');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !src) return;
|
||||
|
||||
const width = containerRef.current.offsetWidth;
|
||||
const height = containerRef.current.offsetHeight;
|
||||
|
||||
// 建错误动画函数
|
||||
const showErrorAnimation = () => {
|
||||
if (!sceneRef.current) return;
|
||||
|
||||
const { particles, positionArray, colorArray, particleSize } = createErrorParticles(width, height);
|
||||
// ... 其余错误动画代码 ...
|
||||
};
|
||||
|
||||
const timeoutId = setTimeout(() => showErrorAnimation(), 5000);
|
||||
|
||||
// 初始化场景
|
||||
const scene = new THREE.Scene();
|
||||
sceneRef.current = scene;
|
||||
|
||||
// 调整相机视角
|
||||
const camera = new THREE.OrthographicCamera(
|
||||
width / -2,
|
||||
width / 2,
|
||||
height / 2,
|
||||
height / -2,
|
||||
1,
|
||||
1000
|
||||
);
|
||||
camera.position.z = 500;
|
||||
cameraRef.current = camera;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
alpha: true,
|
||||
antialias: true
|
||||
});
|
||||
renderer.setSize(width, height);
|
||||
rendererRef.current = renderer;
|
||||
containerRef.current.appendChild(renderer.domElement);
|
||||
|
||||
// 加载图片
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
|
||||
img.onload = () => {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
// 计算目标尺寸和裁剪区域
|
||||
const targetAspect = width / height;
|
||||
const imgAspect = img.width / img.height;
|
||||
|
||||
let sourceWidth = img.width;
|
||||
let sourceHeight = img.height;
|
||||
let sourceX = 0;
|
||||
let sourceY = 0;
|
||||
|
||||
// 裁剪源图片,确保比例匹配目标容器
|
||||
if (imgAspect > targetAspect) {
|
||||
sourceWidth = img.height * targetAspect;
|
||||
sourceX = (img.width - sourceWidth) / 2;
|
||||
} else {
|
||||
sourceHeight = img.width / targetAspect;
|
||||
sourceY = (img.height - sourceHeight) / 2;
|
||||
}
|
||||
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// 清除画布
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// 绘制图像
|
||||
ctx.drawImage(
|
||||
img,
|
||||
sourceX, sourceY, sourceWidth, sourceHeight,
|
||||
0, 0, width, height
|
||||
);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, width, height);
|
||||
const { particles, positionArray, colorArray, particleSize } = createParticlesFromImage(imageData, width, height);
|
||||
|
||||
// 通知父组件新的粒子已创建
|
||||
onParticlesCreated?.(particles);
|
||||
|
||||
// 创建粒子系统
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometryRef.current = geometry;
|
||||
|
||||
const material = new THREE.PointsMaterial({
|
||||
size: particleSize,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
sizeAttenuation: true,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
depthTest: false
|
||||
});
|
||||
materialRef.current = material;
|
||||
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positionArray, 3));
|
||||
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colorArray, 3));
|
||||
|
||||
const points = new THREE.Points(geometry, material);
|
||||
pointsRef.current = points;
|
||||
scene.add(points);
|
||||
|
||||
// 动画分
|
||||
const timeline = gsap.timeline();
|
||||
|
||||
const positionAttribute = geometry.attributes.position;
|
||||
const colorAttribute = geometry.attributes.color;
|
||||
|
||||
// 立即执动画
|
||||
if (transitionType === 'in') {
|
||||
// 设置初始位置和颜色
|
||||
particles.forEach((particle, i) => {
|
||||
const i3 = i * 3;
|
||||
// 设置随机初始位置
|
||||
positionAttribute.array[i3] = (Math.random() - 0.5) * width * 2;
|
||||
positionAttribute.array[i3 + 1] = (Math.random() - 0.5) * height * 2;
|
||||
positionAttribute.array[i3 + 2] = Math.random() * 100;
|
||||
|
||||
// 设置初始颜色
|
||||
colorAttribute.array[i3] = particle.originalColor.r * 0.2;
|
||||
colorAttribute.array[i3 + 1] = particle.originalColor.g * 0.2;
|
||||
colorAttribute.array[i3 + 2] = particle.originalColor.b * 0.2;
|
||||
});
|
||||
|
||||
positionAttribute.needsUpdate = true;
|
||||
colorAttribute.needsUpdate = true;
|
||||
|
||||
// 创建动画
|
||||
particles.forEach((particle, i) => {
|
||||
const i3 = i * 3;
|
||||
|
||||
// 位置动画
|
||||
gsap.to(positionAttribute.array, {
|
||||
duration: 2,
|
||||
delay: particle.delay,
|
||||
[i3]: particle.originalX,
|
||||
[i3 + 1]: particle.originalY,
|
||||
[i3 + 2]: 0,
|
||||
ease: "power2.inOut",
|
||||
onUpdate: () => { positionAttribute.needsUpdate = true; }
|
||||
});
|
||||
|
||||
// 颜色动画
|
||||
gsap.to(colorAttribute.array, {
|
||||
duration: 1.8,
|
||||
delay: particle.delay + 0.2,
|
||||
[i3]: particle.originalColor.r,
|
||||
[i3 + 1]: particle.originalColor.g,
|
||||
[i3 + 2]: particle.originalColor.b,
|
||||
ease: "power2.inOut",
|
||||
onUpdate: () => { colorAttribute.needsUpdate = true; }
|
||||
});
|
||||
});
|
||||
} else if (transitionType === 'out') {
|
||||
particles.forEach((particle, i) => {
|
||||
const i3 = i * 3;
|
||||
gsap.to(colorAttribute.array, {
|
||||
duration: 1,
|
||||
[i3]: particle.originalColor.r * 0.2,
|
||||
[i3 + 1]: particle.originalColor.g * 0.2,
|
||||
[i3 + 2]: particle.originalColor.b * 0.2,
|
||||
ease: "power2.in",
|
||||
onUpdate: () => {
|
||||
colorAttribute.needsUpdate = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 画循环
|
||||
const animate = () => {
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
renderer.render(scene, camera);
|
||||
};
|
||||
animate();
|
||||
|
||||
onLoad?.();
|
||||
}
|
||||
};
|
||||
|
||||
img.src = src;
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
cleanup();
|
||||
};
|
||||
}, [src, cleanup, onLoad, onAnimationComplete, transitionType, previousParticles, onParticlesCreated]);
|
||||
|
||||
return <div ref={containerRef} className="w-full h-full" />;
|
||||
};
|
||||
|
||||
// 修改 ImageLoader 组件的 props 接口
|
||||
interface ImageLoaderProps {
|
||||
src?: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
containerClassName?: string; // 新增容器类名属性
|
||||
}
|
||||
|
||||
// 修改 ImageLoader 组件
|
||||
export const ImageLoader = ({ src, alt, className = '', containerClassName = '' }: ImageLoaderProps) => {
|
||||
const [status, setStatus] = useState<LoaderStatus>({
|
||||
isLoading: true,
|
||||
hasError: false,
|
||||
timeoutError: false,
|
||||
animationPhase: 'assembling'
|
||||
});
|
||||
const [showImage, setShowImage] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout>();
|
||||
const loadingRef = useRef(false);
|
||||
const imageRef = useRef<HTMLImageElement | null>(null);
|
||||
const [currentParticles, setCurrentParticles] = useState<Particle[]>([]);
|
||||
|
||||
// 动画循环
|
||||
const startAnimationCycle = useCallback(async () => {
|
||||
while (true) {
|
||||
// 1. 粒子组合成图像
|
||||
setStatus(prev => ({ ...prev, animationPhase: 'assembling' }));
|
||||
setShowImage(false);
|
||||
await new Promise(resolve => setTimeout(resolve, 2500));
|
||||
|
||||
// 2. 显示实际图片
|
||||
setShowImage(true);
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
|
||||
// 3. 隐藏图片,显示粒子
|
||||
setShowImage(false);
|
||||
setStatus(prev => ({ ...prev, animationPhase: 'dissolving' }));
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
|
||||
// 4. 粒子过渡状态
|
||||
setStatus(prev => ({ ...prev, animationPhase: 'transitioning' }));
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 处理图片预加载
|
||||
const preloadImage = useCallback(() => {
|
||||
if (!src || loadingRef.current) return;
|
||||
|
||||
loadingRef.current = true;
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
|
||||
img.onload = () => {
|
||||
imageRef.current = img;
|
||||
setStatus(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
timeoutError: false
|
||||
}));
|
||||
startAnimationCycle();
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
setStatus(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
hasError: true,
|
||||
timeoutError: false
|
||||
}));
|
||||
};
|
||||
|
||||
img.src = src;
|
||||
}, [src, startAnimationCycle]);
|
||||
|
||||
useEffect(() => {
|
||||
preloadImage();
|
||||
return () => {
|
||||
loadingRef.current = false;
|
||||
};
|
||||
}, [preloadImage]);
|
||||
|
||||
return (
|
||||
<div className={`relative shrink-0 overflow-hidden ${containerClassName}`}>
|
||||
<div className={`absolute inset-0 ${BG_CONFIG.className} rounded-lg overflow-hidden`}>
|
||||
<ParticleImage
|
||||
src={src}
|
||||
status={status}
|
||||
onParticlesCreated={setCurrentParticles}
|
||||
onAnimationPhaseChange={(phase) => {
|
||||
setStatus(prev => ({ ...prev, animationPhase: phase }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!status.hasError && !status.timeoutError && imageRef.current && (
|
||||
<div className="absolute inset-0 rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={imageRef.current.src}
|
||||
alt={alt}
|
||||
className={`
|
||||
w-full h-full object-cover
|
||||
transition-opacity duration-1000
|
||||
${className}
|
||||
${showImage ? 'opacity-100' : 'opacity-0'}
|
||||
`}
|
||||
style={{
|
||||
visibility: showImage ? 'visible' : 'hidden',
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
42
web/czr/hooks/error.tsx
Normal file
42
web/czr/hooks/error.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Template } from "interface/template";
|
||||
|
||||
export default new Template({}, ({ args }) => {
|
||||
const [text, setText] = useState("");
|
||||
const fullText = "404 - 页面不见了 :(";
|
||||
const typingSpeed = 100;
|
||||
|
||||
useEffect(() => {
|
||||
let currentIndex = 0;
|
||||
const typingEffect = setInterval(() => {
|
||||
if (currentIndex < fullText.length) {
|
||||
setText(fullText.slice(0, currentIndex + 1));
|
||||
currentIndex++;
|
||||
} else {
|
||||
clearInterval(typingEffect);
|
||||
}
|
||||
}, typingSpeed);
|
||||
|
||||
return () => clearInterval(typingEffect);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[--background] transition-colors duration-300">
|
||||
<div className="text-center">
|
||||
<h1 className="text-6xl font-bold text-[--foreground] mb-4">
|
||||
{text}
|
||||
<span className="animate-pulse">|</span>
|
||||
</h1>
|
||||
<p className="text-[--muted-foreground] text-xl">
|
||||
抱歉,您访问的页面已经离家出走了
|
||||
</p>
|
||||
<button
|
||||
onClick={() => (window.location.href = "/")}
|
||||
className="mt-8 px-6 py-3 bg-[--primary] hover:bg-[--primary-foreground] text-[--primary-foreground] hover:text-[--primary] rounded-lg transition-colors duration-300"
|
||||
>
|
||||
返回首页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
103
web/czr/hooks/loading.tsx
Normal file
103
web/czr/hooks/loading.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import React, { createContext, useState, useContext } from "react";
|
||||
|
||||
interface LoadingContextType {
|
||||
isLoading: boolean;
|
||||
showLoading: () => void;
|
||||
hideLoading: () => void;
|
||||
}
|
||||
|
||||
const LoadingContext = createContext<LoadingContextType>({
|
||||
isLoading: false,
|
||||
showLoading: () => {},
|
||||
hideLoading: () => {},
|
||||
});
|
||||
|
||||
export const LoadingProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const showLoading = () => setIsLoading(true);
|
||||
const hideLoading = () => setIsLoading(false);
|
||||
|
||||
return (
|
||||
<LoadingContext.Provider value={{ isLoading, showLoading, hideLoading }}>
|
||||
{children}
|
||||
{isLoading && (
|
||||
<div className="fixed inset-0 flex flex-col items-center justify-center bg-black/25 dark:bg-black/40 z-[999999]">
|
||||
<div className="loading-spinner mb-2" />
|
||||
<div className="text-custom-p-light dark:text-custom-p-dark text-sm">
|
||||
加载中...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<style>{`
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 3px solid rgba(59, 130, 246, 0.2);
|
||||
border-radius: 50%;
|
||||
border-top-color: #3B82F6;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.dark .loading-spinner {
|
||||
border: 3px solid rgba(96, 165, 250, 0.2);
|
||||
border-top-color: #60A5FA;
|
||||
}
|
||||
`}</style>
|
||||
</LoadingContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// 全局loading实例
|
||||
let globalShowLoading: (() => void) | null = null;
|
||||
let globalHideLoading: (() => void) | null = null;
|
||||
|
||||
export const LoadingContainer: React.FC = () => {
|
||||
const { showLoading, hideLoading } = useContext(LoadingContext);
|
||||
|
||||
React.useEffect(() => {
|
||||
globalShowLoading = showLoading;
|
||||
globalHideLoading = hideLoading;
|
||||
return () => {
|
||||
globalShowLoading = null;
|
||||
globalHideLoading = null;
|
||||
};
|
||||
}, [showLoading, hideLoading]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 导出loading方法
|
||||
export const loading = {
|
||||
show: () => {
|
||||
if (!globalShowLoading) {
|
||||
console.warn("Loading system not initialized");
|
||||
return;
|
||||
}
|
||||
globalShowLoading();
|
||||
},
|
||||
hide: () => {
|
||||
if (!globalHideLoading) {
|
||||
console.warn("Loading system not initialized");
|
||||
return;
|
||||
}
|
||||
globalHideLoading();
|
||||
},
|
||||
};
|
182
web/czr/hooks/notification.tsx
Normal file
182
web/czr/hooks/notification.tsx
Normal file
@ -0,0 +1,182 @@
|
||||
// @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;
|
||||
};
|
133
web/czr/hooks/themeMode.tsx
Normal file
133
web/czr/hooks/themeMode.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
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";
|
||||
|
||||
// 添加这个脚本来预先设置主题,避免闪烁
|
||||
const themeScript = `
|
||||
(function() {
|
||||
function getInitialTheme() {
|
||||
const savedTheme = localStorage.getItem("${THEME_KEY}");
|
||||
if (savedTheme) return savedTheme;
|
||||
|
||||
const isDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
const theme = isDark ? "dark" : "light";
|
||||
localStorage.setItem("${THEME_KEY}", theme);
|
||||
return theme;
|
||||
}
|
||||
document.documentElement.className = getInitialTheme();
|
||||
})()
|
||||
`;
|
||||
|
||||
export const ThemeScript = () => {
|
||||
return <script dangerouslySetInnerHTML={{ __html: themeScript }} />;
|
||||
};
|
||||
|
||||
export const ThemeModeToggle: React.FC = () => {
|
||||
const [isDark, setIsDark] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedTheme = localStorage.getItem(THEME_KEY);
|
||||
const initialIsDark = savedTheme === 'dark' || document.documentElement.className === 'dark';
|
||||
setIsDark(initialIsDark);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'class') {
|
||||
const isDarkTheme = document.documentElement.className === 'dark';
|
||||
setIsDark(isDarkTheme);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
if (isDark === null) return;
|
||||
const newIsDark = !isDark;
|
||||
setIsDark(newIsDark);
|
||||
const newTheme = newIsDark ? "dark" : "light";
|
||||
document.documentElement.className = newTheme;
|
||||
localStorage.setItem(THEME_KEY, newTheme);
|
||||
};
|
||||
|
||||
if (isDark === null) {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full h-full p-0 rounded-lg transition-all duration-300 transform"
|
||||
aria-label="Loading theme"
|
||||
>
|
||||
<MoonIcon className="w-full h-full" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={toggleTheme}
|
||||
className="w-full h-full p-0 rounded-lg transition-all duration-300 transform"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{isDark ? (
|
||||
<SunIcon className="w-full h-full" />
|
||||
) : (
|
||||
<MoonIcon className="w-full h-full" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
// 更新类型定义
|
||||
declare global {
|
||||
interface Window {
|
||||
__THEME__?: "light" | "dark";
|
||||
}
|
||||
}
|
||||
|
||||
export const useThemeMode = () => {
|
||||
const [mode, setMode] = useState<"light" | "dark">("light");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const saved = localStorage.getItem(THEME_KEY);
|
||||
if (saved) {
|
||||
setMode(saved as "light" | "dark");
|
||||
} else {
|
||||
const isDark = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)",
|
||||
).matches;
|
||||
setMode(isDark ? "dark" : "light");
|
||||
}
|
||||
|
||||
// 监听主题变化事件
|
||||
const handleThemeChange = (e: CustomEvent) => {
|
||||
setMode(e.detail.theme);
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
"theme-change",
|
||||
handleThemeChange as EventListener,
|
||||
);
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
"theme-change",
|
||||
handleThemeChange as EventListener,
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { mode };
|
||||
};
|
12
web/czr/index.html
Normal file
12
web/czr/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Remix App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/entry.client.tsx"></script>
|
||||
</body>
|
||||
</html>
|
81
web/czr/package.json
Normal file
81
web/czr/package.json
Normal file
@ -0,0 +1,81 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "remix vite:dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"serve:static": "node server/static.js",
|
||||
"typecheck": "tsc",
|
||||
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
|
||||
"format": "prettier --write .",
|
||||
"clean": "rm -rf dist",
|
||||
"start": "node server/static.js",
|
||||
"generate:html": "node server/entry.server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/themes": "^3.1.6",
|
||||
"@remix-run/node": "^2.14.0",
|
||||
"@remix-run/react": "^2.14.0",
|
||||
"@remix-run/serve": "^2.14.0",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@types/axios": "^0.14.4",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/three": "^0.170.0",
|
||||
"axios": "^1.7.7",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.1",
|
||||
"gsap": "^3.12.5",
|
||||
"html-react-parser": "^5.1.19",
|
||||
"isbot": "^4.1.0",
|
||||
"r": "^0.0.5",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-syntax-highlighter": "^15.6.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"three": "^0.171.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@remix-run/dev": "^2.14.0",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/lodash": "^4.17.13",
|
||||
"@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",
|
||||
"concurrently": "^9.1.0",
|
||||
"cross-env": "^7.0.3",
|
||||
"eslint": "^8.57.1",
|
||||
"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",
|
||||
"prettier": "^3.3.3",
|
||||
"tailwindcss": "^3.4.4",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.1.6",
|
||||
"vite": "^5.4.11",
|
||||
"vite-tsconfig-paths": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"markdownlint-config": {
|
||||
"$schema": null
|
||||
},
|
||||
"remix": {
|
||||
"future": {
|
||||
"v3_lazyRouteDiscovery": true
|
||||
}
|
||||
}
|
||||
}
|
6
web/czr/postcss.config.js
Normal file
6
web/czr/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
42
web/czr/server/entry.server.js
Normal file
42
web/czr/server/entry.server.js
Normal file
@ -0,0 +1,42 @@
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { RemixServer } from '@remix-run/react';
|
||||
import { createReadStream, createWriteStream } from 'fs';
|
||||
import { mkdir } from 'fs/promises';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = resolve(__dirname, '..');
|
||||
|
||||
async function generateHTML() {
|
||||
try {
|
||||
const distDir = resolve(projectRoot, 'dist');
|
||||
await mkdir(distDir, { recursive: true });
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Your App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/assets/index.js"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const indexPath = resolve(distDir, 'index.html');
|
||||
const writer = createWriteStream(indexPath);
|
||||
writer.write(html);
|
||||
writer.end();
|
||||
|
||||
console.log('HTML file generated successfully at:', indexPath);
|
||||
} catch (error) {
|
||||
console.error('Error generating HTML:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
generateHTML();
|
32
web/czr/server/env.ts
Normal file
32
web/czr/server/env.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
export async function readEnvFile() {
|
||||
const envPath = path.resolve(process.cwd(), ".env");
|
||||
try {
|
||||
const content = await fs.readFile(envPath, "utf-8");
|
||||
return content.split("\n").reduce(
|
||||
(acc, line) => {
|
||||
const [key, value] = line.split("=").map((s) => s.trim());
|
||||
if (key && value) {
|
||||
acc[key] = value.replace(/["']/g, "");
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeEnvFile(env: Record<string, string>) {
|
||||
const envPath = path.resolve(process.cwd(), ".env");
|
||||
const content = Object.entries(env)
|
||||
.map(
|
||||
([key, value]) =>
|
||||
`${key}=${typeof value === "string" ? `"${value}"` : value}`,
|
||||
)
|
||||
.join("\n");
|
||||
await fs.writeFile(envPath, content, "utf-8");
|
||||
}
|
71
web/czr/server/express.ts
Normal file
71
web/czr/server/express.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import { DEFAULT_CONFIG } from "../app/env";
|
||||
import { readEnvFile, writeEnvFile } from "./env";
|
||||
|
||||
const app = express();
|
||||
const address = process.env.VITE_ADDRESS ?? DEFAULT_CONFIG.VITE_ADDRESS;
|
||||
const port = Number(process.env.VITE_PORT ?? DEFAULT_CONFIG.VITE_PORT);
|
||||
|
||||
const ALLOWED_ORIGIN = `http://${address}:${port}`;
|
||||
// 配置 CORS,只允许来自 Vite 服务器的请求
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
if (!origin || origin === ALLOWED_ORIGIN) {
|
||||
callback(null, true);
|
||||
} else {
|
||||
callback(new Error("不允许的来源"));
|
||||
}
|
||||
},
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// 添加 IP 和端口检查中间件
|
||||
const checkAccessMiddleware = (
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) => {
|
||||
const clientIp = req.ip === "::1" ? "localhost" : req.ip;
|
||||
const clientPort = Number(req.get("origin")?.split(":").pop() ?? 0);
|
||||
|
||||
const isLocalIp = clientIp === "localhost" || clientIp === "127.0.0.1";
|
||||
const isAllowedPort = clientPort === port;
|
||||
|
||||
if (isLocalIp && isAllowedPort) {
|
||||
next();
|
||||
} else {
|
||||
res.status(403).json({
|
||||
error: "禁止访问",
|
||||
detail: `仅允许 ${address}:${port} 访问`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
app.use(checkAccessMiddleware);
|
||||
app.use(express.json());
|
||||
|
||||
app.get("/env", async (req, res) => {
|
||||
try {
|
||||
const envData = await readEnvFile();
|
||||
res.json(envData);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: "读取环境变量失败" });
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/env", async (req, res) => {
|
||||
try {
|
||||
const newEnv = req.body;
|
||||
await writeEnvFile(newEnv);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: "更新环境变量失败" });
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(port + 1, address, () => {
|
||||
console.log(`内部服务器运行在 http://${address}:${port + 1}`);
|
||||
});
|
23
web/czr/server/production.js
Normal file
23
web/czr/server/production.js
Normal file
@ -0,0 +1,23 @@
|
||||
import express from 'express';
|
||||
import { createRequestHandler } from "@remix-run/express";
|
||||
import * as build from "../build/server/index.js";
|
||||
|
||||
const app = express();
|
||||
|
||||
// 静态文件服务
|
||||
app.use(express.static("public"));
|
||||
app.use(express.static("build/client"));
|
||||
|
||||
// Remix 请求处理
|
||||
app.all(
|
||||
"*",
|
||||
createRequestHandler({
|
||||
build,
|
||||
mode: "production",
|
||||
})
|
||||
);
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
app.listen(port, () => {
|
||||
console.log(`Express server listening on port ${port}`);
|
||||
});
|
31
web/czr/server/static.js
Normal file
31
web/czr/server/static.js
Normal file
@ -0,0 +1,31 @@
|
||||
import express from 'express';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = resolve(__dirname, '..');
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
// 设置静态文件目录
|
||||
app.use(express.static(resolve(projectRoot, 'dist')));
|
||||
|
||||
// 所有路由都返回 index.html
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(resolve(projectRoot, 'dist', 'index.html'));
|
||||
});
|
||||
|
||||
// 确保dist目录存在
|
||||
import { mkdir } from 'fs/promises';
|
||||
try {
|
||||
await mkdir(resolve(projectRoot, 'dist'), { recursive: true });
|
||||
} catch (error) {
|
||||
console.error('Error creating dist directory:', error);
|
||||
}
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server running at http://localhost:${port}`);
|
||||
console.log('Static files directory:', resolve(projectRoot, 'dist'));
|
||||
console.log('Index file path:', resolve(projectRoot, 'dist', 'index.html'));
|
||||
});
|
7
web/czr/src/entry.client.tsx
Normal file
7
web/czr/src/entry.client.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import { RemixBrowser } from "@remix-run/react";
|
||||
import { startTransition } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(document, <RemixBrowser />);
|
||||
});
|
79
web/czr/start.bat
Normal file
79
web/czr/start.bat
Normal file
@ -0,0 +1,79 @@
|
||||
@echo off
|
||||
title CZR启动程序
|
||||
echo 正在启动程序...
|
||||
|
||||
:: 创建日志文件
|
||||
echo %date% %time% > startup_log.txt
|
||||
echo =============== 启动日志 =============== >> startup_log.txt
|
||||
|
||||
:: 获取批处理文件所在目录
|
||||
cd /d "%~dp0"
|
||||
echo 当前目录: %CD% >> startup_log.txt
|
||||
|
||||
:: 检查czr目录是否存在
|
||||
if not exist "czr" (
|
||||
echo 错误:找不到czr文件夹! >> startup_log.txt
|
||||
echo 错误:找不到czr文件夹!
|
||||
echo 当前目录是:%CD%
|
||||
echo 请确保start.bat文件与czr文件夹在同一目录
|
||||
type startup_log.txt
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: 切换到czr目录
|
||||
cd czr
|
||||
echo 已进入czr目录: %CD% >> startup_log.txt
|
||||
|
||||
:: 检查package.json是否存在
|
||||
if not exist "package.json" (
|
||||
echo 错误:在czr目录中找不到package.json文件! >> startup_log.txt
|
||||
echo 错误:在czr目录中找不到package.json文件!
|
||||
echo 当前目录是:%CD%
|
||||
type startup_log.txt
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: 检查是否安装了Node.js
|
||||
where npm >nul 2>nul
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo 错误:未安装Node.js或npm! >> startup_log.txt
|
||||
echo 错误:未安装Node.js或npm!
|
||||
echo 请先安装Node.js: https://nodejs.org/
|
||||
type startup_log.txt
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:: 检查node_modules是否存在
|
||||
if not exist "node_modules" (
|
||||
echo node_modules文件夹不存在,正在安装依赖... >> startup_log.txt
|
||||
echo node_modules文件夹不存在,正在安装依赖...
|
||||
call npm install
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo npm install 失败! >> startup_log.txt
|
||||
echo npm install 失败!
|
||||
type startup_log.txt
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
)
|
||||
|
||||
:: 执行npm run start
|
||||
echo 正在执行 npm run start... >> startup_log.txt
|
||||
echo 正在执行 npm run start...
|
||||
call npm run start
|
||||
|
||||
:: 如果npm命令执行失败
|
||||
if %ERRORLEVEL% neq 0 (
|
||||
echo 执行npm run start时出错! >> startup_log.txt
|
||||
echo 执行npm run start时出错!
|
||||
echo 错误代码:%ERRORLEVEL%
|
||||
type startup_log.txt
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
type startup_log.txt
|
||||
pause
|
88
web/czr/styles/echoes.css
Normal file
88
web/czr/styles/echoes.css
Normal file
@ -0,0 +1,88 @@
|
||||
.animated-text {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
transform: translateZ(0);
|
||||
-webkit-transform: translateZ(0);
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
will-change: transform;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.animated-text path {
|
||||
fill: transparent;
|
||||
stroke: currentColor;
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: var(--path-length);
|
||||
stroke-dashoffset: var(--path-length);
|
||||
animation: logo-anim 10s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||
transform-origin: center;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
animation-play-state: running !important;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes logo-anim {
|
||||
0% {
|
||||
stroke-dashoffset: var(--path-length);
|
||||
stroke-dasharray: var(--path-length) var(--path-length);
|
||||
fill: transparent;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
5% {
|
||||
opacity: 1;
|
||||
stroke-dashoffset: var(--path-length);
|
||||
stroke-dasharray: var(--path-length) var(--path-length);
|
||||
}
|
||||
|
||||
50% {
|
||||
stroke-dashoffset: 0;
|
||||
stroke-dasharray: var(--path-length) var(--path-length);
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
60%, 75% {
|
||||
stroke-dashoffset: 0;
|
||||
stroke-dasharray: var(--path-length) var(--path-length);
|
||||
fill: currentColor;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
85% {
|
||||
stroke-dashoffset: 0;
|
||||
stroke-dasharray: var(--path-length) var(--path-length);
|
||||
fill: transparent;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
95% {
|
||||
stroke-dashoffset: var(--path-length);
|
||||
stroke-dasharray: var(--path-length) var(--path-length);
|
||||
fill: transparent;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
stroke-dashoffset: var(--path-length);
|
||||
stroke-dasharray: var(--path-length) var(--path-length);
|
||||
fill: transparent;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.animated-text path {
|
||||
stroke: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
.animated-text {
|
||||
touch-action: manipulation;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
36
web/czr/tailwind.config.ts
Normal file
36
web/czr/tailwind.config.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
import typography from '@tailwindcss/typography';
|
||||
|
||||
export default {
|
||||
content: [
|
||||
"./app/**/*.{js,jsx,ts,tsx}",
|
||||
"./common/**/*.{js,jsx,ts,tsx}",
|
||||
"./core/**/*.{js,jsx,ts,tsx}",
|
||||
"./hooks/**/*.{js,jsx,ts,tsx}",
|
||||
"./themes/**/*.{js,jsx,ts,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
important: true,
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["Inter", "system-ui", "sans-serif"],
|
||||
},
|
||||
keyframes: {
|
||||
progress: {
|
||||
from: { transform: "scaleX(1)" },
|
||||
to: { transform: "scaleX(0)" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
progress: "progress 3s linear",
|
||||
},
|
||||
zIndex: {
|
||||
"-10": "-10",
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
typography,
|
||||
],
|
||||
} satisfies Config;
|
32
web/czr/tsconfig.json
Normal file
32
web/czr/tsconfig.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"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": {
|
||||
"~/*": ["app/*"]
|
||||
},
|
||||
|
||||
// Vite takes care of building everything, not tsc.
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
39
web/czr/vite.config.ts
Normal file
39
web/czr/vite.config.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { vitePlugin as remix } from "@remix-run/dev";
|
||||
import { defineConfig } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
remix({
|
||||
future: {
|
||||
v3_fetcherPersist: true,
|
||||
v3_relativeSplatPath: true,
|
||||
v3_throwAbortReason: true,
|
||||
v3_singleFetch: true,
|
||||
}
|
||||
}),
|
||||
tsconfigPaths(),
|
||||
],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets',
|
||||
chunkSizeWarningLimit: 1000,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: [
|
||||
'react',
|
||||
'react-dom',
|
||||
'three',
|
||||
'gsap'
|
||||
],
|
||||
ui: ['@radix-ui/themes', '@radix-ui/react-icons'],
|
||||
},
|
||||
assetFileNames: 'assets/[name]-[hash][extname]',
|
||||
chunkFileNames: 'assets/[name]-[hash].js',
|
||||
entryFileNames: 'assets/[name]-[hash].js'
|
||||
}
|
||||
}
|
||||
},
|
||||
base: '',
|
||||
})
|
Loading…
Reference in New Issue
Block a user