更新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;
|
fill: transparent;
|
||||||
stroke: currentColor;
|
stroke: currentColor;
|
||||||
stroke-width: 2;
|
stroke-width: 2;
|
||||||
|
stroke-dasharray: var(--path-length);
|
||||||
stroke-dashoffset: 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;
|
animation: logo-anim 15s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
|
@ -56,7 +56,7 @@ const App: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadCss = () => {
|
const handleDownloadCss = () => {
|
||||||
const animationCss = `
|
const animationCss = `/* 注意:每个路径都需要在 SVG 中设置 --path-length 变量 */
|
||||||
.animated-text {
|
.animated-text {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
@ -66,8 +66,9 @@ const App: React.FC = () => {
|
|||||||
fill: transparent;
|
fill: transparent;
|
||||||
stroke: currentColor;
|
stroke: currentColor;
|
||||||
stroke-width: 2;
|
stroke-width: 2;
|
||||||
|
/* 使用每个路径自己的长度 */
|
||||||
|
stroke-dasharray: var(--path-length);
|
||||||
stroke-dashoffset: 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;
|
animation: logo-anim 15s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
@ -88,14 +89,12 @@ const App: React.FC = () => {
|
|||||||
stroke-dasharray: var(--path-length) var(--path-length);
|
stroke-dasharray: var(--path-length) var(--path-length);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 慢速绘画过程 */
|
|
||||||
50% {
|
50% {
|
||||||
stroke-dashoffset: 0;
|
stroke-dashoffset: 0;
|
||||||
stroke-dasharray: var(--path-length) var(--path-length);
|
stroke-dasharray: var(--path-length) var(--path-length);
|
||||||
fill: transparent;
|
fill: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 慢慢填充效果 */
|
|
||||||
60% {
|
60% {
|
||||||
stroke-dashoffset: 0;
|
stroke-dashoffset: 0;
|
||||||
stroke-dasharray: var(--path-length) var(--path-length);
|
stroke-dasharray: var(--path-length) var(--path-length);
|
||||||
@ -103,7 +102,6 @@ const App: React.FC = () => {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 保持填充状态 */
|
|
||||||
75% {
|
75% {
|
||||||
stroke-dashoffset: 0;
|
stroke-dashoffset: 0;
|
||||||
stroke-dasharray: var(--path-length) var(--path-length);
|
stroke-dasharray: var(--path-length) var(--path-length);
|
||||||
@ -111,7 +109,6 @@ const App: React.FC = () => {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 变回线条 */
|
|
||||||
85% {
|
85% {
|
||||||
stroke-dashoffset: 0;
|
stroke-dashoffset: 0;
|
||||||
stroke-dasharray: var(--path-length) var(--path-length);
|
stroke-dasharray: var(--path-length) var(--path-length);
|
||||||
@ -119,7 +116,6 @@ const App: React.FC = () => {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 线条消失 */
|
|
||||||
95% {
|
95% {
|
||||||
stroke-dashoffset: var(--path-length);
|
stroke-dashoffset: var(--path-length);
|
||||||
stroke-dasharray: var(--path-length) var(--path-length);
|
stroke-dasharray: var(--path-length) var(--path-length);
|
||||||
@ -133,6 +129,13 @@ const App: React.FC = () => {
|
|||||||
fill: transparent;
|
fill: transparent;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 确保在暗色模式下的颜色正确 */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.animated-text path {
|
||||||
|
stroke: currentColor;
|
||||||
|
}
|
||||||
}`;
|
}`;
|
||||||
|
|
||||||
const blob = new Blob([animationCss], { type: 'text/css' });
|
const blob = new Blob([animationCss], { type: 'text/css' });
|
||||||
|
@ -210,7 +210,8 @@ impl FontHandler {
|
|||||||
|
|
||||||
paths.push(Path::new()
|
paths.push(Path::new()
|
||||||
.set("d", builder.path_data)
|
.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 = () => {
|
const drawGrid = () => {
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.strokeStyle = GRID_COLOR;
|
ctx.strokeStyle = GRID_COLOR;
|
||||||
|
|
||||||
// 画垂直线
|
|
||||||
for (let i = 0; i <= width; i++) {
|
for (let i = 0; i <= width; i++) {
|
||||||
ctx.moveTo(i * (CELL_SIZE + 1) + 1, 0);
|
ctx.moveTo(i * (CELL_SIZE + 1) + 1, 0);
|
||||||
ctx.lineTo(i * (CELL_SIZE + 1) + 1, (CELL_SIZE + 1) * height + 1);
|
ctx.lineTo(i * (CELL_SIZE + 1) + 1, (CELL_SIZE + 1) * height + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 画水平线
|
|
||||||
for (let j = 0; j <= height; j++) {
|
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);
|
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(()=>{
|
useEffect(()=>{
|
||||||
drawChess(chessGame);
|
drawChess(chessGame);
|
||||||
|
console.log(chessGame?.render())
|
||||||
},[])
|
},[])
|
||||||
function chessBoardClick(e: React.MouseEvent<HTMLCanvasElement>) {
|
function chessBoardClick(e: React.MouseEvent<HTMLCanvasElement>) {
|
||||||
const client=e.currentTarget.getBoundingClientRect();
|
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