refactor: 合并为单 crate,前端改为内嵌 React 控制台
合并 stash-cli / stash-core 为根目录单 crate(lib + bin),新增 React + Vite + TS 控制台替换 Askama 模板。 build.rs 用 walkdir 把 stash-web/dist 同步到 $OUT_DIR/web,由 include_dir! 编译期内嵌,产出单文件二进制。 路由改为 / 与 /assets/** 走内嵌前端,其余交给 ServeDir,缺失路径返回内置 404。同步更新依赖、.gitignore 与 README。
This commit is contained in:
Generated
+33
-90
@@ -67,48 +67,6 @@ version = "1.0.104"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "askama"
|
|
||||||
version = "0.14.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4"
|
|
||||||
dependencies = [
|
|
||||||
"askama_derive",
|
|
||||||
"itoa",
|
|
||||||
"percent-encoding",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "askama_derive"
|
|
||||||
version = "0.14.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f"
|
|
||||||
dependencies = [
|
|
||||||
"askama_parser",
|
|
||||||
"basic-toml",
|
|
||||||
"memchr",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"rustc-hash",
|
|
||||||
"serde",
|
|
||||||
"serde_derive",
|
|
||||||
"syn 2.0.119",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "askama_parser"
|
|
||||||
version = "0.14.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358"
|
|
||||||
dependencies = [
|
|
||||||
"memchr",
|
|
||||||
"serde",
|
|
||||||
"serde_derive",
|
|
||||||
"winnow",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-stream"
|
name = "async-stream"
|
||||||
version = "0.3.6"
|
version = "0.3.6"
|
||||||
@@ -189,15 +147,6 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "basic-toml"
|
|
||||||
version = "0.1.10"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.13.2"
|
version = "2.13.2"
|
||||||
@@ -420,6 +369,25 @@ dependencies = [
|
|||||||
"tower-service",
|
"tower-service",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "include_dir"
|
||||||
|
version = "0.7.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
|
||||||
|
dependencies = [
|
||||||
|
"include_dir_macros",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "include_dir_macros"
|
||||||
|
version = "0.7.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "inotify"
|
name = "inotify"
|
||||||
version = "0.11.5"
|
version = "0.11.5"
|
||||||
@@ -480,9 +448,9 @@ checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.190"
|
version = "0.2.191"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
|
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lock_api"
|
name = "lock_api"
|
||||||
@@ -695,12 +663,6 @@ version = "0.8.11"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustc-hash"
|
|
||||||
version = "2.1.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
@@ -830,26 +792,15 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stash-cli"
|
name = "stash"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
|
||||||
"colored",
|
|
||||||
"stash-core",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"tracing-subscriber",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "stash-core"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"askama",
|
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"axum",
|
"axum",
|
||||||
|
"clap",
|
||||||
|
"colored",
|
||||||
|
"include_dir",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
"notify",
|
"notify",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -859,6 +810,7 @@ dependencies = [
|
|||||||
"tower 0.4.13",
|
"tower 0.4.13",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -898,22 +850,22 @@ checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "2.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl",
|
"thiserror-impl",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror-impl"
|
name = "thiserror-impl"
|
||||||
version = "1.0.69"
|
version = "2.0.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.9",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -927,9 +879,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.53.3"
|
version = "1.54.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
|
checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -1235,15 +1187,6 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "winnow"
|
|
||||||
version = "0.7.15"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945"
|
|
||||||
dependencies = [
|
|
||||||
"memchr",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "zmij"
|
name = "zmij"
|
||||||
version = "1.0.23"
|
version = "1.0.23"
|
||||||
|
|||||||
+23
-11
@@ -1,18 +1,30 @@
|
|||||||
[workspace]
|
[package]
|
||||||
resolver = "2"
|
name = "stash"
|
||||||
members = [
|
|
||||||
"stash-core",
|
|
||||||
"stash-cli",
|
|
||||||
]
|
|
||||||
|
|
||||||
[workspace.package]
|
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
description = ""
|
||||||
|
authors = [""]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT OR Apache-2.0"
|
|
||||||
|
|
||||||
[workspace.dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1.38", features = ["full"] }
|
tokio = { version = "1.38", features = ["full"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
thiserror = "1.0"
|
thiserror = "2.0.21"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
clap = { version = "4.6.7", features = ["derive"] }
|
||||||
|
colored = "3.1.1"
|
||||||
|
|
||||||
|
# Web 核心与静态托管
|
||||||
|
axum = "0.8.9"
|
||||||
|
tower-http = { version = "0.7.1", features = ["fs", "cors", "trace"] }
|
||||||
|
tower = { version = "0.4", features = ["util"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
mime_guess = "2.0"
|
||||||
|
include_dir = "0.7"
|
||||||
|
async-stream = "0.3"
|
||||||
|
notify = "8"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
# build.rs 用它递归复制前端产物到 $OUT_DIR/web
|
||||||
|
walkdir = "2.4"
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
// build.rs
|
||||||
|
//
|
||||||
|
// 把前端产物 stash-web/dist 同步到 $OUT_DIR/web,
|
||||||
|
// 供 include_dir!("$OUT_DIR/web") 在编译期内嵌进二进制。
|
||||||
|
//
|
||||||
|
// 这里只做「校验 + 搬运」,不在这里跑 npm:
|
||||||
|
// 那样会把 Node 和网络依赖带进 cargo build。
|
||||||
|
// 编译 Rust 之前请先执行:cd stash-web && npm install && npm run build
|
||||||
|
|
||||||
|
use std::{
|
||||||
|
env, fs,
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
};
|
||||||
|
|
||||||
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
/// 前端工程目录,相对 crate 根目录。
|
||||||
|
const WEB_PROJECT_DIR: &str = "stash-web";
|
||||||
|
/// 前端构建产物目录,相对前端工程目录。
|
||||||
|
const WEB_DIST_DIR: &str = "dist";
|
||||||
|
/// 同步到 $OUT_DIR 下的子目录名,需与 include_dir! 中的路径保持一致。
|
||||||
|
const EMBED_DIR: &str = "web";
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
// 前端产物变了要重新嵌入(Cargo 对目录会递归扫描)
|
||||||
|
println!("cargo:rerun-if-changed={}/{}", WEB_PROJECT_DIR, WEB_DIST_DIR);
|
||||||
|
println!("cargo:rerun-if-changed={}/package.json", WEB_PROJECT_DIR);
|
||||||
|
|
||||||
|
let dist = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join(WEB_PROJECT_DIR)
|
||||||
|
.join(WEB_DIST_DIR);
|
||||||
|
let out = PathBuf::from(env::var("OUT_DIR").expect("构建环境缺少 OUT_DIR")).join(EMBED_DIR);
|
||||||
|
|
||||||
|
// 缺产物就给出人能看懂的报错,而不是 proc-macro 的 "No such file or directory"
|
||||||
|
assert!(
|
||||||
|
dist.join("index.html").is_file(),
|
||||||
|
"未找到前端产物:{}\n请先执行: cd {} && npm install && npm run build",
|
||||||
|
dist.display(),
|
||||||
|
WEB_PROJECT_DIR
|
||||||
|
);
|
||||||
|
|
||||||
|
// 必须整体重建,否则 Vite 换 hash 后旧 chunk 会残留在二进制里
|
||||||
|
let _ = fs::remove_dir_all(&out);
|
||||||
|
fs::create_dir_all(&out).expect("创建内嵌目录失败");
|
||||||
|
|
||||||
|
copy_dir(&dist, &out).expect("复制前端产物失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用 walkdir 递归复制 `from` 到 `to`,并保留目录结构。
|
||||||
|
fn copy_dir(from: &Path, to: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let mut copied = 0usize;
|
||||||
|
|
||||||
|
for entry in WalkDir::new(from) {
|
||||||
|
let entry = entry?;
|
||||||
|
|
||||||
|
let relative = entry.path().strip_prefix(from)?;
|
||||||
|
|
||||||
|
// 跳过 dist 目录自身
|
||||||
|
if relative.as_os_str().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let destination = to.join(relative);
|
||||||
|
|
||||||
|
if entry.file_type().is_dir() {
|
||||||
|
fs::create_dir_all(&destination)?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 符号链接等非普通文件不参与内嵌
|
||||||
|
if !entry.file_type().is_file() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
fs::copy(entry.path(), &destination)?;
|
||||||
|
copied += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if copied == 0 {
|
||||||
|
return Err(std::io::Error::other(format!(
|
||||||
|
"{} 中没有任何文件可内嵌",
|
||||||
|
from.display()
|
||||||
|
))
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 268 KiB |
@@ -0,0 +1,745 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Git 规范标准化与工作流生成器</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-color: #f8fafc;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--text-main: #1e293b;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--border-color: #e2e8f0;
|
||||||
|
--primary: #2563eb;
|
||||||
|
--primary-hover: #1d4ed8;
|
||||||
|
--primary-light: #eff6ff;
|
||||||
|
--success: #10b981;
|
||||||
|
--radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.nav-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
border-bottom: 2px solid var(--border-color);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
color: var(--primary);
|
||||||
|
border-bottom-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 完美的图片展示容器 */
|
||||||
|
.img-viewport {
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: center;
|
||||||
|
overflow-x: auto;
|
||||||
|
box-shadow: inset 0 0 10px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-viewport img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid layout for types */
|
||||||
|
.type-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-card {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 10px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-card:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-card.selected {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary-light);
|
||||||
|
box-shadow: 0 0 0 2px var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tag {
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Styles */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row .form-group {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
label span.opt {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus,
|
||||||
|
textarea:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Output Box */
|
||||||
|
.output-card {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #f8fafc;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn.copied {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Template Cards for Branch */
|
||||||
|
.template-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-btn {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-btn.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-box {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 16px;
|
||||||
|
border-radius: 0 var(--radius) var(--radius) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-box ul {
|
||||||
|
padding-left: 18px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.form-row {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.type-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>Git 规范标准化生成器</h1>
|
||||||
|
<p>企业级 Git 分支管理与 Commit 标准化生成工具</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Nav Tabs -->
|
||||||
|
<div class="nav-tabs">
|
||||||
|
<button class="tab-btn active" onclick="switchTab('init-tab')">项目 Init 提交</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab('commit-tab')">常规 Commit 提交</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab('branch-tab')">分支命名与流转图</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TAB 1: 项目 Init 提交 -->
|
||||||
|
<div id="init-tab" class="tab-content active">
|
||||||
|
<div class="card">
|
||||||
|
<div class="section-title">初始化项目 Commit 模版</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="init-proj-name">项目名称</label>
|
||||||
|
<input type="text" id="init-proj-name" placeholder="例: git_helper" oninput="updateInitOutput()">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="init-proj-desc">项目描述</label>
|
||||||
|
<input type="text" id="init-proj-desc" placeholder="例: 基于 Spring Boot 的企业级后台" oninput="updateInitOutput()">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="init-env">开发环境 / 技术栈</label>
|
||||||
|
<input type="text" id="init-env" placeholder="例: JDK 17, Spring Boot 3.x, MySQL" oninput="updateInitOutput()">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="init-items">初始化完成工作 <span class="opt">(按行分隔)</span></label>
|
||||||
|
<textarea id="init-items" rows="5" oninput="updateInitOutput()">创建了基本的项目结构
|
||||||
|
添加了初始的 README 文件
|
||||||
|
添加了 .gitignore 文件
|
||||||
|
配置了基本的开发环境(如 ESLint, Prettier)
|
||||||
|
设置了基础文件夹结构(src, public, config)</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="init-next">下一步计划 <span class="opt">(按行分隔)</span></label>
|
||||||
|
<textarea id="init-next" rows="3" oninput="updateInitOutput()">填充 README 文件
|
||||||
|
添加项目基本设计文档
|
||||||
|
配置 CI/CD 自动化部署流水线</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="output-card">
|
||||||
|
<div class="output-header">
|
||||||
|
<span class="output-title">生成的 Init Commit Message</span>
|
||||||
|
<button class="copy-btn" onclick="copyText('init-result', this)">复制初始化信息</button>
|
||||||
|
</div>
|
||||||
|
<pre id="init-result"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TAB 2: 常规 Commit -->
|
||||||
|
<div id="commit-tab" class="tab-content">
|
||||||
|
<div class="card">
|
||||||
|
<div class="section-title">1. 选择提交类型 (Type)</div>
|
||||||
|
<div class="type-grid" id="commit-type-grid"></div>
|
||||||
|
|
||||||
|
<div class="section-title" style="margin-top:20px;">2. 填写提交内容</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="commit-scope">影响范围 Scope <span class="opt">(可选,如: auth, db)</span></label>
|
||||||
|
<input type="text" id="commit-scope" placeholder="例: auth" oninput="updateCommitOutput()">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="commit-subject">简明描述 Description <span class="opt">(必填,建议主动语态)</span></label>
|
||||||
|
<input type="text" id="commit-subject" placeholder="例: add user login functionality" oninput="updateCommitOutput()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="commit-body">详细说明 Body <span class="opt">(可选,补充变动原因及细节)</span></label>
|
||||||
|
<textarea id="commit-body" placeholder="- Implemented login endpoint with JWT authentication - Added login form to frontend" oninput="updateCommitOutput()"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title" style="margin-top:20px;">3. 脚注选项 (Footer)</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="commit-footer">关联 Issue / 脚注 <span class="opt">(可选,例: Closes #45)</span></label>
|
||||||
|
<input type="text" id="commit-footer" placeholder="例: Closes #45" oninput="updateCommitOutput()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="commit-wip">WIP 状态 <span class="opt">(可选,正在进行中的工作)</span></label>
|
||||||
|
<input type="text" id="commit-wip" placeholder="例: ing 登录模块UI正在联调" oninput="updateCommitOutput()">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="commit-fixup">FIXUP 目标 <span class="opt">(可选,修复先前提交的错误)</span></label>
|
||||||
|
<input type="text" id="commit-fixup" placeholder="例: 修正上一个提交的拼写错误" oninput="updateCommitOutput()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-group" style="display:flex; align-items:center; gap:8px; cursor:pointer;">
|
||||||
|
<input type="checkbox" id="commit-breaking" onchange="updateCommitOutput()">
|
||||||
|
<span>包含破坏性变动 (BREAKING CHANGE)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="output-card">
|
||||||
|
<div class="output-header">
|
||||||
|
<span class="output-title">生成的 Commit Message</span>
|
||||||
|
<button class="copy-btn" onclick="copyText('commit-result', this)">复制提交信息</button>
|
||||||
|
</div>
|
||||||
|
<pre id="commit-result"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TAB 3: 分支命名与高清图片流转图 -->
|
||||||
|
<div id="branch-tab" class="tab-content">
|
||||||
|
<div class="card">
|
||||||
|
<div class="section-title">企业敏捷 Git 演进架构图 (原生高清预览)</div>
|
||||||
|
|
||||||
|
<!-- 使用图片完美还原 Obsidian 效果 -->
|
||||||
|
<div class="img-viewport">
|
||||||
|
<img src="git-flow.png" alt="企业敏捷 Git 拓扑流转图">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title">选择分支类型生成 Git 命令</div>
|
||||||
|
<div class="template-selector">
|
||||||
|
<button class="tpl-btn active" onclick="selectBranchType('develop', this)">创建 develop 分支</button>
|
||||||
|
<button class="tpl-btn" onclick="selectBranchType('feature', this)">迭代功能分支 (SNAPSHOT)</button>
|
||||||
|
<button class="tpl-btn" onclick="selectBranchType('personal', this)">组员个人开发分支</button>
|
||||||
|
<button class="tpl-btn" onclick="selectBranchType('staging', this)">预生产分支 (Release)</button>
|
||||||
|
<button class="tpl-btn" onclick="selectBranchType('hotfix', this)">Hotfix 修复分支</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title" style="margin-top:16px;">填写项目与人员配置</div>
|
||||||
|
<div class="form-row" id="proj-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="branch-proj">项目名称</label>
|
||||||
|
<input type="text" id="branch-proj" value="git_helper" oninput="updateBranchOutput()">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="version-group">
|
||||||
|
<label for="branch-version">版本号</label>
|
||||||
|
<input type="text" id="branch-version" value="v2.1.1" oninput="updateBranchOutput()">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="date-group">
|
||||||
|
<label for="branch-date">日期</label>
|
||||||
|
<input type="text" id="branch-date" oninput="updateBranchOutput()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row" id="personal-fields" style="display:none;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="branch-author">组员姓名/拼音</label>
|
||||||
|
<input type="text" id="branch-author" value="zhangsan" oninput="updateBranchOutput()">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="branch-seq">同名序号 <span class="opt">(可选,如: 2)</span></label>
|
||||||
|
<input type="text" id="branch-seq" placeholder="例: 2" oninput="updateBranchOutput()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rules-box">
|
||||||
|
<strong>当前分支说明与规则:</strong>
|
||||||
|
<ul id="branch-rules-list">
|
||||||
|
<li>从 <code>main</code> 拉取并创建 <code>develop</code> 分支作为团队日常开发基线。</li>
|
||||||
|
<li>执行命令:<code>git checkout -b develop main</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="output-card">
|
||||||
|
<div class="output-header">
|
||||||
|
<span class="output-title">生成的 Git 创建/切换分支命令</span>
|
||||||
|
<button class="copy-btn" onclick="copyText('branch-result', this)">复制 Git 命令</button>
|
||||||
|
</div>
|
||||||
|
<pre id="branch-result"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 1. Commit Types Definition
|
||||||
|
const commitTypes = [
|
||||||
|
{ type: 'feat', label: '✨ feat', desc: '新功能 / 新特性' },
|
||||||
|
{ type: 'fix', label: '🐛 fix', desc: '修复 bug' },
|
||||||
|
{ type: 'docs', label: '📚 docs', desc: '文档更新' },
|
||||||
|
{ type: 'style', label: '💄 style', desc: '代码格式(不影响功能)' },
|
||||||
|
{ type: 'refactor', label: '💎 refactor', desc: '重构(既不修复bug也不加功能)' },
|
||||||
|
{ type: 'perf', label: '⚡ perf', desc: '性能提升' },
|
||||||
|
{ type: 'test', label: '🚨 test', desc: '添加或修改测试' },
|
||||||
|
{ type: 'chore', label: '🔧 chore', desc: '构建/工具等杂项变动' },
|
||||||
|
{ type: 'build', label: '📦 build', desc: '影响构建系统或依赖的变动' },
|
||||||
|
{ type: 'security', label: '🛡️ security', desc: '安全性修复或增强' }
|
||||||
|
];
|
||||||
|
|
||||||
|
let currentCommitType = 'feat';
|
||||||
|
let currentBranchType = 'develop';
|
||||||
|
|
||||||
|
// Initialize Page
|
||||||
|
window.onload = () => {
|
||||||
|
renderCommitTypes();
|
||||||
|
const today = new Date();
|
||||||
|
const yyyy = today.getFullYear();
|
||||||
|
const mm = String(today.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(today.getDate()).padStart(2, '0');
|
||||||
|
document.getElementById('branch-date').value = `${yyyy}${mm}${dd}`;
|
||||||
|
|
||||||
|
updateCommitOutput();
|
||||||
|
updateInitOutput();
|
||||||
|
updateBranchOutput();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tab Switching
|
||||||
|
function switchTab(tabId) {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
||||||
|
|
||||||
|
event.target.classList.add('active');
|
||||||
|
document.getElementById(tabId).classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Commit Type Cards
|
||||||
|
function renderCommitTypes() {
|
||||||
|
const grid = document.getElementById('commit-type-grid');
|
||||||
|
grid.innerHTML = commitTypes.map(t => `
|
||||||
|
<div class="type-card ${t.type === currentCommitType ? 'selected' : ''}" onclick="selectCommitType('${t.type}', this)">
|
||||||
|
<div class="type-header">
|
||||||
|
<span class="type-tag">${t.label}</span>
|
||||||
|
</div>
|
||||||
|
<div class="type-desc">${t.desc}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCommitType(type, el) {
|
||||||
|
currentCommitType = type;
|
||||||
|
document.querySelectorAll('#commit-type-grid .type-card').forEach(c => c.classList.remove('selected'));
|
||||||
|
el.classList.add('selected');
|
||||||
|
updateCommitOutput();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Commit Output
|
||||||
|
function updateCommitOutput() {
|
||||||
|
const scope = document.getElementById('commit-scope').value.trim();
|
||||||
|
const subject = document.getElementById('commit-subject').value.trim();
|
||||||
|
const body = document.getElementById('commit-body').value.trim();
|
||||||
|
const footer = document.getElementById('commit-footer').value.trim();
|
||||||
|
const wip = document.getElementById('commit-wip').value.trim();
|
||||||
|
const fixup = document.getElementById('commit-fixup').value.trim();
|
||||||
|
const isBreaking = document.getElementById('commit-breaking').checked;
|
||||||
|
|
||||||
|
let header = currentCommitType;
|
||||||
|
if (scope) {
|
||||||
|
header += `(${scope})`;
|
||||||
|
}
|
||||||
|
header += `: ${subject || '<描述>'}`;
|
||||||
|
|
||||||
|
let result = header;
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
result += `\n\n${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let footers = [];
|
||||||
|
if (isBreaking) {
|
||||||
|
footers.push(`BREAKING CHANGE: 包含破坏性变更,可能不向下兼容。`);
|
||||||
|
}
|
||||||
|
if (wip) {
|
||||||
|
footers.push(`WIP: ${wip}`);
|
||||||
|
}
|
||||||
|
if (fixup) {
|
||||||
|
footers.push(`FIXUP: ${fixup}`);
|
||||||
|
}
|
||||||
|
if (footer) {
|
||||||
|
footers.push(footer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (footers.length > 0) {
|
||||||
|
result += `\n\n${footers.join('\n')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('commit-result').textContent = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Init Output
|
||||||
|
function updateInitOutput() {
|
||||||
|
const projName = document.getElementById('init-proj-name').value.trim() || '[项目名称]';
|
||||||
|
const projDesc = document.getElementById('init-proj-desc').value.trim() || '[项目描述]';
|
||||||
|
const env = document.getElementById('init-env').value.trim() || '[开发环境]';
|
||||||
|
|
||||||
|
const items = document.getElementById('init-items').value.trim().split('\n').filter(i => i).map(i => `- ${i}`).join('\n');
|
||||||
|
const nexts = document.getElementById('init-next').value.trim().split('\n').filter(i => i).map(i => `- ${i}`).join('\n');
|
||||||
|
|
||||||
|
const result = `init: 初始化项目\n\n${items}\n\n项目说明:\n- 项目名称:${projName}\n- 项目描述:${projDesc}\n- 开发环境:${env}\n\n下一步:\n${nexts}\n\n感谢您的贡献!`;
|
||||||
|
|
||||||
|
document.getElementById('init-result').textContent = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Branch Type Selection
|
||||||
|
function selectBranchType(type, btn) {
|
||||||
|
currentBranchType = type;
|
||||||
|
document.querySelectorAll('.template-selector .tpl-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
|
||||||
|
const personalFields = document.getElementById('personal-fields');
|
||||||
|
const versionGroup = document.getElementById('version-group');
|
||||||
|
const dateGroup = document.getElementById('date-group');
|
||||||
|
const rulesList = document.getElementById('branch-rules-list');
|
||||||
|
|
||||||
|
if (type === 'develop') {
|
||||||
|
versionGroup.style.display = 'none';
|
||||||
|
dateGroup.style.display = 'none';
|
||||||
|
personalFields.style.display = 'none';
|
||||||
|
rulesList.innerHTML = `
|
||||||
|
<li>从 <code>main</code> 拉取并创建 <code>develop</code> 分支作为团队日常开发基线。</li>
|
||||||
|
<li>执行命令:<code>git checkout -b develop main</code></li>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
versionGroup.style.display = 'block';
|
||||||
|
dateGroup.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'feature') {
|
||||||
|
personalFields.style.display = 'none';
|
||||||
|
rulesList.innerHTML = `
|
||||||
|
<li>开发前从 <code>develop</code> 分支拉取,保持起始点一致。用于自动化部署测试环境。</li>
|
||||||
|
<li>规范格式:<code>feature-{项目名}-{版本}-SNAPSHOT-{日期}</code></li>
|
||||||
|
`;
|
||||||
|
} else if (type === 'personal') {
|
||||||
|
personalFields.style.display = 'flex';
|
||||||
|
rulesList.innerHTML = `
|
||||||
|
<li>开发中组员基于<b>迭代功能分支</b>拉取个人临时分支,提测时合并回功能分支。</li>
|
||||||
|
<li>规范格式:<code>feature-{项目名}-{版本}-SNAPSHOT-{日期}-{姓名}</code></li>
|
||||||
|
`;
|
||||||
|
} else if (type === 'staging') {
|
||||||
|
personalFields.style.display = 'none';
|
||||||
|
rulesList.innerHTML = `
|
||||||
|
<li>开发环境测试结束后,从功能分支拉出去掉 <code>SNAPSHOT</code> 标识的预生产分支。</li>
|
||||||
|
<li>上线验证通过后,将预生产分支分别合并回 <code>main</code> 和 <code>develop</code>。</li>
|
||||||
|
<li>规范格式:<code>feature-{项目名}-{版本}-{日期}</code></li>
|
||||||
|
`;
|
||||||
|
} else if (type === 'hotfix') {
|
||||||
|
personalFields.style.display = 'none';
|
||||||
|
rulesList.innerHTML = `
|
||||||
|
<li>线上紧急修复,从 <code>main</code> 拉取,修复验证后同时合并回 <code>main</code> 与 <code>develop</code>。</li>
|
||||||
|
<li>规范格式:<code>hotfix-{项目名}-{版本}-{日期}</code></li>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBranchOutput();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Branch Output
|
||||||
|
function updateBranchOutput() {
|
||||||
|
const proj = document.getElementById('branch-proj').value.trim() || 'git_helper';
|
||||||
|
const version = document.getElementById('branch-version').value.trim() || 'v2.1.1';
|
||||||
|
const date = document.getElementById('branch-date').value.trim() || '20240703';
|
||||||
|
const author = document.getElementById('branch-author').value.trim() || 'zhangsan';
|
||||||
|
const seq = document.getElementById('branch-seq').value.trim();
|
||||||
|
|
||||||
|
let command = '';
|
||||||
|
|
||||||
|
if (currentBranchType === 'develop') {
|
||||||
|
command = `git checkout -b develop main`;
|
||||||
|
} else if (currentBranchType === 'feature') {
|
||||||
|
const branchName = `feature-${proj}-${version}-SNAPSHOT-${date}`;
|
||||||
|
command = `git checkout -b ${branchName} develop`;
|
||||||
|
} else if (currentBranchType === 'personal') {
|
||||||
|
const seqStr = seq ? `-${seq}` : '';
|
||||||
|
const baseBranch = `feature-${proj}-${version}-SNAPSHOT-${date}`;
|
||||||
|
const branchName = `${baseBranch}-${author}${seqStr}`;
|
||||||
|
command = `git checkout -b ${branchName} ${baseBranch}`;
|
||||||
|
} else if (currentBranchType === 'staging') {
|
||||||
|
const baseBranch = `feature-${proj}-${version}-SNAPSHOT-${date}`;
|
||||||
|
const branchName = `feature-${proj}-${version}-${date}`;
|
||||||
|
command = `git checkout -b ${branchName} ${baseBranch}`;
|
||||||
|
} else if (currentBranchType === 'hotfix') {
|
||||||
|
const branchName = `hotfix-${proj}-${version}-${date}`;
|
||||||
|
command = `git checkout -b ${branchName} main`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('branch-result').textContent = command;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy helper
|
||||||
|
function copyText(elementId, btn) {
|
||||||
|
const text = document.getElementById(elementId).textContent;
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
const originalText = btn.textContent;
|
||||||
|
btn.textContent = '已复制!';
|
||||||
|
btn.classList.add('copied');
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = originalText;
|
||||||
|
btn.classList.remove('copied');
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -17,7 +17,7 @@ pub struct Config {
|
|||||||
impl Default for Config {
|
impl Default for Config {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
root: PathBuf::from("./public"),
|
root: PathBuf::from("../public"),
|
||||||
port: 8080,
|
port: 8080,
|
||||||
host: "127.0.0.1".to_string(),
|
host: "127.0.0.1".to_string(),
|
||||||
directory_listing: true,
|
directory_listing: true,
|
||||||
@@ -11,9 +11,6 @@ pub enum StashError {
|
|||||||
#[error("IO 错误: {0}")]
|
#[error("IO 错误: {0}")]
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
|
|
||||||
#[error("模板渲染失败: {0}")]
|
|
||||||
Template(#[from] askama::Error),
|
|
||||||
|
|
||||||
#[error("文件监听失败: {0}")]
|
#[error("文件监听失败: {0}")]
|
||||||
Watch(#[from] notify::Error),
|
Watch(#[from] notify::Error),
|
||||||
|
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
use askama::Template;
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::{fmt::Write as _, path::Path};
|
use std::{fmt::Write as _, path::Path};
|
||||||
|
|
||||||
use crate::error::StashError;
|
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
|
#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
|
||||||
pub struct AppProject {
|
pub struct AppProject {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -21,18 +18,6 @@ impl AppProject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
|
||||||
#[template(path = "index.html")]
|
|
||||||
struct DashboardTemplate<'a> {
|
|
||||||
projects: &'a [AppProject],
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Template)]
|
|
||||||
#[template(path = "404.html")]
|
|
||||||
struct NotFoundTemplate<'a> {
|
|
||||||
path: &'a str,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn scan_projects(root: &Path) -> Vec<AppProject> {
|
pub fn scan_projects(root: &Path) -> Vec<AppProject> {
|
||||||
let mut projects = Vec::new();
|
let mut projects = Vec::new();
|
||||||
|
|
||||||
@@ -66,14 +51,6 @@ pub fn scan_projects(root: &Path) -> Vec<AppProject> {
|
|||||||
projects
|
projects
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_dashboard(projects: &[AppProject]) -> Result<String, StashError> {
|
|
||||||
Ok(DashboardTemplate { projects }.render()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn render_not_found(path: &str) -> Result<String, StashError> {
|
|
||||||
Ok(NotFoundTemplate { path }.render()?)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn encode_path_segment(segment: &str) -> String {
|
fn encode_path_segment(segment: &str) -> String {
|
||||||
let mut encoded = String::with_capacity(segment.len());
|
let mut encoded = String::with_capacity(segment.len());
|
||||||
|
|
||||||
@@ -129,18 +106,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn contains_escaped_less_than(html: &str) -> bool {
|
|
||||||
["<", "<", "<", "<"]
|
|
||||||
.iter()
|
|
||||||
.any(|entity| html.contains(entity))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn contains_escaped_ampersand(html: &str) -> bool {
|
|
||||||
["&", "&", "&", "&"]
|
|
||||||
.iter()
|
|
||||||
.any(|entity| html.contains(entity))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn scan_projects_detects_only_immediate_visible_projects() {
|
fn scan_projects_detects_only_immediate_visible_projects() {
|
||||||
let root = TestDir::new();
|
let root = TestDir::new();
|
||||||
@@ -192,46 +157,4 @@ mod tests {
|
|||||||
fn path_segment_encoding_preserves_unreserved_characters() {
|
fn path_segment_encoding_preserves_unreserved_characters() {
|
||||||
assert_eq!(encode_path_segment("aZ09-_.~/?#%"), "aZ09-_.~%2F%3F%23%25");
|
assert_eq!(encode_path_segment("aZ09-_.~/?#%"), "aZ09-_.~%2F%3F%23%25");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dashboard_renders_empty_state() {
|
|
||||||
let html = render_dashboard(&[]).expect("空项目列表应能正常渲染");
|
|
||||||
|
|
||||||
assert!(html.contains("这里还没有项目"));
|
|
||||||
assert!(html.contains("<code>index.html</code>"));
|
|
||||||
assert!(!html.contains("class=\"site-item\""));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn dashboard_escapes_project_content() {
|
|
||||||
let projects = vec![AppProject {
|
|
||||||
name: "<项目 & \"测试\">".to_string(),
|
|
||||||
route_path: "/safe%20path/".to_string(),
|
|
||||||
}];
|
|
||||||
|
|
||||||
let html = render_dashboard(&projects).expect("项目卡片应能正常渲染");
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
contains_escaped_less_than(&html),
|
|
||||||
"项目名称中的 < 应被 HTML 转义,实际输出: {html}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
contains_escaped_ampersand(&html),
|
|
||||||
"项目名称中的 & 应被 HTML 转义,实际输出: {html}"
|
|
||||||
);
|
|
||||||
assert!(!html.contains("<项目 & \"测试\">"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn not_found_page_renders_and_escapes_request_path() {
|
|
||||||
let html =
|
|
||||||
render_not_found("/missing/<script>alert(1)</script>").expect("404 页面应能正常渲染");
|
|
||||||
|
|
||||||
assert!(html.contains("资源或页面不存在"));
|
|
||||||
assert!(
|
|
||||||
contains_escaped_less_than(&html),
|
|
||||||
"请求路径中的 < 应被 HTML 转义,实际输出: {html}"
|
|
||||||
);
|
|
||||||
assert!(!html.contains("<script>alert(1)</script>"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use colored::*;
|
use colored::*;
|
||||||
use stash_core::{Config, start};
|
use stash::{Config, start};
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
@@ -93,8 +93,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
println!("{}", "==================================================".bright_blue());
|
println!("{}", "==================================================".bright_blue());
|
||||||
println!(" ➜ 本地访问: {}", format!("http://{}", handle.local_addr).cyan().bold().underline());
|
println!(" ➜ 本地访问: {}", format!("http://{}", handle.local_addr).cyan().bold().underline());
|
||||||
println!(" ➜ 托管路径: {}", abs_root.display().to_string().white());
|
println!(" ➜ 托管路径: {}", abs_root.display().to_string().white());
|
||||||
println!(" ➜ 目录导航页: {}", if !cli.no_listing { "开启".green() } else { "关闭".red() });
|
println!(" ➜ 前端资源: {}", "已内嵌 (stash-web/dist)".white());
|
||||||
println!(" ➜ 接口列表: {}", format!("http://{}/api/pages", handle.local_addr).dimmed());
|
println!(" ➜ 接口列表: {}", format!("http://{}/api/projects", handle.local_addr).dimmed());
|
||||||
println!("{}", "--------------------------------------------------".bright_blue());
|
println!("{}", "--------------------------------------------------".bright_blue());
|
||||||
println!(" 按 {} 停止服务", "Ctrl+C".yellow().bold());
|
println!(" 按 {} 停止服务", "Ctrl+C".yellow().bold());
|
||||||
println!();
|
println!();
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
extract::{OriginalUri, State},
|
extract::{OriginalUri, Path, State},
|
||||||
http::{StatusCode, header},
|
http::{HeaderValue, StatusCode, header},
|
||||||
response::sse::{Event, KeepAlive, Sse},
|
response::sse::{Event, KeepAlive, Sse},
|
||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
routing::get,
|
routing::get,
|
||||||
};
|
};
|
||||||
|
use include_dir::{Dir, include_dir};
|
||||||
use std::{convert::Infallible, net::SocketAddr, sync::Arc, time::Duration};
|
use std::{convert::Infallible, net::SocketAddr, sync::Arc, time::Duration};
|
||||||
use tokio::sync::{broadcast, oneshot};
|
use tokio::sync::{broadcast, oneshot};
|
||||||
use tower_http::{
|
use tower_http::{
|
||||||
@@ -17,11 +18,16 @@ use tracing::{info, warn};
|
|||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::error::StashError;
|
use crate::error::StashError;
|
||||||
use crate::listing::{AppProject, render_dashboard, render_not_found, scan_projects};
|
use crate::listing::{AppProject, scan_projects};
|
||||||
use crate::watcher;
|
use crate::watcher;
|
||||||
|
|
||||||
const DASHBOARD_CSS: &str = include_str!("../assets/dashboard.css");
|
/// build.rs 会把 `stash-web/dist` 同步到 `$OUT_DIR/web`,
|
||||||
const DASHBOARD_JS: &str = include_str!("../assets/dashboard.js");
|
/// 这里把整棵目录树在编译期内嵌,最终产出带前端的单文件二进制。
|
||||||
|
static WEB: Dir<'static> = include_dir!("$OUT_DIR/web");
|
||||||
|
|
||||||
|
/// Vite 产物文件名带内容 hash,可以长期强缓存;HTML 每次都要回源校验。
|
||||||
|
const HASHED_ASSET_CACHE: &str = "public, max-age=31536000, immutable";
|
||||||
|
const HTML_CACHE: &str = "no-cache";
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
@@ -40,46 +46,80 @@ impl ServerHandle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 根路径处理器
|
/// 根路径:返回内嵌前端的入口页。
|
||||||
async fn root_handler(State(state): State<AppState>) -> Result<Response, StashError> {
|
async fn web_index() -> Response {
|
||||||
let index_file = state.config.root.join("index.html");
|
embedded_file("index.html").unwrap_or_else(|| not_found_page("/"))
|
||||||
|
|
||||||
// 1. 如果托管目录本身就是一个完整的前端项目(含 index.html),优先直接展示
|
|
||||||
if index_file.exists() {
|
|
||||||
let content = tokio::fs::read_to_string(&index_file).await?;
|
|
||||||
return Ok((
|
|
||||||
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
|
|
||||||
Html(content),
|
|
||||||
)
|
|
||||||
.into_response());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 否则展示筛选后的可用前端应用大厅
|
/// `/assets/**`:Vite 产出的 CSS / JS / 图片。
|
||||||
let projects = scan_projects(&state.config.root);
|
async fn web_asset(OriginalUri(uri): OriginalUri, Path(path): Path<String>) -> Response {
|
||||||
let html = render_dashboard(&projects)?;
|
let file = format!("assets/{path}");
|
||||||
Ok(Html(html).into_response())
|
embedded_file(&file).unwrap_or_else(|| not_found_page(uri.path()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 全局 404 自定义页面处理器
|
/// 前端放在 dist 根目录的其他文件(favicon 等)。
|
||||||
async fn not_found_handler(
|
async fn web_root_file(OriginalUri(uri): OriginalUri) -> Response {
|
||||||
OriginalUri(uri): OriginalUri,
|
let path = uri.path().trim_start_matches('/');
|
||||||
) -> Result<(StatusCode, Html<String>), StashError> {
|
embedded_file(path).unwrap_or_else(|| not_found_page(uri.path()))
|
||||||
let html = render_not_found(uri.path())?;
|
|
||||||
Ok((StatusCode::NOT_FOUND, Html(html)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn dashboard_css() -> impl IntoResponse {
|
/// 从内嵌目录取文件并组装响应;找不到返回 `None`。
|
||||||
|
fn embedded_file(path: &str) -> Option<Response> {
|
||||||
|
let file = WEB.get_file(path)?;
|
||||||
|
|
||||||
|
let mime = mime_guess::from_path(path).first_or_octet_stream();
|
||||||
|
let content_type = HeaderValue::from_str(mime.as_ref())
|
||||||
|
.unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"));
|
||||||
|
let cache = if path.starts_with("assets/") {
|
||||||
|
HeaderValue::from_static(HASHED_ASSET_CACHE)
|
||||||
|
} else {
|
||||||
|
HeaderValue::from_static(HTML_CACHE)
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(
|
||||||
(
|
(
|
||||||
[(header::CONTENT_TYPE, "text/css; charset=utf-8")],
|
StatusCode::OK,
|
||||||
DASHBOARD_CSS,
|
[
|
||||||
|
(header::CONTENT_TYPE, content_type),
|
||||||
|
(header::CACHE_CONTROL, cache),
|
||||||
|
],
|
||||||
|
file.contents(),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn dashboard_js() -> impl IntoResponse {
|
/// 统一 404:内嵌前端没有这个文件,托管目录里也没有这个路径。
|
||||||
(
|
fn not_found_page(path: &str) -> Response {
|
||||||
[(header::CONTENT_TYPE, "text/javascript; charset=utf-8")],
|
let body = format!(
|
||||||
DASHBOARD_JS,
|
r#"<!doctype html>
|
||||||
)
|
<html lang="zh-CN">
|
||||||
|
<head><meta charset="utf-8"><title>404 · 页面不存在</title></head>
|
||||||
|
<body style="margin:0;display:grid;place-items:center;min-height:100vh;background:#f7f9fb;color:#1e293b;font-family:system-ui,-apple-system,'Segoe UI','PingFang SC','Microsoft YaHei',sans-serif">
|
||||||
|
<main style="text-align:center;padding:24px">
|
||||||
|
<p style="margin:0;font-size:64px;font-weight:800;line-height:1;color:#cbd5e1">404</p>
|
||||||
|
<h1 style="margin:12px 0 8px;font-size:18px;font-weight:600">资源或页面不存在</h1>
|
||||||
|
<p style="margin:0 0 20px;color:#64748b"><code style="font-family:ui-monospace,Consolas,monospace;font-size:13px">{}</code></p>
|
||||||
|
<a href="/" style="display:inline-block;padding:8px 16px;border-radius:8px;background:#3b82f6;color:#fff;text-decoration:none;font-size:14px">返回本地应用</a>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>"#,
|
||||||
|
escape_html(path)
|
||||||
|
);
|
||||||
|
|
||||||
|
(StatusCode::NOT_FOUND, Html(body)).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn not_found_handler(OriginalUri(uri): OriginalUri) -> Response {
|
||||||
|
not_found_page(uri.path())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn escape_html(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// API: 健康检查
|
/// API: 健康检查
|
||||||
@@ -153,7 +193,8 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
|
|||||||
project_updates,
|
project_updates,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 静态文件服务:找不到静态资源时 fallback 到自定义 not_found_handler
|
// 托管目录里找不到的路径,交给 404 页面而不是 SPA 回落:
|
||||||
|
// 前端目前没有客户端路由,把 404 冒充 200 只会让排错更难。
|
||||||
let serve_dir = ServeDir::new(&config.root)
|
let serve_dir = ServeDir::new(&config.root)
|
||||||
.append_index_html_on_directories(true)
|
.append_index_html_on_directories(true)
|
||||||
.fallback(axum::handler::HandlerWithoutStateExt::into_service(
|
.fallback(axum::handler::HandlerWithoutStateExt::into_service(
|
||||||
@@ -164,9 +205,10 @@ pub async fn start(config: Config) -> Result<ServerHandle, StashError> {
|
|||||||
.route("/api/health", get(health_check))
|
.route("/api/health", get(health_check))
|
||||||
.route("/api/projects", get(list_projects_api))
|
.route("/api/projects", get(list_projects_api))
|
||||||
.route("/api/projects/events", get(project_events))
|
.route("/api/projects/events", get(project_events))
|
||||||
.route("/__stash__/dashboard.css", get(dashboard_css))
|
.route("/", get(web_index))
|
||||||
.route("/__stash__/dashboard.js", get(dashboard_js))
|
.route("/index.html", get(web_index))
|
||||||
.route("/", get(root_handler))
|
.route("/favicon.svg", get(web_root_file))
|
||||||
|
.route("/assets/{*path}", get(web_asset))
|
||||||
.fallback_service(serve_dir)
|
.fallback_service(serve_dir)
|
||||||
.with_state(shared_state)
|
.with_state(shared_state)
|
||||||
.layer(TraceLayer::new_for_http());
|
.layer(TraceLayer::new_for_http());
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "stash-cli"
|
|
||||||
version = { workspace = true }
|
|
||||||
edition = { workspace = true }
|
|
||||||
license = { workspace = true }
|
|
||||||
|
|
||||||
[[bin]]
|
|
||||||
name = "stash"
|
|
||||||
path = "src/main.rs"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
stash-core = { path = "../stash-core",version = "0.1.0" }
|
|
||||||
tokio = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
|
||||||
tracing-subscriber = { workspace = true }
|
|
||||||
clap = { version = "4.6.7", features = ["derive"] }
|
|
||||||
anyhow = { workspace = true }
|
|
||||||
colored = "3.1.1"
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "stash-core"
|
|
||||||
version = { workspace = true }
|
|
||||||
edition = { workspace = true }
|
|
||||||
license = { workspace = true }
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
tokio = { workspace = true }
|
|
||||||
tracing = { workspace = true }
|
|
||||||
thiserror = { workspace = true }
|
|
||||||
anyhow = { workspace = true }
|
|
||||||
|
|
||||||
# Web 核心与静态托管
|
|
||||||
axum = "0.8.9"
|
|
||||||
tower-http = { version = "0.7.1", features = ["fs", "cors", "trace"] }
|
|
||||||
tower = { version = "0.4", features = ["util"] }
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
|
||||||
serde_json = "1.0"
|
|
||||||
walkdir = "2.4"
|
|
||||||
mime_guess = "2.0"
|
|
||||||
askama = "0.14"
|
|
||||||
async-stream = "0.3"
|
|
||||||
notify = "8"
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the Oxlint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"options": {
|
||||||
|
"typeAware": true
|
||||||
|
},
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="color-scheme" content="light" />
|
||||||
|
<title>Stash · 本地应用工作区</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+1300
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "stash-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.3.1",
|
||||||
|
"react-dom": "^19.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.19.2",
|
||||||
|
"@types/react": "^19.3.0",
|
||||||
|
"@types/react-dom": "^19.3.0",
|
||||||
|
"@vitejs/plugin-react": "^6.1.2",
|
||||||
|
"oxlint": "^1.88.0",
|
||||||
|
"typescript": "~6.0.3",
|
||||||
|
"vite": "^8.3.4"
|
||||||
|
},
|
||||||
|
"volta": {
|
||||||
|
"node": "24.12.0",
|
||||||
|
"npm": "11.6.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 268 KiB |
@@ -0,0 +1,745 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Git 规范标准化与工作流生成器</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-color: #f8fafc;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--text-main: #1e293b;
|
||||||
|
--text-muted: #64748b;
|
||||||
|
--border-color: #e2e8f0;
|
||||||
|
--primary: #2563eb;
|
||||||
|
--primary-hover: #1d4ed8;
|
||||||
|
--primary-light: #eff6ff;
|
||||||
|
--success: #10b981;
|
||||||
|
--radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 24px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1040px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-main);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.nav-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
border-bottom: 2px solid var(--border-color);
|
||||||
|
margin-bottom: 20px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
color: var(--primary);
|
||||||
|
border-bottom-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 完美的图片展示容器 */
|
||||||
|
.img-viewport {
|
||||||
|
background: #0f172a;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
text-align: center;
|
||||||
|
overflow-x: auto;
|
||||||
|
box-shadow: inset 0 0 10px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.img-viewport img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid layout for types */
|
||||||
|
.type-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-card {
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 10px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-card:hover {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-card.selected {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary-light);
|
||||||
|
box-shadow: 0 0 0 2px var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-tag {
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.type-desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Styles */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row .form-group {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
label span.opt {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: normal;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-main);
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus,
|
||||||
|
textarea:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Output Box */
|
||||||
|
.output-card {
|
||||||
|
background: #1e293b;
|
||||||
|
color: #f8fafc;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 16px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-title {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn:hover {
|
||||||
|
background: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-btn.copied {
|
||||||
|
background: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Template Cards for Branch */
|
||||||
|
.template-selector {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-btn {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 140px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
background: #fff;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tpl-btn.active {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-box {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-left: 4px solid var(--primary);
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-top: 16px;
|
||||||
|
border-radius: 0 var(--radius) var(--radius) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-box ul {
|
||||||
|
padding-left: 18px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.form-row {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
.type-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>Git 规范标准化生成器</h1>
|
||||||
|
<p>企业级 Git 分支管理与 Commit 标准化生成工具</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Nav Tabs -->
|
||||||
|
<div class="nav-tabs">
|
||||||
|
<button class="tab-btn active" onclick="switchTab('init-tab')">项目 Init 提交</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab('commit-tab')">常规 Commit 提交</button>
|
||||||
|
<button class="tab-btn" onclick="switchTab('branch-tab')">分支命名与流转图</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TAB 1: 项目 Init 提交 -->
|
||||||
|
<div id="init-tab" class="tab-content active">
|
||||||
|
<div class="card">
|
||||||
|
<div class="section-title">初始化项目 Commit 模版</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="init-proj-name">项目名称</label>
|
||||||
|
<input type="text" id="init-proj-name" placeholder="例: git_helper" oninput="updateInitOutput()">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="init-proj-desc">项目描述</label>
|
||||||
|
<input type="text" id="init-proj-desc" placeholder="例: 基于 Spring Boot 的企业级后台" oninput="updateInitOutput()">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="init-env">开发环境 / 技术栈</label>
|
||||||
|
<input type="text" id="init-env" placeholder="例: JDK 17, Spring Boot 3.x, MySQL" oninput="updateInitOutput()">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="init-items">初始化完成工作 <span class="opt">(按行分隔)</span></label>
|
||||||
|
<textarea id="init-items" rows="5" oninput="updateInitOutput()">创建了基本的项目结构
|
||||||
|
添加了初始的 README 文件
|
||||||
|
添加了 .gitignore 文件
|
||||||
|
配置了基本的开发环境(如 ESLint, Prettier)
|
||||||
|
设置了基础文件夹结构(src, public, config)</textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="init-next">下一步计划 <span class="opt">(按行分隔)</span></label>
|
||||||
|
<textarea id="init-next" rows="3" oninput="updateInitOutput()">填充 README 文件
|
||||||
|
添加项目基本设计文档
|
||||||
|
配置 CI/CD 自动化部署流水线</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="output-card">
|
||||||
|
<div class="output-header">
|
||||||
|
<span class="output-title">生成的 Init Commit Message</span>
|
||||||
|
<button class="copy-btn" onclick="copyText('init-result', this)">复制初始化信息</button>
|
||||||
|
</div>
|
||||||
|
<pre id="init-result"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TAB 2: 常规 Commit -->
|
||||||
|
<div id="commit-tab" class="tab-content">
|
||||||
|
<div class="card">
|
||||||
|
<div class="section-title">1. 选择提交类型 (Type)</div>
|
||||||
|
<div class="type-grid" id="commit-type-grid"></div>
|
||||||
|
|
||||||
|
<div class="section-title" style="margin-top:20px;">2. 填写提交内容</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="commit-scope">影响范围 Scope <span class="opt">(可选,如: auth, db)</span></label>
|
||||||
|
<input type="text" id="commit-scope" placeholder="例: auth" oninput="updateCommitOutput()">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="commit-subject">简明描述 Description <span class="opt">(必填,建议主动语态)</span></label>
|
||||||
|
<input type="text" id="commit-subject" placeholder="例: add user login functionality" oninput="updateCommitOutput()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="commit-body">详细说明 Body <span class="opt">(可选,补充变动原因及细节)</span></label>
|
||||||
|
<textarea id="commit-body" placeholder="- Implemented login endpoint with JWT authentication - Added login form to frontend" oninput="updateCommitOutput()"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title" style="margin-top:20px;">3. 脚注选项 (Footer)</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="commit-footer">关联 Issue / 脚注 <span class="opt">(可选,例: Closes #45)</span></label>
|
||||||
|
<input type="text" id="commit-footer" placeholder="例: Closes #45" oninput="updateCommitOutput()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="commit-wip">WIP 状态 <span class="opt">(可选,正在进行中的工作)</span></label>
|
||||||
|
<input type="text" id="commit-wip" placeholder="例: ing 登录模块UI正在联调" oninput="updateCommitOutput()">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="commit-fixup">FIXUP 目标 <span class="opt">(可选,修复先前提交的错误)</span></label>
|
||||||
|
<input type="text" id="commit-fixup" placeholder="例: 修正上一个提交的拼写错误" oninput="updateCommitOutput()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="checkbox-group" style="display:flex; align-items:center; gap:8px; cursor:pointer;">
|
||||||
|
<input type="checkbox" id="commit-breaking" onchange="updateCommitOutput()">
|
||||||
|
<span>包含破坏性变动 (BREAKING CHANGE)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="output-card">
|
||||||
|
<div class="output-header">
|
||||||
|
<span class="output-title">生成的 Commit Message</span>
|
||||||
|
<button class="copy-btn" onclick="copyText('commit-result', this)">复制提交信息</button>
|
||||||
|
</div>
|
||||||
|
<pre id="commit-result"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TAB 3: 分支命名与高清图片流转图 -->
|
||||||
|
<div id="branch-tab" class="tab-content">
|
||||||
|
<div class="card">
|
||||||
|
<div class="section-title">企业敏捷 Git 演进架构图 (原生高清预览)</div>
|
||||||
|
|
||||||
|
<!-- 使用图片完美还原 Obsidian 效果 -->
|
||||||
|
<div class="img-viewport">
|
||||||
|
<img src="git-flow.png" alt="企业敏捷 Git 拓扑流转图">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title">选择分支类型生成 Git 命令</div>
|
||||||
|
<div class="template-selector">
|
||||||
|
<button class="tpl-btn active" onclick="selectBranchType('develop', this)">创建 develop 分支</button>
|
||||||
|
<button class="tpl-btn" onclick="selectBranchType('feature', this)">迭代功能分支 (SNAPSHOT)</button>
|
||||||
|
<button class="tpl-btn" onclick="selectBranchType('personal', this)">组员个人开发分支</button>
|
||||||
|
<button class="tpl-btn" onclick="selectBranchType('staging', this)">预生产分支 (Release)</button>
|
||||||
|
<button class="tpl-btn" onclick="selectBranchType('hotfix', this)">Hotfix 修复分支</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-title" style="margin-top:16px;">填写项目与人员配置</div>
|
||||||
|
<div class="form-row" id="proj-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="branch-proj">项目名称</label>
|
||||||
|
<input type="text" id="branch-proj" value="git_helper" oninput="updateBranchOutput()">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="version-group">
|
||||||
|
<label for="branch-version">版本号</label>
|
||||||
|
<input type="text" id="branch-version" value="v2.1.1" oninput="updateBranchOutput()">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="date-group">
|
||||||
|
<label for="branch-date">日期</label>
|
||||||
|
<input type="text" id="branch-date" oninput="updateBranchOutput()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row" id="personal-fields" style="display:none;">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="branch-author">组员姓名/拼音</label>
|
||||||
|
<input type="text" id="branch-author" value="zhangsan" oninput="updateBranchOutput()">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="branch-seq">同名序号 <span class="opt">(可选,如: 2)</span></label>
|
||||||
|
<input type="text" id="branch-seq" placeholder="例: 2" oninput="updateBranchOutput()">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rules-box">
|
||||||
|
<strong>当前分支说明与规则:</strong>
|
||||||
|
<ul id="branch-rules-list">
|
||||||
|
<li>从 <code>main</code> 拉取并创建 <code>develop</code> 分支作为团队日常开发基线。</li>
|
||||||
|
<li>执行命令:<code>git checkout -b develop main</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="output-card">
|
||||||
|
<div class="output-header">
|
||||||
|
<span class="output-title">生成的 Git 创建/切换分支命令</span>
|
||||||
|
<button class="copy-btn" onclick="copyText('branch-result', this)">复制 Git 命令</button>
|
||||||
|
</div>
|
||||||
|
<pre id="branch-result"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 1. Commit Types Definition
|
||||||
|
const commitTypes = [
|
||||||
|
{ type: 'feat', label: '✨ feat', desc: '新功能 / 新特性' },
|
||||||
|
{ type: 'fix', label: '🐛 fix', desc: '修复 bug' },
|
||||||
|
{ type: 'docs', label: '📚 docs', desc: '文档更新' },
|
||||||
|
{ type: 'style', label: '💄 style', desc: '代码格式(不影响功能)' },
|
||||||
|
{ type: 'refactor', label: '💎 refactor', desc: '重构(既不修复bug也不加功能)' },
|
||||||
|
{ type: 'perf', label: '⚡ perf', desc: '性能提升' },
|
||||||
|
{ type: 'test', label: '🚨 test', desc: '添加或修改测试' },
|
||||||
|
{ type: 'chore', label: '🔧 chore', desc: '构建/工具等杂项变动' },
|
||||||
|
{ type: 'build', label: '📦 build', desc: '影响构建系统或依赖的变动' },
|
||||||
|
{ type: 'security', label: '🛡️ security', desc: '安全性修复或增强' }
|
||||||
|
];
|
||||||
|
|
||||||
|
let currentCommitType = 'feat';
|
||||||
|
let currentBranchType = 'develop';
|
||||||
|
|
||||||
|
// Initialize Page
|
||||||
|
window.onload = () => {
|
||||||
|
renderCommitTypes();
|
||||||
|
const today = new Date();
|
||||||
|
const yyyy = today.getFullYear();
|
||||||
|
const mm = String(today.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(today.getDate()).padStart(2, '0');
|
||||||
|
document.getElementById('branch-date').value = `${yyyy}${mm}${dd}`;
|
||||||
|
|
||||||
|
updateCommitOutput();
|
||||||
|
updateInitOutput();
|
||||||
|
updateBranchOutput();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tab Switching
|
||||||
|
function switchTab(tabId) {
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
||||||
|
|
||||||
|
event.target.classList.add('active');
|
||||||
|
document.getElementById(tabId).classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render Commit Type Cards
|
||||||
|
function renderCommitTypes() {
|
||||||
|
const grid = document.getElementById('commit-type-grid');
|
||||||
|
grid.innerHTML = commitTypes.map(t => `
|
||||||
|
<div class="type-card ${t.type === currentCommitType ? 'selected' : ''}" onclick="selectCommitType('${t.type}', this)">
|
||||||
|
<div class="type-header">
|
||||||
|
<span class="type-tag">${t.label}</span>
|
||||||
|
</div>
|
||||||
|
<div class="type-desc">${t.desc}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectCommitType(type, el) {
|
||||||
|
currentCommitType = type;
|
||||||
|
document.querySelectorAll('#commit-type-grid .type-card').forEach(c => c.classList.remove('selected'));
|
||||||
|
el.classList.add('selected');
|
||||||
|
updateCommitOutput();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Commit Output
|
||||||
|
function updateCommitOutput() {
|
||||||
|
const scope = document.getElementById('commit-scope').value.trim();
|
||||||
|
const subject = document.getElementById('commit-subject').value.trim();
|
||||||
|
const body = document.getElementById('commit-body').value.trim();
|
||||||
|
const footer = document.getElementById('commit-footer').value.trim();
|
||||||
|
const wip = document.getElementById('commit-wip').value.trim();
|
||||||
|
const fixup = document.getElementById('commit-fixup').value.trim();
|
||||||
|
const isBreaking = document.getElementById('commit-breaking').checked;
|
||||||
|
|
||||||
|
let header = currentCommitType;
|
||||||
|
if (scope) {
|
||||||
|
header += `(${scope})`;
|
||||||
|
}
|
||||||
|
header += `: ${subject || '<描述>'}`;
|
||||||
|
|
||||||
|
let result = header;
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
result += `\n\n${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let footers = [];
|
||||||
|
if (isBreaking) {
|
||||||
|
footers.push(`BREAKING CHANGE: 包含破坏性变更,可能不向下兼容。`);
|
||||||
|
}
|
||||||
|
if (wip) {
|
||||||
|
footers.push(`WIP: ${wip}`);
|
||||||
|
}
|
||||||
|
if (fixup) {
|
||||||
|
footers.push(`FIXUP: ${fixup}`);
|
||||||
|
}
|
||||||
|
if (footer) {
|
||||||
|
footers.push(footer);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (footers.length > 0) {
|
||||||
|
result += `\n\n${footers.join('\n')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('commit-result').textContent = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Init Output
|
||||||
|
function updateInitOutput() {
|
||||||
|
const projName = document.getElementById('init-proj-name').value.trim() || '[项目名称]';
|
||||||
|
const projDesc = document.getElementById('init-proj-desc').value.trim() || '[项目描述]';
|
||||||
|
const env = document.getElementById('init-env').value.trim() || '[开发环境]';
|
||||||
|
|
||||||
|
const items = document.getElementById('init-items').value.trim().split('\n').filter(i => i).map(i => `- ${i}`).join('\n');
|
||||||
|
const nexts = document.getElementById('init-next').value.trim().split('\n').filter(i => i).map(i => `- ${i}`).join('\n');
|
||||||
|
|
||||||
|
const result = `init: 初始化项目\n\n${items}\n\n项目说明:\n- 项目名称:${projName}\n- 项目描述:${projDesc}\n- 开发环境:${env}\n\n下一步:\n${nexts}\n\n感谢您的贡献!`;
|
||||||
|
|
||||||
|
document.getElementById('init-result').textContent = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Branch Type Selection
|
||||||
|
function selectBranchType(type, btn) {
|
||||||
|
currentBranchType = type;
|
||||||
|
document.querySelectorAll('.template-selector .tpl-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
|
||||||
|
const personalFields = document.getElementById('personal-fields');
|
||||||
|
const versionGroup = document.getElementById('version-group');
|
||||||
|
const dateGroup = document.getElementById('date-group');
|
||||||
|
const rulesList = document.getElementById('branch-rules-list');
|
||||||
|
|
||||||
|
if (type === 'develop') {
|
||||||
|
versionGroup.style.display = 'none';
|
||||||
|
dateGroup.style.display = 'none';
|
||||||
|
personalFields.style.display = 'none';
|
||||||
|
rulesList.innerHTML = `
|
||||||
|
<li>从 <code>main</code> 拉取并创建 <code>develop</code> 分支作为团队日常开发基线。</li>
|
||||||
|
<li>执行命令:<code>git checkout -b develop main</code></li>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
versionGroup.style.display = 'block';
|
||||||
|
dateGroup.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'feature') {
|
||||||
|
personalFields.style.display = 'none';
|
||||||
|
rulesList.innerHTML = `
|
||||||
|
<li>开发前从 <code>develop</code> 分支拉取,保持起始点一致。用于自动化部署测试环境。</li>
|
||||||
|
<li>规范格式:<code>feature-{项目名}-{版本}-SNAPSHOT-{日期}</code></li>
|
||||||
|
`;
|
||||||
|
} else if (type === 'personal') {
|
||||||
|
personalFields.style.display = 'flex';
|
||||||
|
rulesList.innerHTML = `
|
||||||
|
<li>开发中组员基于<b>迭代功能分支</b>拉取个人临时分支,提测时合并回功能分支。</li>
|
||||||
|
<li>规范格式:<code>feature-{项目名}-{版本}-SNAPSHOT-{日期}-{姓名}</code></li>
|
||||||
|
`;
|
||||||
|
} else if (type === 'staging') {
|
||||||
|
personalFields.style.display = 'none';
|
||||||
|
rulesList.innerHTML = `
|
||||||
|
<li>开发环境测试结束后,从功能分支拉出去掉 <code>SNAPSHOT</code> 标识的预生产分支。</li>
|
||||||
|
<li>上线验证通过后,将预生产分支分别合并回 <code>main</code> 和 <code>develop</code>。</li>
|
||||||
|
<li>规范格式:<code>feature-{项目名}-{版本}-{日期}</code></li>
|
||||||
|
`;
|
||||||
|
} else if (type === 'hotfix') {
|
||||||
|
personalFields.style.display = 'none';
|
||||||
|
rulesList.innerHTML = `
|
||||||
|
<li>线上紧急修复,从 <code>main</code> 拉取,修复验证后同时合并回 <code>main</code> 与 <code>develop</code>。</li>
|
||||||
|
<li>规范格式:<code>hotfix-{项目名}-{版本}-{日期}</code></li>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBranchOutput();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update Branch Output
|
||||||
|
function updateBranchOutput() {
|
||||||
|
const proj = document.getElementById('branch-proj').value.trim() || 'git_helper';
|
||||||
|
const version = document.getElementById('branch-version').value.trim() || 'v2.1.1';
|
||||||
|
const date = document.getElementById('branch-date').value.trim() || '20240703';
|
||||||
|
const author = document.getElementById('branch-author').value.trim() || 'zhangsan';
|
||||||
|
const seq = document.getElementById('branch-seq').value.trim();
|
||||||
|
|
||||||
|
let command = '';
|
||||||
|
|
||||||
|
if (currentBranchType === 'develop') {
|
||||||
|
command = `git checkout -b develop main`;
|
||||||
|
} else if (currentBranchType === 'feature') {
|
||||||
|
const branchName = `feature-${proj}-${version}-SNAPSHOT-${date}`;
|
||||||
|
command = `git checkout -b ${branchName} develop`;
|
||||||
|
} else if (currentBranchType === 'personal') {
|
||||||
|
const seqStr = seq ? `-${seq}` : '';
|
||||||
|
const baseBranch = `feature-${proj}-${version}-SNAPSHOT-${date}`;
|
||||||
|
const branchName = `${baseBranch}-${author}${seqStr}`;
|
||||||
|
command = `git checkout -b ${branchName} ${baseBranch}`;
|
||||||
|
} else if (currentBranchType === 'staging') {
|
||||||
|
const baseBranch = `feature-${proj}-${version}-SNAPSHOT-${date}`;
|
||||||
|
const branchName = `feature-${proj}-${version}-${date}`;
|
||||||
|
command = `git checkout -b ${branchName} ${baseBranch}`;
|
||||||
|
} else if (currentBranchType === 'hotfix') {
|
||||||
|
const branchName = `hotfix-${proj}-${version}-${date}`;
|
||||||
|
command = `git checkout -b ${branchName} main`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('branch-result').textContent = command;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy helper
|
||||||
|
function copyText(elementId, btn) {
|
||||||
|
const text = document.getElementById(elementId).textContent;
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
const originalText = btn.textContent;
|
||||||
|
btn.textContent = '已复制!';
|
||||||
|
btn.classList.add('copied');
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = originalText;
|
||||||
|
btn.classList.remove('copied');
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,939 @@
|
|||||||
|
/* ============ 基础 ============ */
|
||||||
|
|
||||||
|
.app {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-box {
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-main);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 仅屏幕阅读器可见 */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 顶栏 ============ */
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: var(--top-nav-height);
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶栏内容与下方 .layout 使用同一套宽度与左右内边距,
|
||||||
|
这样 logo、右侧图标才能和侧栏、内容区严格对齐 */
|
||||||
|
.topbar-inner {
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--layout-max-width);
|
||||||
|
height: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 24px 0 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 180px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #0f172a;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-name {
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 跑马灯 */
|
||||||
|
|
||||||
|
.ticker {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 560px;
|
||||||
|
height: 36px;
|
||||||
|
margin: 0 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
-webkit-mask-image: linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent,
|
||||||
|
#000 10%,
|
||||||
|
#000 90%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
mask-image: linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent,
|
||||||
|
#000 10%,
|
||||||
|
#000 90%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker-track {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 40px;
|
||||||
|
white-space: nowrap;
|
||||||
|
animation: ticker 26s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker:hover .ticker-track {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--text-sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker-tag {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--primary-soft);
|
||||||
|
color: var(--primary-color);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ticker {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶栏操作 */
|
||||||
|
|
||||||
|
.top-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #475569;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s,
|
||||||
|
color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:hover {
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button svg {
|
||||||
|
width: 19px;
|
||||||
|
height: 19px;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button[data-spinning='true'] svg,
|
||||||
|
.service-action[data-spinning='true'] svg {
|
||||||
|
animation: spin 0.9s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 搜索框 ============ */
|
||||||
|
|
||||||
|
.search-row {
|
||||||
|
position: fixed;
|
||||||
|
top: 76px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: min(480px, calc(100vw - 48px));
|
||||||
|
height: 44px;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
color: var(--text-sub);
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 36px;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 13.5px;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s,
|
||||||
|
color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear:hover {
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear svg {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2.4;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 双列布局 ============ */
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
width: 100%;
|
||||||
|
max-width: var(--layout-max-width);
|
||||||
|
/* 宽屏时整体居中,避免内容全部贴左 */
|
||||||
|
margin: var(--top-nav-height) auto 0;
|
||||||
|
height: calc(100vh - var(--top-nav-height));
|
||||||
|
padding: 36px 24px 0 20px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--sidebar-width) minmax(0, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧栏 */
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
min-height: 0;
|
||||||
|
margin-top: 68px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 用户资料模块(数据占位,见 profile.ts) */
|
||||||
|
|
||||||
|
.profile-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 18px 14px 14px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar {
|
||||||
|
position: relative;
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: linear-gradient(135deg, #dbeafe, #bfdbfe);
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar svg {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 1.8;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-badge {
|
||||||
|
position: absolute;
|
||||||
|
right: -2px;
|
||||||
|
bottom: -2px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--card-bg);
|
||||||
|
background-color: #f59e0b;
|
||||||
|
color: #fff;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-role {
|
||||||
|
padding: 1px 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-bio {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 11.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text-sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-location {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-card {
|
||||||
|
padding: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-group {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-sub);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s,
|
||||||
|
color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover,
|
||||||
|
.nav-link.active {
|
||||||
|
background-color: var(--primary-soft);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-count {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-trailing {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link.active .nav-count,
|
||||||
|
.nav-link:hover .nav-count {
|
||||||
|
background-color: #fff;
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-arrow {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.open > .nav-link .nav-arrow {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu {
|
||||||
|
list-style: none;
|
||||||
|
padding-left: 12px;
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.3s cubic-bezier(0, 1, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.open > .submenu {
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
transition: max-height 0.3s ease-in-out;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu .nav-link {
|
||||||
|
padding: 7px 12px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu .nav-link > span:first-child {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
cursor: default;
|
||||||
|
font-size: 12.5px;
|
||||||
|
padding: 7px 12px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 本地服务面板:独立模块,不与导航项混排 */
|
||||||
|
|
||||||
|
.service-panel {
|
||||||
|
padding: 12px 14px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-pill.is-on {
|
||||||
|
background-color: rgba(34, 197, 94, 0.12);
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-stats {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-stats > div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-stats dt {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--text-sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-stats dd {
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 11.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 面板底部操作:重新扫描 / 查看接口 */
|
||||||
|
.service-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-action {
|
||||||
|
flex: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
color: var(--text-sub);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
color 0.2s,
|
||||||
|
background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-action:not(:disabled):hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background-color: var(--primary-soft);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-action:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.service-action svg {
|
||||||
|
width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #cbd5e1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s,
|
||||||
|
box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.is-on {
|
||||||
|
background-color: #22c55e;
|
||||||
|
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右侧内容 */
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
padding-top: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
/* 顶部留出渐隐区,避免第一个分区标题被 mask 吃掉 */
|
||||||
|
padding-top: 24px;
|
||||||
|
padding-right: 4px;
|
||||||
|
padding-bottom: 40px;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
-webkit-mask-image: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
transparent 0,
|
||||||
|
#000 22px,
|
||||||
|
#000 100%
|
||||||
|
);
|
||||||
|
mask-image: linear-gradient(to bottom, transparent 0, #000 22px, #000 100%);
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-scroll::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title::before {
|
||||||
|
content: '';
|
||||||
|
width: 4px;
|
||||||
|
height: 15px;
|
||||||
|
margin-right: 8px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右上角数量角标:粉色渐变胶囊 */
|
||||||
|
.section-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 26px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-image: linear-gradient(90deg, #ff8a8a 0%, #f42a63 100%);
|
||||||
|
box-shadow: 0 2px 6px rgba(244, 42, 99, 0.28);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-group + .section-group {
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
transition:
|
||||||
|
transform 0.2s,
|
||||||
|
box-shadow 0.2s,
|
||||||
|
border-color 0.2s;
|
||||||
|
animation: card-in 0.28s ease backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: rgba(59, 130, 246, 0.35);
|
||||||
|
box-shadow: var(--shadow-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
margin-right: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
color: var(--primary-color);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-desc {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-sub);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 路径类描述用等宽字体,普通文案保持无衬线 */
|
||||||
|
.card-desc.is-mono {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes card-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 空状态 / 错误 */
|
||||||
|
|
||||||
|
.notice {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 36px 24px;
|
||||||
|
border: 1px dashed var(--border-color);
|
||||||
|
border-radius: var(--radius-main);
|
||||||
|
background-color: rgba(255, 255, 255, 0.6);
|
||||||
|
color: var(--text-sub);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice strong {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice code {
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: #eef2f7;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-error {
|
||||||
|
border-style: solid;
|
||||||
|
border-color: #f3c6c6;
|
||||||
|
background-color: #fff7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-error strong {
|
||||||
|
color: #b42318;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-action {
|
||||||
|
align-self: flex-start;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 6px 14px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-action:hover {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 回到顶部 */
|
||||||
|
|
||||||
|
.back-to-top {
|
||||||
|
position: fixed;
|
||||||
|
right: 30px;
|
||||||
|
bottom: 30px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #e2e8f0;
|
||||||
|
color: #1e293b;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 90;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s,
|
||||||
|
transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-to-top:hover {
|
||||||
|
background-color: #cbd5e1;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-to-top svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2.5;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 响应式 ============ */
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
:root {
|
||||||
|
--sidebar-width: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.app {
|
||||||
|
height: auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-inner {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-row {
|
||||||
|
position: static;
|
||||||
|
top: auto;
|
||||||
|
left: auto;
|
||||||
|
transform: none;
|
||||||
|
width: auto;
|
||||||
|
height: 44px;
|
||||||
|
margin: 16px 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
margin-top: 0;
|
||||||
|
height: auto;
|
||||||
|
padding: 16px;
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-scroll {
|
||||||
|
overflow: visible;
|
||||||
|
-webkit-mask-image: none;
|
||||||
|
mask-image: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-to-top {
|
||||||
|
right: 20px;
|
||||||
|
bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { useMemo, useRef, useState } from 'react'
|
||||||
|
import { BackToTop } from './components/BackToTop'
|
||||||
|
import { SearchBar } from './components/SearchBar'
|
||||||
|
import { Sidebar } from './components/Sidebar'
|
||||||
|
import { SiteCard } from './components/SiteCard'
|
||||||
|
import { TopBar } from './components/TopBar'
|
||||||
|
import { MOCK_SECTIONS, type CatalogCard, type CatalogSection } from './catalog'
|
||||||
|
import { useProjects } from './useProjects'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
function initialOf(name: string): string {
|
||||||
|
const [first] = Array.from(name.trim())
|
||||||
|
return first ? first.toUpperCase() : '#'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { status, projects, error, live, refreshing, refresh } = useProjects()
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// 真实项目放最前面,占位分类跟在后面
|
||||||
|
const sections = useMemo<CatalogSection[]>(() => {
|
||||||
|
const localCards: CatalogCard[] = projects.map((project) => ({
|
||||||
|
id: project.route_path,
|
||||||
|
title: project.name,
|
||||||
|
desc: project.route_path,
|
||||||
|
href: project.route_path,
|
||||||
|
initial: initialOf(project.name),
|
||||||
|
mono: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return [{ id: 'local', title: '本地服务', cards: localCards }, ...MOCK_SECTIONS]
|
||||||
|
}, [projects])
|
||||||
|
|
||||||
|
const query = keyword.trim().toLowerCase()
|
||||||
|
const searching = query !== ''
|
||||||
|
|
||||||
|
// 搜索时过滤卡片,并丢掉没有命中的分区
|
||||||
|
const sectionsToRender = useMemo(() => {
|
||||||
|
if (!searching) return sections
|
||||||
|
|
||||||
|
return sections
|
||||||
|
.map((section) => ({
|
||||||
|
...section,
|
||||||
|
cards: section.cards.filter(
|
||||||
|
(card) =>
|
||||||
|
card.title.toLowerCase().includes(query) ||
|
||||||
|
card.desc.toLowerCase().includes(query),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
.filter((section) => section.cards.length > 0)
|
||||||
|
}, [sections, searching, query])
|
||||||
|
|
||||||
|
const noMatch = searching && sectionsToRender.length === 0
|
||||||
|
|
||||||
|
function renderLocalPlaceholder() {
|
||||||
|
if (status === 'error') {
|
||||||
|
return (
|
||||||
|
<div className="notice notice-error" role="alert">
|
||||||
|
<strong>连不上 Stash 服务</strong>
|
||||||
|
<p>{error}</p>
|
||||||
|
<p>
|
||||||
|
请确认后端已启动(默认 <code>127.0.0.1:8080</code>),开发时 Vite 会把{' '}
|
||||||
|
<code>/api</code> 代理到后端。
|
||||||
|
</p>
|
||||||
|
<button type="button" className="notice-action" onClick={refresh}>
|
||||||
|
重试
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'loading') {
|
||||||
|
return (
|
||||||
|
<div className="notice">
|
||||||
|
<strong>正在读取项目列表…</strong>
|
||||||
|
<p>首次加载会扫描一次托管目录。</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="notice">
|
||||||
|
<strong>这里还没有项目</strong>
|
||||||
|
<p>
|
||||||
|
在托管根目录下创建子目录并放入 <code>index.html</code>,刷新后即可在这里看到它。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSection(section: CatalogSection) {
|
||||||
|
return (
|
||||||
|
<section className="section-group" id={section.id} key={section.id}>
|
||||||
|
<div className="section-heading">
|
||||||
|
<h2 className="section-title">{section.title}</h2>
|
||||||
|
<span className="section-badge">{section.cards.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{section.cards.length > 0 ? (
|
||||||
|
<div className="grid">
|
||||||
|
{section.cards.map((card, index) => (
|
||||||
|
<SiteCard key={card.id} card={card} index={index} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
renderLocalPlaceholder()
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<TopBar
|
||||||
|
projects={projects}
|
||||||
|
live={live}
|
||||||
|
onFocusSearch={() => searchRef.current?.focus()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="search-row">
|
||||||
|
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="layout">
|
||||||
|
<Sidebar
|
||||||
|
projects={projects}
|
||||||
|
live={live}
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={refresh}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="content">
|
||||||
|
<div className="content-scroll" id="content-scroll">
|
||||||
|
<h1 className="sr-only">Stash 本地应用工作区</h1>
|
||||||
|
|
||||||
|
{sectionsToRender.map(renderSection)}
|
||||||
|
|
||||||
|
{noMatch && (
|
||||||
|
<div className="notice">
|
||||||
|
<strong>没有匹配「{keyword.trim()}」的内容</strong>
|
||||||
|
<p>换个关键字试试,或清空搜索查看全部。</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BackToTop />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
export type Project = {
|
||||||
|
name: string
|
||||||
|
route_path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectsPayload = {
|
||||||
|
projects?: Project[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROJECTS_ENDPOINT = '/api/projects'
|
||||||
|
const EVENTS_ENDPOINT = '/api/projects/events'
|
||||||
|
|
||||||
|
/** 读取服务端扫描出的项目列表。 */
|
||||||
|
export async function fetchProjects(signal?: AbortSignal): Promise<Project[]> {
|
||||||
|
const response = await fetch(PROJECTS_ENDPOINT, {
|
||||||
|
signal,
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`请求 ${PROJECTS_ENDPOINT} 失败:HTTP ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as ProjectsPayload
|
||||||
|
return Array.isArray(payload.projects) ? payload.projects : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectEventsHandlers = {
|
||||||
|
onProjects: (projects: Project[]) => void
|
||||||
|
/** SSE 连接建立 / 断开时回调,浏览器的 EventSource 会自行重连。 */
|
||||||
|
onConnectionChange?: (connected: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅服务端的项目快照推送。
|
||||||
|
* 浏览器不支持 SSE 时静默降级为空清理函数,页面仍可用手动刷新。
|
||||||
|
*/
|
||||||
|
export function subscribeProjects(handlers: ProjectEventsHandlers): () => void {
|
||||||
|
if (typeof EventSource === 'undefined') {
|
||||||
|
return () => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = new EventSource(EVENTS_ENDPOINT)
|
||||||
|
|
||||||
|
const handleProjects = (event: Event) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse((event as MessageEvent<string>).data) as ProjectsPayload
|
||||||
|
if (Array.isArray(payload.projects)) {
|
||||||
|
handlers.onProjects(payload.projects)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 单条事件格式异常时保留旧数据,等待下一次推送
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOpen = () => handlers.onConnectionChange?.(true)
|
||||||
|
const handleError = () => handlers.onConnectionChange?.(false)
|
||||||
|
|
||||||
|
source.addEventListener('projects', handleProjects)
|
||||||
|
source.addEventListener('open', handleOpen)
|
||||||
|
source.addEventListener('error', handleError)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
source.removeEventListener('projects', handleProjects)
|
||||||
|
source.removeEventListener('open', handleOpen)
|
||||||
|
source.removeEventListener('error', handleError)
|
||||||
|
source.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
/**
|
||||||
|
* 占位数据:沿用 index9 设计稿里的分类与卡片。
|
||||||
|
*
|
||||||
|
* 后端目前只提供扁平的项目列表,没有分类概念,
|
||||||
|
* 所以这里的内容仅用于撑起界面结构,等接口支持分类后再替换。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type CatalogCard = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
desc: string
|
||||||
|
href: string
|
||||||
|
/** 卡片左侧的字符标记 */
|
||||||
|
initial: string
|
||||||
|
/** desc 是路径时用等宽字体 */
|
||||||
|
mono?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CatalogSection = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
cards: CatalogCard[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NavLink = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NavGroup = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
links: NavLink[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 侧栏顶层链接 */
|
||||||
|
export const MOCK_NAV_PRIMARY: NavLink[] = [{ id: 'common', label: '常用推荐' }]
|
||||||
|
|
||||||
|
/** 侧栏可折叠分组 */
|
||||||
|
export const MOCK_NAV_GROUPS: NavGroup[] = [
|
||||||
|
{
|
||||||
|
id: 'assets',
|
||||||
|
label: '素材资源',
|
||||||
|
links: [
|
||||||
|
{ id: 'design', label: 'UI 设计资源' },
|
||||||
|
{ id: 'icon', label: '矢量图标' },
|
||||||
|
{ id: 'photo', label: '免费壁纸' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tools',
|
||||||
|
label: '开发工具',
|
||||||
|
links: [
|
||||||
|
{ id: 'dev', label: '前端调试工具' },
|
||||||
|
{ id: 'code', label: '在线编辑器' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
/** 内容区占位分区,顺序与设计稿一致 */
|
||||||
|
export const MOCK_SECTIONS: CatalogSection[] = [
|
||||||
|
{
|
||||||
|
id: 'common',
|
||||||
|
title: '常用推荐',
|
||||||
|
cards: [
|
||||||
|
{
|
||||||
|
id: 'github',
|
||||||
|
title: 'GitHub',
|
||||||
|
desc: '代码托管与开源社区',
|
||||||
|
href: '#',
|
||||||
|
initial: 'G',
|
||||||
|
},
|
||||||
|
{ id: 'zhihu', title: '知乎', desc: '深度内容问答社区', href: '#', initial: 'Z' },
|
||||||
|
{
|
||||||
|
id: 'bilibili',
|
||||||
|
title: '哔哩哔哩',
|
||||||
|
desc: '弹幕视频分享网站',
|
||||||
|
href: '#',
|
||||||
|
initial: 'B',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'v2ex',
|
||||||
|
title: 'V2EX',
|
||||||
|
desc: '创意工作者讨论社区',
|
||||||
|
href: '#',
|
||||||
|
initial: 'V',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dev',
|
||||||
|
title: '前端调试工具',
|
||||||
|
cards: [
|
||||||
|
{
|
||||||
|
id: 'stackoverflow',
|
||||||
|
title: 'Stack Overflow',
|
||||||
|
desc: '程序员技术问答',
|
||||||
|
href: '#',
|
||||||
|
initial: 'S',
|
||||||
|
},
|
||||||
|
{ id: 'juejin', title: '掘金', desc: '开发者技术社区', href: '#', initial: 'J' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'code',
|
||||||
|
title: '在线编辑器',
|
||||||
|
cards: [
|
||||||
|
{
|
||||||
|
id: 'codepen',
|
||||||
|
title: 'CodePen',
|
||||||
|
desc: '前端在线代码演练场',
|
||||||
|
href: '#',
|
||||||
|
initial: 'C',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'design',
|
||||||
|
title: 'UI 设计资源',
|
||||||
|
cards: [
|
||||||
|
{
|
||||||
|
id: 'dribbble',
|
||||||
|
title: 'Dribbble',
|
||||||
|
desc: '全球设计作品分享社区',
|
||||||
|
href: '#',
|
||||||
|
initial: 'D',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
export function BackToTop() {
|
||||||
|
const handleClick = () => {
|
||||||
|
// 桌面端滚动的是内容容器,窄屏滚动的是窗口
|
||||||
|
document.getElementById('content-scroll')?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="back-to-top"
|
||||||
|
title="回到顶部"
|
||||||
|
aria-label="回到顶部"
|
||||||
|
onClick={handleClick}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<line x1="5" y1="4" x2="19" y2="4" />
|
||||||
|
<path d="M12 20V8M6 14l6-6 6 6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { PLACEHOLDER_PROFILE } from '../profile'
|
||||||
|
|
||||||
|
/** 登录后展示的用户资料卡(数据为占位,见 profile.ts)。 */
|
||||||
|
export function ProfileCard() {
|
||||||
|
const { name, role, bio, location } = PLACEHOLDER_PROFILE
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="profile-card card-box" aria-label="用户信息">
|
||||||
|
<div className="profile-avatar" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24">
|
||||||
|
<circle cx="12" cy="9" r="3.4" />
|
||||||
|
<path d="M5.6 19.4a6.6 6.6 0 0 1 12.8 0" />
|
||||||
|
</svg>
|
||||||
|
<span className="profile-badge">✓</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="profile-name">{name}</div>
|
||||||
|
<span className="profile-role">{role}</span>
|
||||||
|
<p className="profile-bio">{bio}</p>
|
||||||
|
<div className="profile-location">IP属地:{location}</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect, type RefObject } from 'react'
|
||||||
|
|
||||||
|
type SearchBarProps = {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
inputRef: RefObject<HTMLInputElement | null>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchBar({ value, onChange, inputRef }: SearchBarProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
|
||||||
|
event.preventDefault()
|
||||||
|
inputRef.current?.focus()
|
||||||
|
inputRef.current?.select()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [inputRef])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="search-bar card-box">
|
||||||
|
<svg className="search-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
className="search-input"
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
placeholder="搜索本地项目…"
|
||||||
|
aria-label="搜索本地项目"
|
||||||
|
spellCheck={false}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Escape') onChange('')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{value !== '' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="search-clear"
|
||||||
|
aria-label="清空搜索"
|
||||||
|
onClick={() => onChange('')}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M18 6 6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { Project } from '../api'
|
||||||
|
import { LinkIcon, RefreshIcon } from './icons'
|
||||||
|
|
||||||
|
type ServicePanelProps = {
|
||||||
|
projects: Project[]
|
||||||
|
live: boolean
|
||||||
|
refreshing: boolean
|
||||||
|
onRefresh: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 本地服务面板:状态概览 + 操作入口。
|
||||||
|
* 项目列表本身在右侧内容区以卡片呈现,这里不再展开。
|
||||||
|
*/
|
||||||
|
export function ServicePanel({ projects, live, refreshing, onRefresh }: ServicePanelProps) {
|
||||||
|
const host = typeof window === 'undefined' ? '-' : window.location.host
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="service-panel card-box" aria-label="本地服务状态">
|
||||||
|
<header className="service-head">
|
||||||
|
<h2 className="service-title">本地服务</h2>
|
||||||
|
<span className={`service-pill${live ? ' is-on' : ''}`}>
|
||||||
|
<span className={`status-dot${live ? ' is-on' : ''}`} aria-hidden="true" />
|
||||||
|
{live ? '已连接' : '未连接'}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<dl className="service-stats">
|
||||||
|
<div>
|
||||||
|
<dt>项目数</dt>
|
||||||
|
<dd>{projects.length}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>访问地址</dt>
|
||||||
|
<dd title={host}>{host}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="service-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="service-action"
|
||||||
|
title="重新扫描项目"
|
||||||
|
data-spinning={refreshing}
|
||||||
|
disabled={refreshing}
|
||||||
|
onClick={onRefresh}
|
||||||
|
>
|
||||||
|
<RefreshIcon />
|
||||||
|
<span>重新扫描</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a
|
||||||
|
className="service-action"
|
||||||
|
href="/api/projects"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
title="查看项目接口"
|
||||||
|
>
|
||||||
|
<LinkIcon />
|
||||||
|
<span>查看接口</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { Project } from '../api'
|
||||||
|
import { MOCK_NAV_GROUPS, MOCK_NAV_PRIMARY } from '../catalog'
|
||||||
|
import { ProfileCard } from './ProfileCard'
|
||||||
|
import { ServicePanel } from './ServicePanel'
|
||||||
|
|
||||||
|
type SidebarProps = {
|
||||||
|
projects: Project[]
|
||||||
|
live: boolean
|
||||||
|
refreshing: boolean
|
||||||
|
onRefresh: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ projects, live, refreshing, onRefresh }: SidebarProps) {
|
||||||
|
const [openGroups, setOpenGroups] = useState<string[]>([])
|
||||||
|
const [activeId, setActiveId] = useState('common')
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
setOpenGroups((previous) =>
|
||||||
|
previous.includes(id) ? previous.filter((item) => item !== id) : [...previous, id],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOpen = (id: string) => openGroups.includes(id)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="sidebar">
|
||||||
|
<ProfileCard />
|
||||||
|
|
||||||
|
<ServicePanel
|
||||||
|
projects={projects}
|
||||||
|
live={live}
|
||||||
|
refreshing={refreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<nav className="nav-card card-box" aria-label="站点分类">
|
||||||
|
<ul className="nav-group">
|
||||||
|
{MOCK_NAV_PRIMARY.map((link) => (
|
||||||
|
<li className="nav-item" key={link.id}>
|
||||||
|
<a
|
||||||
|
className={`nav-link${activeId === link.id ? ' active' : ''}`}
|
||||||
|
href={`#${link.id}`}
|
||||||
|
onClick={() => setActiveId(link.id)}
|
||||||
|
>
|
||||||
|
<span>{link.label}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{MOCK_NAV_GROUPS.map((group) => (
|
||||||
|
<li className={`nav-item${isOpen(group.id) ? ' open' : ''}`} key={group.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="nav-link"
|
||||||
|
aria-expanded={isOpen(group.id)}
|
||||||
|
onClick={() => toggle(group.id)}
|
||||||
|
>
|
||||||
|
<span>{group.label}</span>
|
||||||
|
<span className="nav-arrow" aria-hidden="true">
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ul className="submenu">
|
||||||
|
{group.links.map((link) => (
|
||||||
|
<li key={link.id}>
|
||||||
|
<a
|
||||||
|
className={`nav-link${activeId === link.id ? ' active' : ''}`}
|
||||||
|
href={`#${link.id}`}
|
||||||
|
onClick={() => setActiveId(link.id)}
|
||||||
|
>
|
||||||
|
<span>{link.label}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { CatalogCard } from '../catalog'
|
||||||
|
|
||||||
|
type SiteCardProps = {
|
||||||
|
card: CatalogCard
|
||||||
|
index: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SiteCard({ card, index }: SiteCardProps) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
className="card"
|
||||||
|
href={card.href}
|
||||||
|
title={card.title}
|
||||||
|
// 轻微错峰入场,最多延迟 12 个卡片
|
||||||
|
style={{ animationDelay: `${Math.min(index, 12) * 24}ms` }}
|
||||||
|
>
|
||||||
|
<span className="card-icon" aria-hidden="true">
|
||||||
|
{card.initial}
|
||||||
|
</span>
|
||||||
|
<span className="card-body">
|
||||||
|
<span className="card-title">{card.title}</span>
|
||||||
|
<span className={`card-desc${card.mono ? ' is-mono' : ''}`}>{card.desc}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import type { Project } from '../api'
|
||||||
|
import { LoginIcon, SearchIcon } from './icons'
|
||||||
|
|
||||||
|
type TopBarProps = {
|
||||||
|
projects: Project[]
|
||||||
|
live: boolean
|
||||||
|
onFocusSearch: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type TickerItem = {
|
||||||
|
tag: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopBar({ projects, live, onFocusSearch }: TopBarProps) {
|
||||||
|
return (
|
||||||
|
<header className="topbar">
|
||||||
|
<div className="topbar-inner">
|
||||||
|
<a className="brand" href="/" aria-label="Stash 首页">
|
||||||
|
<span className="brand-mark" aria-hidden="true">
|
||||||
|
S
|
||||||
|
</span>
|
||||||
|
<span className="brand-name">STASH</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<Ticker projects={projects} live={live} />
|
||||||
|
|
||||||
|
<div className="top-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-button"
|
||||||
|
title="搜索(Ctrl+K)"
|
||||||
|
aria-label="聚焦搜索框"
|
||||||
|
onClick={onFocusSearch}
|
||||||
|
>
|
||||||
|
<SearchIcon />
|
||||||
|
</button>
|
||||||
|
{/* TODO(auth): 后端登录接口就绪后接上,目前仅作入口占位 */}
|
||||||
|
<button type="button" className="icon-button" title="登录" aria-label="登录">
|
||||||
|
<LoginIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 顶栏跑马灯:展示连接状态、项目数量与前几个项目名。 */
|
||||||
|
function Ticker({ projects, live }: { projects: Project[]; live: boolean }) {
|
||||||
|
const items = useMemo(() => buildTickerItems(projects, live), [projects, live])
|
||||||
|
|
||||||
|
// 复制一份实现无缝循环,动画位移为 -50%
|
||||||
|
const loop = [...items, ...items]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ticker" aria-hidden="true">
|
||||||
|
<div className="ticker-track">
|
||||||
|
{loop.map((item, index) => (
|
||||||
|
<span className="ticker-item" key={`${item.tag}-${item.text}-${index}`}>
|
||||||
|
<span className="ticker-tag">{item.tag}</span>
|
||||||
|
{item.text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTickerItems(projects: Project[], live: boolean): TickerItem[] {
|
||||||
|
const items: TickerItem[] = [
|
||||||
|
{ tag: '状态', text: live ? '实时同步已连接' : '等待服务连接' },
|
||||||
|
{ tag: '项目', text: `共 ${projects.length} 个本地应用` },
|
||||||
|
]
|
||||||
|
|
||||||
|
if (projects.length === 0) {
|
||||||
|
items.push({
|
||||||
|
tag: '提示',
|
||||||
|
text: '把静态站点放进托管目录,并保留 index.html,就会出现在这里',
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const project of projects.slice(0, 6)) {
|
||||||
|
items.push({ tag: '本地', text: project.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/** 统一的小图标集合:只负责路径,尺寸与颜色由使用处的 CSS 决定。 */
|
||||||
|
|
||||||
|
export function RefreshIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
|
||||||
|
<path d="M21 3v6h-6" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SearchIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LinkIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.07 0l3-3a5 5 0 0 0-7.07-7.07l-1.5 1.5" />
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.07 0l-3 3a5 5 0 0 0 7.07 7.07l1.5-1.5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="8" r="3.6" />
|
||||||
|
<path d="M4.8 20a7.2 7.2 0 0 1 14.4 0" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
:root {
|
||||||
|
--bg-color: #f7f9fb;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--primary-color: #3b82f6;
|
||||||
|
--primary-soft: #eff6ff;
|
||||||
|
--text-main: #1e293b;
|
||||||
|
--text-sub: #64748b;
|
||||||
|
--text-muted: #94a3b8;
|
||||||
|
--border-color: #e2e8f0;
|
||||||
|
--radius-main: 12px;
|
||||||
|
--shadow-card: 0 4px 12px rgba(0, 0, 0, 0.03), 0 1px 3px rgba(0, 0, 0, 0.02);
|
||||||
|
--shadow-hover: 0 10px 20px rgba(0, 0, 0, 0.06), 0 4px 6px rgba(0, 0, 0, 0.03);
|
||||||
|
|
||||||
|
--sidebar-width: 240px;
|
||||||
|
--top-nav-height: 60px;
|
||||||
|
/* 内容区最大宽度,宽屏时整体居中 */
|
||||||
|
--layout-max-width: 1280px;
|
||||||
|
|
||||||
|
--font-sans:
|
||||||
|
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||||
|
'PingFang SC', 'Microsoft YaHei', Arial, sans-serif;
|
||||||
|
--font-mono: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', monospace;
|
||||||
|
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-main);
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color-scheme: light;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 桌面端锁死视口,滚动交给右侧内容区 */
|
||||||
|
body {
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-main);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--primary-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
height: auto;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* 登录后展示的用户模块数据。
|
||||||
|
*
|
||||||
|
* 后端目前没有鉴权 / 用户信息接口,这里先用占位数据,
|
||||||
|
* 等类似 `/api/me` 的接口就绪后替换为真实数据即可。
|
||||||
|
*/
|
||||||
|
export type UserProfile = {
|
||||||
|
name: string
|
||||||
|
role: string
|
||||||
|
bio: string
|
||||||
|
location: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PLACEHOLDER_PROFILE: UserProfile = {
|
||||||
|
name: '本地管理员',
|
||||||
|
role: '管理员',
|
||||||
|
bio: '本地静态站点托管与预览,所有项目都运行在这台机器上。',
|
||||||
|
location: '局域网',
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { fetchProjects, subscribeProjects, type Project } from './api'
|
||||||
|
|
||||||
|
export type ProjectsStatus = 'loading' | 'ready' | 'error'
|
||||||
|
|
||||||
|
export type ProjectsState = {
|
||||||
|
status: ProjectsStatus
|
||||||
|
projects: Project[]
|
||||||
|
error: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ProjectsController = ProjectsState & {
|
||||||
|
/** SSE 是否已连接 */
|
||||||
|
live: boolean
|
||||||
|
/** 是否正在手动刷新 */
|
||||||
|
refreshing: boolean
|
||||||
|
refresh: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function toErrorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 刷新失败时保留上一份列表,避免页面突然清空。 */
|
||||||
|
function toErrorState(previous: ProjectsState, error: unknown): ProjectsState {
|
||||||
|
return { status: 'error', projects: previous.projects, error: toErrorMessage(error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 项目列表:首屏拉取 + SSE 推送 + 手动刷新。 */
|
||||||
|
export function useProjects(): ProjectsController {
|
||||||
|
const [state, setState] = useState<ProjectsState>({
|
||||||
|
status: 'loading',
|
||||||
|
projects: [],
|
||||||
|
error: null,
|
||||||
|
})
|
||||||
|
const [live, setLive] = useState(false)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
|
const controllerRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
|
// 首屏加载:setState 只发生在 Promise 回调里。
|
||||||
|
// 不在 effect 内同步 setState,否则会触发 react-hooks/set-state-in-effect。
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
controllerRef.current = controller
|
||||||
|
|
||||||
|
fetchProjects(controller.signal)
|
||||||
|
.then((projects) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setState({ status: 'ready', projects, error: null })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setState((previous) => toErrorState(previous, error))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => controller.abort()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 服务端已做防抖与去重,这里直接整体替换快照。
|
||||||
|
useEffect(() => {
|
||||||
|
return subscribeProjects({
|
||||||
|
onProjects: (projects) => setState({ status: 'ready', projects, error: null }),
|
||||||
|
onConnectionChange: setLive,
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
controllerRef.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
controllerRef.current = controller
|
||||||
|
|
||||||
|
setRefreshing(true)
|
||||||
|
fetchProjects(controller.signal)
|
||||||
|
.then((projects) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setState({ status: 'ready', projects, error: null })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setState((previous) => toErrorState(previous, error))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!controller.signal.aborted) setRefreshing(false)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { ...state, live, refreshing, refresh }
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023", "DOM"],
|
||||||
|
"module": "esnext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"allowArbitraryExtensions": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "es2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"module": "nodenext",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
const BACKEND = 'http://127.0.0.1:8080'
|
||||||
|
|
||||||
|
// 前后端分离开发:dev / preview 都把 /api 透传给本地 Rust 服务
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: BACKEND, changeOrigin: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
port: 4173,
|
||||||
|
proxy: {
|
||||||
|
'/api': { target: BACKEND, changeOrigin: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<div class="error-code">404</div>
|
<div class="error-code">404</div>
|
||||||
<h1>资源或页面不存在</h1>
|
<h1>资源或页面不存在</h1>
|
||||||
<p>请求的路径 <code>{{ path }}</code> 不存在或缺少 index.html</p>
|
<p>请求的路径 <code>{{ path }}</code> 不存在或缺少 index.html</p>
|
||||||
<a href="/" class="button">返回应用大厅</a>
|
<a href="/public" class="button">返回应用大厅</a>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
<div class="workspace">
|
<div class="workspace">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<header class="sidebar-header">
|
<header class="sidebar-header">
|
||||||
<a class="brand" href="/" aria-label="Stash 首页">
|
<a class="brand" href="/public" aria-label="Stash 首页">
|
||||||
<span class="brand-mark" aria-hidden="true">
|
<span class="brand-mark" aria-hidden="true">
|
||||||
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="21" height="21" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<rect x="3" y="3" width="7" height="7" rx="2"></rect>
|
<rect x="3" y="3" width="7" height="7" rx="2"></rect>
|
||||||
Reference in New Issue
Block a user