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:
2026-10-21 17:59:08 +08:00
parent 92c510e959
commit e522a98f6f
48 changed files with 5113 additions and 273 deletions
Generated
+33 -90
View File
@@ -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
View File
@@ -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"
+88
View File
@@ -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

+745
View File
@@ -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&#10;- 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>
+1 -1
View File
@@ -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,
-3
View File
@@ -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 {
["&lt;", "&#60;", "&#x3C;", "&#x3c;"]
.iter()
.any(|entity| html.contains(entity))
}
fn contains_escaped_ampersand(html: &str) -> bool {
["&amp;", "&#38;", "&#x26;", "&#X26;"]
.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>"));
}
} }
+3 -3
View File
@@ -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!();
+86 -44
View File
@@ -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),优先直接展示 /// `/assets/**`:Vite 产出的 CSS / JS / 图片。
if index_file.exists() { async fn web_asset(OriginalUri(uri): OriginalUri, Path(path): Path<String>) -> Response {
let content = tokio::fs::read_to_string(&index_file).await?; let file = format!("assets/{path}");
return Ok(( embedded_file(&file).unwrap_or_else(|| not_found_page(uri.path()))
[(header::CONTENT_TYPE, "text/html; charset=utf-8")], }
Html(content),
/// 前端放在 dist 根目录的其他文件(favicon 等)。
async fn web_root_file(OriginalUri(uri): OriginalUri) -> Response {
let path = uri.path().trim_start_matches('/');
embedded_file(path).unwrap_or_else(|| not_found_page(uri.path()))
}
/// 从内嵌目录取文件并组装响应;找不到返回 `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(
(
StatusCode::OK,
[
(header::CONTENT_TYPE, content_type),
(header::CACHE_CONTROL, cache),
],
file.contents(),
) )
.into_response()); .into_response(),
}
// 2. 否则展示筛选后的可用前端应用大厅
let projects = scan_projects(&state.config.root);
let html = render_dashboard(&projects)?;
Ok(Html(html).into_response())
}
/// 全局 404 自定义页面处理器
async fn not_found_handler(
OriginalUri(uri): OriginalUri,
) -> Result<(StatusCode, Html<String>), StashError> {
let html = render_not_found(uri.path())?;
Ok((StatusCode::NOT_FOUND, Html(html)))
}
async fn dashboard_css() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "text/css; charset=utf-8")],
DASHBOARD_CSS,
) )
} }
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
} }
/// 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());
-18
View File
@@ -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"
-23
View File
@@ -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"
+24
View File
@@ -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?
+8
View File
@@ -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 }]
}
}
+32
View File
@@ -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.
+14
View File
@@ -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>
+1300
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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

+745
View File
@@ -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&#10;- 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>
+939
View File
@@ -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;
}
}
+152
View File
@@ -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>
)
}
+69
View File
@@ -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()
}
}
+130
View File
@@ -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',
},
],
},
]
+22
View File
@@ -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>
)
}
+23
View File
@@ -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>
)
}
+58
View File
@@ -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>
)
}
+65
View File
@@ -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>
)
}
+84
View File
@@ -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>
)
}
+26
View File
@@ -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>
)
}
+89
View File
@@ -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
}
+37
View File
@@ -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>
)
}
+82
View File
@@ -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;
}
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
+19
View File
@@ -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: '局域网',
}
+92
View File
@@ -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 }
}
+26
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+23
View File
@@ -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"]
}
+25
View File
@@ -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>