diff --git a/rust/blog_os/.cargo/config.toml b/rust/blog_os/.cargo/config.toml new file mode 100644 index 0000000..bd02bb2 --- /dev/null +++ b/rust/blog_os/.cargo/config.toml @@ -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" \ No newline at end of file diff --git a/rust/blog_os/Cargo.toml b/rust/blog_os/Cargo.toml new file mode 100644 index 0000000..6ae87d4 --- /dev/null +++ b/rust/blog_os/Cargo.toml @@ -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 时栈展开 diff --git a/rust/blog_os/src/main.rs b/rust/blog_os/src/main.rs new file mode 100644 index 0000000..9b760fb --- /dev/null +++ b/rust/blog_os/src/main.rs @@ -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 {} +} \ No newline at end of file diff --git a/rust/blog_os/x86_64-blog_os.json b/rust/blog_os/x86_64-blog_os.json new file mode 100644 index 0000000..97da923 --- /dev/null +++ b/rust/blog_os/x86_64-blog_os.json @@ -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" +} \ No newline at end of file diff --git a/rust/svg/src/App.css b/rust/svg/src/App.css index bd593b2..c63d8db 100644 --- a/rust/svg/src/App.css +++ b/rust/svg/src/App.css @@ -210,8 +210,8 @@ fill: transparent; stroke: currentColor; stroke-width: 2; + stroke-dasharray: var(--path-length); stroke-dashoffset: var(--path-length); - stroke-dasharray: var(--path-length) var(--path-length); animation: logo-anim 15s cubic-bezier(0.4, 0, 0.2, 1) infinite; transform-origin: center; stroke-linecap: round; diff --git a/rust/svg/src/App.tsx b/rust/svg/src/App.tsx index 605292a..06921e8 100644 --- a/rust/svg/src/App.tsx +++ b/rust/svg/src/App.tsx @@ -56,7 +56,7 @@ const App: React.FC = () => { }; const handleDownloadCss = () => { - const animationCss = ` + const animationCss = `/* 注意:每个路径都需要在 SVG 中设置 --path-length 变量 */ .animated-text { max-width: 100%; height: auto; @@ -66,8 +66,9 @@ const App: React.FC = () => { fill: transparent; stroke: currentColor; stroke-width: 2; + /* 使用每个路径自己的长度 */ + stroke-dasharray: var(--path-length); stroke-dashoffset: var(--path-length); - stroke-dasharray: var(--path-length) var(--path-length); animation: logo-anim 15s cubic-bezier(0.4, 0, 0.2, 1) infinite; transform-origin: center; stroke-linecap: round; @@ -88,14 +89,12 @@ const App: React.FC = () => { stroke-dasharray: var(--path-length) var(--path-length); } - /* 慢速绘画过程 */ 50% { stroke-dashoffset: 0; stroke-dasharray: var(--path-length) var(--path-length); fill: transparent; } - /* 慢慢填充效果 */ 60% { stroke-dashoffset: 0; stroke-dasharray: var(--path-length) var(--path-length); @@ -103,7 +102,6 @@ const App: React.FC = () => { opacity: 1; } - /* 保持填充状态 */ 75% { stroke-dashoffset: 0; stroke-dasharray: var(--path-length) var(--path-length); @@ -111,7 +109,6 @@ const App: React.FC = () => { opacity: 1; } - /* 变回线条 */ 85% { stroke-dashoffset: 0; stroke-dasharray: var(--path-length) var(--path-length); @@ -119,7 +116,6 @@ const App: React.FC = () => { opacity: 1; } - /* 线条消失 */ 95% { stroke-dashoffset: var(--path-length); stroke-dasharray: var(--path-length) var(--path-length); @@ -133,6 +129,13 @@ const App: React.FC = () => { fill: transparent; opacity: 0; } +} + +/* 确保在暗色模式下的颜色正确 */ +@media (prefers-color-scheme: dark) { + .animated-text path { + stroke: currentColor; + } }`; const blob = new Blob([animationCss], { type: 'text/css' }); diff --git a/rust/svg/src/lib.rs b/rust/svg/src/lib.rs index b21a52d..9c51fba 100644 --- a/rust/svg/src/lib.rs +++ b/rust/svg/src/lib.rs @@ -210,7 +210,8 @@ impl FontHandler { paths.push(Path::new() .set("d", builder.path_data) - .set("fill", "currentColor")); + .set("fill", "currentColor") + .set("style", format!("--path-length: {};", builder.path_length))); } } diff --git a/rust/svg/src/vite-env.d.ts b/rust/svg/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/rust/svg/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/rust/temp/Cargo.toml b/rust/temp/Cargo.toml new file mode 100644 index 0000000..5a1b222 --- /dev/null +++ b/rust/temp/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "temp" +version = "0.1.0" +edition = "2021" + +[dependencies] diff --git a/rust/temp/src/core/mod.rs b/rust/temp/src/core/mod.rs deleted file mode 100644 index 19b6bd8..0000000 --- a/rust/temp/src/core/mod.rs +++ /dev/null @@ -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>, -} -impl Server { - pub fn new(listener_port:i32) -> Arc { - 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) { - 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,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, path: &'static str, method: &'static str, function: impl Fn(Request, Arc) + 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); - } -} diff --git a/rust/temp/src/main.rs b/rust/temp/src/main.rs index 50f2d2f..e69de29 100644 --- a/rust/temp/src/main.rs +++ b/rust/temp/src/main.rs @@ -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(); -} \ No newline at end of file diff --git a/rust/temp/src/request/mod.rs b/rust/temp/src/request/mod.rs deleted file mode 100644 index 5c4791a..0000000 --- a/rust/temp/src/request/mod.rs +++ /dev/null @@ -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, -} - -impl Request{ - pub fn build(content:String) -> Option{ - 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::::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 - } -} diff --git a/rust/temp/src/respond/mod.rs b/rust/temp/src/respond/mod.rs deleted file mode 100644 index 55ec2b4..0000000 --- a/rust/temp/src/respond/mod.rs +++ /dev/null @@ -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> -} - - -impl Respond<'_> { - pub fn build(socket: TcpStream) -> Arc> { - 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) -> 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) { - 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"); - } - }); - } -} - - - diff --git a/rust/temp/src/route/mod.rs b/rust/temp/src/route/mod.rs deleted file mode 100644 index 3fbe9db..0000000 --- a/rust/temp/src/route/mod.rs +++ /dev/null @@ -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) + Send + Sync>, -} - diff --git a/rust/wasm/life_game/src/index.js b/rust/wasm/life_game/src/index.js index cc2027e..ae255e9 100644 --- a/rust/wasm/life_game/src/index.js +++ b/rust/wasm/life_game/src/index.js @@ -32,16 +32,12 @@ const renderLoop = () => { const drawGrid = () => { ctx.beginPath(); ctx.strokeStyle = GRID_COLOR; - - // 画垂直线 for (let i = 0; i <= width; i++) { ctx.moveTo(i * (CELL_SIZE + 1) + 1, 0); ctx.lineTo(i * (CELL_SIZE + 1) + 1, (CELL_SIZE + 1) * height + 1); } - - // 画水平线 for (let j = 0; j <= height; j++) { - ctx.moveTo(0, j * (CELL_SIZE + 1) + 1); + ctx.lineTo(0, j * (CELL_SIZE + 1) + 1); ctx.lineTo((CELL_SIZE + 1) * width + 1, j * (CELL_SIZE + 1) + 1); } diff --git a/web/chess/README.md b/web/chess/README.md new file mode 100644 index 0000000..74872fd --- /dev/null +++ b/web/chess/README.md @@ -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, + }, +}) +``` diff --git a/web/chess/eslint.config.js b/web/chess/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/web/chess/eslint.config.js @@ -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 }, + ], + }, + }, +) diff --git a/web/chess/index.html b/web/chess/index.html new file mode 100644 index 0000000..e4b78ea --- /dev/null +++ b/web/chess/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/web/chess/src/App.tsx b/web/chess/src/App.tsx index 7559f20..26dac85 100644 --- a/web/chess/src/App.tsx +++ b/web/chess/src/App.tsx @@ -189,6 +189,7 @@ const Game: React.FC = () => { useEffect(()=>{ drawChess(chessGame); + console.log(chessGame?.render()) },[]) function chessBoardClick(e: React.MouseEvent) { const client=e.currentTarget.getBoundingClientRect(); diff --git a/web/chess/tsconfig.app.json b/web/chess/tsconfig.app.json new file mode 100644 index 0000000..5a2def4 --- /dev/null +++ b/web/chess/tsconfig.app.json @@ -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"] +} diff --git a/web/chess/tsconfig.json b/web/chess/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/web/chess/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web/chess/tsconfig.node.json b/web/chess/tsconfig.node.json new file mode 100644 index 0000000..9dad701 --- /dev/null +++ b/web/chess/tsconfig.node.json @@ -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"] +} diff --git a/web/chess/vite.config.ts b/web/chess/vite.config.ts new file mode 100644 index 0000000..0169386 --- /dev/null +++ b/web/chess/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()] +}); \ No newline at end of file diff --git a/web/cxm/README.md b/web/cxm/README.md new file mode 100644 index 0000000..74872fd --- /dev/null +++ b/web/cxm/README.md @@ -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, + }, +}) +``` diff --git a/web/cxm/eslint.config.js b/web/cxm/eslint.config.js new file mode 100644 index 0000000..092408a --- /dev/null +++ b/web/cxm/eslint.config.js @@ -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 }, + ], + }, + }, +) diff --git a/web/cxm/index.html b/web/cxm/index.html new file mode 100644 index 0000000..e4b78ea --- /dev/null +++ b/web/cxm/index.html @@ -0,0 +1,13 @@ + + + + + + + Vite + React + TS + + +
+ + + diff --git a/web/cxm/package.json b/web/cxm/package.json new file mode 100644 index 0000000..13b871d --- /dev/null +++ b/web/cxm/package.json @@ -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" + } +} diff --git a/web/cxm/src/App.css b/web/cxm/src/App.css new file mode 100644 index 0000000..8214e66 --- /dev/null +++ b/web/cxm/src/App.css @@ -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; +} diff --git a/web/cxm/src/App.d.ts b/web/cxm/src/App.d.ts new file mode 100644 index 0000000..29b5ce3 --- /dev/null +++ b/web/cxm/src/App.d.ts @@ -0,0 +1,3 @@ +import './App.css'; +declare function App(): import("react/jsx-runtime").JSX.Element; +export default App; diff --git a/web/cxm/src/App.js b/web/cxm/src/App.js new file mode 100644 index 0000000..f85044b --- /dev/null +++ b/web/cxm/src/App.js @@ -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; diff --git a/web/cxm/src/App.tsx b/web/cxm/src/App.tsx new file mode 100644 index 0000000..b5922a4 --- /dev/null +++ b/web/cxm/src/App.tsx @@ -0,0 +1,12 @@ +import { Parallax } from './components/Parallax'; +import './App.css'; + +function App() { + return ( +
+ +
+ ); +} + +export default App; diff --git a/web/cxm/src/assets/z.woff2 b/web/cxm/src/assets/z.woff2 new file mode 100644 index 0000000..a04f457 Binary files /dev/null and b/web/cxm/src/assets/z.woff2 differ diff --git a/web/cxm/src/components/ImageModal.css b/web/cxm/src/components/ImageModal.css new file mode 100644 index 0000000..6bf7f9c --- /dev/null +++ b/web/cxm/src/components/ImageModal.css @@ -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; +} \ No newline at end of file diff --git a/web/cxm/src/components/ImageModal.d.ts b/web/cxm/src/components/ImageModal.d.ts new file mode 100644 index 0000000..2297cf8 --- /dev/null +++ b/web/cxm/src/components/ImageModal.d.ts @@ -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 {}; diff --git a/web/cxm/src/components/ImageModal.js b/web/cxm/src/components/ImageModal.js new file mode 100644 index 0000000..522b787 --- /dev/null +++ b/web/cxm/src/components/ImageModal.js @@ -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" })] }) })); +}; diff --git a/web/cxm/src/components/ImageModal.tsx b/web/cxm/src/components/ImageModal.tsx new file mode 100644 index 0000000..39efecb --- /dev/null +++ b/web/cxm/src/components/ImageModal.tsx @@ -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 ( +
+
e.stopPropagation()}> + + +
+
+ ); +}; \ No newline at end of file diff --git a/web/cxm/src/components/Parallax.css b/web/cxm/src/components/Parallax.css new file mode 100644 index 0000000..dfd1d4c --- /dev/null +++ b/web/cxm/src/components/Parallax.css @@ -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; +} + \ No newline at end of file diff --git a/web/cxm/src/components/Parallax.d.ts b/web/cxm/src/components/Parallax.d.ts new file mode 100644 index 0000000..6393aa6 --- /dev/null +++ b/web/cxm/src/components/Parallax.d.ts @@ -0,0 +1,2 @@ +import './Parallax.css'; +export declare const Parallax: () => import("react/jsx-runtime").JSX.Element; diff --git a/web/cxm/src/components/Parallax.js b/web/cxm/src/components/Parallax.js new file mode 100644 index 0000000..86714be --- /dev/null +++ b/web/cxm/src/components/Parallax.js @@ -0,0 +1,237 @@ +import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; +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 = []; +// 修改节流函数,使用闭包来保存状态 +function throttle(func, limit) { + let inThrottle; + let lastResult; + return function (...args) { + 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(null); + const [selectedImage, setSelectedImage] = useState(null); + const [displayedCards, setDisplayedCards] = useState(() => { + 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; + let isAnimating = false; + let autoPlayInterval; + function raf(time) { + 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; + 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); + 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 (_jsxs("div", { className: "parallax", children: [_jsx("section", { className: "parallax__header", children: _jsxs("div", { className: "parallax__visuals", children: [_jsx("div", { className: "parallax__black-line-overflow" }), _jsxs("div", { ref: layersRef, "data-parallax-layers": true, className: "parallax__layers", children: [_jsx("img", { src: layer3, loading: "eager", width: "800", "data-parallax-layer": "1", alt: "", className: "parallax__layer-img" }), _jsx("img", { src: layer2, loading: "eager", width: "800", "data-parallax-layer": "2", alt: "", className: "parallax__layer-img" }), _jsx("div", { "data-parallax-layer": "3", className: "parallax__layer-title", children: _jsx("h2", { className: "parallax__title", children: "\u65C5\u884C\u6545\u4E8B" }) }), _jsx("img", { src: layer1, loading: "eager", width: "800", "data-parallax-layer": "4", alt: "", className: "parallax__layer-img" })] }), _jsx("div", { className: "parallax__fade" })] }) }), _jsxs("section", { className: "parallax__content", children: [_jsxs("div", { className: "travel-grid", children: [_jsxs("div", { className: "travel-item", onClick: () => setSelectedImage(xizhang), children: [_jsx("h3", { children: "\u897F\u85CF" }), _jsx("p", { children: "\u5728\u6D77\u62D44000\u7C73\u7684\u9AD8\u539F\u4E0A\uFF0C\u611F\u53D7\u7740\u6700\u63A5\u8FD1\u5929\u7A7A\u7684\u4FE1\u4EF0\u3002\u5E03\u8FBE\u62C9\u5BAB\u7684\u5E84\u4E25\u8083\u7A46\uFF0C\u5927\u662D\u5BFA\u7684\u8654\u8BDA\u9999\u706B\uFF0C\u4EE5\u53CA\u7EB3\u6728\u9519\u5723\u6E56\u7684\u78A7\u84DD\u6E56\u6C34\uFF0C\u90FD\u8BA9\u4EBA\u5185\u5FC3\u65E0\u6BD4\u5E73\u9759\u3002\u9AD8\u539F\u7684\u661F\u7A7A\u7480\u74A8\u5F97\u8BA9\u4EBA\u5C4F\u606F\u3002" })] }), _jsxs("div", { className: "travel-item", onClick: () => setSelectedImage(shichuan), children: [_jsx("h3", { children: "\u56DB\u5DDD" }), _jsx("p", { children: "\u5728\u6210\u90FD\u7684\u5BBD\u7A84\u5DF7\u5B50\uFF0C\u611F\u53D7\u60A0\u95F2\u7684\u56DB\u5DDD\u6587\u5316\u3002\u7A3B\u57CE\u4E01\u7684\u96EA\u5C71\u3001\u8349\u4E0E\u6E56\u6CCA\u6784\u6210\u4E86\u4EBA\u95F4\u6700\u540E\u7684\u9999\u683C\u91CC\u62C9\u3002\u4E50\u5C71\u5927\u4F5B\u5DCD\u5CE8\u5E84\u4E25\uFF0C\u90FD\u6C5F\u5830\u5343\u5E74\u667A\u6167\uFF0C\u8BA9\u4EBA\u4E0D\u7981\u611F\u53F9\u53E4\u4EBA\u7684\u5320\u5FC3\u3002" })] }), _jsxs("div", { className: "travel-item", onClick: () => setSelectedImage(chongqing), children: [_jsx("h3", { children: "\u91CD\u5E86" }), _jsx("p", { children: "\u591C\u5E55\u964D\u4E34\uFF0C\u8FD9\u5EA7\u4E0D\u591C\u57CE\u5F00\u59CB\u7EFD\u653E\u5149\u3002\u6D2A\u5D16\u6D1E\u5C42\u5C42\u53E0\u53E0\u7684\u706F\u5149\u5012\u6620\u5728\u6C5F\u9762\uFF0C\u4E24\u6C5F\u4EA4\u6C47\u5904\u7684\u7480\u74A8\u591C\u666F\u4EE4\u4EBA\u6C89\u9189\u3002\u5B50\u575D\u8F7B\u8F68\u7A7F\u697C\u800C\u8FC7\uFF0C\u89E3\u653E\u7891\u7684\u7E41\u534E\u591C\u8272\uFF0C\u6784\u6210\u4E86\u8FD9\u5EA7\u7ACB\u4F53\u57CE\u5E02\u6700\u8FF7\u4EBA\u7684\u753B\u5377\u3002" })] }), _jsxs("div", { className: "travel-item", onClick: () => setSelectedImage(haerbing), children: [_jsx("h3", { children: "\u54C8\u5C14\u6EE8" }), _jsx("p", { children: "\u51B0\u96EA\u4E16\u754C\u7684\u6676\u83B9\u5254\u900F\uFF0C\u4E2D\u592E\u5927\u8857\u7684\u6B27\u5F0F\u5EFA\u7B51\uFF0C\u677E\u82B1\u6C5F\u7684\u51B0\u96EA\u5947\u7F18\u3002\u5728\u96F6\u4E0B20\u5EA6\u7684\u5BD2\u51AC\u611F\u53D7\u7740\u8FD9\u5EA7\u57CE\u5E02\u72EC\u7279\u7684\u4FC4\u7F57\u65AF\u98CE\u60C5\u548C\u51B0\u96EA\u827A\u672F\u3002" })] }), _jsxs("div", { className: "travel-item", onClick: () => setSelectedImage(changbaishan), children: [_jsx("h3", { children: "\u957F\u767D\u5C71" }), _jsx("p", { children: "\u5929\u6C60\u7684\u6DF1\u9083\u795E\u79D8\uFF0C\u7011\u5E03\u7684\u6C14\u52BF\u78C5\u7934\uFF0C\u6E29\u6CC9\u7684\u6E29\u6696\u6CBB\u6108\u3002\u5728\u8FD9\u5EA7\u706B\u5C71\u4E0E\u51B0\u96EA\u7684\u5929\u5802\uFF0C\u611F\u53D7\u5927\u81EA\u7136\u7684\u9B3C\u65A7\u795E\u5DE5\u4F53\u9A8C\u4E1C\u5317\u539F\u59CB\u68EE\u6797\u7684\u795E\u79D8\u3002" })] }), _jsxs("div", { className: "travel-item", onClick: () => setSelectedImage(qindao), children: [_jsx("h3", { children: "\u9752\u5C9B" }), _jsx("p", { children: "\u7684\u6D77\u8F7B\u629A\uFF0C\u516B\u5173\u7684\u5EFA\uFF0C\u9152\u535A\u7269\u9986\u91D1\u8272\u56DE\u3002\u6F2B\u6B65\u5728\u6D77\u8FB9\uFF0C\u54C1\u5C1D\u7740\u65B0\u9C9C\u7684\u6D77\u9C9C\uFF0C\u6B23\u8D4F\u7740\u8FD9\u5EA7\u5145\u6EE1\u5FB7\u56FD\u98CE\u60C5\u7684\u6D77\u6EE8\u57CE\u5E02\u3002" })] }), _jsxs("div", { className: "travel-item", onClick: () => setSelectedImage(qinghuangdao), children: [_jsx("h3", { children: "\u79E6\u7687\u5C9B" }), _jsx("p", { children: "\u5728\u4E07\u91CC\u957F\u7684\u8D77\u70B9\u5317\u6234\u6CB3\uFF0C\u542C\u7740\u6E24\u6D77\u6E7E\u7684\u6D6A\u6D9B\u3002\u6F2B\u6B65\u5728\u767D\u8272\u7684\u6C99\u6EE9\u4E0A\uFF0C\u65E5\u51FA\u4E1C\u65B9\uFF0C\u971E\u6EE1\u3002\u8FD9\u91CC\u6709\u6700\u7F8E\u7684\u6D77\u5CB8\u7EBF\uFF0C\u4E5F\u6709\u6700\u52A8\u4EBA\u7684\u5386\u53F2\u6545\u4E8B\u3002" })] }), _jsxs("div", { className: "travel-item", onClick: () => setSelectedImage(tianjin), children: [_jsx("h3", { children: "\u5929\u6D25" }), _jsx("p", { children: "\u4E94\u5927\u6D0B\u697C\u7FA4\u8BC9\u7740\u767E\u5E74\u5386\u53F2\uFF0C\u610F\u5F0F\u98CE\u60C5\u533A\u7684\u5730\u57DF\u60C5\u8C03\u3002\u53E4\u6587\u5316\u8857\u7684\u6D25\u5473\u5C0F\u5403\uFF0C\u6D77\u6CB3\u4E24\u5CB8\u7684\u74A8\u591C\u666F\u3002\u5728\u8FD9\u5EA7\u4E2D\u897F\u5408\u74A7\u7684\u57CE\u5E02\u91CC\uFF0C\u611F\u53D7\u7740\u72EC\u7279\u7684\u6D77\u6D3E\u6587\u5316\u3002" })] }), _jsxs("div", { className: "travel-item", onClick: () => setSelectedImage(taian), children: [_jsx("h3", { children: "\u6CF0\u5B89" }), _jsx("p", { children: "\u6CF0\u5C71\u5DCD\u5CE8\u96C4\u4F1F\uFF0C\u4E91\u6D77\u65E5\u51FA\u58EE\u89C2\u65E0\u6BD4\u3002\u4E0A\u5357\u5929\u95E8\uFF0C\u4FEF\u77B0\u4F17\u6E3A\u5C0F\u3002\u5CB1\u5E99\u7684\u53E4\u8001\u5EFA\uFF0C\u8BC9\u7740\u5343\u5386\u53F2\u3002\u8FD9\u91CC\u4E0D\u4EC5\u662F\u4E94\u5CB3\u4E4B\u9996\uFF0C\u66F4\u662F\u4E2D\u534E\u6587\u5316\u7684\u7CBE\u795E\u8C61\u5F81\u3002" })] })] }), selectedImage && (_jsx(ImageModal, { image: selectedImage, onClose: () => setSelectedImage(null) }))] }), _jsxs("section", { className: "slider-section", children: [_jsx("div", { className: "circle-decoration circle-left" }), _jsx("div", { className: "circle-decoration circle-right" }), _jsx("div", { className: "number-decoration number-top", children: "01" }), _jsx("div", { className: "number-decoration number-bottom", children: "21" }), _jsx("div", { className: "side-text side-text-left", children: "TRAVEL MEMORIES \u00B7 \u65C5\u884C\u8BB0\u5FC6" }), _jsx("div", { className: "side-text side-text-right", children: "PHOTO COLLECTION \u00B7 \u5F71\u50CF\u96C6" }), _jsx("div", { className: "year-display", children: "2022 - 2024" }), _jsx("div", { className: "container", children: _jsx("div", { className: "slider", children: displayedCards.map((img, index) => (_jsx("div", { className: "card", children: _jsx("img", { src: img, alt: "" }) }, index))) }) })] })] })); +}; diff --git a/web/cxm/src/components/Parallax.tsx b/web/cxm/src/components/Parallax.tsx new file mode 100644 index 0000000..9130b14 --- /dev/null +++ b/web/cxm/src/components/Parallax.tsx @@ -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(null); + const [selectedImage, setSelectedImage] = useState(null); + const [displayedCards, setDisplayedCards] = useState(() => { + 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; + + 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 ( +
+
+
+
+
+ + +
+

旅行故事

+
+ +
+
+
+
+
+
+
setSelectedImage(xizhang)}> +

西藏

+

在海拔4000米的高原上,感受着最接近天空的信仰。布达拉宫的庄严肃穆,大昭寺的虔诚香火,以及纳木错圣湖的碧蓝湖水,都让人内心无比平静。高原的星空璀璨得让人屏息。

+
+ +
setSelectedImage(shichuan)}> +

四川

+

在成都的宽窄巷子,感受悠闲的四川文化。稻城丁的雪山、草与湖泊构成了人间最后的香格里拉。乐山大佛巍峨庄严,都江堰千年智慧,让人不禁感叹古人的匠心。

+
+ +
setSelectedImage(chongqing)}> +

重庆

+

夜幕降临,这座不夜城开始绽放光。洪崖洞层层叠叠的灯光倒映在江面,两江交汇处的璀璨夜景令人沉醉。子坝轻轨穿楼而过,解放碑的繁华夜色,构成了这座立体城市最迷人的画卷。

+
+ +
setSelectedImage(haerbing)}> +

哈尔滨

+

冰雪世界的晶莹剔透,中央大街的欧式建筑,松花江的冰雪奇缘。在零下20度的寒冬感受着这座城市独特的俄罗斯风情和冰雪艺术。

+
+ +
setSelectedImage(changbaishan)}> +

长白山

+

天池的深邃神秘,瀑布的气势磅礴,温泉的温暖治愈。在这座火山与冰雪的天堂,感受大自然的鬼斧神工体验东北原始森林的神秘。

+
+ +
setSelectedImage(qindao)}> +

青岛

+

的海轻抚,八关的建,酒博物馆金色回。漫步在海边,品尝着新鲜的海鲜,欣赏着这座充满德国风情的海滨城市。

+
+ +
setSelectedImage(qinghuangdao)}> +

秦皇岛

+

在万里长的起点北戴河,听着渤海湾的浪涛。漫步在白色的沙滩上,日出东方,霞满。这里有最美的海岸线,也有最动人的历史故事。

+
+ +
setSelectedImage(tianjin)}> +

天津

+

五大洋楼群诉着百年历史,意式风情区的地域情调。古文化街的津味小吃,海河两岸的璨夜景。在这座中西合璧的城市里,感受着独特的海派文化。

+
+ +
setSelectedImage(taian)}> +

泰安

+

泰山巍峨雄伟,云海日出壮观无比。上南天门,俯瞰众渺小。岱庙的古老建,诉着千历史。这里不仅是五岳之首,更是中华文化的精神象征。

+
+
+ {selectedImage && ( + setSelectedImage(null)} + /> + )} +
+ +
+
+
+
01
+
21
+
+ TRAVEL MEMORIES · 旅行记忆 +
+
+ PHOTO COLLECTION · 影像集 +
+
+ 2022 - 2024 +
+
+
+ {displayedCards.map((img, index) => ( +
+ +
+ ))} +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/web/cxm/src/index.css b/web/cxm/src/index.css new file mode 100644 index 0000000..8214735 --- /dev/null +++ b/web/cxm/src/index.css @@ -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; +} diff --git a/web/cxm/src/main.d.ts b/web/cxm/src/main.d.ts new file mode 100644 index 0000000..6a9a4b1 --- /dev/null +++ b/web/cxm/src/main.d.ts @@ -0,0 +1 @@ +import './index.css'; diff --git a/web/cxm/src/main.js b/web/cxm/src/main.js new file mode 100644 index 0000000..2121b5e --- /dev/null +++ b/web/cxm/src/main.js @@ -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, {}) })); diff --git a/web/cxm/src/main.tsx b/web/cxm/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/web/cxm/src/main.tsx @@ -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( + + + , +) diff --git a/web/cxm/src/vite-env.d.ts b/web/cxm/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/cxm/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/cxm/tsconfig.app.json b/web/cxm/tsconfig.app.json new file mode 100644 index 0000000..f3c56fe --- /dev/null +++ b/web/cxm/tsconfig.app.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "paths": { + "@babel/*": ["./node_modules/@babel/*"] + } + }, + "include": ["src"] +} diff --git a/web/cxm/tsconfig.app.tsbuildinfo b/web/cxm/tsconfig.app.tsbuildinfo new file mode 100644 index 0000000..f02ce6d --- /dev/null +++ b/web/cxm/tsconfig.app.tsbuildinfo @@ -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"} \ No newline at end of file diff --git a/web/cxm/tsconfig.json b/web/cxm/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/web/cxm/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web/cxm/tsconfig.node.json b/web/cxm/tsconfig.node.json new file mode 100644 index 0000000..5fe31d5 --- /dev/null +++ b/web/cxm/tsconfig.node.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "module": "ESNext", + "target": "ES2015", + "moduleResolution": "bundler" + }, + "include": ["vite.config.ts"] +} diff --git a/web/cxm/tsconfig.node.tsbuildinfo b/web/cxm/tsconfig.node.tsbuildinfo new file mode 100644 index 0000000..2e20a11 --- /dev/null +++ b/web/cxm/tsconfig.node.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./vite.config.ts"],"errors":true,"version":"5.6.3"} \ No newline at end of file diff --git a/web/cxm/vite.config.d.ts b/web/cxm/vite.config.d.ts new file mode 100644 index 0000000..340562a --- /dev/null +++ b/web/cxm/vite.config.d.ts @@ -0,0 +1,2 @@ +declare const _default: import("vite").UserConfig; +export default _default; diff --git a/web/cxm/vite.config.js b/web/cxm/vite.config.js new file mode 100644 index 0000000..bf919fb --- /dev/null +++ b/web/cxm/vite.config.js @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +}); diff --git a/web/cxm/vite.config.ts b/web/cxm/vite.config.ts new file mode 100644 index 0000000..8b0f57b --- /dev/null +++ b/web/cxm/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +}) diff --git a/web/czr/.eslintrc.cjs b/web/czr/.eslintrc.cjs new file mode 100644 index 0000000..4f6f59e --- /dev/null +++ b/web/czr/.eslintrc.cjs @@ -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, + }, + }, + ], +}; diff --git a/web/czr/app/components/Carousel.tsx b/web/czr/app/components/Carousel.tsx new file mode 100644 index 0000000..e31e75c --- /dev/null +++ b/web/czr/app/components/Carousel.tsx @@ -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 ( +
+
+
+ {items[currentIndex].content} +
+
+
+ ); +} \ No newline at end of file diff --git a/web/czr/app/components/Navigation.tsx b/web/czr/app/components/Navigation.tsx new file mode 100644 index 0000000..5cc8180 --- /dev/null +++ b/web/czr/app/components/Navigation.tsx @@ -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 ( + + ); +} \ No newline at end of file diff --git a/web/czr/app/entry.client.tsx b/web/czr/app/entry.client.tsx new file mode 100644 index 0000000..94d5dc0 --- /dev/null +++ b/web/czr/app/entry.client.tsx @@ -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, + + + + ); +}); diff --git a/web/czr/app/entry.server.tsx b/web/czr/app/entry.server.tsx new file mode 100644 index 0000000..b390646 --- /dev/null +++ b/web/czr/app/entry.server.tsx @@ -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( + , + { + 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( + , + { + 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); + }); +} diff --git a/web/czr/app/env.ts b/web/czr/app/env.ts new file mode 100644 index 0000000..01d7e98 --- /dev/null +++ b/web/czr/app/env.ts @@ -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; + } +} diff --git a/web/czr/app/hooks/ParticleImage.tsx b/web/czr/app/hooks/ParticleImage.tsx new file mode 100644 index 0000000..1cb617f --- /dev/null +++ b/web/czr/app/hooks/ParticleImage.tsx @@ -0,0 +1,3 @@ +import React from 'react'; +import { useEffect, useRef, useState } from 'react'; +// ... 其他导入保持不变 diff --git a/web/czr/app/index.css b/web/czr/app/index.css new file mode 100644 index 0000000..f08cad0 --- /dev/null +++ b/web/czr/app/index.css @@ -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; + } +} \ No newline at end of file diff --git a/web/czr/app/init.tsx b/web/czr/app/init.tsx new file mode 100644 index 0000000..ba43738 --- /dev/null +++ b/web/czr/app/init.tsx @@ -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({ + currentStep: 1, + setCurrentStep: () => {}, +}); + +// 步骤组件的通用属性接口 +interface StepProps { + onNext: () => void; +} + +const StepContainer: React.FC<{ title: string; children: React.ReactNode }> = ({ + title, + children, +}) => ( + + + {title} + + + {children} + + +); + +// 通用的导航按钮组件 +const NavigationButtons: React.FC< + StepProps & { loading?: boolean; disabled?: boolean } +> = ({ onNext, loading = false, disabled = false }) => ( + + + +); + +// 修改输入框组件 +const InputField: React.FC<{ + label: string; + name: string; + defaultValue?: string | number; + hint?: string; + required?: boolean; +}> = ({ label, name, defaultValue, hint, required = true }) => ( + + + {label} {required && *} + + + + + {hint && ( + + {hint} + + )} + +); + +const Introduction: React.FC = ({ onNext }) => ( + + + 欢迎使用 Echoes + + + +); + +const DatabaseConfig: React.FC = ({ 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, + ); + + 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 ( + +
+ + + 数据库类型 + + + + + + PostgreSQL + MySQL + SQLite + + + + + + {dbType === "postgresql" && ( + <> + + + + + + + + )} + {dbType === "mysql" && ( + <> + + + + + + + + )} + {dbType === "sqllite" && ( + <> + + + + )} + +
+
+ ); +}; + +interface InstallReplyData { + token: string; + username: string; + password: string; +} + +const AdminConfig: React.FC = ({ 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, + ); + + 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 ( + +
+ + + + +
+
+ ); +}; + +const SetupComplete: React.FC = () => ( + + + + 恭喜!安装已完成 + + 系统正在重启中,请稍候... + + + + + + + +); + +export default function SetupPage() { + const [currentStep, setCurrentStep] = useState(() => { + return Number(import.meta.env.VITE_INIT_STATUS ?? 0) + 1; + }); + + return ( + + + + + + + + + + + + + + + + {currentStep === 1 && ( + setCurrentStep(currentStep + 1)} /> + )} + {currentStep === 2 && ( + setCurrentStep(currentStep + 1)} + /> + )} + {currentStep === 3 && ( + setCurrentStep(currentStep + 1)} /> + )} + {currentStep === 4 && } + + + + + + ); +} diff --git a/web/czr/app/root.tsx b/web/czr/app/root.tsx new file mode 100644 index 0000000..eec2753 --- /dev/null +++ b/web/czr/app/root.tsx @@ -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 ( + + + + + + + + + +
+ +
+ + + + + ); +} diff --git a/web/czr/app/routes.tsx b/web/czr/app/routes.tsx new file mode 100644 index 0000000..37543b7 --- /dev/null +++ b/web/czr/app/routes.tsx @@ -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: 'indexerroraboutpost', + }; + + 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, + }); +} diff --git a/web/czr/app/routes/_index.tsx b/web/czr/app/routes/_index.tsx new file mode 100644 index 0000000..4991238 --- /dev/null +++ b/web/czr/app/routes/_index.tsx @@ -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 ( +
+ + {/* Hero区域 */} +
+
+
+

+ 创新科技,守护地球 +

+

+ 致力于开发环保科技解决方案,用创新力量推动可持续发展 +

+ +
+
+
+ + {/* 核心技术 */} +
+
+

核心创新技术

+
+
+
+ + + +
+

智能环保系统

+

采用AI技术优化资源利用,提高能源使用效率

+
+ +
+
+ + + +
+

新能源转换

+

创新能源转换技术,实现清洁能源的高效利用

+
+ +
+
+ + + +
+

生态监测

+

全方位环境监测系统,保护生态平衡

+
+
+
+
+ + {/* 解决方案 */} +
+
+

创新解决方案

+
+
+

智慧城市环保系统

+

整合城市环境数据,提供智能化环保解决方案

+
    +
  • • 空气质量实时监测
  • +
  • • 智能垃圾分类系统
  • +
  • • 城市能源管理优化
  • +
+
+ +
+

工业节能方案

+

为工业企业提供全方位的节能减排解决方案

+
    +
  • • 能源使用效率优化
  • +
  • • 废物循环利用系统
  • +
  • • 清洁生产技术改造
  • +
+
+
+
+
+ + {/* 页脚 */} +
+
+
+
+

关于我们

+

新纪元科技致力于环保科技创新,为地球可持续发展贡献力量

+
+
+

联系方式

+

电话:400-888-8888

+

邮箱:contact@xingjiyuan.com

+
+
+

解决方案

+ +
+
+

公司地址

+

中国上海市浦东新区科技创新大道888号

+
+
+
+

© 2024 新纪元科技 版权所有

+
+
+
+
+ ); +} \ No newline at end of file diff --git a/web/czr/app/routes/about.tsx b/web/czr/app/routes/about.tsx new file mode 100644 index 0000000..c6d3b9b --- /dev/null +++ b/web/czr/app/routes/about.tsx @@ -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 ( +
+ + {/* 页面标题 */} +
+
+

+ 关于我们 +

+

+ 致力于用科技创新推动环保事业发展 +

+
+
+ + {/* 公司介绍 */} +
+
+
+
+

+ 公司简介 +

+

+ 新纪元科技成立于2020年,是一家专注于环保科技创新的高新技术企业。我们致力于通过技术创新解决环境问题,推动可持续发展。 +

+

+ 公司拥有一支专业的研发团队,在环保技术领域具有深厚的积累和创新能力。 +

+
+
+

+ 愿景使命 +

+
+
+

愿景

+

成为全球领先的环保科技创新企业

+
+
+

使命

+

用科技创新守护地球家园

+
+
+
+
+
+
+ + {/* 页脚 */} +
+ {/* 同首页页脚内容 */} +
+
+ ); +} \ No newline at end of file diff --git a/web/czr/app/routes/innovations.tsx b/web/czr/app/routes/innovations.tsx new file mode 100644 index 0000000..9e63e47 --- /dev/null +++ b/web/czr/app/routes/innovations.tsx @@ -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(); + + return ( +
+ {/* 头部区域:标题 + 轮播图 */} +
+
+ {/* 标题部分 */} +
+

+ 创新技术 +

+

+ 引领环保科技发展,推动行业技术革新 +

+
+ + {/* 轮播图部分 */} + {isClient ? ( +
+
+
+
+ ({ + content: ( +
+ +
+
+

{innovation.title}

+

探索环保科技的无限可能

+
+
+ ), + }))} + interval={5000} + /> +
+
+
+ ) : ( +
+
+
+ )} +
+
+ + {/* 技术详情部分 */} +
+
+

+ + 核心技术详解 + +

+
+
+
+ + + +
+

+ AI环境优化 + +

+

+ 运用人工智能技术,实现环境数据的智能分析和决策优化,提供精准的环境治理方案。 +

+
+ +
+
+ + + +
+

+ 清洁能源转换 + +

+

+ 创新的能源转换技术,提高清洁能源利用效率,推动色能源革命。 +

+
+ +
+
+ + + +
+

+ 生态修复系统 + +

+

+ 综合性生态环境修复解决方案,助力自然生态系统恢复与保护 +

+
+
+
+
+ + {/* 页脚 */} +
+ {/* 同首页页脚内容 */} +
+
+ ); +} \ No newline at end of file diff --git a/web/czr/app/routes/solutions.tsx b/web/czr/app/routes/solutions.tsx new file mode 100644 index 0000000..5ff58a4 --- /dev/null +++ b/web/czr/app/routes/solutions.tsx @@ -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(); + + return ( +
+ {/* 页面标题和轮播图 */} +
+
+
+ {isClient ? ( +
+ {/* 轮播图代码从这里开始 */} +
+ ) : ( +
+ )} +

+ 解决方案 +

+

+ 为不同行业提供定制化的环保科技解决方案,助力企业实现可持续发展 +

+
+
+
+ + {/* 解决方案详情 */} +
+
+
+
+

+ 智慧城市解决方案 + +

+
+

通过智能技术优化城市环境管理

+
    +
  • + • 智能环境监测系统 +
  • +
  • + • 城市垃圾分类管理 +
  • +
  • + • 智慧能源管理平台 +
  • +
  • + • 城市空气质量优化 +
  • +
+
+
+ +
+

+ 工业节能方案 + +

+
+

帮助工业企业实现节能减排

+
    +
  • + • 工业能源审计 +
  • +
  • + • 节能改造方案 +
  • +
  • + • 废物循环利用 +
  • +
  • + • 清洁生产技术 +
  • +
+
+
+
+
+
+ + {/* 页脚 - 可以提取为共享组件 */} +
+ {/* 同首页页脚内容 */} +
+
+ ); +} \ No newline at end of file diff --git a/web/czr/app/styles/navigation.css b/web/czr/app/styles/navigation.css new file mode 100644 index 0000000..3d6ff15 --- /dev/null +++ b/web/czr/app/styles/navigation.css @@ -0,0 +1,5 @@ +.nav-logo svg { + width: 120px; + height: auto; + color: #1a1a1a; +} \ No newline at end of file diff --git a/web/czr/hooks/ParticleImage.tsx b/web/czr/hooks/ParticleImage.tsx new file mode 100644 index 0000000..80a46db --- /dev/null +++ b/web/czr/hooks/ParticleImage.tsx @@ -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(null); + const sceneRef = useRef(); + const cameraRef = useRef(); + const rendererRef = useRef(); + const animationFrameRef = useRef(); + const geometryRef = useRef(); + const materialRef = useRef(); + const pointsRef = useRef(); + + // 清理函数 + 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
; +}; + +// 修改 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({ + isLoading: true, + hasError: false, + timeoutError: false, + animationPhase: 'assembling' + }); + const [showImage, setShowImage] = useState(false); + const timeoutRef = useRef(); + const loadingRef = useRef(false); + const imageRef = useRef(null); + const [currentParticles, setCurrentParticles] = useState([]); + + // 动画循环 + 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 ( +
+
+ { + setStatus(prev => ({ ...prev, animationPhase: phase })); + }} + /> +
+ {!status.hasError && !status.timeoutError && imageRef.current && ( +
+ {alt} +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/web/czr/hooks/error.tsx b/web/czr/hooks/error.tsx new file mode 100644 index 0000000..fad69d9 --- /dev/null +++ b/web/czr/hooks/error.tsx @@ -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 ( +
+
+

+ {text} + | +

+

+ 抱歉,您访问的页面已经离家出走了 +

+ +
+
+ ); +}); diff --git a/web/czr/hooks/loading.tsx b/web/czr/hooks/loading.tsx new file mode 100644 index 0000000..848f2b0 --- /dev/null +++ b/web/czr/hooks/loading.tsx @@ -0,0 +1,103 @@ +import React, { createContext, useState, useContext } from "react"; + +interface LoadingContextType { + isLoading: boolean; + showLoading: () => void; + hideLoading: () => void; +} + +const LoadingContext = createContext({ + 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 ( + + {children} + {isLoading && ( +
+
+
+ 加载中... +
+
+ )} + + + ); +}; + +// 全局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(); + }, +}; diff --git a/web/czr/hooks/notification.tsx b/web/czr/hooks/notification.tsx new file mode 100644 index 0000000..d5e39b1 --- /dev/null +++ b/web/czr/hooks/notification.tsx @@ -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.SUCCESS]: { + icon: , + bgColor: "bg-[rgba(0,168,91,0.85)]", + }, + [NotificationType.ERROR]: { + icon: , + bgColor: "bg-[rgba(225,45,57,0.85)]", + }, + [NotificationType.INFO]: { + icon: , + 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({ + 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([]); + + // 统一参数顺序: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 ( + + {notifications.length > 0 && ( + + {notifications.map((notification) => ( + + + + + + + {notificationConfigs[notification.type].icon} + + {notification.title && ( + + {notification.title} + + )} + + + {notification.message} + + +
+
+
+ + + ))} + + )} + {children} + + ); +}; + +// 导出hook +export const useNotification = () => { + const context = useContext(NotificationContext); + if (!context) { + throw new Error( + "useNotification must be used within a NotificationProvider", + ); + } + return context; +}; diff --git a/web/czr/hooks/themeMode.tsx b/web/czr/hooks/themeMode.tsx new file mode 100644 index 0000000..d6f24b5 --- /dev/null +++ b/web/czr/hooks/themeMode.tsx @@ -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 + + \ No newline at end of file diff --git a/web/czr/package.json b/web/czr/package.json new file mode 100644 index 0000000..d071521 --- /dev/null +++ b/web/czr/package.json @@ -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 + } + } +} diff --git a/web/czr/postcss.config.js b/web/czr/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/web/czr/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/web/czr/server/entry.server.js b/web/czr/server/entry.server.js new file mode 100644 index 0000000..fd8609e --- /dev/null +++ b/web/czr/server/entry.server.js @@ -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 = ` + + + + + + Your App + + +
+ + +`; + + 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(); \ No newline at end of file diff --git a/web/czr/server/env.ts b/web/czr/server/env.ts new file mode 100644 index 0000000..c4c71ef --- /dev/null +++ b/web/czr/server/env.ts @@ -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, + ); + } catch { + return {}; + } +} + +export async function writeEnvFile(env: Record) { + 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"); +} diff --git a/web/czr/server/express.ts b/web/czr/server/express.ts new file mode 100644 index 0000000..385a3ca --- /dev/null +++ b/web/czr/server/express.ts @@ -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}`); +}); diff --git a/web/czr/server/production.js b/web/czr/server/production.js new file mode 100644 index 0000000..f1959c1 --- /dev/null +++ b/web/czr/server/production.js @@ -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}`); +}); \ No newline at end of file diff --git a/web/czr/server/static.js b/web/czr/server/static.js new file mode 100644 index 0000000..3a8cc70 --- /dev/null +++ b/web/czr/server/static.js @@ -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')); +}); \ No newline at end of file diff --git a/web/czr/src/entry.client.tsx b/web/czr/src/entry.client.tsx new file mode 100644 index 0000000..c1d76b4 --- /dev/null +++ b/web/czr/src/entry.client.tsx @@ -0,0 +1,7 @@ +import { RemixBrowser } from "@remix-run/react"; +import { startTransition } from "react"; +import { hydrateRoot } from "react-dom/client"; + +startTransition(() => { + hydrateRoot(document, ); +}); \ No newline at end of file diff --git a/web/czr/start.bat b/web/czr/start.bat new file mode 100644 index 0000000..cbbc232 --- /dev/null +++ b/web/czr/start.bat @@ -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 \ No newline at end of file diff --git a/web/czr/styles/echoes.css b/web/czr/styles/echoes.css new file mode 100644 index 0000000..b2f9ddf --- /dev/null +++ b/web/czr/styles/echoes.css @@ -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; + } + } + \ No newline at end of file diff --git a/web/czr/tailwind.config.ts b/web/czr/tailwind.config.ts new file mode 100644 index 0000000..4af7f84 --- /dev/null +++ b/web/czr/tailwind.config.ts @@ -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; diff --git a/web/czr/tsconfig.json b/web/czr/tsconfig.json new file mode 100644 index 0000000..952cc89 --- /dev/null +++ b/web/czr/tsconfig.json @@ -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 + } +} diff --git a/web/czr/vite.config.ts b/web/czr/vite.config.ts new file mode 100644 index 0000000..c5a1ea1 --- /dev/null +++ b/web/czr/vite.config.ts @@ -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: '', +})