Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01a4681f98 | ||
|
|
c4f54203a4 | ||
|
|
8fa46a2b8d | ||
|
|
07d2050c5f | ||
|
|
cbc47336d6 | ||
|
|
ba3cf726b4 | ||
|
|
6b589fa62a | ||
|
|
4ca86da55f | ||
|
|
eae4596aa3 | ||
|
|
518f1e757e |
No files matched your search
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: grill-me
|
||||
description: >-
|
||||
Relentlessly interview the user to stress-test a plan, requirement, or architectural
|
||||
choice before writing code or selecting a solution. Activate this skill when the user
|
||||
asks to "grill me", clarify requirements, align on a design, or choose between technical
|
||||
approaches.
|
||||
---
|
||||
|
||||
# Grill Me — Decision-Tree Architecture & Requirement Interview
|
||||
|
||||
Instead of guessing requirements or dumping a massive, superficial plan, walk the decision tree of the user's idea **depth-first** through structured, high-leverage questions until every critical branch is resolved.
|
||||
|
||||
## Core Protocol
|
||||
|
||||
1. **Codebase-First Rule**:
|
||||
- Never ask the user a question that the existing codebase or system state (`GEMINI.md`, repository files, running services) can already answer. Check the codebase first.
|
||||
2. **Model the Decision Tree**:
|
||||
- Break the problem into **Top-Level Branches** (core user workflow, primary goal, deployment boundary), **Sub-Branches** (trade-offs, UX expectations, integration points), and **Leaf Nodes** (implementation details).
|
||||
3. **Depth-First & Frontier Questioning**:
|
||||
- Ask **1 to 3 sharp, high-leverage questions per round** using the `ask_question` tool (when available) or structured multiple-choice options.
|
||||
- Do not ask about downstream implementation details before upstream workflow decisions are settled.
|
||||
4. **Always Provide a `(Recommended)` Option**:
|
||||
- Every question MUST put a well-reasoned recommended answer first, prefixed with `(Recommended)`, explaining *why* it fits the user's architecture best.
|
||||
5. **Converge to a Concrete Decision Record**:
|
||||
- Once the critical branches of the decision tree are resolved, synthesize the locked-in decisions into a concise execution plan and ask for final confirmation to build/deploy.
|
||||
@@ -2,3 +2,4 @@
|
||||
stacks/gateway/certs/*.key
|
||||
stacks/gateway/certs/*.pem
|
||||
stacks/lucky/goodluck/
|
||||
**/node_modules/
|
||||
@@ -0,0 +1,73 @@
|
||||
# HomeLab & Cloud-Edge Architecture Memory (`GEMINI.md`)
|
||||
|
||||
本文件为 `agy` (Antigravity CLI) 的**全局持久上下文记忆文件**。无论在 M5 MacBook Air 还是 M6 Mac mini 网页端 (`https://agy.961504.xyz`) 启动新会话,均自动加载本架构规范。
|
||||
|
||||
---
|
||||
|
||||
## 1. 交互与工作准则
|
||||
1. **汇报 + 确认机制**:每次架构变更或较大改动前,先说明方案,再执行;代码与基础设施配置统一维护在 Git 仓库 [`/Users/ixvii0iv/homelab`](file:///Users/ixvii0iv/homelab) 并同步至 `mini:~/homelab`。
|
||||
2. **代码链接规范**:回答中涉及所有文件路径或代码符号时,必须使用可点击的 Markdown 链接(如 `[TopRibbon.tsx](file:///Users/ixvii0iv/homelab/stacks/portal/web/src/components/TopRibbon.tsx)`)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心硬件与网络拓扑(4 大节点)
|
||||
|
||||
| 节点角色 | 设备型号 / 系统 | 局域网 / 公网 IP | Tailscale 专线 IP | SSH 快捷别名 | 核心职责 |
|
||||
| :--- | :--- | :--- | :--- | :--- | :--- |
|
||||
| **随身开发机** | Apple M5 MacBook Air (macOS) | `192.168.31.83` | `100.89.152.95` | 本机 | 日常主力操作终端、本地 Git 仓库源头 |
|
||||
| **家庭计算核心** | Apple M6 Mac mini (24GB RAM, 460GB SSD) | `192.168.31.164` | `100.108.205.40` | `ssh mini` / `ssh mini-ts` | 7×24h All-in-One 服务器、OrbStack 容器宿主机、AGY 网页端宿主、Tailscale 子网路由 (`192.168.31.0/24`) |
|
||||
| **家庭存储中心** | 极空间 Z4 (20TB RAID) | `192.168.31.185` | 经 Mac mini 子网路由可达 | Web `5055` / SMB `445` | 20TB 大容量冷热存储、通过 LaunchAgent 自动 SMB 挂载至 Mac mini `~/nas/{共享文件,个人空间}` |
|
||||
| **家庭主路由** | 小米路由器 (开启 SSH + ShellCrash/Mihomo) | `192.168.31.1` (联通公网 `124.90.142.21`) | 经 Mac mini 子网路由可达 | `ssh root@192.168.31.1` | PPPoE 拨号、端口转发 (`10443`/`41641` -> `192.168.31.164`)、内网 Split-DNS、锁定 AI 流量走狮城节点 |
|
||||
| **海外云端前哨** | 新加坡 Azure VM (Ubuntu 24.04) | `104.43.105.243` | `100.115.204.51` | `ssh azure` | 443 免端口公网 HTTPS 反向代理入口、AI 算力固定出口、Uptime Kuma 外部独立监控哨兵 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 域名与双通道访问矩阵 (`961504.xyz`)
|
||||
|
||||
### 通道 A:443 免端口标准 HTTPS(经新加坡 Azure Caddy -> Tailscale P2P 回源)
|
||||
- **导航中控台 (HomeLab Console V3)**: `https://nav.961504.xyz` -> `100.108.205.40:3002`
|
||||
- **Agent 协同编排工作台 (Codeg Base + 自研大盘)**: `https://nav.961504.xyz/agents` & `https://codeg.961504.xyz` -> `100.108.205.40:3080` (SSO 免密 Proxy Auth)
|
||||
- **Gitea 私有代码托管与冷备中枢**: `https://git.961504.xyz` -> `100.108.205.40:3005` (Mac SSD 热层 + 极空间 20TB 冷层)
|
||||
- **Storybook 设计系统工作台**: `https://nav.961504.xyz/storybook` -> `100.108.205.40:3002/storybook`
|
||||
- **AGY 云端编程工作台 (`ttyd` + `tmux`)**: `https://agy.961504.xyz` -> `100.108.205.40:7681` (受 HomeLab SSO 2.0 保护)
|
||||
- **Vaultwarden 密码保险箱**: `https://vw.961504.xyz` -> `100.108.205.40:8222`
|
||||
- **Uptime Kuma 外部监控哨兵**: `https://status.961504.xyz` -> `127.0.0.1:3001` (Azure 本地容器)
|
||||
|
||||
### 通道 B:10443 家庭宽带公网直连 & 局域网 Split-DNS 秒开(Mac mini Caddy Wildcard TLS)
|
||||
- 在家连 Wi-Fi 时,小米路由器 Mihomo 将 `home.961504.xyz` 与 `*.home.961504.xyz` 直接解析为 `192.168.31.164`(并在 `fake_ip_filter` 中豁免 Fake-IP),实现 <1ms 局域网直连。
|
||||
- 在外网时,Cloudflare DNS 将 `*.home.961504.xyz` 动态解析至家庭联通公网 IP (`124.90.142.21`),通过路由器 `10443` 端口转发直达 Mac mini Caddy:
|
||||
- `https://home.961504.xyz:10443` (HomeLab Console V3)
|
||||
- `https://codeg.home.961504.xyz:10443` (Codeg 多智能体协同工作台)
|
||||
- `https://git.home.961504.xyz:10443` (Gitea 私有代码托管中枢)
|
||||
- `https://agy.home.961504.xyz:10443` (AGY 云端工作台)
|
||||
- `https://vw.home.961504.xyz:10443` (Vaultwarden)
|
||||
- `https://nas.home.961504.xyz:10443` (极空间 Z4 Web `192.168.31.185:5055`)
|
||||
- `https://clash.home.961504.xyz:10443` (路由器 Mihomo 面板 `192.168.31.1:9999`)
|
||||
- `https://router.home.961504.xyz:10443` (小米路由器后台 `192.168.31.1:80`)
|
||||
- `https://status.home.961504.xyz:10443` (Uptime Kuma `100.115.204.51:3001`)
|
||||
|
||||
---
|
||||
|
||||
## 4. 前端中控台工程架构 (`stacks/portal/web`)
|
||||
- **技术栈**: React 19 + Vite 6 + TypeScript + Zustand + Tailwind CSS v4 + Lucide Icons
|
||||
- **设计语言**:
|
||||
- **顶部导航 (`src/components/TopRibbon.tsx`)**: 标准 `shadcn/ui` 双层 AppShell Header。第一层 (`h-14`) 左侧为品牌与 `[中控台 | 📖 Storybook 设计规范]` 视图切换,右侧通过 `ml-auto shrink-0` 严格固定 `[🔍 搜索 ⌘K | >_ AGY 终端 | ☀️/🌙 主题切换]`;第二层 (`h-11`) 左侧为 4 枚硬件/网络微缩仪表胶囊,右侧为四模路由切换器。
|
||||
- **双主流主题自动切换 (`src/design-system/tokens.ts`)**: 仅保留 `light` (Shadcn Clean Light) 与 `dark` (Catppuccin/Linear Dark) 两个主流主题,默认监听 `window.matchMedia('(prefers-color-scheme: dark)')` 跟随设备深浅色自动切换,也可在右上角手动切换。
|
||||
- **内置 Storybook (`src/components/StorybookWorkbench.tsx`)**: 支持实时切换 3 种主流布局 (`homepage-3col` / `bento-split` / `compact-2col`)、查看双主题设计 Token、交互式调试 `ServiceCard` 组件变体。
|
||||
- **构建与发布命令**:
|
||||
```bash
|
||||
cd /Users/ixvii0iv/homelab/stacks/portal/web && npm run build
|
||||
rsync -az --delete /Users/ixvii0iv/homelab/stacks/portal/ ixvii0iv@192.168.31.164:~/homelab/stacks/portal/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Mac mini 后台常驻服务清单 (`LaunchAgents` & `LaunchDaemons`)
|
||||
- `/Library/LaunchDaemons/com.homelab.caddy.plist`: 系统级 Caddy 网关(监听 `10443`,提供 `*.home.961504.xyz` Let's Encrypt DNS-01 通配符证书反代)
|
||||
- `~/Library/LaunchAgents/com.homelab.portal.plist`: HomeLab Console V3 后端与静态资源服务 (`stacks/portal/server.py`,监听 `3002`/`3004`,提供 `/api/telemetry`、`/api/agents/overview`、`/api/clawbot/*` 与 `/api/action/*`)
|
||||
- `~/Library/LaunchAgents/com.homelab.codeg.plist`: Codeg 多智能体协同工作台底座 (`scripts/start-codeg.sh`,监听 `3080`,内置 4 大分工角色与 20+ CLI 协议适配)
|
||||
- `~/Library/LaunchAgents/com.homelab.gitea.plist`: Gitea 私有代码托管中枢 (`scripts/start-gitea.sh`,监听 `3005`/SSH `2222`,Mac SSD 热层 + `scripts/sync-git-to-nas.sh` 极空间 20TB 冷层归档)
|
||||
- `~/Library/LaunchAgents/com.homelab.agy-web.plist`: AGY 网页终端 (`scripts/start-agy-web.sh`,通过 `ttyd` 暴露 `7681` 端口并附着到 `tmux` 会话 `agy`)
|
||||
- `~/Library/LaunchAgents/com.homelab.cf-ddns.plist`: 每 5 分钟检测家庭公网 IP 并更新 Cloudflare `home.961504.xyz` / `*.home.961504.xyz`
|
||||
- `~/Library/LaunchAgents/com.homelab.mount-zspace.plist`: 自动挂载极空间 SMB 至 `~/nas/共享文件` 与 `~/nas/个人空间`
|
||||
- `~/Library/LaunchAgents/com.homelab.nas-backup.plist`: 每天凌晨 03:30 自动打包备份 Vaultwarden、Caddy、Gitea 冷备与基础设施配置至极空间 `~/nas/个人空间/MacMini_Backups/`
|
||||
@@ -9,12 +9,12 @@ export LANG="en_US.UTF-8"
|
||||
export LC_ALL="en_US.UTF-8"
|
||||
export TERM="xterm-256color"
|
||||
|
||||
mkdir -p "$HOME/workspace"
|
||||
mkdir -p "$HOME/homelab" "$HOME/workspace"
|
||||
|
||||
# 确保 tmux 会话 agy 存在并在其中默认打开 zsh (可直接运行 agy)
|
||||
# 确保 tmux 会话 agy 存在并在其中默认恢复最近一次会话 (agy -c)
|
||||
if ! /opt/homebrew/bin/tmux has-session -t agy 2>/dev/null; then
|
||||
/opt/homebrew/bin/tmux new-session -d -s agy -c "$HOME/workspace"
|
||||
/opt/homebrew/bin/tmux send-keys -t agy "agy" C-m
|
||||
/opt/homebrew/bin/tmux new-session -d -s agy -c "$HOME/homelab"
|
||||
/opt/homebrew/bin/tmux send-keys -t agy "agy -c" C-m
|
||||
fi
|
||||
|
||||
exec /opt/homebrew/bin/ttyd \
|
||||
@@ -22,4 +22,4 @@ exec /opt/homebrew/bin/ttyd \
|
||||
-p 7681 \
|
||||
-t fontSize=15 \
|
||||
-t 'titleFixed=Mac mini (M6) - AGY 云端工作台' \
|
||||
/opt/homebrew/bin/tmux new-session -A -s agy -c "$HOME/workspace"
|
||||
/opt/homebrew/bin/tmux new-session -A -s agy -c "$HOME/homelab"
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# start-codeg.sh — 启动 Codeg 多智能体协同工作台服务端 (M6 Mac mini 原生无头底座)
|
||||
# 监听端口: 3080 (Web UI + JSON/WebSocket API)
|
||||
# ==============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
export HOME="/Users/ixvii0iv"
|
||||
export USER="ixvii0iv"
|
||||
export LANG="en_US.UTF-8"
|
||||
export LC_ALL="en_US.UTF-8"
|
||||
export PATH="/Users/ixvii0iv/.local/bin:/Users/ixvii0iv/.antigravity-cli/bin:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
|
||||
export CODEG_PORT="3080"
|
||||
export CODEG_HOST="0.0.0.0"
|
||||
export CODEG_TOKEN="homelab2026"
|
||||
export CODEG_STATIC_DIR="/Users/ixvii0iv/.local/share/codeg/web"
|
||||
export CODEG_MCP_BIN="/Users/ixvii0iv/.local/bin/codeg-mcp"
|
||||
|
||||
exec /Users/ixvii0iv/.local/bin/codeg-server
|
||||
Executable
+103
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# start-gitea.sh — 启动 Gitea 私有代码托管中枢 (Mac SSD 热层 + 极空间 NAS 冷层归档)
|
||||
# HTTP 端口: 3005 | 内置 Git SSH 端口: 2222
|
||||
# ==============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
export HOME="/Users/ixvii0iv"
|
||||
export USER="ixvii0iv"
|
||||
export USERNAME="ixvii0iv"
|
||||
export LANG="en_US.UTF-8"
|
||||
export LC_ALL="en_US.UTF-8"
|
||||
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
|
||||
HOT_DIR="/Users/ixvii0iv/homelab/stacks/git"
|
||||
mkdir -p "$HOT_DIR/data/repositories" \
|
||||
"$HOT_DIR/data/lfs" \
|
||||
"$HOT_DIR/data/attachments" \
|
||||
"$HOT_DIR/data/packages" \
|
||||
"$HOT_DIR/data/repo-archive" \
|
||||
"$HOT_DIR/custom/conf" \
|
||||
"$HOT_DIR/log"
|
||||
|
||||
CONF_FILE="$HOT_DIR/custom/conf/app.ini"
|
||||
if [ ! -f "$CONF_FILE" ]; then
|
||||
SECRET_KEY=$(/opt/homebrew/opt/gitea/bin/gitea generate secret SECRET_KEY)
|
||||
INTERNAL_TOKEN=$(/opt/homebrew/opt/gitea/bin/gitea generate secret INTERNAL_TOKEN)
|
||||
JWT_SECRET=$(/opt/homebrew/opt/gitea/bin/gitea generate secret JWT_SECRET)
|
||||
LFS_JWT_SECRET=$(/opt/homebrew/opt/gitea/bin/gitea generate secret LFS_JWT_SECRET)
|
||||
|
||||
cat > "$CONF_FILE" <<EOF
|
||||
APP_NAME = HomeLab Git Hub (Mac SSD Hot + ZSpace 20TB Cold)
|
||||
RUN_USER = ixvii0iv
|
||||
RUN_MODE = prod
|
||||
WORK_PATH = $HOT_DIR
|
||||
|
||||
[server]
|
||||
PROTOCOL = http
|
||||
DOMAIN = git.961504.xyz
|
||||
ROOT_URL = https://git.961504.xyz/
|
||||
HTTP_ADDR = 0.0.0.0
|
||||
HTTP_PORT = 3005
|
||||
DISABLE_SSH = false
|
||||
START_SSH_SERVER = true
|
||||
SSH_DOMAIN = 192.168.31.164
|
||||
SSH_PORT = 2222
|
||||
SSH_LISTEN_PORT = 2222
|
||||
LFS_START_SERVER = true
|
||||
LFS_JWT_SECRET = $LFS_JWT_SECRET
|
||||
OFFLINE_MODE = false
|
||||
|
||||
[database]
|
||||
DB_TYPE = sqlite3
|
||||
PATH = $HOT_DIR/data/gitea.db
|
||||
|
||||
[repository]
|
||||
ROOT = $HOT_DIR/data/repositories
|
||||
DEFAULT_BRANCH = main
|
||||
|
||||
[lfs]
|
||||
STORAGE_TYPE = local
|
||||
PATH = $HOT_DIR/data/lfs
|
||||
|
||||
[attachment]
|
||||
STORAGE_TYPE = local
|
||||
PATH = $HOT_DIR/data/attachments
|
||||
MAX_SIZE = 2048
|
||||
MAX_FILES = 50
|
||||
|
||||
[packages]
|
||||
STORAGE_TYPE = local
|
||||
PATH = $HOT_DIR/data/packages
|
||||
|
||||
[repo-archive]
|
||||
STORAGE_TYPE = local
|
||||
PATH = $HOT_DIR/data/repo-archive
|
||||
|
||||
[security]
|
||||
INSTALL_LOCK = true
|
||||
SECRET_KEY = $SECRET_KEY
|
||||
INTERNAL_TOKEN = $INTERNAL_TOKEN
|
||||
PASSWORD_HASH_ALGO = pbkdf2
|
||||
REVERSE_PROXY_TRUSTED_PROXIES = 127.0.0.0/8,100.64.0.0/10,192.168.0.0/16,::1/128
|
||||
|
||||
[oauth2]
|
||||
JWT_SECRET = $JWT_SECRET
|
||||
|
||||
[service]
|
||||
DISABLE_REGISTRATION = true
|
||||
REQUIRE_SIGNIN_VIEW = false
|
||||
ENABLE_NOTIFY_MAIL = false
|
||||
ENABLE_REVERSE_PROXY_AUTHENTICATION = true
|
||||
ENABLE_REVERSE_PROXY_AUTO_REGISTRATION = true
|
||||
REVERSE_PROXY_AUTHENTICATION_USER = X-Webauth-User
|
||||
|
||||
[log]
|
||||
MODE = console
|
||||
LEVEL = Info
|
||||
ROOT_PATH = $HOT_DIR/log
|
||||
EOF
|
||||
fi
|
||||
|
||||
exec /opt/homebrew/opt/gitea/bin/gitea web --work-path "$HOT_DIR" --custom-path "$HOT_DIR/custom" --config "$CONF_FILE"
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# sync-agy-session.sh
|
||||
# 用途:将本机 (MacBook Air) 当前的 AGY 会话上下文与 GEMINI.md 永久记忆同步至
|
||||
# Mac mini 云端工作台 (https://agy.961504.xyz),实现网页端无缝接力
|
||||
# ==============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
SESSION_ID="${1:-6293ebb4-df60-4db6-bf5d-df33433ba106}"
|
||||
MINI_HOST="${MINI_HOST:-mini}"
|
||||
AGY_DIR="$HOME/.gemini/antigravity-cli"
|
||||
|
||||
echo "==> [1/4] 同步全局永久记忆文件 GEMINI.md 至本机与 Mac mini..."
|
||||
cp /Users/ixvii0iv/homelab/GEMINI.md "$HOME/GEMINI.md"
|
||||
ssh "$MINI_HOST" "mkdir -p ~/homelab ~/workspace ~/.gemini/antigravity-cli/conversations ~/.gemini/antigravity-cli/brain"
|
||||
rsync -az /Users/ixvii0iv/homelab/GEMINI.md "$MINI_HOST:~/GEMINI.md"
|
||||
rsync -az /Users/ixvii0iv/homelab/GEMINI.md "$MINI_HOST:~/homelab/GEMINI.md"
|
||||
rsync -az /Users/ixvii0iv/homelab/GEMINI.md "$MINI_HOST:~/workspace/GEMINI.md"
|
||||
|
||||
echo "==> [2/4] 同步会话索引库与历史记录 (conversation_summaries.db / history.jsonl)..."
|
||||
for f in conversation_summaries.db conversation_summaries.db-wal conversation_summaries.db-shm jetbox_summaries_proto.pb history.jsonl; do
|
||||
if [ -f "$AGY_DIR/$f" ]; then
|
||||
rsync -az "$AGY_DIR/$f" "$MINI_HOST:~/.gemini/antigravity-cli/$f"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "==> [3/4] 同步当前会话数据库与 Brain 上下文 ($SESSION_ID)..."
|
||||
rsync -az "$AGY_DIR/conversations/${SESSION_ID}".db* "$MINI_HOST:~/.gemini/antigravity-cli/conversations/"
|
||||
if [ -d "$AGY_DIR/brain/${SESSION_ID}" ]; then
|
||||
rsync -az "$AGY_DIR/brain/${SESSION_ID}/" "$MINI_HOST:~/.gemini/antigravity-cli/brain/${SESSION_ID}/"
|
||||
fi
|
||||
|
||||
echo "==> [4/4] 在 Mac mini 的 tmux (agy) 会话中配置一键恢复别名..."
|
||||
ssh "$MINI_HOST" "grep -q 'alias agy-resume=' ~/.zshrc 2>/dev/null || echo \"alias agy-resume='agy --conversation ${SESSION_ID}'\" >> ~/.zshrc"
|
||||
|
||||
echo "✅ 同步完成!"
|
||||
echo " • 网页端工作台地址: https://agy.961504.xyz (或在中控台右上角点击 [>_ AGY 终端])"
|
||||
echo " • 恢复当前完整会话: agy --conversation ${SESSION_ID} (或直接输入: agy-resume / agy -c)"
|
||||
echo " • 开启新会话(自带完整架构记忆): agy"
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# sync-git-to-nas.sh — 将 Mac mini 上的 Gitea 热层数据与大文件同步归档至极空间 Z4 20TB NAS
|
||||
# ==============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
HOT_DIR="/Users/ixvii0iv/homelab/stacks/git/data"
|
||||
COLD_DIR="/Users/ixvii0iv/nas/个人空间/Git_Cold_Storage"
|
||||
|
||||
if [ ! -d "/Users/ixvii0iv/nas/个人空间" ]; then
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] WARN: 极空间 NAS 未挂载,跳过冷存储同步"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$COLD_DIR/lfs" "$COLD_DIR/attachments" "$COLD_DIR/packages" "$COLD_DIR/repo-archive" "$COLD_DIR/bundles" "$COLD_DIR/db"
|
||||
|
||||
# 1. 增量同步 LFS 大文件、Release 附件、制品包与归档
|
||||
rsync -a "$HOT_DIR/lfs/" "$COLD_DIR/lfs/"
|
||||
rsync -a "$HOT_DIR/attachments/" "$COLD_DIR/attachments/"
|
||||
rsync -a "$HOT_DIR/packages/" "$COLD_DIR/packages/"
|
||||
rsync -a "$HOT_DIR/repo-archive/" "$COLD_DIR/repo-archive/"
|
||||
|
||||
# 2. 将所有 Git 裸仓库打包为独立自包含的 .bundle 冷备文件(任何时候可直接 git clone 恢复)
|
||||
if [ -d "$HOT_DIR/repositories" ]; then
|
||||
find "$HOT_DIR/repositories" -maxdepth 2 -type d -name "*.git" | while read -r repo; do
|
||||
rel_name=$(echo "${repo#$HOT_DIR/repositories/}" | tr '/' '_')
|
||||
/usr/bin/git -C "$repo" bundle create "$COLD_DIR/bundles/${rel_name%.git}.bundle" --all >/dev/null 2>&1 || true
|
||||
done
|
||||
fi
|
||||
|
||||
# 3. 备份 Gitea SQLite 元数据快照
|
||||
if [ -f "$HOT_DIR/gitea.db" ]; then
|
||||
/usr/bin/sqlite3 "$HOT_DIR/gitea.db" ".backup '$COLD_DIR/db/gitea-latest.db'" 2>/dev/null || cp "$HOT_DIR/gitea.db" "$COLD_DIR/db/gitea-latest.db"
|
||||
fi
|
||||
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] OK: Git 热层仓库与 LFS 大文件已同步归档至极空间 ($COLD_DIR)"
|
||||
@@ -10,7 +10,6 @@ status.961504.xyz:443 {
|
||||
}
|
||||
|
||||
# 2. 家庭统一导航总入口免端口 443 访问 -> https://nav.961504.xyz
|
||||
# (通过 Tailscale 加密专线回源至家里 Mac mini 的 Sun-Panel)
|
||||
nav.961504.xyz:443 {
|
||||
encode zstd gzip
|
||||
reverse_proxy 100.108.205.40:3002
|
||||
@@ -23,10 +22,11 @@ wx.961504.xyz:443 {
|
||||
}
|
||||
|
||||
# 4. AGY 24小时云端 Web 工作台 (443 免端口访问) -> https://agy.961504.xyz
|
||||
# 使用 forward_auth 对接 HomeLab SSO 2.0 自定义登录卡片,彻底告别浏览器原生丑陋弹窗!
|
||||
agy.961504.xyz:443 {
|
||||
encode zstd gzip
|
||||
basicauth /* {
|
||||
ixvii0iv $2a$14$VaaNvgIjsCJFzXCpIxjcOerKUIdkN7GhVWAlGh3/ZZjBOADY9lg..
|
||||
forward_auth 100.108.205.40:3002 {
|
||||
uri /api/auth/verify
|
||||
}
|
||||
reverse_proxy 100.108.205.40:7681
|
||||
}
|
||||
@@ -38,3 +38,28 @@ vw.961504.xyz:443 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
|
||||
# 6. Codeg 多智能体协同工作台底座 (443 免端口访问) -> https://codeg.961504.xyz
|
||||
codeg.961504.xyz:443 {
|
||||
encode zstd gzip
|
||||
forward_auth 100.108.205.40:3002 {
|
||||
uri /api/auth/verify
|
||||
}
|
||||
reverse_proxy 100.108.205.40:3080 {
|
||||
header_up Authorization "Bearer homelab2026"
|
||||
}
|
||||
}
|
||||
|
||||
# 7. Gitea 私有代码托管中枢 (443 免端口访问) -> https://git.961504.xyz
|
||||
git.961504.xyz:443 {
|
||||
encode zstd gzip
|
||||
@browser {
|
||||
header Accept *text/html*
|
||||
}
|
||||
forward_auth @browser 100.108.205.40:3002 {
|
||||
uri /api/auth/verify
|
||||
}
|
||||
reverse_proxy 100.108.205.40:3005 {
|
||||
header_up X-Webauth-User admin
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
auto_https disable_redirects
|
||||
}
|
||||
|
||||
# 0. 家庭统一导航总入口 Sun-Panel (https://home.961504.xyz:10443)
|
||||
# 0. 家庭统一导航总入口 HomeLab Console V3 (https://home.961504.xyz:10443)
|
||||
home.961504.xyz:10443, nav.home.961504.xyz:10443 {
|
||||
tls /Users/ixvii0iv/homelab/stacks/gateway/certs/fullchain.pem /Users/ixvii0iv/homelab/stacks/gateway/certs/privkey.pem
|
||||
encode zstd gzip
|
||||
@@ -22,12 +22,12 @@ nas.home.961504.xyz:10443, z4.home.961504.xyz:10443 {
|
||||
reverse_proxy 192.168.31.185:5055
|
||||
}
|
||||
|
||||
# 2. 路由器 Mihomo / Clash 面板 (已开启 HTTP Basic Auth 密码锁保护: ixvii0iv / Mac mini密码)
|
||||
# 2. 路由器 Mihomo / Clash 面板 (通过 HomeLab SSO 2.0 自定义登录卡片保护)
|
||||
clash.home.961504.xyz:10443 {
|
||||
tls /Users/ixvii0iv/homelab/stacks/gateway/certs/fullchain.pem /Users/ixvii0iv/homelab/stacks/gateway/certs/privkey.pem
|
||||
encode zstd gzip
|
||||
basic_auth {
|
||||
ixvii0iv $2a$14$VaaNvgIjsCJFzXCpIxjcOerKUIdkN7GhVWAlGh3/ZZjBOADY9lg..
|
||||
forward_auth 127.0.0.1:3002 {
|
||||
uri /api/auth/verify
|
||||
}
|
||||
reverse_proxy 192.168.31.1:9999
|
||||
}
|
||||
@@ -36,8 +36,8 @@ clash.home.961504.xyz:10443 {
|
||||
router.home.961504.xyz:10443 {
|
||||
tls /Users/ixvii0iv/homelab/stacks/gateway/certs/fullchain.pem /Users/ixvii0iv/homelab/stacks/gateway/certs/privkey.pem
|
||||
encode zstd gzip
|
||||
basic_auth {
|
||||
ixvii0iv $2a$14$VaaNvgIjsCJFzXCpIxjcOerKUIdkN7GhVWAlGh3/ZZjBOADY9lg..
|
||||
forward_auth 127.0.0.1:3002 {
|
||||
uri /api/auth/verify
|
||||
}
|
||||
reverse_proxy 192.168.31.1:80
|
||||
}
|
||||
@@ -50,11 +50,12 @@ lucky.home.961504.xyz:10443 {
|
||||
}
|
||||
|
||||
# 5. AGY 24小时云端 Web 工作台 (https://agy.home.961504.xyz:10443)
|
||||
# 使用 forward_auth 对接 HomeLab SSO 2.0 自定义登录卡片,彻底告别浏览器原生丑陋弹窗!
|
||||
agy.home.961504.xyz:10443 {
|
||||
tls /Users/ixvii0iv/homelab/stacks/gateway/certs/fullchain.pem /Users/ixvii0iv/homelab/stacks/gateway/certs/privkey.pem
|
||||
encode zstd gzip
|
||||
basic_auth {
|
||||
ixvii0iv $2a$14$VaaNvgIjsCJFzXCpIxjcOerKUIdkN7GhVWAlGh3/ZZjBOADY9lg..
|
||||
forward_auth 127.0.0.1:3002 {
|
||||
uri /api/auth/verify
|
||||
}
|
||||
reverse_proxy 127.0.0.1:7681
|
||||
}
|
||||
@@ -67,3 +68,37 @@ vw.home.961504.xyz:10443 {
|
||||
header_up X-Real-IP {remote_host}
|
||||
}
|
||||
}
|
||||
|
||||
# 7. Uptime Kuma 外部独立监控哨兵 (https://status.home.961504.xyz:10443)
|
||||
status.home.961504.xyz:10443 {
|
||||
tls /Users/ixvii0iv/homelab/stacks/gateway/certs/fullchain.pem /Users/ixvii0iv/homelab/stacks/gateway/certs/privkey.pem
|
||||
encode zstd gzip
|
||||
reverse_proxy 100.115.204.51:3001
|
||||
}
|
||||
|
||||
# 8. Codeg 多智能体协同工作台底座 (https://codeg.home.961504.xyz:10443)
|
||||
codeg.home.961504.xyz:10443 {
|
||||
tls /Users/ixvii0iv/homelab/stacks/gateway/certs/fullchain.pem /Users/ixvii0iv/homelab/stacks/gateway/certs/privkey.pem
|
||||
encode zstd gzip
|
||||
forward_auth 127.0.0.1:3002 {
|
||||
uri /api/auth/verify
|
||||
}
|
||||
reverse_proxy 127.0.0.1:3080 {
|
||||
header_up Authorization "Bearer homelab2026"
|
||||
}
|
||||
}
|
||||
|
||||
# 9. Gitea 私有代码托管中枢 (https://git.home.961504.xyz:10443)
|
||||
git.home.961504.xyz:10443 {
|
||||
tls /Users/ixvii0iv/homelab/stacks/gateway/certs/fullchain.pem /Users/ixvii0iv/homelab/stacks/gateway/certs/privkey.pem
|
||||
encode zstd gzip
|
||||
@browser {
|
||||
header Accept *text/html*
|
||||
}
|
||||
forward_auth @browser 127.0.0.1:3002 {
|
||||
uri /api/auth/verify
|
||||
}
|
||||
reverse_proxy 127.0.0.1:3005 {
|
||||
header_up X-Webauth-User admin
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,629 @@
|
||||
#!/usr/bin/env python3
|
||||
# ==============================================================================
|
||||
# HomeLab WeChat ClawBot Engine (Tencent iLink Bot Protocol v2.4.10)
|
||||
# 100% 兼容 @tencent-weixin/openclaw-weixin 官方协议 (ilinkai.weixin.qq.com)
|
||||
# 支持:
|
||||
# 1. 生成微信 ClawBot 绑定二维码 & 轮询扫码状态 (支持 IDC 重定向与配对验证码)
|
||||
# 2. 7x24h 长轮询接收微信消息 (getupdates) + "正在输入..." 状态推送 (sendtyping)
|
||||
# 3. 微信内直接查询 Mac mini / 极空间状态、触发备份、执行终端指令、与 AGY AI 对话
|
||||
# ==============================================================================
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
FIXED_BASE_URL = "https://ilinkai.weixin.qq.com"
|
||||
DEFAULT_BOT_TYPE = "3"
|
||||
CHANNEL_VERSION = "2.4.10"
|
||||
# (2 << 16) | (4 << 8) | 10 = 132106
|
||||
ILINK_CLIENT_VERSION = str((2 << 16) | (4 << 8) | 10)
|
||||
ILINK_APP_ID = "bot"
|
||||
|
||||
STATE_DIR = Path("/Users/ixvii0iv/homelab/stacks/wechat-bot")
|
||||
SESSION_FILE = STATE_DIR / "clawbot_session.json"
|
||||
SYNC_BUF_FILE = STATE_DIR / "clawbot_sync_buf.txt"
|
||||
DEFAULT_AGY_CONV_ID = "6293ebb4-df60-4db6-bf5d-df33433ba106"
|
||||
|
||||
|
||||
def _random_wechat_uin() -> str:
|
||||
uint32 = random.randint(10000000, 0xFFFFFFFF)
|
||||
return base64.b64encode(str(uint32).encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def _common_headers() -> dict:
|
||||
return {
|
||||
"iLink-App-Id": ILINK_APP_ID,
|
||||
"iLink-App-ClientVersion": ILINK_CLIENT_VERSION,
|
||||
}
|
||||
|
||||
|
||||
def _post_headers(token: str = "") -> dict:
|
||||
h = {
|
||||
"Content-Type": "application/json",
|
||||
"AuthorizationType": "ilink_bot_token",
|
||||
"X-WECHAT-UIN": _random_wechat_uin(),
|
||||
**_common_headers(),
|
||||
}
|
||||
if token.strip():
|
||||
h["Authorization"] = f"Bearer {token.strip()}"
|
||||
return h
|
||||
|
||||
|
||||
def _base_info() -> dict:
|
||||
return {
|
||||
"channel_version": CHANNEL_VERSION,
|
||||
"bot_agent": "OpenClaw",
|
||||
}
|
||||
|
||||
|
||||
def _parse_lossless_json(raw_text: str) -> dict:
|
||||
# Quote uint64 message_id / msg_id / svr_id fields so JS/Python keeps exact strings
|
||||
patched = re.sub(
|
||||
r'("(?:message_id|msg_id|svr_id)"\s*:\s*)(-?\d+)',
|
||||
r'\1"\2"',
|
||||
raw_text,
|
||||
)
|
||||
return json.loads(patched)
|
||||
|
||||
|
||||
def _api_get(base_url: str, endpoint: str, timeout: float = 36.0) -> dict:
|
||||
url = base_url.rstrip("/") + "/" + endpoint.lstrip("/")
|
||||
req = urllib.request.Request(url, headers=_common_headers(), method="GET")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
return _parse_lossless_json(raw)
|
||||
|
||||
|
||||
def _api_post(base_url: str, endpoint: str, payload: dict, token: str = "", timeout: float = 20.0) -> dict:
|
||||
url = base_url.rstrip("/") + "/" + endpoint.lstrip("/")
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, headers=_post_headers(token), method="POST")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read().decode("utf-8")
|
||||
return _parse_lossless_json(raw)
|
||||
|
||||
|
||||
class ClawBotManager:
|
||||
def __init__(self, telemetry_fn=None, action_runner_fn=None):
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
self.telemetry_fn = telemetry_fn
|
||||
self.action_runner_fn = action_runner_fn
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Saved session credentials
|
||||
self.session = self._load_session()
|
||||
|
||||
# Active QR login state
|
||||
self.login_state = {
|
||||
"active": False,
|
||||
"qrcode": "",
|
||||
"qrcode_url": "",
|
||||
"status": "idle", # idle | wait | scaned | need_verifycode | confirmed | expired | error
|
||||
"message": "尚未发起扫码登录",
|
||||
"api_base_url": FIXED_BASE_URL,
|
||||
"pending_verify_code": "",
|
||||
"started_at": 0.0,
|
||||
}
|
||||
|
||||
# Runtime monitor stats
|
||||
self.last_event_ts = 0.0
|
||||
self.last_inbound_text = ""
|
||||
self.last_reply_text = ""
|
||||
self.msg_count = 0
|
||||
self._stop_event = threading.Event()
|
||||
self._monitor_thread = None
|
||||
|
||||
if self.is_connected():
|
||||
self._start_monitor_thread()
|
||||
|
||||
def _load_session(self) -> dict:
|
||||
if SESSION_FILE.exists():
|
||||
try:
|
||||
return json.loads(SESSION_FILE.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
def _save_session(self, data: dict):
|
||||
self.session = data
|
||||
SESSION_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
return bool(self.session.get("bot_token") and self.session.get("ilink_bot_id"))
|
||||
|
||||
def get_public_status(self) -> dict:
|
||||
return {
|
||||
"connected": self.is_connected(),
|
||||
"bot_id": self.session.get("ilink_bot_id", ""),
|
||||
"user_id": self.session.get("ilink_user_id", ""),
|
||||
"base_url": self.session.get("baseurl", FIXED_BASE_URL),
|
||||
"connected_at": self.session.get("connected_at", ""),
|
||||
"msg_count": self.msg_count,
|
||||
"last_inbound_text": self.last_inbound_text,
|
||||
"last_reply_text": self.last_reply_text,
|
||||
"login": {
|
||||
"active": self.login_state["active"],
|
||||
"qrcode_url": self.login_state["qrcode_url"],
|
||||
"status": self.login_state["status"],
|
||||
"message": self.login_state["message"],
|
||||
},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. QR Code Login Flow
|
||||
# ------------------------------------------------------------------
|
||||
def start_qr_login(self) -> dict:
|
||||
try:
|
||||
local_tokens = [self.session["bot_token"]] if self.session.get("bot_token") else []
|
||||
resp = _api_post(
|
||||
FIXED_BASE_URL,
|
||||
f"ilink/bot/get_bot_qrcode?bot_type={DEFAULT_BOT_TYPE}",
|
||||
{"local_token_list": local_tokens},
|
||||
timeout=12.0,
|
||||
)
|
||||
qrcode = resp.get("qrcode", "")
|
||||
qrcode_url = resp.get("qrcode_img_content", "")
|
||||
if not qrcode or not qrcode_url:
|
||||
raise RuntimeError(f"未获取到二维码: {resp}")
|
||||
|
||||
with self._lock:
|
||||
self.login_state = {
|
||||
"active": True,
|
||||
"qrcode": qrcode,
|
||||
"qrcode_url": qrcode_url,
|
||||
"status": "wait",
|
||||
"message": "请使用手机微信扫描二维码(或在微信 ClawBot 页面点击「开始扫一扫」)",
|
||||
"api_base_url": FIXED_BASE_URL,
|
||||
"pending_verify_code": "",
|
||||
"started_at": time.time(),
|
||||
}
|
||||
|
||||
t = threading.Thread(target=self._poll_qr_worker, args=(qrcode,), daemon=True)
|
||||
t.start()
|
||||
return {"ok": True, **self.get_public_status()}
|
||||
except Exception as e:
|
||||
with self._lock:
|
||||
self.login_state["status"] = "error"
|
||||
self.login_state["message"] = f"获取二维码失败: {e}"
|
||||
return {"ok": False, "message": str(e)}
|
||||
|
||||
def submit_verify_code(self, code: str) -> dict:
|
||||
clean = code.strip()
|
||||
if not clean:
|
||||
return {"ok": False, "message": "请输入有效的数字配对码"}
|
||||
with self._lock:
|
||||
self.login_state["pending_verify_code"] = clean
|
||||
self.login_state["message"] = f"已提交配对码 {clean},正在向微信服务器验证..."
|
||||
return {"ok": True, **self.get_public_status()}
|
||||
|
||||
def disconnect(self) -> dict:
|
||||
self._stop_event.set()
|
||||
if SESSION_FILE.exists():
|
||||
SESSION_FILE.unlink()
|
||||
if SYNC_BUF_FILE.exists():
|
||||
SYNC_BUF_FILE.unlink()
|
||||
self.session = {}
|
||||
with self._lock:
|
||||
self.login_state["active"] = False
|
||||
self.login_state["status"] = "idle"
|
||||
self.login_state["message"] = "已断开微信 ClawBot 连接"
|
||||
return {"ok": True, **self.get_public_status()}
|
||||
|
||||
def _poll_qr_worker(self, initial_qrcode: str):
|
||||
deadline = time.time() + 300.0 # 5 minutes
|
||||
while time.time() < deadline:
|
||||
with self._lock:
|
||||
if not self.login_state["active"] or self.login_state["qrcode"] != initial_qrcode:
|
||||
return
|
||||
base_url = self.login_state["api_base_url"]
|
||||
qrcode = self.login_state["qrcode"]
|
||||
verify_code = self.login_state["pending_verify_code"]
|
||||
|
||||
endpoint = f"ilink/bot/get_qrcode_status?qrcode={urllib.parse.quote(qrcode)}"
|
||||
if verify_code:
|
||||
endpoint += f"&verify_code={urllib.parse.quote(verify_code)}"
|
||||
|
||||
try:
|
||||
resp = _api_get(base_url, endpoint, timeout=35.0)
|
||||
except Exception:
|
||||
resp = {"status": "wait"}
|
||||
|
||||
st = resp.get("status", "wait")
|
||||
with self._lock:
|
||||
if self.login_state["qrcode"] != initial_qrcode:
|
||||
return
|
||||
self.login_state["status"] = st
|
||||
|
||||
if st == "wait":
|
||||
pass
|
||||
elif st == "scaned":
|
||||
self.login_state["pending_verify_code"] = ""
|
||||
self.login_state["message"] = "📱 已扫码!请在手机微信上点击确认授权..."
|
||||
elif st == "scaned_but_redirect":
|
||||
r_host = resp.get("redirect_host", "")
|
||||
if r_host:
|
||||
self.login_state["api_base_url"] = f"https://{r_host}"
|
||||
self.login_state["message"] = "📱 已扫码,正在切换微信数据中心节点..."
|
||||
elif st == "need_verifycode":
|
||||
if self.login_state["pending_verify_code"]:
|
||||
self.login_state["message"] = "❌ 配对码不匹配,请重新输入手机微信上显示的数字:"
|
||||
self.login_state["pending_verify_code"] = ""
|
||||
else:
|
||||
self.login_state["message"] = "🔢 请输入手机微信屏幕上显示的配对数字:"
|
||||
elif st == "binded_redirect":
|
||||
self.login_state["active"] = False
|
||||
self.login_state["message"] = "✅ 此微信已绑定过当前节点!"
|
||||
return
|
||||
elif st in ("expired", "verify_code_blocked"):
|
||||
self.login_state["active"] = False
|
||||
self.login_state["message"] = "⏳ 二维码已过期,请点击「刷新二维码」重新扫描"
|
||||
return
|
||||
elif st == "confirmed":
|
||||
bot_token = resp.get("bot_token", "")
|
||||
bot_id = resp.get("ilink_bot_id", "")
|
||||
baseurl = resp.get("baseurl") or FIXED_BASE_URL
|
||||
user_id = resp.get("ilink_user_id", "")
|
||||
self._save_session(
|
||||
{
|
||||
"bot_token": bot_token,
|
||||
"ilink_bot_id": bot_id,
|
||||
"baseurl": baseurl,
|
||||
"ilink_user_id": user_id,
|
||||
"connected_at": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
)
|
||||
self.login_state["active"] = False
|
||||
self.login_state["message"] = "🎉 微信 ClawBot 绑定成功!现在可以直接在微信里收发消息了"
|
||||
self._start_monitor_thread()
|
||||
# Send immediate welcome message to the user on WeChat!
|
||||
if user_id:
|
||||
threading.Thread(
|
||||
target=self.send_text_message,
|
||||
args=(
|
||||
user_id,
|
||||
"✅ M6 Mac mini (HomeLab & AGY AI) 已成功连接微信 ClawBot!\n\n"
|
||||
"你可以直接发送以下内容与我交互:\n"
|
||||
"• 发送「状态」:查看 M6 Mac mini 与极空间 Z4 实时遥测\n"
|
||||
"• 发送「备份」:立即打包备份核心数据至极空间 20TB\n"
|
||||
"• 发送「屏幕」:查看 AGY 云端终端当前实时画面\n"
|
||||
"• 发送「$ 命令」:在 Mac mini 上执行终端命令 (如 $ df -h)\n"
|
||||
"• 直接发任何问题:直接转发给 Mac mini 上的 AI 助手回答",
|
||||
),
|
||||
daemon=True,
|
||||
).start()
|
||||
return
|
||||
|
||||
time.sleep(1.0)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Long-Poll Monitor & Outbound Messaging
|
||||
# ------------------------------------------------------------------
|
||||
def _start_monitor_thread(self):
|
||||
if self._monitor_thread and self._monitor_thread.is_alive():
|
||||
return
|
||||
self._stop_event.clear()
|
||||
self._monitor_thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
||||
self._monitor_thread.start()
|
||||
|
||||
def send_text_message(self, to_user_id: str, text: str, context_token: str = "", run_id: str = ""):
|
||||
if not self.is_connected() or not to_user_id or not text:
|
||||
return
|
||||
base_url = self.session.get("baseurl") or FIXED_BASE_URL
|
||||
token = self.session.get("bot_token", "")
|
||||
client_id = f"openclaw-weixin-{uuid.uuid4().hex[:12]}"
|
||||
msg_obj = {
|
||||
"from_user_id": "",
|
||||
"to_user_id": to_user_id,
|
||||
"client_id": client_id,
|
||||
"message_type": 2, # BOT
|
||||
"message_state": 2, # FINISH
|
||||
"item_list": [{"type": 1, "text_item": {"text": text}}],
|
||||
}
|
||||
if context_token:
|
||||
msg_obj["context_token"] = context_token
|
||||
if run_id:
|
||||
msg_obj["run_id"] = run_id
|
||||
|
||||
payload = {
|
||||
"msg": msg_obj,
|
||||
"base_info": _base_info(),
|
||||
}
|
||||
try:
|
||||
_api_post(base_url, "ilink/bot/sendmessage", payload, token=token, timeout=15.0)
|
||||
self.last_reply_text = text[:120]
|
||||
except Exception as e:
|
||||
print(f"[ClawBot] send_text_message error: {e}")
|
||||
|
||||
def _send_typing(self, to_user_id: str, context_token: str = ""):
|
||||
if not self.is_connected() or not to_user_id:
|
||||
return
|
||||
base_url = self.session.get("baseurl") or FIXED_BASE_URL
|
||||
token = self.session.get("bot_token", "")
|
||||
try:
|
||||
cfg = _api_post(
|
||||
base_url,
|
||||
"ilink/bot/getconfig",
|
||||
{
|
||||
"ilink_user_id": to_user_id,
|
||||
"context_token": context_token,
|
||||
"base_info": _base_info(),
|
||||
},
|
||||
token=token,
|
||||
timeout=8.0,
|
||||
)
|
||||
ticket = cfg.get("typing_ticket", "")
|
||||
if ticket:
|
||||
_api_post(
|
||||
base_url,
|
||||
"ilink/bot/sendtyping",
|
||||
{
|
||||
"ilink_user_id": to_user_id,
|
||||
"typing_ticket": ticket,
|
||||
"status": 1,
|
||||
"base_info": _base_info(),
|
||||
},
|
||||
token=token,
|
||||
timeout=8.0,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _monitor_loop(self):
|
||||
base_url = self.session.get("baseurl") or FIXED_BASE_URL
|
||||
token = self.session.get("bot_token", "")
|
||||
try:
|
||||
_api_post(
|
||||
base_url,
|
||||
"ilink/bot/msg/notifystart",
|
||||
{"base_info": _base_info()},
|
||||
token=token,
|
||||
timeout=10.0,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sync_buf = ""
|
||||
if SYNC_BUF_FILE.exists():
|
||||
try:
|
||||
sync_buf = SYNC_BUF_FILE.read_text(encoding="utf-8").strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
while not self._stop_event.is_set() and self.is_connected():
|
||||
base_url = self.session.get("baseurl") or FIXED_BASE_URL
|
||||
token = self.session.get("bot_token", "")
|
||||
try:
|
||||
resp = _api_post(
|
||||
base_url,
|
||||
"ilink/bot/getupdates",
|
||||
{"get_updates_buf": sync_buf, "base_info": _base_info()},
|
||||
token=token,
|
||||
timeout=38.0,
|
||||
)
|
||||
new_buf = resp.get("get_updates_buf")
|
||||
if new_buf:
|
||||
sync_buf = new_buf
|
||||
SYNC_BUF_FILE.write_text(sync_buf, encoding="utf-8")
|
||||
|
||||
msgs = resp.get("msgs") or []
|
||||
for m in msgs:
|
||||
# Only handle USER messages (message_type == 1)
|
||||
if m.get("message_type") == 2:
|
||||
continue
|
||||
from_user = m.get("from_user_id", "")
|
||||
ctx_token = m.get("context_token", "")
|
||||
run_id = m.get("run_id", "")
|
||||
items = m.get("item_list") or []
|
||||
text_parts = []
|
||||
for it in items:
|
||||
t_type = it.get("type")
|
||||
if t_type == 1 and it.get("text_item", {}).get("text"):
|
||||
text_parts.append(it["text_item"]["text"])
|
||||
elif t_type == 3 and it.get("voice_item", {}).get("text"):
|
||||
text_parts.append(it["voice_item"]["text"])
|
||||
user_text = "\n".join(text_parts).strip()
|
||||
if from_user and user_text:
|
||||
self.msg_count += 1
|
||||
self.last_inbound_text = user_text
|
||||
self.last_event_ts = time.time()
|
||||
threading.Thread(
|
||||
target=self._handle_inbound_message,
|
||||
args=(from_user, user_text, ctx_token, run_id),
|
||||
daemon=True,
|
||||
).start()
|
||||
except urllib.error.URLError as e:
|
||||
# Normal long-poll timeout
|
||||
if "timed out" in str(e).lower():
|
||||
continue
|
||||
time.sleep(2.0)
|
||||
except TimeoutError:
|
||||
continue
|
||||
except Exception as e:
|
||||
if "timed out" in str(e).lower():
|
||||
continue
|
||||
print(f"[ClawBot] getupdates error: {e}")
|
||||
time.sleep(3.0)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Intelligent Command & AI Router for WeChat Messages
|
||||
# ------------------------------------------------------------------
|
||||
def _handle_inbound_message(self, from_user: str, text: str, ctx_token: str, run_id: str):
|
||||
self._send_typing(from_user, ctx_token)
|
||||
cmd = text.strip()
|
||||
low = cmd.lower()
|
||||
|
||||
# 1. Help
|
||||
if low in ("帮助", "help", "/help", "?"):
|
||||
reply = (
|
||||
"🍎 HomeLab & AGY 微信中控台指令清单:\n"
|
||||
"━━━━━━━━━━━━━━\n"
|
||||
"1️⃣ 发送「状态」或 /status\n"
|
||||
" 查看 M6 Mac mini、极空间 20TB、联通公网 IP 与狮城专线实时指标\n"
|
||||
"2️⃣ 发送「备份」或 /backup\n"
|
||||
" 立即将 Vaultwarden 与核心配置打包灾备至极空间 Z4\n"
|
||||
"3️⃣ 发送「刷新」或 /ddns\n"
|
||||
" 立即刷新 Cloudflare 泛域名 DDNS 解析\n"
|
||||
"4️⃣ 发送「屏幕」或 /screen\n"
|
||||
" 抓取 Mac mini 上 tmux (agy) 终端的实时画面\n"
|
||||
"5️⃣ 发送「$ <终端命令>」\n"
|
||||
" 在 Mac mini 上直接运行 Shell 命令并返回结果 (如: $ df -h)\n"
|
||||
"6️⃣ 发送「> <按键内容>」\n"
|
||||
" 直接向 Mac mini 的 tmux (agy) 终端输入文字并回车\n"
|
||||
"7️⃣ 直接发送任意问题\n"
|
||||
" 自动调用 Mac mini AI 助手为你解答或执行任务"
|
||||
)
|
||||
self.send_text_message(from_user, reply, ctx_token, run_id)
|
||||
return
|
||||
|
||||
# 2. Telemetry Status
|
||||
if low in ("状态", "status", "/status", "监控"):
|
||||
if self.telemetry_fn:
|
||||
t = self.telemetry_fn()
|
||||
mini = t["mini"]
|
||||
nas = t["nas"]
|
||||
net = t["network"]
|
||||
reply = (
|
||||
f"🍏 HomeLab 实时状态 ({t['timestamp']})\n"
|
||||
f"━━━━━━━━━━━━━━\n"
|
||||
f"🖥 M6 Mac mini ({mini['ip']})\n"
|
||||
f"• CPU 负载: {mini['cpu_pct']}% (1m: {mini['load_1m']})\n"
|
||||
f"• 内存占用: {mini['mem_used_gb']} / {mini['mem_total_gb']} GB ({mini['mem_pct']}%)\n"
|
||||
f"• SSD 占用: {mini['disk_used_gb']} / {mini['disk_total_gb']} GB ({mini['disk_pct']}%)\n"
|
||||
f"• 在线时长: {mini['uptime']}\n"
|
||||
f"• AGY 终端: {'🟢 常驻运行中' if mini['agy_tmux'] else '⚪ 待机'}\n\n"
|
||||
f"💾 极空间 Z4 ({nas['ip']})\n"
|
||||
f"• 可用余量: {nas['free_tb']} TB / {nas['total_tb']} TB\n"
|
||||
f"• 最近灾备: {nas['latest_backup']}\n\n"
|
||||
f"🌐 网络与 AI 算力专线\n"
|
||||
f"• 家庭公网 IP: {net['home_wan_ip']}\n"
|
||||
f"• AI 分流策略: {net['ai_policy']}\n"
|
||||
f"• 狮城节点延迟: {net['sg_delay_ms']} ms\n"
|
||||
f"• 免端口中控台: https://nav.961504.xyz"
|
||||
)
|
||||
else:
|
||||
reply = "状态采集接口未就绪"
|
||||
self.send_text_message(from_user, reply, ctx_token, run_id)
|
||||
return
|
||||
|
||||
# 3. Backup to ZSpace NAS
|
||||
if low in ("备份", "backup", "/backup"):
|
||||
out = self._exec_shell("/Users/ixvii0iv/homelab/scripts/backup-to-nas.sh", timeout=20.0)
|
||||
self.send_text_message(from_user, f"✅ 极空间 Z4 灾备执行完毕:\n{out or '已完成备份'}", ctx_token, run_id)
|
||||
return
|
||||
|
||||
# 4. Refresh DDNS
|
||||
if low in ("刷新", "ddns", "/ddns"):
|
||||
out = self._exec_shell("/Users/ixvii0iv/homelab/scripts/cloudflare-ddns.sh", timeout=15.0)
|
||||
self.send_text_message(from_user, f"✅ Cloudflare DDNS 刷新结果:\n{out or '已同步最新公网 IP'}", ctx_token, run_id)
|
||||
return
|
||||
|
||||
# 5. Capture tmux screen
|
||||
if low in ("屏幕", "screen", "/screen", "tmux"):
|
||||
out = self._exec_shell("/opt/homebrew/bin/tmux capture-pane -t agy -p | tail -n 25", timeout=5.0)
|
||||
self.send_text_message(
|
||||
from_user,
|
||||
f"🖥 AGY 终端 (tmux: agy) 最新画面:\n━━━━━━━━━━━━━━\n{out or '(空)'}",
|
||||
ctx_token,
|
||||
run_id,
|
||||
)
|
||||
return
|
||||
|
||||
# 6. Direct Shell Command ($ ...)
|
||||
if cmd.startswith("$"):
|
||||
shell_cmd = cmd[1:].strip()
|
||||
if not shell_cmd:
|
||||
self.send_text_message(from_user, "请在 $ 后输入要执行的命令,例如:$ df -h", ctx_token, run_id)
|
||||
return
|
||||
out = self._exec_shell(shell_cmd, timeout=25.0)
|
||||
if len(out) > 3500:
|
||||
out = out[-3500:] + "\n...(已截断)"
|
||||
self.send_text_message(from_user, f"💻 终端执行结果 ($ {shell_cmd}):\n━━━━━━━━━━━━━━\n{out or '(无输出)'}", ctx_token, run_id)
|
||||
return
|
||||
|
||||
# 7. Inject keys into tmux agy session (> ...)
|
||||
if cmd.startswith(">"):
|
||||
keys = cmd[1:].strip()
|
||||
safe_keys = keys.replace('"', '\\"')
|
||||
self._exec_shell(f'/opt/homebrew/bin/tmux send-keys -t agy "{safe_keys}" C-m', timeout=5.0)
|
||||
time.sleep(2.5)
|
||||
out = self._exec_shell("/opt/homebrew/bin/tmux capture-pane -t agy -p | tail -n 25", timeout=5.0)
|
||||
self.send_text_message(
|
||||
from_user,
|
||||
f"⌨️ 已向 AGY 终端发送指令并捕获最新画面:\n━━━━━━━━━━━━━━\n{out or '(已发送)'}",
|
||||
ctx_token,
|
||||
run_id,
|
||||
)
|
||||
return
|
||||
|
||||
# 7b. One-step Google OAuth Code injection from WeChat (e.g. "4/0A..." or "授权 4/0A...")
|
||||
if cmd.startswith("4/") or low.startswith("授权 ") or low.startswith("auth "):
|
||||
auth_code = cmd.split(None, 1)[-1].strip() if (" " in cmd) else cmd.strip()
|
||||
safe_code = auth_code.replace('"', '\\"')
|
||||
self._exec_shell(f'/opt/homebrew/bin/tmux send-keys -t agy "{safe_code}" C-m', timeout=5.0)
|
||||
time.sleep(5.0)
|
||||
out = self._exec_shell("/opt/homebrew/bin/tmux capture-pane -t agy -p | tail -n 20", timeout=5.0)
|
||||
self.send_text_message(
|
||||
from_user,
|
||||
f"🔐 已将 Google 授权码注入 Mac mini 的 AGY 终端!\n"
|
||||
f"已挂载当前会话 ID: {DEFAULT_AGY_CONV_ID}\n"
|
||||
f"━━━━━━━━━━━━━━\n{out or '(授权处理中)'}\n\n"
|
||||
f"现在你可以直接在微信里和我继续刚才的对话了!",
|
||||
ctx_token,
|
||||
run_id,
|
||||
)
|
||||
return
|
||||
|
||||
# 8. Natural language -> Resume exact migrated session (6293ebb4-df60-4db6-bf5d-df33433ba106) on Mac mini!
|
||||
agy_out = self._exec_shell(
|
||||
f'cd /Users/ixvii0iv/homelab && /Users/ixvii0iv/.local/bin/agy --conversation {DEFAULT_AGY_CONV_ID} --dangerously-skip-permissions -p {json.dumps(cmd)}',
|
||||
timeout=120.0,
|
||||
)
|
||||
if agy_out and "authentication required" not in agy_out.lower():
|
||||
if len(agy_out) > 3800:
|
||||
agy_out = agy_out[:3800] + "\n...(内容较长,完整输出请查看 https://agy.961504.xyz)"
|
||||
self.send_text_message(from_user, agy_out, ctx_token, run_id)
|
||||
else:
|
||||
oauth_url = (
|
||||
"https://accounts.google.com/o/oauth2/auth?access_type=offline"
|
||||
"&client_id=1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com"
|
||||
"&code_challenge=6R2ML1GiPTslGESIIdY7kBlWPGi3Z7J_SUKiEb-wVc0&code_challenge_method=S256"
|
||||
"&prompt=consent&redirect_uri=https%3A%2F%2Fantigravity.google%2Foauth-callback"
|
||||
"&response_type=code&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcloud-platform+"
|
||||
"https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+"
|
||||
"https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile+"
|
||||
"https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fcclog+"
|
||||
"https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fexperimentsandconfigs+"
|
||||
"https%3A%2F%2Fwww.googleapis.com%2Fauth%2Faicode+openid&state=EHQdZm-HmDWTKGqBFxqf1g"
|
||||
)
|
||||
self.send_text_message(
|
||||
from_user,
|
||||
f"🤖 当前会话 ({DEFAULT_AGY_CONV_ID[:8]}...) 已同步至 Mac mini!\n\n"
|
||||
"🔐 只需最后一步激活 Mac mini 的 Google AI 登录态:\n"
|
||||
f"1️⃣ 点击打开此链接登录 Google:\n{oauth_url}\n\n"
|
||||
"2️⃣ 复制页面生成的授权码(以 4/ 开头),直接发到这个微信对话框里,即可在微信无缝接力我们当前的完整会话!",
|
||||
ctx_token,
|
||||
run_id,
|
||||
)
|
||||
|
||||
def _exec_shell(self, cmd: str, timeout: float = 15.0) -> str:
|
||||
try:
|
||||
env = os.environ.copy()
|
||||
env["PATH"] = "/Users/ixvii0iv/.local/bin:/Users/ixvii0iv/.orbstack/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
env["HOME"] = "/Users/ixvii0iv"
|
||||
env["LANG"] = "en_US.UTF-8"
|
||||
res = subprocess.run(
|
||||
cmd, shell=True, capture_output=True, text=True, timeout=timeout, env=env
|
||||
)
|
||||
out = (res.stdout or "") + ("\n" + res.stderr if res.stderr else "")
|
||||
return out.strip()
|
||||
except Exception as e:
|
||||
return f"执行超时或异常: {e}"
|
||||
@@ -0,0 +1 @@
|
||||
/*! tailwindcss v4.3.5 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:block))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-divide-x-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-6xl:72rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height: 1.5 ;--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wider:.05em;--tracking-widest:.1em;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--animate-spin:spin 1s linear infinite;--blur-sm:8px;--blur-md:12px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;line-height:1.5;font-family:var(--default-font-family,-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring:where(:not(iframe)){outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){Line truncated
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
var p0=i=>{throw TypeError(i)};var b0=(i,u,c)=>u.has(i)||p0("Cannot "+c);var St=(i,u,c)=>(b0(i,u,"read from private field"),c?c.call(i):u.get(i)),x0=(i,u,c)=>u.has(i)?p0("Cannot add the same private member more than once"):u instanceof WeakSet?u.add(i):u.set(i,c),cf=(i,u,c,o)=>(b0(i,u,"write to private field"),o?o.call(i,c):u.set(i,c),c);function X2(i,u){for(var c=0;c<u.length;c++){const o=u[c];if(typeof o!="string"&&!Array.isArray(o)){for(const s in o)if(s!=="default"&&!(s in i)){const d=Object.getOwnPropertyDescriptor(o,s);d&&Object.defineProperty(i,s,d.get?d:{enumerable:!0,get:()=>o[s]})}}}return Object.freeze(Object.defineProperty(i,Symbol.toStringTag,{value:"Module"}))}(function(){const u=document.createElement("link").relList;if(u&&u.supports&&u.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))o(s);new MutationObserver(s=>{for(const d of s)if(d.type==="childList")for(const h of d.addedNodes)h.tagName==="LINK"&&h.rel==="modulepreload"&&o(h)}).observe(document,{childList:!0,subtree:!0});function c(s){const d={};return s.integrity&&(d.integrity=s.integrity),s.referrerPolicy&&(d.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?d.credentials="include":s.crossOrigin==="anonymous"?d.credentials="omit":d.credentials="same-origin",d}function o(s){if(s.ep)return;s.ep=!0;const d=c(s);fetch(s.href,d)}})();function gd(i){return i&&i.__esModule&&Object.prototype.hasOwnProperty.call(i,"default")?i.default:i}var sf={exports:{}},fr={};/**
|
||||
* @license React
|
||||
* react-jsx-runtime.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var S0;function Z2(){if(S0)return fr;S0=1;var i=Symbol.for("react.transitional.element"),u=Symbol.for("react.fragment");function c(o,s,d){var h=null;if(d!==void 0&&(h=""+d),s.key!==void 0&&(h=""+s.key),"key"in s){d={};for(var v in s)v!=="key"&&(d[v]=s[v])}else d=s;return s=d.ref,{$$typeof:i,type:o,key:h,ref:s!==void 0?s:null,props:d}}return fr.Fragment=u,fr.jsx=c,fr.jsxs=c,fr}var N0;function K2(){return N0||(N0=1,sf.exports=Z2()),sf.exports}var f=K2(),ff={exports:{}},be={};/**
|
||||
* @license React
|
||||
* react.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var C0;function I2(){if(C0)return be;C0=1;var i=Symbol.for("react.transitional.element"),u=Symbol.for("react.portal"),c=Symbol.for("react.fragment"),o=Symbol.for("react.strict_mode"),s=Symbol.for("react.profiler"),d=Symbol.for("react.consumer"),h=Symbol.for("react.context"),v=Symbol.for("react.forward_ref"),g=Symbol.for("react.suspense"),x=Symbol.for("react.memo"),S=Symbol.for("react.lazy"),p=Symbol.for("react.activity"),N=Symbol.for("react.view_transition"),A=Symbol.iterator;function j(w){return w===null||typeof w!="object"?null:(w=A&&w[A]||w["@@iterator"],typeof w=="function"?w:null)}var z={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},H=Object.assign,q={};function G(w,U,$){this.props=w,this.context=U,this.refs=q,this.updater=$||z}G.prototype.isReactComponent={},G.prototype.setState=function(w,U){if(typeof w!="object"&&typeof w!="function"&&w!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,w,U,"setState")},G.prototype.forceUpdate=function(w){this.updater.enqueueForceUpdate(this,w,"forceUpdate")};function k(){}k.prototype=G.prototype;function V(w,U,$){this.props=w,this.context=U,this.refs=q,this.updater=$||z}var K=V.prototype=new k;K.constructor=V,H(K,G.prototype),K.isPureReactComponent=!0;var _=Array.isArray;function E(){}var R={H:null,A:null,T:null,S:null},Q=Object.prototype.hasOwnProperty;function te(w,U,$){var ee=$.ref;return{$$typeof:i,type:w,key:U,ref:ee!==void 0?ee:null,props:$}}function W(w,U){return te(w.type,U,w.props)}function P(w){return typeof w=="object"&&w!==null&&w.$$typeof===i}function ne(w){var U={"=":"=0",":":"=2"};return"$"+w.replace(/[=:]/g,function($){return U[$]})}var re=/\/+/g;function ge(w,U){return typeof w=="object"&&w!==null&&w.key!=null?ne(""+w.key):U.toString(36)}function F(w){switch(w.status){case"fulfilled":return w.value;case"rejected":throw w.reason;default:switch(typeof w.status=="string"?w.then(E,E):(w.status="pending",w.then(function(U){w.status==="pending"&&(w.status="fulfilled",w.value=U)},function(U){w.status==="pending"&&(w.status="rejected",w.reason=U)})),w.status){case"fulfilled":return w.value;case"rejected":throw w.reason}}throw w}function ae(w,U,$,ee,he){var ie=typeof w;(ie==="undefined"||ie==="boolean")&&(w=null);var Ne=!1;if(w===null)Ne=!0;else switch(ie){case"bigint":case"string":case"number":Ne=!0;break;case"object":switch(w.$$typeof){case i:case u:Ne=!0;break;case S:return Ne=w._init,ae(Ne(w._payload),U,$,ee,he)}}if(Ne)return he=he(w),Ne=ee===""?"."+ge(w,0):ee,_(he)?($="",Ne!=null&&($=Ne.replace(re,"$&/")+"/"),ae(he,U,$,"",function(Ut){return Ut})):he!=null&&(P(he)&&(he=W(he,$+(he.key==null||w&&w.key===he.key?"":(""+he.key).replace(re,"$&/")+"/")+Ne)),U.push(he)),1;Ne=0;var fe=ee===""?".":ee+":";if(_(w))for(var ye=0;ye<w.length;ye++)ee=w[ye],ie=fe+ge(ee,ye),Ne+=ae(ee,U,$,ie,he);else if(ye=j(w),typeof ye=="function")for(w=ye.call(w),ye=0;!(ee=w.next()).done;)ee=ee.value,ie=fe+ge(ee,ye++),Ne+=ae(ee,U,$,ie,he);else if(ie==="object"){if(typeof w.then=="function")return ae(F(w),U,$,ee,he);throw U=String(w),Error("Objects are not valid as a React child (found: "+(U==="[object Object]"?"object with keys {"+Object.keys(w).join(", ")+"}":U)+"). If you meant to render a collection of children, use an array instead.")}return Ne}function ce(w,U,$){if(w==null)return w;var ee=[],he=0;return ae(w,ee,"","",function(ie){return U.call($,ie,he++)}),ee}function xe(w){if(w._status===-1){var U=w._result,$=U();$.then(function(ee){(w._status===0||w._status===-1)&&(w._status=1,w._result=ee,$.status===void 0&&($.status="fulfilled",$.value=ee))},function(ee){(w._status===0||w._status===-1)&&(w._status=2,w._result=ee,$.status===void 0&&($.status="rejected",$.reason=ee))}),w._status===-1&&(w._status=0,w._result=$)}if(w._status===1)return w._result.default;throw w._result}var I=typeof reportError=="function"?reportError:function(w){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var U=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof w=="object"&&w!==null&&typeof w.message=="string"?String(w.message):String(w),error:w});if(!window.dispatchEvent(U))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",w);return}console.error(w)};function Se(w){var U=R.T,$={};$.types=U!==null?U.types:null,R.T=$;try{var ee=w(),he=R.S;he!==null&&he($,ee),typeof ee=="object"&&ee!==null&&typeof ee.then=="function"&&ee.then(E,I)}catch(ie){I(ie)}finally{U!==null&&$.types!==null&&(U.types=$.types),R.T=U}}function J(w){var U=R.T;if(U!==null){var $=U.types;$===null?U.types=[w]:$.indexOf(w)===-1&&$.push(w)}else Se(J.bind(null,w))}var me={map:ce,forEach:function(w,U,$){ce(w,function(){U.apply(this,arguments)},$)},count:function(w){var U=0;return ce(w,function(){U++}),U},toArray:function(w){return ce(w,function(U){return U})||[]},only:Line truncated
|
||||
* @license React
|
||||
* scheduler.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var w0;function J2(){return w0||(w0=1,(function(i){function u(F,ae){var ce=F.length;F.push(ae);e:for(;0<ce;){var xe=ce-1>>>1,I=F[xe];if(0<s(I,ae))F[xe]=ae,F[ce]=I,ce=xe;else break e}}function c(F){return F.length===0?null:F[0]}function o(F){if(F.length===0)return null;var ae=F[0],ce=F.pop();if(ce!==ae){F[0]=ce;e:for(var xe=0,I=F.length,Se=I>>>1;xe<Se;){var J=2*(xe+1)-1,me=F[J],w=J+1,U=F[w];if(0>s(me,ce))w<I&&0>s(U,me)?(F[xe]=U,F[w]=ce,xe=w):(F[xe]=me,F[J]=ce,xe=J);else if(w<I&&0>s(U,ce))F[xe]=U,F[w]=ce,xe=w;else break e}}return ae}function s(F,ae){var ce=F.sortIndex-ae.sortIndex;return ce!==0?ce:F.id-ae.id}if(i.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var d=performance;i.unstable_now=function(){return d.now()}}else{var h=Date,v=h.now();i.unstable_now=function(){return h.now()-v}}var g=[],x=[],S=1,p=null,N=3,A=!1,j=!1,z=!1,H=!1,q=typeof setTimeout=="function"?setTimeout:null,G=typeof clearTimeout=="function"?clearTimeout:null,k=typeof setImmediate<"u"?setImmediate:null;function V(F){for(var ae=c(x);ae!==null;){if(ae.callback===null)o(x);else if(ae.startTime<=F)o(x),ae.sortIndex=ae.expirationTime,u(g,ae);else break;ae=c(x)}}function K(F){if(z=!1,V(F),!j)if(c(g)!==null)j=!0,_||(_=!0,P());else{var ae=c(x);ae!==null&&ge(K,ae.startTime-F)}}var _=!1,E=-1,R=5,Q=-1;function te(){return H?!0:!(i.unstable_now()-Q<R)}function W(){if(H=!1,_){var F=i.unstable_now();Q=F;var ae=!0;try{e:{j=!1,z&&(z=!1,G(E),E=-1),A=!0;var ce=N;try{t:{for(V(F),p=c(g);p!==null&&!(p.expirationTime>F&&te());){var xe=p.callback;if(typeof xe=="function"){p.callback=null,N=p.priorityLevel;var I=xe(p.expirationTime<=F);if(F=i.unstable_now(),typeof I=="function"){p.callback=I,V(F),ae=!0;break t}p===c(g)&&o(g),V(F)}else o(g);p=c(g)}if(p!==null)ae=!0;else{var Se=c(x);Se!==null&&ge(K,Se.startTime-F),ae=!1}}break e}finally{p=null,N=ce,A=!1}ae=void 0}}finally{ae?P():_=!1}}}var P;if(typeof k=="function")P=function(){k(W)};else if(typeof MessageChannel<"u"){var ne=new MessageChannel,re=ne.port2;ne.port1.onmessage=W,P=function(){re.postMessage(null)}}else P=function(){q(W,0)};function ge(F,ae){E=q(function(){F(i.unstable_now())},ae)}i.unstable_IdlePriority=5,i.unstable_ImmediatePriority=1,i.unstable_LowPriority=4,i.unstable_NormalPriority=3,i.unstable_Profiling=null,i.unstable_UserBlockingPriority=2,i.unstable_cancelCallback=function(F){F.callback=null},i.unstable_forceFrameRate=function(F){0>F||125<F?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):R=0<F?Math.floor(1e3/F):5},i.unstable_getCurrentPriorityLevel=function(){return N},i.unstable_next=function(F){switch(N){case 1:case 2:case 3:var ae=3;break;default:ae=N}var ce=N;N=ae;try{return F()}finally{N=ce}},i.unstable_requestPaint=function(){H=!0},i.unstable_runWithPriority=function(F,ae){switch(F){case 1:case 2:case 3:case 4:case 5:break;default:F=3}var ce=N;N=F;try{return ae()}finally{N=ce}},i.unstable_scheduleCallback=function(F,ae,ce){var xe=i.unstable_now();switch(typeof ce=="object"&&ce!==null?(ce=ce.delay,ce=typeof ce=="number"&&0<ce?xe+ce:xe):ce=xe,F){case 1:var I=-1;break;case 2:I=250;break;case 5:I=1073741823;break;case 4:I=1e4;break;default:I=5e3}return I=ce+I,F={id:S++,callback:ae,priorityLevel:F,startTime:ce,expirationTime:I,sortIndex:-1},ce>xe?(F.sortIndex=ce,u(x,F),c(g)===null&&F===c(x)&&(z?(G(E),E=-1):z=!0,ge(K,ce-xe))):(F.sortIndex=I,u(g,F),j||A||(j=!0,_||(_=!0,P()))),F},i.unstable_shouldYield=te,i.unstable_wrapCallback=function(F){var ae=N;return function(){var ce=N;N=ae;try{return F.apply(this,arguments)}finally{N=ce}}}})(hf)),hf}var T0;function F2(){return T0||(T0=1,mf.exports=J2()),mf.exports}var vf={exports:{}},Nt={};/**
|
||||
* @license React
|
||||
* react-dom.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var j0;function P2(){if(j0)return Nt;j0=1;var i=yd();function u(S){var p="https://react.dev/errors/"+S;if(1<arguments.length){p+="?args[]="+encodeURIComponent(arguments[1]);for(var N=2;N<arguments.length;N++)p+="&args[]="+encodeURIComponent(arguments[N])}return"Minified React error #"+S+"; visit "+p+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function c(){}var o={d:{f:c,r:function(){throw Error(u(522))},D:c,C:c,L:c,m:c,X:c,S:c,M:c},p:0,findDOMNode:null},s=Symbol.for("react.portal"),d=Symbol.for("react.recoverable"),h=Symbol.for("react.optimistic_key");function v(S,p,N){var A=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:s,key:A==null?null:A===h?h:""+A,children:S,containerInfo:p,implementation:N}}var g=i.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function x(S,p){if(S==="font")return"";if(typeof p=="string")return p==="use-credentials"?p:""}return Nt.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=o,Nt.browser=function(S){return{$$typeof:d,_reason:S}},Nt.createPortal=function(S,p){var N=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!p||p.nodeType!==1&&p.nodeType!==9&&p.nodeType!==11)throw Error(u(299));return v(S,p,null,N)},Nt.flushSync=function(S){var p=g.T,N=o.p;try{if(g.T=null,o.p=2,S)return S()}finally{g.T=p,o.p=N,o.d.f()}},Nt.preconnect=function(S,p){typeof S=="string"&&(p?(p=p.crossOrigin,p=typeof p=="string"?p==="use-credentials"?p:"":void 0):p=null,o.d.C(S,p))},Nt.prefetchDNS=function(S){typeof S=="string"&&o.d.D(S)},Nt.preinit=function(S,p){if(typeof S=="string"&&p&&typeof p.as=="string"){var N=p.as,A=x(N,p.crossOrigin),j=typeof p.integrity=="string"?p.integrity:void 0,z=typeof p.fetchPriority=="string"?p.fetchPriority:void 0;N==="style"?o.d.S(S,typeof p.precedence=="string"?p.precedence:void 0,{crossOrigin:A,integrity:j,fetchPriority:z}):N==="script"&&o.d.X(S,{crossOrigin:A,integrity:j,fetchPriority:z,nonce:typeof p.nonce=="string"?p.nonce:void 0})}},Nt.preinitModule=function(S,p){if(typeof S=="string")if(typeof p=="object"&&p!==null){if(p.as==null||p.as==="script"){var N=x(p.as,p.crossOrigin);o.d.M(S,{crossOrigin:N,integrity:typeof p.integrity=="string"?p.integrity:void 0,nonce:typeof p.nonce=="string"?p.nonce:void 0,fetchPriority:typeof p.fetchPriority=="string"?p.fetchPriority:void 0})}}else p==null&&o.d.M(S)},Nt.preload=function(S,p){if(typeof S=="string"&&typeof p=="object"&&p!==null&&typeof p.as=="string"){var N=p.as,A=x(N,p.crossOrigin);o.d.L(S,N,{crossOrigin:A,integrity:typeof p.integrity=="string"?p.integrity:void 0,nonce:typeof p.nonce=="string"?p.nonce:void 0,type:typeof p.type=="string"?p.type:void 0,fetchPriority:typeof p.fetchPriority=="string"?p.fetchPriority:void 0,referrerPolicy:typeof p.referrerPolicy=="string"?p.referrerPolicy:void 0,imageSrcSet:typeof p.imageSrcSet=="string"?p.imageSrcSet:void 0,imageSizes:typeof p.imageSizes=="string"?p.imageSizes:void 0,media:typeof p.media=="string"?p.media:void 0})}},Nt.preloadModule=function(S,p){if(typeof S=="string")if(p){var N=x(p.as,p.crossOrigin);o.d.m(S,{as:typeof p.as=="string"&&p.as!=="script"?p.as:void 0,crossOrigin:N,integrity:typeof p.integrity=="string"?p.integrity:void 0,nonce:typeof p.nonce=="string"?p.nonce:void 0,fetchPriority:typeof p.fetchPriority=="string"?p.fetchPriority:void 0})}else o.d.m(S)},Nt.requestFormReset=function(S){o.d.r(S)},Nt.unstable_batchedUpdates=function(S,p){return S(p)},Nt.useFormState=function(S,p,N){return g.H.useFormState(S,p,N)},Nt.useFormStatus=function(){return g.H.useHostTransitionStatus()},Nt.version="19.3.1",Nt}var _0;function Uy(){if(_0)return vf.exports;_0=1;function i(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(i)}catch(u){console.error(u)}}return i(),vf.exports=P2(),vf.exports}/**
|
||||
* @license React
|
||||
* react-dom-client.production.js
|
||||
*
|
||||
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/var A0;function $2(){if(A0)return dr;A0=1;var i=F2(),u=yd(),c=Uy();function o(e){var t="https://react.dev/errors/"+e;if(1<arguments.length){t+="?args[]="+encodeURIComponent(arguments[1]);for(var n=2;n<arguments.length;n++)t+="&args[]="+encodeURIComponent(arguments[n])}return"Minified React error #"+e+"; visit "+t+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function s(e){return!(!e||e.nodeType!==1&&e.nodeType!==9&&e.nodeType!==11)}function d(e){for(var t=e,n=t;n&&!n.alternate;)t=n,(t.flags&4098)!==0&&(e=t.return),n=t.return;for(;t.return;)t=t.return;return t.tag===3?e:null}function h(e){if(e.tag===13){var t=e.memoizedState;if(t===null&&(e=e.alternate,e!==null&&(t=e.memoizedState)),t!==null)return t.dehydrated}return null}function v(e){if(e.tag===31){var t=e.memoizedState;if(t===null&&(e=e.alternate,e!==null&&(t=e.memoizedState)),t!==null)return t.dehydrated}return null}function g(e){if(d(e)!==e)throw Error(o(188))}function x(e){var t=e.alternate;if(!t){if(t=d(e),t===null)throw Error(o(188));return t!==e?null:e}for(var n=e,l=t;;){var a=n.return;if(a===null)break;var r=a.alternate;if(r===null){if(l=a.return,l!==null){n=l;continue}break}if(a.child===r.child){for(r=a.child;r;){if(r===n)return g(a),e;if(r===l)return g(a),t;r=r.sibling}throw Error(o(188))}if(n.return!==l.return)n=a,l=r;else{for(var m=!1,y=a.child;y;){if(y===n){m=!0,n=a,l=r;break}if(y===l){m=!0,l=a,n=r;break}y=y.sibling}if(!m){for(y=r.child;y;){if(y===n){m=!0,n=r,l=a;break}if(y===l){m=!0,l=r,n=a;break}y=y.sibling}if(!m)throw Error(o(189))}}if(n.alternate!==l)throw Error(o(190))}if(n.tag!==3)throw Error(o(188));return n.stateNode.current===n?e:t}function S(e){var t=e.tag;if(t===5||t===26||t===27||t===6)return e;for(e=e.child;e!==null;){if(t=S(e),t!==null)return t;e=e.sibling}return null}function p(e,t,n,l,a,r){for(;e!==null;){if((e.tag===5||e.tag===27||e.tag===6)&&n(e,l,a,r)||(e.tag!==22||e.memoizedState===null)&&(t||e.tag!==5&&e.tag!==27)&&p(e.child,t,n,l,a,r))return!0;e=e.sibling}return!1}function N(e){for(e=e.return;e!==null;){if(e.tag===3||e.tag===5||e.tag===27)return e;e=e.return}return null}function A(e){var t=!1;for(e=e.return;e!==null&&(e.tag===4&&(t=!0),!(e.tag===3||e.tag===5||e.tag===27));)e=e.return;return t}function j(e){var t=[null,null],n=N(e);return n===null||z(t,e,n.child,{foundSelf:!1}),t}function z(e,t,n,l){for(;n!==null;){if(n===t)l.foundSelf=!0;else if(n.tag===5||n.tag===27||n.tag===6){if(l.foundSelf)return e[1]=n,!0;e[0]=n}else if((n.tag!==22||n.memoizedState===null)&&z(e,t,n.child,l))return!0;n=n.sibling}return!1}function H(e){switch(e.tag){case 5:case 27:case 6:return e.stateNode;case 3:return e.stateNode.containerInfo;default:throw Error(o(559))}}var q=null,G=null;function k(e,t,n){return e===n?!0:e===t?(q=e,!0):!1}function V(e,t,n){return e===n?(G=e,!1):e===t?(G!==null&&(q=e),!0):!1}function K(e){if(e===null)return null;do e=e===null?null:e.return;while(e&&e.tag!==5&&e.tag!==27&&e.tag!==3);return e||null}function _(e,t,n){for(var l=0,a=e;a;a=n(a))l++;a=0;for(var r=t;r;r=n(r))a++;for(;0<l-a;)e=n(e),l--;for(;0<a-l;)t=n(t),a--;for(;l--;){if(e===t||t!==null&&e===t.alternate)return e;e=n(e),t=n(t)}return null}var E=Object.assign,R=Symbol.for("react.element"),Q=Symbol.for("react.transitional.element"),te=Symbol.for("react.portal"),W=Symbol.for("react.fragment"),P=Symbol.for("react.strict_mode"),ne=Symbol.for("react.profiler"),re=Symbol.for("react.consumer"),ge=Symbol.for("react.context"),F=Symbol.for("react.forward_ref"),ae=Symbol.for("react.suspense"),ce=Symbol.for("react.suspense_list"),xe=Symbol.for("react.memo"),I=Symbol.for("react.lazy"),Se=Symbol.for("react.activity"),J=Symbol.for("react.legacy_hidden"),me=Symbol.for("react.memo_cache_sentinel"),w=Symbol.for("react.view_transition"),U=Symbol.for("react.recoverable"),$=Symbol.iterator;function ee(e){return e===null||typeof e!="object"?null:(e=$&&e[$]||e["@@iterator"],typeof e=="function"?e:null)}var he=Symbol.for("react.client.reference");function ie(e){if(e==null)return null;if(typeof e=="function")return e.$$typeof===he?null:e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case W:return"Fragment";case ne:return"Profiler";case P:return"StrictMode";case ae:return"Suspense";case ce:return"SuspenseList";case Se:return"Activity";case w:return"ViewTransition"}if(typeof e=="object")switch(e.$$typeof){case te:return"Portal";case ge:return e.displayName||"Context";case re:return(e._context.displayName||"Context")+".Consumer";case F:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case xe:return t=e.displayName||null,t!==null?t:ie(e.type)||"Memo";case I:t=e._payload,e=e._init;try{return ie(e(t))}catch{}}return null}var Ne=Array.isArray,fe=u.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,ye=c.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,Ut={pending:!1,data:null,method:null,action:null},tl=[],ga=-Line truncated
|
||||
at`)?" (<anonymous>)":-1<n.stack.indexOf("@")?"@unknown:0:0":""}return`
|
||||
`+Ao+e+Id}var Ro=!1;function Mo(e,t){if(!e||Ro)return"";Ro=!0;var n=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var l={DetermineComponentFrameRoot:function(){try{if(t){var Z=function(){throw Error()};if(Object.defineProperty(Z.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(Z,[])}catch(le){var M=le}Reflect.construct(e,[],Z)}else{try{Z.call()}catch(le){M=le}Z=!1;try{var L=Object.getOwnPropertyDescriptor(e.prototype,"props");Object.defineProperty(e.prototype,"props",{configurable:!0,set:function(){throw Error()}}),Z=!0,new e}finally{Z&&(L!==void 0?Object.defineProperty(e.prototype,"props",L):delete e.prototype.props)}}}else{try{throw Error()}catch(le){M=le}(Z=e())&&typeof Z.catch=="function"&&Z.catch(function(){})}}catch(le){if(le&&M&&typeof le.stack=="string")return[le.stack,M.stack]}return[null,null]}};l.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var a=Object.getOwnPropertyDescriptor(l.DetermineComponentFrameRoot,"name");a&&a.configurable&&Object.defineProperty(l.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var r=l.DetermineComponentFrameRoot(),m=r[0],y=r[1];if(m&&y){var C=m.split(`
|
||||
`),D=y.split(`
|
||||
`);for(a=l=0;l<C.length&&!C[l].includes("DetermineComponentFrameRoot");)l++;for(;a<D.length&&!D[a].includes("DetermineComponentFrameRoot");)a++;if(l===C.length||a===D.length)for(l=C.length-1,a=D.length-1;1<=l&&0<=a&&C[l]!==D[a];)a--;for(;1<=l&&0<=a;l--,a--)if(C[l]!==D[a]){if(l!==1||a!==1)do if(l--,a--,0>a||C[l]!==D[a]){var Y=`
|
||||
`+C[l].replace(" at new "," at ");return e.displayName&&Y.includes("<anonymous>")&&(Y=Y.replace("<anonymous>",e.displayName)),Y}while(1<=l&&0<=a);break}}}finally{Ro=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:"")?ll(n):""}function Jp(e,t){switch(e.tag){case 26:case 27:case 5:return ll(e.type);case 16:return ll("Lazy");case 13:return e.child!==t&&t!==null?ll("Suspense Fallback"):ll("Suspense");case 19:return ll("SuspenseList");case 0:case 15:return Mo(e.type,!1);case 11:return Mo(e.type.render,!1);case 1:return Mo(e.type,!0);case 31:return ll("Activity");case 30:return ll("ViewTransition");default:return""}}function Jd(e){try{var t="",n=null;do t+=Jp(e,n),n=e,e=e.return;while(e);return t}catch(l){return`
|
||||
Error generating stack: `+l.message+`
|
||||
`+l.stack}}var Oo=Object.prototype.hasOwnProperty,zo=i.unstable_scheduleCallback,Do=i.unstable_cancelCallback,Fp=i.unstable_shouldYield,Pp=i.unstable_requestPaint,Bt=i.unstable_now,$p=i.unstable_getCurrentPriorityLevel,Fd=i.unstable_ImmediatePriority,Pd=i.unstable_UserBlockingPriority,Tr=i.unstable_NormalPriority,Wp=i.unstable_LowPriority,$d=i.unstable_IdlePriority,eb=i.log,tb=i.unstable_setDisableYieldValue,pi=null,kt=null;function al(e){if(typeof eb=="function"&&tb(e),kt&&typeof kt.setStrictMode=="function")try{kt.setStrictMode(pi,e)}catch{}}var Lt=Math.clz32?Math.clz32:ab,nb=Math.log,lb=Math.LN2;function ab(e){return e>>>=0,e===0?32:31-(nb(e)/lb|0)|0}var jr=256,_r=262144,Ar=4194304;function kl(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&-e;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function Rr(e,t,n){var l=e.pendingLanes;if(l===0)return 0;var a=0,r=e.suspendedLanes,m=e.pingedLanes;e=e.warmLanes;var y=l&134217727;return y!==0?(l=y&~r,l!==0?a=kl(l):(m&=y,m!==0?a=kl(m):n||(n=y&~e,n!==0&&(a=kl(n))))):(y=l&~r,y!==0?a=kl(y):m!==0?a=kl(m):n||(n=l&~e,n!==0&&(a=kl(n)))),a===0?0:t!==0&&t!==a&&(t&r)===0&&(r=a&-a,n=t&-t,r>=n||r===32&&(n&4194048)!==0)?t:a}function bi(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function Wd(e,t){(t&8)!==0&&(t|=t&32);var n=e.entangledLanes;if(n!==0)for(e=e.entanglements,n&=t;0<n;){var l=31-Lt(n),a=1<<l;t|=e[l],n&=~a}return t}function ib(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function em(){var e=Ar;return Ar<<=1,(Ar&62914560)===0&&(Ar=4194304),e}function Uo(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function xi(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function rb(e,t,n,l,a,r){var m=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var y=e.entanglements,C=e.expirationTimes,D=e.hiddenUpdates;for(n=m&~n;0<n;){var Y=31-Lt(n),Z=1<<Y;y[Y]=0,C[Y]=-1;var M=D[Y];if(M!==null)for(D[Y]=null,Y=0;Y<M.length;Y++){var L=M[Y];L!==null&&(L.lane&=-536870913)}n&=~Z}l!==0&&tm(e,l,0),r!==0&&a===0&&e.tag!==0&&(e.suspendedLanes|=r&~(m&~t))}function tm(e,t,n){e.pendingLanes|=t,e.suspendedLanes&=~t;var l=31-Lt(t);e.entangledLanes|=t,e.entanglements[l]=e.entanglements[l]|1073741824|n&261930}function nm(e,t){var n=e.entangledLanes|=t;for(e=e.entanglements;n;){var l=31-Lt(n),a=1<<l;a&t|e[l]&t&&(e[l]|=t),n&=~a}}function lm(e,t){var n=t&-t;return n=(n&42)!==0?1:Bo(n),(n&(e.suspendedLanes|t))!==0?0:n}function Bo(e){switch(e){case 2:e=1;break;case 8:e=4;break;case 32:e=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:e=128;break;case 268435456:e=134217728;break;default:e=0}return e}function ko(e){return e&=-e,2<e?8<e?(e&134217727)!==0?32:268435456:8:2}function am(){var e=ye.p;return e!==0?e:(e=window.event,e===void 0?32:f0(e.type))}function im(e,t){var n=ye.p;try{return ye.p=e,t()}finally{ye.p=n}}var Bn=Math.random().toString(36).slice(2),vt="__reactFiber$"+Bn,_t="__reactProps$"+Bn,pa="__reactContainer$"+Bn,rm="__reactEvents$"+Bn,ub="__reactListeners$"+Bn,ob="__reactHandles$"+Bn,um="__reactResources$"+Bn,Si="__reactMarker$"+Bn,Mr="__reactLoad$"+Bn;function Or(e){delete e[vt],delete e[_t],delete e[ub],delete e[ob]}function Ll(e){var t;if(t=e[vt])return t;for(var n=e.parentNode;n;){if(t=n[pa]||n[vt]){if(n=t.alternate,t.child!==null||n!==null&&n.child!==null)for(e=Xg(e);e!==null;){if(n=e[vt])return n;e=Xg(e)}return t}e=n,n=e.parentNode}return null}function ba(e){if(e=e[vt]||e[pa]){var t=e.tag;if(t===5||t===6||t===13||t===31||t===26||t===27||t===3)return e}return null}function Ni(e){var t=e.tag;if(t===5||t===26||t===27||t===6)return e.stateNode;throw Error(o(33))}function xa(e){var t=e[um];return t||(t=e[um]={hoistableStyles:new Map,hoistableScripts:new Map}),t}function ot(e){e[Si]=!0}function om(e){e[Mr]=void 0}var cm=new Set,sm={};function Hl(e,t){Sa(e,t),Sa(e+"Capture",t)}funct
Line truncated
|
||||
`).replace(Jx,"")}function wg(e,t){return t=Eg(t),Eg(e)===t}function He(e,t,n,l,a,r){switch(n){case"children":if(typeof l=="string")t==="body"||t==="textarea"&&l===""||Ca(e,l);else if(typeof l=="number"||typeof l=="bigint")t!=="body"&&Ca(e,""+l);else return;break;case"className":Dr(e,"class",l);break;case"tabIndex":Dr(e,"tabindex",l);break;case"dir":case"role":case"viewBox":case"width":case"height":Dr(e,n,l);break;case"style":xm(e,l,r);return;case"data":if(t!=="object"){Dr(e,"data",l);break}case"src":case"href":if(l===""&&(t!=="a"||n!=="href")){e.removeAttribute(n);break}if(l==null||typeof l=="function"||typeof l=="symbol"||typeof l=="boolean"){e.removeAttribute(n);break}l=Ur(l),e.setAttribute(n,l);break;case"action":case"formAction":if(typeof l=="function"){e.setAttribute(n,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof r=="function"&&(n==="formAction"?(t!=="input"&&He(e,t,"name",a.name,a,null),He(e,t,"formEncType",a.formEncType,a,null),He(e,t,"formMethod",a.formMethod,a,null),He(e,t,"formTarget",a.formTarget,a,null)):(He(e,t,"encType",a.encType,a,null),He(e,t,"method",a.method,a,null),He(e,t,"target",a.target,a,null)));if(l==null||typeof l=="symbol"||typeof l=="boolean"){e.removeAttribute(n);break}l=Ur(l),e.setAttribute(n,l);break;case"onClick":l!=null&&(e.onclick=bn);return;case"onScroll":l!=null&&_e("scroll",e);return;case"onScrollEnd":l!=null&&_e("scrollend",e);return;case"dangerouslySetInnerHTML":if(l!=null){if(typeof l!="object"||!("__html"in l))throw Error(o(61));if(n=l.__html,n!=null){if(a.children!=null)throw Error(o(60));(r!=null?r.__html:void 0)!==n&&(e.innerHTML=n)}}break;case"multiple":e.multiple=l&&typeof l!="function"&&typeof l!="symbol";break;case"muted":e.muted=l&&typeof l!="function"&&typeof l!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(l==null||typeof l=="function"||typeof l=="boolean"||typeof l=="symbol"){e.removeAttribute("xlink:href");break}n=Ur(l),e.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",n);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":l!=null&&typeof l!="function"&&typeof l!="symbol"?e.setAttribute(n,l):e.removeAttribute(n);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"credentialless":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":l&&typeof l!="function"&&typeof l!="symbol"?e.setAttribute(n,""):e.removeAttribute(n);break;case"capture":case"download":l===!0?e.setAttribute(n,""):l!==!1&&l!=null&&typeof l!="function"&&typeof l!="symbol"?e.setAttribute(n,l):e.removeAttribute(n);break;case"cols":case"rows":case"size":case"span":l!=null&&typeof l!="function"&&typeof l!="symbol"&&!isNaN(l)&&1<=l?e.setAttribute(n,l):e.removeAttribute(n);break;case"rowSpan":case"start":l==null||typeof l=="function"||typeof l=="symbol"||isNaN(l)?e.removeAttribute(n):e.setAttribute(n,l);break;case"popover":_e("beforetoggle",e),_e("toggle",e),zr(e,"popover",l);break;case"xlinkActuate":kn(e,"http://www.w3.org/1999/xlink","xlink:actuate",l);break;case"xlinkArcrole":kn(e,"http://www.w3.org/1999/xlink","xlink:arcrole",l);break;case"xlinkRole":kn(e,"http://www.w3.org/1999/xlink","xlink:role",l);break;case"xlinkShow":kn(e,"http://www.w3.org/1999/xlink","xlink:show",l);break;case"xlinkTitle":kn(e,"http://www.w3.org/1999/xlink","xlink:title",l);break;case"xlinkType":kn(e,"http://www.w3.org/1999/xlink","xlink:type",l);break;case"xmlBase":kn(e,"http://www.w3.org/XML/1998/namespace","xml:base",l);break;case"xmlLang":kn(e,"http://www.w3.org/XML/1998/namespace","xml:lang",l);break;case"xmlSpace":kn(e,"http://www.w3.org/XML/1998/namespace","xml:space",l);break;case"is":zr(e,"is",l);break;case"innerText":case"textContent":return;default:if(!(2<n.length)||n[0]!=="o"&&n[0]!=="O"||n[1]!=="n"&&n[1]!=="N")n=hb.get(n)||n,zr(e,n,l);else return}ze=!0}function Hs(e,t,n,l,a,r){switch(n){case"style":xm(e,l,r);return;case"dangerouslySetInnerHTML":if(l!=null){if(typeof l!="object"||!("__html"in l))throw Error(o(61));if(n=l.__html,n!=null){if(a.children!=null)throw Error(o(60));(r!=null?r.__html:void 0)!==n&&(e.innerHTML=n)}}break;case"children":if(typeof l=="string")Ca(e,l);else if(typeof l=="number"||typeof l=="bigint")Ca(e,""+l);else return;break;case"onScroll":l!=null&&_e("scroll",e);return;case"onScrollEnd":l!=null&&_e("scro
Line truncated
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const fS=i=>i.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),Ly=(...i)=>i.filter((u,c,o)=>!!u&&u.trim()!==""&&o.indexOf(u)===c).join(" ").trim();/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/var dS={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const mS=b.forwardRef(({color:i="currentColor",size:u=24,strokeWidth:c=2,absoluteStrokeWidth:o,className:s="",children:d,iconNode:h,...v},g)=>b.createElement("svg",{ref:g,...dS,width:u,height:u,stroke:i,strokeWidth:o?Number(c)*24/Number(u):c,className:Ly("lucide",s),...v},[...h.map(([x,S])=>b.createElement(x,S)),...Array.isArray(d)?d:[d]]));/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const De=(i,u)=>{const c=b.forwardRef(({className:o,...s},d)=>b.createElement(mS,{ref:d,iconNode:u,className:Ly(`lucide-${fS(i)}`,o),...s}));return c.displayName=`${i}`,c};/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const hS=[["rect",{width:"20",height:"5",x:"2",y:"3",rx:"1",key:"1wp1u1"}],["path",{d:"M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8",key:"1s80jp"}],["path",{d:"M10 12h4",key:"a56b0p"}]],vS=De("Archive",hS);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const gS=[["path",{d:"M12 7v14",key:"1akyts"}],["path",{d:"M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z",key:"ruj8y"}]],Hy=De("BookOpen",gS);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const yS=[["path",{d:"M12 8V4H8",key:"hb8ula"}],["rect",{width:"16",height:"12",x:"4",y:"8",rx:"2",key:"enze0r"}],["path",{d:"M2 14h2",key:"vft8re"}],["path",{d:"M20 14h2",key:"4cs60a"}],["path",{d:"M15 13v2",key:"1xurst"}],["path",{d:"M9 13v2",key:"rq6x2g"}]],qy=De("Bot",yS);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const pS=[["path",{d:"M20 6 9 17l-5-5",key:"1gmf2c"}]],bS=De("Check",pS);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const xS=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]],SS=De("CircleCheck",xS);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const NS=[["rect",{width:"14",height:"14",x:"8",y:"8",rx:"2",ry:"2",key:"17jyea"}],["path",{d:"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2",key:"zix9uf"}]],z0=De("Copy",NS);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const CS=[["path",{d:"M15 3h6v6",key:"1q9fwt"}],["path",{d:"M10 14 21 3",key:"gplh6r"}],["path",{d:"M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6",key:"a6xqqp"}]],eo=De("ExternalLink",CS);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const ES=[["path",{d:"M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49",key:"ct8e1f"}],["path",{d:"M14.084 14.158a3 3 0 0 1-4.242-4.242",key:"151rxh"}],["path",{d:"M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143",key:"13bj9a"}],["path",{d:"m2 2 20 20",key:"1ooewy"}]],wS=De("EyeOff",ES);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const TS=[["path",{d:"M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0",key:"1nclc0"}],["circle",{cx:"12",cy:"12",r:"3",key:"1v7zrd"}]],jS=De("Eye",TS);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const _S=[["path",{d:"M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v5",key:"1w6njk"}],["circle",{cx:"13",cy:"12",r:"2",key:"1j92g6"}],["path",{d:"M18 19c-2.8 0-5-2.2-5-5v8",key:"pkpw2h"}],["circle",{cx:"20",cy:"19",r:"2",key:"1obnsp"}]],D0=De("FolderGit2",_S);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const AS=[["line",{x1:"6",x2:"6",y1:"3",y2:"15",key:"17qcm7"}],["circle",{cx:"18",cy:"6",r:"3",key:"1h7g24"}],["circle",{cx:"6",cy:"18",r:"3",key:"fqmcym"}],["path",{d:"M18 9a9 9 0 0 1-9 9",key:"n2h4wq"}]],RS=De("GitBranch",AS);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const MS=[["line",{x1:"22",x2:"2",y1:"12",y2:"12",key:"1y58io"}],["path",{d:"M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z",key:"oot6mr"}],["line",{x1:"6",x2:"6.01",y1:"16",y2:"16",key:"sgf278"}],["line",{x1:"10",x2:"10.01",y1:"16",y2:"16",key:"1l4acy"}]],OS=De("HardDrive",MS);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const zS=[["path",{d:"M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z",key:"1s6t7t"}],["circle",{cx:"16.5",cy:"7.5",r:".5",fill:"currentColor",key:"w0ekpg"}]],U0=De("KeyRound",zS);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const DS=[["path",{d:"M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z",key:"zw3jo"}],["path",{d:"M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12",key:"1wduqc"}],["path",{d:"M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17",key:"kqbvx6"}]],Gy=De("Layers",DS);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const US=[["rect",{width:"7",height:"7",x:"3",y:"3",rx:"1",key:"1g98yp"}],["rect",{width:"7",height:"7",x:"14",y:"3",rx:"1",key:"6d4xhi"}],["rect",{width:"7",height:"7",x:"14",y:"14",rx:"1",key:"nxv5o0"}],["rect",{width:"7",height:"7",x:"3",y:"14",rx:"1",key:"1bb6yr"}]],BS=De("LayoutGrid",US);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const kS=[["rect",{width:"18",height:"11",x:"3",y:"11",rx:"2",ry:"2",key:"1w4ew1"}],["path",{d:"M7 11V7a5 5 0 0 1 10 0v4",key:"fwvmzm"}]],LS=De("Lock",kS);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const HS=[["path",{d:"M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z",key:"1lielz"}]],pd=De("MessageSquare",HS);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const qS=[["path",{d:"M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z",key:"a7tn18"}]],GS=De("Moon",qS);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const YS=[["circle",{cx:"13.5",cy:"6.5",r:".5",fill:"currentColor",key:"1okk4w"}],["circle",{cx:"17.5",cy:"10.5",r:".5",fill:"currentColor",key:"f64h9f"}],["circle",{cx:"8.5",cy:"7.5",r:".5",fill:"currentColor",key:"fotxhn"}],["circle",{cx:"6.5",cy:"12.5",r:".5",fill:"currentColor",key:"qy21gx"}],["path",{d:"M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z",key:"12rzf8"}]],VS=De("Palette",YS);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const QS=[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",key:"afitv7"}],["path",{d:"M3 9h18",key:"1pudct"}],["path",{d:"M9 21V9",key:"1oto5p"}]],XS=De("PanelsTopLeft",QS);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const ZS=[["rect",{width:"5",height:"5",x:"3",y:"3",rx:"1",key:"1tu5fj"}],["rect",{width:"5",height:"5",x:"16",y:"3",rx:"1",key:"1v8r4q"}],["rect",{width:"5",height:"5",x:"3",y:"16",rx:"1",key:"1x03jg"}],["path",{d:"M21 16h-3a2 2 0 0 0-2 2v3",key:"177gqh"}],["path",{d:"M21 21v.01",key:"ents32"}],["path",{d:"M12 7v3a2 2 0 0 1-2 2H7",key:"8crl2c"}],["path",{d:"M3 12h.01",key:"nlz23k"}],["path",{d:"M12 3h.01",key:"n36tog"}],["path",{d:"M12 16v.01",key:"133mhm"}],["path",{d:"M16 12h1",key:"1slzba"}],["path",{d:"M21 12v.01",key:"1lwtk9"}],["path",{d:"M12 21v-1",key:"1880an"}]],KS=De("QrCode",ZS);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const IS=[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8",key:"v9h5vc"}],["path",{d:"M21 3v5h-5",key:"1q7to0"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16",key:"3uifl3"}],["path",{d:"M8 16H3v5",key:"1cv678"}]],Yy=De("RefreshCw",IS);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const JS=[["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}],["path",{d:"m21 21-4.3-4.3",key:"1qie3q"}]],bd=De("Search",JS);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const FS=[["path",{d:"M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z",key:"1ffxy3"}],["path",{d:"m21.854 2.147-10.94 10.939",key:"12cjpa"}]],PS=De("Send",FS);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const $S=[["path",{d:"M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z",key:"oel41y"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]],WS=De("ShieldCheck",$S);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const eN=[["rect",{width:"14",height:"20",x:"5",y:"2",rx:"2",ry:"2",key:"1yt0o3"}],["path",{d:"M12 18h.01",key:"mhygvu"}]],tN=De("Smartphone",eN);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const nN=[["path",{d:"M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z",key:"4pj2yx"}],["path",{d:"M20 3v4",key:"1olli1"}],["path",{d:"M22 5h-4",key:"1gvqau"}],["path",{d:"M4 17v2",key:"vumght"}],["path",{d:"M5 18H3",key:"zchphs"}]],$f=De("Sparkles",nN);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const lN=[["circle",{cx:"12",cy:"12",r:"4",key:"4exip2"}],["path",{d:"M12 2v2",key:"tus03m"}],["path",{d:"M12 20v2",key:"1lh1kg"}],["path",{d:"m4.93 4.93 1.41 1.41",key:"149t6j"}],["path",{d:"m17.66 17.66 1.41 1.41",key:"ptbguv"}],["path",{d:"M2 12h2",key:"1t8f8n"}],["path",{d:"M20 12h2",key:"1q8mjw"}],["path",{d:"m6.34 17.66-1.41 1.41",key:"1m8zz5"}],["path",{d:"m19.07 4.93-1.41 1.41",key:"1shlcs"}]],aN=De("Sun",lN);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const iN=[["polyline",{points:"4 17 10 11 4 5",key:"akl6gq"}],["line",{x1:"12",x2:"20",y1:"19",y2:"19",key:"q2wloq"}]],yr=De("Terminal",iN);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const rN=[["path",{d:"m19 5 3-3",key:"yk6iyv"}],["path",{d:"m2 22 3-3",key:"19mgm9"}],["path",{d:"M6.3 20.3a2.4 2.4 0 0 0 3.4 0L12 18l-6-6-2.3 2.3a2.4 2.4 0 0 0 0 3.4Z",key:"goz73y"}],["path",{d:"M7.5 13.5 10 11",key:"7xgeeb"}],["path",{d:"M10.5 16.5 13 14",key:"10btkg"}],["path",{d:"m12 6 6 6 2.3-2.3a2.4 2.4 0 0 0 0-3.4l-2.6-2.6a2.4 2.4 0 0 0-3.4 0Z",key:"1snsnr"}]],uN=De("Unplug",rN);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const oN=[["path",{d:"M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2",key:"975kel"}],["circle",{cx:"12",cy:"7",r:"4",key:"17ys0d"}]],cN=De("User",oN);/**
|
||||
* @license lucide-react v0.475.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const sN=[["path",{d:"M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2",key:"1yyitq"}],["circle",{cx:"9",cy:"7",r:"4",key:"nufk8"}],["path",{d:"M22 21v-2a4 4 0 0 0-3-3.87",key:"kshegd"}],["path",{d:"M16 3.13a4 4 0 0 1 0 7.75",key:"1da9ce"}]],fN=De("Users",sN);var dN=Object.defineProperty,xd=(i,u)=>dN(i,"name",{value:u,configurable:!0});function Wf(i,u){if(typeof i=="function")return i(u);i!=null&&(i.current=u)}xd(Wf,"setRef");function ua(...i){return u=>{let c=!1;const o=i.map(s=>{const d=Wf(s,u);return!c&&typeof d=="function"&&(c=!0),d});if(c)return()=>{for(let s=0;s<o.length;s++){const d=o[s];typeof d=="function"?d():Wf(i[s],null)}}}}xd(ua,"composeRefs");function Dt(...i){return b.useCallback(ua(...i),i)}xd(Dt,"useComposedRefs");var mN=Object.defineProperty,It=(i,u)=>mN(i,"name",{value:u,configurable:!0});function zl(i){const u=b.forwardRef((c,o)=>{let{children:s,...d}=c,h=null,v=!1;const g=[];s=Iy(s),b.Children.forEach(s,N=>{var A;if(Zy(N)){v=!0;const j=N;let z="child"in j.props?j.props.child:j.props.children;for(;Sd(z)&&typeof vo=="function";)z=vo(z._payload);h=gN(j,z),g.push((A=h==null?void 0:h.props)==null?void 0:A.children)}else g.push(N)}),h?h=b.cloneElement(h,void 0,g):!v&&b.Children.count(s)===1&&b.isValidElement(s)&&(h=s);const x=h?Xy(h):void 0,S=Dt(o,x);if(!h){if(s||s===0)throw new Error(v?bN(i):pN(i));return s}const p=Qy(d,h.props??{});return h.type!==b.Fragment&&(p.ref=o?S:x),b.cloneElement(h,p)});return u.displayName=`${i}.Slot`,u}It(zl,"createSlot");var hN=zl("Slot"),Vy=Symbol.for("radix.slottable");function vN(i){const u=It(c=>"child"in c?c.children(c.child):c.children,"Slottable");return u.displayName=`${i}.Slottable`,u.__radixId=Vy,u}It(vN,"createSlottable");var gN=It((i,u)=>"child"in i.props?b.isValidElement(u)?b.cloneElement(u,void 0,i.props.children(u.props.children)):null:b.isValidElement(u)?u:null,"getSlottableElementFromSlottable");function Qy(i,u){const c={...u};for(const o in u){const s=i[o],d=u[o];/^on[A-Z]/.test(o)?s&&d?c[o]=(...v)=>{const g=d(...v);return s(...v),g}:s&&(c[o]=s):o==="style"?c[o]={...s,...d}:o==="className"?c[o]=[s,d].filter(Boolean).join(" "):o==="aria-describedby"&&(c[o]=Jy(d,s))}return{...i,...c}}It(Qy,"mergeProps");function Xy(i){var o,s;let u=(o=Object.getOwnPropertyDescriptor(i.props,"ref"))==null?void 0:o.get,c=u&&"isReactWarning"in u&&u.isReactWarning;return c?i.ref:(u=(s=Object.getOwnPropertyDescriptor(i,"ref"))==null?void 0:s.get,c=u&&"isReactWarning"in u&&u.isReactWarning,c?i.props.ref:i.props.ref||i.ref)}It(Xy,"getElementRef");function Zy(i){return b.isValidElement(i)&&typeof i.type=="function"&&"__radixId"in i.type&&i.type.__radixId===Vy}It(Zy,"isSlottable");var yN=Symbol.for("react.lazy");function Sd(i){return i!=null&&typeof i=="object"&&"$$typeof"in i&&i.$$typeof===yN&&"_payload"in i&&Ky(i._payload)}It(Sd,"isLazyComponent");function Ky(i){return typeof i=="object"&&i!==null&&"then"in i}It(Ky,"isPromiseLike");function Iy(i){let u=i;for(;Sd(u)&&typeof vo=="function";)u=vo(u._payload);return u}It(Iy,"useUnwrappedLazy");function Jy(...i){const u=new Set;for(const c of i)if(typeof c=="string")for(const o of String(c).trim().split(/\s+/))o&&u.add(o);return u.size>0?Array.from(u).join(" "):void 0}It(Jy,"concatAriaDescribedby");var pN=It(i=>`${i} failed to slot onto its children. Expected a single React element child or \`Slottable\`.`,"createSlotError"),bN=It(i=>`${i} failed to slot onto its \`Slottable\`. Expected \`Slottable\` to receive a single React element child.`,"createSlottableError"),vo=sa[" use ".trim().toString()];function Fy(i){var u,c,o="";if(typeof i=="string"||typeof i=="number")o+=i;else if(typeof i=="object")if(Array.isArray(i)){var s=i.length;for(u=0;u<s;u++)i[u]&&(c=Fy(i[u]))&&(o&&(o+=" "),o+=c)}else for(c in i)i[c]&&(o&&(o+=" "),o+=c);return o}function Py(){for(var i,u,c=0,o="",s=arguments.length;c<s;c++)(i=arguments[c])&&(u=Fy(i))&&(o&&(o+=" "),o+=u);return o}const B0=i=>typeof i=="boolean"?`${i}`:i===0?"0":i,k0=Py,$y=(i,u)=>c=>{var o;if((u==null?void 0:u.variants)==null)return k0(i,c==null?void 0:c.class,c==null?void 0:c.className);const{variants:s,defaultVariants:d}=u,h=Object.keys(s).map(x=>{const S=c==null?void 0:c[x],p=d==null?void 0:d[x];if(S===null)return null;const N=B0(S)||B0(p);return s[x][N]}),v=c&&Object.entries(c).reduce((x,S)=>{let[p,N]=S;return N===void 0||(x[p]=N),x},{}),g=u==null||(o=u.compoundVariants)===null||o===void 0?void 0:o.reduce((x,S)=>{let{class:p,className:N,...A}=S;return Object.entries(A).every(j=>{let[z,H]=j;return Array.isArray(H)?H.includes({...d,...v}[z]):{...d,...v}[z]===H})?[...x,p,N]:x},[]);return k0(i,h,g,c==null?void 0:c.class,c==null?void 0:c.className)},xN=(i,u)=>{const c=new Array(i.length+u.length);for(let o=0;o<i.length;o++)c[o]=i[o];for(let o=0;o<u.length;o++)c[i.length+o]=u[o];return c},SN=(i,u)=>({classGroupId:i,validator:u}),Wy=(i=new Map,u=null,c)=>({nextPart:i,validators:u,classGroupId:c}),go="-",L0=[],NN="arbitrary..",CN=i=>{const u=wN(i),{conflictingClassGroups:c,conflictingClassGroupLine truncated
|
||||
.`.concat(OC,` {
|
||||
overflow: hidden `).concat(o,`;
|
||||
padding-right: `).concat(v,"px ").concat(o,`;
|
||||
}
|
||||
body[`).concat(hi,`] {
|
||||
overflow: hidden `).concat(o,`;
|
||||
overscroll-behavior: contain;
|
||||
`).concat([u&&"position: relative ".concat(o,";"),c==="margin"&&`
|
||||
padding-left: `.concat(s,`px;
|
||||
padding-top: `).concat(d,`px;
|
||||
padding-right: `).concat(h,`px;
|
||||
margin-left:0;
|
||||
margin-top:0;
|
||||
margin-right: `).concat(v,"px ").concat(o,`;
|
||||
`),c==="padding"&&"padding-right: ".concat(v,"px ").concat(o,";")].filter(Boolean).join(""),`
|
||||
}
|
||||
|
||||
.`).concat(mo,` {
|
||||
right: `).concat(v,"px ").concat(o,`;
|
||||
}
|
||||
|
||||
.`).concat(ho,` {
|
||||
margin-right: `).concat(v,"px ").concat(o,`;
|
||||
}
|
||||
|
||||
.`).concat(mo," .").concat(mo,` {
|
||||
right: 0 `).concat(o,`;
|
||||
}
|
||||
|
||||
.`).concat(ho," .").concat(ho,` {
|
||||
margin-right: 0 `).concat(o,`;
|
||||
}
|
||||
|
||||
body[`).concat(hi,`] {
|
||||
`).concat(zC,": ").concat(v,`px;
|
||||
}
|
||||
`)},ny=function(){var i=parseInt(document.body.getAttribute(hi)||"0",10);return isFinite(i)?i:0},$C=function(){b.useEffect(function(){return document.body.setAttribute(hi,(ny()+1).toString()),function(){var i=ny()-1;i<=0?document.body.removeAttribute(hi):document.body.setAttribute(hi,i.toString())}},[])},WC=function(i){var u=i.noRelative,c=i.noImportant,o=i.gapMode,s=o===void 0?"margin":o;$C();var d=b.useMemo(function(){return JC(s)},[s]);return b.createElement(FC,{styles:PC(d,!u,s,c?"":"!important")})},sd=!1;if(typeof window<"u")try{var io=Object.defineProperty({},"passive",{get:function(){return sd=!0,!0}});window.addEventListener("test",io,io),window.removeEventListener("test",io,io)}catch{sd=!1}var ui=sd?{passive:!1}:!1,e3=function(i){return i.tagName==="TEXTAREA"},dp=function(i,u){if(!(i instanceof Element))return!1;var c=window.getComputedStyle(i);return c[u]!=="hidden"&&!(c.overflowY===c.overflowX&&!e3(i)&&c[u]==="visible")},t3=function(i){return dp(i,"overflowY")},n3=function(i){return dp(i,"overflowX")},ly=function(i,u){var c=u.ownerDocument,o=u;do{typeof ShadowRoot<"u"&&o instanceof ShadowRoot&&(o=o.host);var s=mp(i,o);if(s){var d=hp(i,o),h=d[1],v=d[2];if(h>v)return!0}o=o.parentNode}while(o&&o!==c.body);return!1},l3=function(i){var u=i.scrollTop,c=i.scrollHeight,o=i.clientHeight;return[u,c,o]},a3=function(i){var u=i.scrollLeft,c=i.scrollWidth,o=i.clientWidth;return[u,c,o]},mp=function(i,u){return i==="v"?t3(u):n3(u)},hp=function(i,u){return i==="v"?l3(u):a3(u)},i3=function(i,u){return i==="h"&&u==="rtl"?-1:1},r3=function(i,u,c,o,s){var d=i3(i,window.getComputedStyle(u).direction),h=d*o,v=c.target,g=u.contains(v),x=!1,S=h>0,p=0,N=0;do{if(!v)break;var A=hp(i,v),j=A[0],z=A[1],H=A[2],q=z-H-d*j;(j||q)&&mp(i,v)&&(p+=q,N+=j);var G=v.parentNode;v=G&&G.nodeType===Node.DOCUMENT_FRAGMENT_NODE?G.host:G}while(!g&&v!==document.body||g&&(u.contains(v)||u===v));return(S&&Math.abs(p)<1||!S&&Math.abs(N)<1)&&(x=!0),x},ro=function(i){return"changedTouches"in i?[i.changedTouches[0].clientX,i.changedTouches[0].clientY]:[0,0]},ay=function(i){return[i.deltaX,i.deltaY]},iy=function(i){return i&&"current"in i?i.current:i},u3=function(i,u){return i[0]===u[0]&&i[1]===u[1]},o3=function(i){return`
|
||||
.block-interactivity-`.concat(i,` {pointer-events: none;}
|
||||
.allow-interactivity-`).concat(i,` {pointer-events: all;}
|
||||
`)},c3=0,oi=[];function s3(i){var u=b.useRef([]),c=b.useRef([0,0]),o=b.useRef(),s=b.useState(c3++)[0],d=b.useState(fp)[0],h=b.useRef(i);b.useEffect(function(){h.current=i},[i]),b.useEffect(function(){if(i.inert){document.body.classList.add("block-interactivity-".concat(s));var z=MC([i.lockRef.current],(i.shards||[]).map(iy),!0).filter(Boolean);return z.forEach(function(H){return H.classList.add("allow-interactivity-".concat(s))}),function(){document.body.classList.remove("block-interactivity-".concat(s)),z.forEach(function(H){return H.classList.remove("allow-interactivity-".concat(s))})}}},[i.inert,i.lockRef.current,i.shards]);var v=b.useCallback(function(z,H){if("touches"in z&&z.touches.length===2||z.type==="wheel"&&z.ctrlKey)return!h.current.allowPinchZoom;var q=ro(z),G=c.current,k="deltaX"in z?z.deltaX:G[0]-q[0],V="deltaY"in z?z.deltaY:G[1]-q[1],K,_=z.target,E=Math.abs(k)>Math.abs(V)?"h":"v";if("touches"in z&&E==="h"&&_.type==="range")return!1;var R=window.getSelection(),Q=R&&R.anchorNode,te=Q?Q===_||Q.contains(_):!1;if(te)return!1;var W=ly(E,_);if(!W)return!0;if(W?K=E:(K=E==="v"?"h":"v",W=ly(E,_)),!W)return!1;if(!o.current&&"changedTouches"in z&&(k||V)&&(o.current=K),!K)return!0;var P=o.current||K;return r3(P,H,z,P==="h"?k:V)},[]),g=b.useCallback(function(z){var H=z;if(!(!oi.length||oi[oi.length-1]!==d)){var q="deltaY"in H?ay(H):ro(H),G=u.current.filter(function(K){return K.name===H.type&&(K.target===H.target||H.target===K.shadowParent)&&u3(K.delta,q)})[0];if(G&&G.should){H.cancelable&&H.preventDefault();return}if(!G){var k=(h.current.shards||[]).map(iy).filter(Boolean).filter(function(K){return K.contains(H.target)}),V=k.length>0?v(H,k[0]):!h.current.noIsolation;V&&H.cancelable&&H.preventDefault()}}},[]),x=b.useCallback(function(z,H,q,G){var k={name:z,delta:H,target:q,should:G,shadowParent:f3(q)};u.current.push(k),setTimeout(function(){u.current=u.current.filter(function(V){return V!==k})},1)},[]),S=b.useCallback(function(z){c.current=ro(z),o.current=void 0},[]),p=b.useCallback(function(z){x(z.type,ay(z),z.target,v(z,i.lockRef.current))},[]),N=b.useCallback(function(z){x(z.type,ro(z),z.target,v(z,i.lockRef.current))},[]);b.useEffect(function(){return oi.push(d),i.setCallbacks({onScrollCapture:p,onWheelCapture:p,onTouchMoveCapture:N}),document.addEventListener("wheel",g,ui),document.addEventListener("touchmove",g,ui),document.addEventListener("touchstart",S,ui),function(){oi=oi.filter(function(z){return z!==d}),document.removeEventListener("wheel",g,ui),document.removeEventListener("touchmove",g,ui),document.removeEventListener("touchstart",S,ui)}},[]);var A=i.removeScrollBar,j=i.inert;return b.createElement(b.Fragment,null,j?b.createElement(d,{styles:o3(s)}):null,A?b.createElement(WC,{noRelative:i.noRelative,gapMode:i.gapMode}):null)}function f3(i){for(var u=null;i!==null;)i instanceof ShadowRoot&&(u=i.host,i=i.host),i=i.parentNode;return u}const d3=qC(sp,s3);var vp=b.forwardRef(function(i,u){return b.createElement(Co,zn({},i,{ref:u,sideCar:d3}))});vp.classNames=Co.classNames;var m3=function(i){if(typeof document>"u")return null;var u=Array.isArray(i)?i[0]:i;return u.ownerDocument.body},ci=new WeakMap,uo=new WeakMap,oo={},Tf=0,gp=function(i){return i&&(i.host||gp(i.parentNode))},h3=function(i,u){return u.map(function(c){if(i.contains(c))return c;var o=gp(c);return o&&i.contains(o)?o:(console.error("aria-hidden",c,"in not contained inside",i,". Doing nothing"),null)}).filter(function(c){return!!c})},v3=function(i,u,c,o){var s=h3(u,Array.isArray(i)?i:[i]);oo[c]||(oo[c]=new WeakMap);var d=oo[c],h=[],v=new Set,g=new Set(s),x=function(p){!p||v.has(p)||(v.add(p),x(p.parentNode))};s.forEach(x);var S=function(p){!p||g.has(p)||Array.prototype.forEach.call(p.children,function(N){if(v.has(N))S(N);else try{var A=N.getAttribute(o),j=A!==null&&A!=="false",z=(ci.get(N)||0)+1,H=(d.get(N)||0)+1;ci.set(N,z),d.set(N,H),h.push(N),z===1&&j&&uo.set(N,!0),H===1&&N.setAttribute(c,"true"),j||N.setAttribute(o,"true")}catch(q){console.error("aria-hidden: cannot operate on ",N,q)}})};return S(u),v.clear(),Tf++,function(){h.forEach(function(p){var N=ci.get(p)-1,A=d.get(p)-1;ci.set(p,N),d.set(p,A),N||(uo.has(p)||p.removeAttribute(o),uo.delete(p)),A||p.removeAttribute(c)}),Tf--,Tf||(ci=new WeakMap,ci=new WeakMap,uo=new WeakMap,oo={})}},g3=function(i,u,c){c===void 0&&(c="data-aria-hidden");var o=Array.from(Array.isArray(i)?i:[i]),s=m3(i);return s?(o.push.apply(o,Array.from(s.querySelectorAll("[aria-live], script"))),v3(o,s,c,"aria-hidden")):function(){return null}},y3=Object.defineProperty,cn=(i,u)=>y3(i,"name",{value:u,configurable:!0}),Bd="Dialog",[yp,ME]=da(Bd),[p3,el]=yp(Bd),pp=cn(i=>{const{__scopeDialog:u,children:c,open:o,defaultOpen:s,onOpenChange:d,modal:h=!0}=i,v=b.useRef(null),g=b.useRef(null),{nodes:x,registry:S}=tp(),[p,N]=Sr({prop:o,defaultProp:s??!1,onChange:d,caller:Bd}),[A,j]=b.useState(0),[z,H]=b.useState(0);return f.jsx(p3,{scope:u,triggerRef:v,contentRef:g,contentId:un(),titleId:un(),descriptionId:un(),titlePresen
Line truncated
|
||||
]))+`;On.KANJI=new RegExp(c,"g"),On.BYTE_KANJI=new RegExp("[^A-Z0-9 $%*+\\-./:]+","g"),On.BYTE=new RegExp(o,"g"),On.NUMERIC=new RegExp(i,"g"),On.ALPHANUMERIC=new RegExp(u,"g");const s=new RegExp("^"+c+"$"),d=new RegExp("^"+i+"$"),h=new RegExp("^[A-Z0-9 $%*+\\-./:]+$");return On.testKanji=function(g){return s.test(g)},On.testNumeric=function(g){return d.test(g)},On.testAlphanumeric=function(g){return h.test(g)},On}var Sy;function va(){return Sy||(Sy=1,(function(i){const u=Zp(),c=Kp();i.NUMERIC={id:"Numeric",bit:1,ccBits:[10,12,14]},i.ALPHANUMERIC={id:"Alphanumeric",bit:2,ccBits:[9,11,13]},i.BYTE={id:"Byte",bit:4,ccBits:[8,16,16]},i.KANJI={id:"Kanji",bit:8,ccBits:[8,10,12]},i.MIXED={bit:-1},i.getCharCountIndicator=function(d,h){if(!d.ccBits)throw new Error("Invalid mode: "+d);if(!u.isValid(h))throw new Error("Invalid version: "+h);return h>=1&&h<10?d.ccBits[0]:h<27?d.ccBits[1]:d.ccBits[2]},i.getBestModeForData=function(d){return c.testNumeric(d)?i.NUMERIC:c.testAlphanumeric(d)?i.ALPHANUMERIC:c.testKanji(d)?i.KANJI:i.BYTE},i.toString=function(d){if(d&&d.id)return d.id;throw new Error("Invalid mode")},i.isValid=function(d){return d&&d.bit&&d.ccBits};function o(s){if(typeof s!="string")throw new Error("Param is not a string");switch(s.toLowerCase()){case"numeric":return i.NUMERIC;case"alphanumeric":return i.ALPHANUMERIC;case"kanji":return i.KANJI;case"byte":return i.BYTE;default:throw new Error("Unknown mode: "+s)}}i.from=function(d,h){if(i.isValid(d))return d;try{return o(d)}catch{return h}}})(Hf)),Hf}var Ny;function sE(){return Ny||(Ny=1,(function(i){const u=ha(),c=Xp(),o=Kd(),s=va(),d=Zp(),h=7973,v=u.getBCHDigit(h);function g(N,A,j){for(let z=1;z<=40;z++)if(A<=i.getCapacity(z,j,N))return z}function x(N,A){return s.getCharCountIndicator(N,A)+4}function S(N,A){let j=0;return N.forEach(function(z){const H=x(z.mode,A);j+=H+z.getBitsLength()}),j}function p(N,A){for(let j=1;j<=40;j++)if(S(N,j)<=i.getCapacity(j,A,s.MIXED))return j}i.from=function(A,j){return d.isValid(A)?parseInt(A,10):j},i.getCapacity=function(A,j,z){if(!d.isValid(A))throw new Error("Invalid QR Code version");typeof z>"u"&&(z=s.BYTE);const H=u.getSymbolTotalCodewords(A),q=c.getTotalCodewordsCount(A,j),G=(H-q)*8;if(z===s.MIXED)return G;const k=G-x(z,A);switch(z){case s.NUMERIC:return Math.floor(k/10*3);case s.ALPHANUMERIC:return Math.floor(k/11*2);case s.KANJI:return Math.floor(k/13);case s.BYTE:default:return Math.floor(k/8)}},i.getBestVersionForData=function(A,j){let z;const H=o.from(j,o.M);if(Array.isArray(A)){if(A.length>1)return p(A,H);if(A.length===0)return 1;z=A[0]}else z=A;return g(z.mode,z.getLength(),H)},i.getEncodedBits=function(A){if(!d.isValid(A)||A<7)throw new Error("Invalid QR Code version");let j=A<<12;for(;u.getBCHDigit(j)-v>=0;)j^=h<<u.getBCHDigit(j)-v;return A<<12|j}})(Lf)),Lf}var Gf={},Cy;function fE(){if(Cy)return Gf;Cy=1;const i=ha(),u=1335,c=21522,o=i.getBCHDigit(u);return Gf.getEncodedBits=function(d,h){const v=d.bit<<3|h;let g=v<<10;for(;i.getBCHDigit(g)-o>=0;)g^=u<<i.getBCHDigit(g)-o;return(v<<10|g)^c},Gf}var Yf={},Vf,Ey;function dE(){if(Ey)return Vf;Ey=1;const i=va();function u(c){this.mode=i.NUMERIC,this.data=c.toString()}return u.getBitsLength=function(o){return 10*Math.floor(o/3)+(o%3?o%3*3+1:0)},u.prototype.getLength=function(){return this.data.length},u.prototype.getBitsLength=function(){return u.getBitsLength(this.data.length)},u.prototype.write=function(o){let s,d,h;for(s=0;s+3<=this.data.length;s+=3)d=this.data.substr(s,3),h=parseInt(d,10),o.put(h,10);const v=this.data.length-s;v>0&&(d=this.data.substr(s),h=parseInt(d,10),o.put(h,v*3+1))},Vf=u,Vf}var Qf,wy;function mE(){if(wy)return Qf;wy=1;const i=va(),u=["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F","G","H","I","J","K","L","M","N","O","P","Q","R","S","T","U","V","W","X","Y","Z"," ","$","%","*","+","-",".","/",":"];function c(o){this.mode=i.ALPHANUMERIC,this.data=o}return c.getBitsLength=function(s){return 11*Math.floor(s/2)+6*(s%2)},c.prototype.getLength=function(){return this.data.length},c.prototype.getBitsLength=function(){return c.getBitsLength(this.data.length)},c.prototype.write=function(s){let d;for(d=0;d+2<=this.data.length;d+=2){let h=u.indexOf(this.data[d])*45;h+=u.indexOf(this.data[d+1]),s.put(h,11)}this.data.length%2&&s.put(u.indexOf(this.data[d]),6)},Qf=c,Qf}var Xf,Ty;function hE(){if(Ty)return Xf;Ty=1;const i=va();function u(c){this.mode=i.BYTE,typeof c=="string"?this.data=new TextEncoder().encode(c):this.data=new Uint8Array(c)}return u.getBitsLength=function(o){return o*8},u.prototype.getLength=function(){return this.data.length},u.prototype.getBitsLength=function(){return u.getBitsLength(this.data.length)},u.prototype.write=function(c){for(let o=0,s=this.data.length;o<s;o++)c.put(this.data[o],8)},Xf=u,Xf}var Zf,jy;function vE(){if(jy)return Zf;jy=1;const i=va(),u=ha();function c(o){this.mode=i.KANJI,this.data=o}return c.getBitsLength=function(s){return s*13},c.prototype.getLength=function(){return this.data.length},c.prototype.g
Line truncated
|
||||
Make sure your charset is UTF-8`);d=(d>>>8&255)*192+(d&255),o.put(d,13)}},Zf=c,Zf}var Kf={exports:{}},_y;function gE(){return _y||(_y=1,(function(i){var u={single_source_shortest_paths:function(c,o,s){var d={},h={};h[o]=0;var v=u.PriorityQueue.make();v.push(o,0);for(var g,x,S,p,N,A,j,z,H;!v.empty();){g=v.pop(),x=g.value,p=g.cost,N=c[x]||{};for(S in N)N.hasOwnProperty(S)&&(A=N[S],j=p+A,z=h[S],H=typeof h[S]>"u",(H||z>j)&&(h[S]=j,v.push(S,j),d[S]=x))}if(typeof s<"u"&&typeof h[s]>"u"){var q=["Could not find a path from ",o," to ",s,"."].join("");throw new Error(q)}return d},extract_shortest_path_from_predecessor_list:function(c,o){for(var s=[],d=o;d;)s.push(d),c[d],d=c[d];return s.reverse(),s},find_path:function(c,o,s){var d=u.single_source_shortest_paths(c,o,s);return u.extract_shortest_path_from_predecessor_list(d,s)},PriorityQueue:{make:function(c){var o=u.PriorityQueue,s={},d;c=c||{};for(d in o)o.hasOwnProperty(d)&&(s[d]=o[d]);return s.queue=[],s.sorter=c.sorter||o.default_sorter,s},default_sorter:function(c,o){return c.cost-o.cost},push:function(c,o){var s={value:c,cost:o};this.queue.push(s),this.queue.sort(this.sorter)},pop:function(){return this.queue.shift()},empty:function(){return this.queue.length===0}}};i.exports=u})(Kf)),Kf.exports}var Ay;function yE(){return Ay||(Ay=1,(function(i){const u=va(),c=dE(),o=mE(),s=hE(),d=vE(),h=Kp(),v=ha(),g=gE();function x(q){return unescape(encodeURIComponent(q)).length}function S(q,G,k){const V=[];let K;for(;(K=q.exec(k))!==null;)V.push({data:K[0],index:K.index,mode:G,length:K[0].length});return V}function p(q){const G=S(h.NUMERIC,u.NUMERIC,q),k=S(h.ALPHANUMERIC,u.ALPHANUMERIC,q);let V,K;return v.isKanjiModeEnabled()?(V=S(h.BYTE,u.BYTE,q),K=S(h.KANJI,u.KANJI,q)):(V=S(h.BYTE_KANJI,u.BYTE,q),K=[]),G.concat(k,V,K).sort(function(E,R){return E.index-R.index}).map(function(E){return{data:E.data,mode:E.mode,length:E.length}})}function N(q,G){switch(G){case u.NUMERIC:return c.getBitsLength(q);case u.ALPHANUMERIC:return o.getBitsLength(q);case u.KANJI:return d.getBitsLength(q);case u.BYTE:return s.getBitsLength(q)}}function A(q){return q.reduce(function(G,k){const V=G.length-1>=0?G[G.length-1]:null;return V&&V.mode===k.mode?(G[G.length-1].data+=k.data,G):(G.push(k),G)},[])}function j(q){const G=[];for(let k=0;k<q.length;k++){const V=q[k];switch(V.mode){case u.NUMERIC:G.push([V,{data:V.data,mode:u.ALPHANUMERIC,length:V.length},{data:V.data,mode:u.BYTE,length:V.length}]);break;case u.ALPHANUMERIC:G.push([V,{data:V.data,mode:u.BYTE,length:V.length}]);break;case u.KANJI:G.push([V,{data:V.data,mode:u.BYTE,length:x(V.data)}]);break;case u.BYTE:G.push([{data:V.data,mode:u.BYTE,length:x(V.data)}])}}return G}function z(q,G){const k={},V={start:{}};let K=["start"];for(let _=0;_<q.length;_++){const E=q[_],R=[];for(let Q=0;Q<E.length;Q++){const te=E[Q],W=""+_+Q;R.push(W),k[W]={node:te,lastCount:0},V[W]={};for(let P=0;P<K.length;P++){const ne=K[P];k[ne]&&k[ne].node.mode===te.mode?(V[ne][W]=N(k[ne].lastCount+te.length,te.mode)-N(k[ne].lastCount,te.mode),k[ne].lastCount+=te.length):(k[ne]&&(k[ne].lastCount=te.length),V[ne][W]=N(te.length,te.mode)+4+u.getCharCountIndicator(te.mode,G))}}K=R}for(let _=0;_<K.length;_++)V[K[_]].end=0;return{map:V,table:k}}function H(q,G){let k;const V=u.getBestModeForData(q);if(k=u.from(G,V),k!==u.BYTE&&k.bit<V.bit)throw new Error('"'+q+'" cannot be encoded with mode '+u.toString(k)+`.
|
||||
Suggested mode is: `+u.toString(V));switch(k===u.KANJI&&!v.isKanjiModeEnabled()&&(k=u.BYTE),k){case u.NUMERIC:return new c(q);case u.ALPHANUMERIC:return new o(q);case u.KANJI:return new d(q);case u.BYTE:return new s(q)}}i.fromArray=function(G){return G.reduce(function(k,V){return typeof V=="string"?k.push(H(V,null)):V.data&&k.push(H(V.data,V.mode)),k},[])},i.fromString=function(G,k){const V=p(G,v.isKanjiModeEnabled()),K=j(V),_=z(K,k),E=g.find_path(_.map,"start","end"),R=[];for(let Q=1;Q<E.length-1;Q++)R.push(_.table[E[Q]].node);return i.fromArray(A(R))},i.rawSplit=function(G){return i.fromArray(p(G,v.isKanjiModeEnabled()))}})(Yf)),Yf}var Ry;function pE(){if(Ry)return Af;Ry=1;const i=ha(),u=Kd(),c=nE(),o=lE(),s=aE(),d=iE(),h=rE(),v=Xp(),g=cE(),x=sE(),S=fE(),p=va(),N=yE();function A(_,E){const R=_.size,Q=d.getPositions(E);for(let te=0;te<Q.length;te++){const W=Q[te][0],P=Q[te][1];for(let ne=-1;ne<=7;ne++)if(!(W+ne<=-1||R<=W+ne))for(let re=-1;re<=7;re++)P+re<=-1||R<=P+re||(ne>=0&&ne<=6&&(re===0||re===6)||re>=0&&re<=6&&(ne===0||ne===6)||ne>=2&&ne<=4&&re>=2&&re<=4?_.set(W+ne,P+re,!0,!0):_.set(W+ne,P+re,!1,!0))}}function j(_){const E=_.size;for(let R=8;R<E-8;R++){const Q=R%2===0;_.set(R,6,Q,!0),_.set(6,R,Q,!0)}}function z(_,E){const R=s.getPositions(E);for(let Q=0;Q<R.length;Q++){const te=R[Q][0],W=R[Q][1];for(let P=-2;P<=2;P++)for(let ne=-2;ne<=2;ne++)P===-2||P===2||ne===-2||ne===2||P===0&&ne===0?_.set(te+P,W+ne,!0,!0):_.set(te+P,W+ne,!1,!0)}}function H(_,E){const R=_.size,Q=x.getEncodedBits(E);let te,W,P;for(let ne=0;ne<18;ne++)te=Math.floor(ne/3),W=ne%3+R-8-3,P=(Q>>ne&1)===1,_.set(te,W,P,!0),_.set(W,te,P,!0)}function q(_,E,R){const Q=_.size,te=S.getEncodedBits(E,R);let W,P;for(W=0;W<15;W++)P=(te>>W&1)===1,W<6?_.set(W,8,P,!0):W<8?_.set(W+1,8,P,!0):_.set(Q-15+W,8,P,!0),W<8?_.set(8,Q-W-1,P,!0):W<9?_.set(8,15-W-1+1,P,!0):_.set(8,15-W-1,P,!0);_.set(Q-8,8,1,!0)}function G(_,E){const R=_.size;let Q=-1,te=R-1,W=7,P=0;for(let ne=R-1;ne>0;ne-=2)for(ne===6&&ne--;;){for(let re=0;re<2;re++)if(!_.isReserved(te,ne-re)){let ge=!1;P<E.length&&(ge=(E[P]>>>W&1)===1),_.set(te,ne-re,ge),W--,W===-1&&(P++,W=7)}if(te+=Q,te<0||R<=te){te-=Q,Q=-Q;break}}}function k(_,E,R){const Q=new c;R.forEach(function(re){Q.put(re.mode.bit,4),Q.put(re.getLength(),p.getCharCountIndicator(re.mode,_)),re.write(Q)});const te=i.getSymbolTotalCodewords(_),W=v.getTotalCodewordsCount(_,E),P=(te-W)*8;for(Q.getLengthInBits()+4<=P&&Q.put(0,4);Q.getLengthInBits()%8!==0;)Q.putBit(0);const ne=(P-Q.getLengthInBits())/8;for(let re=0;re<ne;re++)Q.put(re%2?17:236,8);return V(Q,_,E)}function V(_,E,R){const Q=i.getSymbolTotalCodewords(E),te=v.getTotalCodewordsCount(E,R),W=Q-te,P=v.getBlocksCount(E,R),ne=Q%P,re=P-ne,ge=Math.floor(Q/P),F=Math.floor(W/P),ae=F+1,ce=ge-F,xe=new g(ce);let I=0;const Se=new Array(P),J=new Array(P);let me=0;const w=new Uint8Array(_.buffer);for(let ie=0;ie<P;ie++){const Ne=ie<re?F:ae;Se[ie]=w.slice(I,I+Ne),J[ie]=xe.encode(Se[ie]),I+=Ne,me=Math.max(me,Ne)}const U=new Uint8Array(Q);let $=0,ee,he;for(ee=0;ee<me;ee++)for(he=0;he<P;he++)ee<Se[he].length&&(U[$++]=Se[he][ee]);for(ee=0;ee<ce;ee++)for(he=0;he<P;he++)U[$++]=J[he][ee];return U}function K(_,E,R,Q){let te;if(Array.isArray(_))te=N.fromArray(_);else if(typeof _=="string"){let ge=E;if(!ge){const F=N.rawSplit(_);ge=x.getBestVersionForData(F,R)}te=N.fromString(_,ge||40)}else throw new Error("Invalid data");const W=x.getBestVersionForData(te,R);if(!W)throw new Error("The amount of data is too big to be stored in a QR Code");if(!E)E=W;else if(E<W)throw new Error(`
|
||||
The chosen QR Code version cannot contain this amount of data.
|
||||
Minimum version required to store current data is: `+W+`.
|
||||
`);const P=k(E,R,te),ne=i.getSymbolSize(E),re=new o(ne);return A(re,E),j(re),z(re,E),q(re,R,0),E>=7&&H(re,E),G(re,P),isNaN(Q)&&(Q=h.getBestMask(re,q.bind(null,re,R))),h.applyMask(Q,re),q(re,R,Q),{modules:re,version:E,errorCorrectionLevel:R,maskPattern:Q,segments:te}}return Af.create=function(E,R){if(typeof E>"u"||E==="")throw new Error("No input text");let Q=u.M,te,W;return typeof R<"u"&&(Q=u.from(R.errorCorrectionLevel,u.M),te=x.from(R.version),W=h.from(R.maskPattern),R.toSJISFunc&&i.setToSJISFunction(R.toSJISFunc)),K(E,te,Q,W)},Af}var If={},Jf={},My;function Ip(){return My||(My=1,(function(i){function u(c){if(typeof c=="number"&&(c=c.toString()),typeof c!="string")throw new Error("Color should be defined as hex string");let o=c.slice().replace("#","").split("");if(o.length<3||o.length===5||o.length>8)throw new Error("Invalid hex color: "+c);(o.length===3||o.length===4)&&(o=Array.prototype.concat.apply([],o.map(function(d){return[d,d]}))),o.length===6&&o.push("F","F");const s=parseInt(o.join(""),16);return{r:s>>24&255,g:s>>16&255,b:s>>8&255,a:s&255,hex:"#"+o.slice(0,6).join("")}}i.getOptions=function(o){o||(o={}),o.color||(o.color={});const s=typeof o.margin>"u"||o.margin===null||o.margin<0?4:o.margin,d=o.width&&o.width>=21?o.width:void 0,h=o.scale||4;return{width:d,scale:d?4:h,margin:s,color:{dark:u(o.color.dark||"#000000ff"),light:u(o.color.light||"#ffffffff")},type:o.type,rendererOpts:o.rendererOpts||{}}},i.getScale=function(o,s){return s.width&&s.width>=o+s.margin*2?s.width/(o+s.margin*2):s.scale},i.getImageWidth=function(o,s){const d=i.getScale(o,s);return Math.floor((o+s.margin*2)*d)},i.qrToImageData=function(o,s,d){const h=s.modules.size,v=s.modules.data,g=i.getScale(h,d),x=Math.floor((h+d.margin*2)*g),S=d.margin*g,p=[d.color.light,d.color.dark];for(let N=0;N<x;N++)for(let A=0;A<x;A++){let j=(N*x+A)*4,z=d.color.light;if(N>=S&&A>=S&&N<x-S&&A<x-S){const H=Math.floor((N-S)/g),q=Math.floor((A-S)/g);z=p[v[H*h+q]?1:0]}o[j++]=z.r,o[j++]=z.g,o[j++]=z.b,o[j]=z.a}}})(Jf)),Jf}var Oy;function bE(){return Oy||(Oy=1,(function(i){const u=Ip();function c(s,d,h){s.clearRect(0,0,d.width,d.height),d.style||(d.style={}),d.height=h,d.width=h,d.style.height=h+"px",d.style.width=h+"px"}function o(){try{return document.createElement("canvas")}catch{throw new Error("You need to specify a canvas element")}}i.render=function(d,h,v){let g=v,x=h;typeof g>"u"&&(!h||!h.getContext)&&(g=h,h=void 0),h||(x=o()),g=u.getOptions(g);const S=u.getImageWidth(d.modules.size,g),p=x.getContext("2d"),N=p.createImageData(S,S);return u.qrToImageData(N.data,d,g),c(p,x,S),p.putImageData(N,0,0),x},i.renderToDataURL=function(d,h,v){let g=v;typeof g>"u"&&(!h||!h.getContext)&&(g=h,h=void 0),g||(g={});const x=i.render(d,h,g),S=g.type||"image/png",p=g.rendererOpts||{};return x.toDataURL(S,p.quality)}})(If)),If}var Ff={},zy;function xE(){if(zy)return Ff;zy=1;const i=Ip();function u(s,d){const h=s.a/255,v=d+'="'+s.hex+'"';return h<1?v+" "+d+'-opacity="'+h.toFixed(2).slice(1)+'"':v}function c(s,d,h){let v=s+d;return typeof h<"u"&&(v+=" "+h),v}function o(s,d,h){let v="",g=0,x=!1,S=0;for(let p=0;p<s.length;p++){const N=Math.floor(p%d),A=Math.floor(p/d);!N&&!x&&(x=!0),s[p]?(S++,p>0&&N>0&&s[p-1]||(v+=x?c("M",N+h,.5+A+h):c("m",g,0),g=0,x=!1),N+1<d&&s[p+1]||(v+=c("h",S),S=0)):g++}return v}return Ff.render=function(d,h,v){const g=i.getOptions(h),x=d.modules.size,S=d.modules.data,p=x+g.margin*2,N=g.color.light.a?"<path "+u(g.color.light,"fill")+' d="M0 0h'+p+"v"+p+'H0z"/>':"",A="<path "+u(g.color.dark,"stroke")+' d="'+o(S,x,g.margin)+'"/>',j='viewBox="0 0 '+p+" "+p+'"',H='<svg xmlns="http://www.w3.org/2000/svg" '+(g.width?'width="'+g.width+'" height="'+g.width+'" ':"")+j+' shape-rendering="crispEdges">'+N+A+`</svg>
|
||||
`;return typeof v=="function"&&v(null,H),H},Ff}var Dy;function SE(){if(Dy)return si;Dy=1;const i=tE(),u=pE(),c=bE(),o=xE();function s(d,h,v,g,x){const S=[].slice.call(arguments,1),p=S.length,N=typeof S[p-1]=="function";if(!N&&!i())throw new Error("Callback required as last argument");if(N){if(p<2)throw new Error("Too few arguments provided");p===2?(x=v,v=h,h=g=void 0):p===3&&(h.getContext&&typeof x>"u"?(x=g,g=void 0):(x=g,g=v,v=h,h=void 0))}else{if(p<1)throw new Error("Too few arguments provided");return p===1?(v=h,h=g=void 0):p===2&&!h.getContext&&(g=v,v=h,h=void 0),new Promise(function(A,j){try{const z=u.create(v,g);A(d(z,h,g))}catch(z){j(z)}})}try{const A=u.create(v,g);x(null,d(A,h,g))}catch(A){x(A)}}return si.create=u.create,si.toCanvas=s.bind(null,c.render),si.toDataURL=s.bind(null,c.renderToDataURL),si.toString=s.bind(null,function(d,h,v){return o.render(d,v)}),si}var NE=SE();const Pf=gd(NE),CE=()=>{const{showWechatModal:i,setShowWechatModal:u,showToast:c,fetchTelemetry:o}=Un(),[s,d]=b.useState(null),[h,v]=b.useState(""),[g,x]=b.useState(!1),[S,p]=b.useState(""),[N,A]=b.useState(!1),j=async()=>{var k;x(!0);try{const K=await(await fetch("/api/clawbot/qrcode",{method:"POST"})).json();d(K);const _=(k=K==null?void 0:K.login)==null?void 0:k.qrcode_url;if(_){const E=await Pf.toDataURL(_,{width:240,margin:2,color:{dark:"#09090b",light:"#ffffff"}});v(E)}}catch{c("获取微信 ClawBot 二维码失败")}finally{x(!1)}};b.useEffect(()=>{if(!i)return;let k=!1;(async()=>{var _,E,R;try{const te=await(await fetch("/api/clawbot/status")).json();if(k)return;if(d(te),!te.connected&&(!((_=te.login)!=null&&_.active)||!((E=te.login)!=null&&E.qrcode_url)))await j();else if((R=te.login)!=null&&R.qrcode_url){const W=await Pf.toDataURL(te.login.qrcode_url,{width:240,margin:2,color:{dark:"#09090b",light:"#ffffff"}});k||v(W)}}catch{}})();const K=setInterval(async()=>{var _;try{const E=await fetch("/api/clawbot/status");if(!E.ok)return;const R=await E.json();if(k)return;if(d(R),(_=R.login)!=null&&_.qrcode_url){const Q=await Pf.toDataURL(R.login.qrcode_url,{width:240,margin:2,color:{dark:"#09090b",light:"#ffffff"}});k||v(Q)}}catch{}},1500);return()=>{k=!0,clearInterval(K)}},[i]);const z=async k=>{if(k.preventDefault(),!!S.trim()){A(!0);try{const K=await(await fetch("/api/clawbot/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:S.trim()})})).json();d(K),p(""),c("配对码已提交,正在等待微信验证...")}catch{c("配对码提交失败")}finally{A(!1)}}},H=async()=>{try{const V=await(await fetch("/api/clawbot/disconnect",{method:"POST"})).json();d(V),v(""),o(),c("已断开微信 ClawBot 连接")}catch{c("断开失败")}},q=!!(s!=null&&s.connected),G=s==null?void 0:s.login;return f.jsx(Gd,{open:i,onOpenChange:u,children:f.jsxs(wo,{className:"max-w-lg p-6 space-y-5",children:[f.jsx("div",{className:"flex items-start justify-between gap-3 border-b hl-border pb-4",children:f.jsxs("div",{className:"flex items-center gap-3",children:[f.jsx("div",{className:"w-10 h-10 rounded-xl flex items-center justify-center border hl-border shrink-0",style:{backgroundColor:"rgba(16, 185, 129, 0.14)",color:"var(--success)"},children:f.jsx(pd,{className:"w-5 h-5"})}),f.jsxs("div",{children:[f.jsxs("div",{className:"flex items-center gap-2",children:[f.jsx(To,{className:"text-sm font-bold",children:"微信 ClawBot · M6 Mac mini 直连通道"}),f.jsx(rt,{variant:q?"success":"accent",children:q?"已连接在线":"扫码绑定"})]}),f.jsx(jo,{className:"text-xs font-mono-num mt-0.5",children:"@tencent-weixin/openclaw-weixin · ilinkai.weixin.qq.com"})]})]})}),q?f.jsxs("div",{className:"space-y-4",children:[f.jsxs("div",{className:"p-4 rounded-xl border flex items-center justify-between",style:{backgroundColor:"var(--success-soft)",borderColor:"rgba(16, 185, 129, 0.3)"},children:[f.jsxs("div",{className:"flex items-center gap-3",children:[f.jsx(SS,{className:"w-5 h-5 shrink-0",style:{color:"var(--success)"}}),f.jsxs("div",{children:[f.jsx("div",{className:"text-xs font-bold",children:"微信 ClawBot 长轮询守护线程运行中"}),f.jsxs("div",{className:"text-[11px] font-mono-num mt-0.5",style:{color:"var(--text-muted)"},children:["Bot ID: ",s==null?void 0:s.bot_id," · 累计收发: ",(s==null?void 0:s.msg_count)??0," 条"]})]})]}),f.jsxs(Oe,{variant:"outline",size:"sm",onClick:H,children:[f.jsx(uN,{className:"w-3.5 h-3.5"}),f.jsx("span",{children:"重新绑定"})]})]}),f.jsxs("div",{className:"p-4 rounded-xl hl-muted border hl-border space-y-2.5 text-xs",children:[f.jsx("div",{className:"font-semibold",children:"📱 现在打开手机微信「微信 ClawBot」可直接发送:"}),f.jsxs("div",{className:"grid grid-cols-1 sm:grid-cols-2 gap-2 font-mono-num text-[11px]",children:[f.jsxs("div",{className:"p-2 rounded hl-card",children:[f.jsx("span",{style:{color:"var(--accent)"},children:"状态"})," · 查看 M6 & 极空间遥测"]}),f.jsxs("div",{className:"p-2 rounded hl-card",children:[f.jsx("span
Line truncated
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>HomeLab Console · 家庭云边中控台</title>
|
||||
<link rel="icon" href="https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/apple.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<script type="module" crossorigin src="/assets/index-DnLoh9Rp.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BHNk_TrW.css">
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
+324
-10
@@ -1,28 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
# ==============================================================================
|
||||
# HomeLab All-in-One 专属中控台后端服务 (Port 3002)
|
||||
# 提供 Apple/Shadcn 风格单页应用与全屋设备实时遥测 API (/api/telemetry, /api/action)
|
||||
# HomeLab All-in-One 专属中控台后端服务 (Port 3002 & 3004)
|
||||
# 提供:
|
||||
# 1. Apple/Shadcn 风格单页应用静态资源
|
||||
# 2. 全屋设备实时遥测 API (/api/telemetry, /api/action/*)
|
||||
# 3. 统一自定义 SSO 认证网关 (/api/auth/verify, /api/auth/login, /api/auth/status)
|
||||
# 彻底替代浏览器原生丑陋的 HTTP Basic Auth 弹窗!
|
||||
# ==============================================================================
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from http.cookies import SimpleCookie
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from clawbot import ClawBotManager
|
||||
|
||||
PORT = int(os.environ.get("PORT", "3004"))
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
DIST_DIR = BASE_DIR / "dist"
|
||||
INDEX_FILE = BASE_DIR / "index.html"
|
||||
|
||||
# Unified SSO Credentials & HMAC Secret
|
||||
AUTH_USER = os.environ.get("HOMELAB_USER", "ixvii0iv")
|
||||
AUTH_PASS = os.environ.get("HOMELAB_PASS", "k10y15ZXC.")
|
||||
AUTH_SECRET = os.environ.get("HOMELAB_SECRET", "hl-m6-macmini-sso-secret-2026-961504xyz").encode("utf-8")
|
||||
COOKIE_NAME = "hl_sso"
|
||||
COOKIE_MAX_AGE = 30 * 86400 # 30 Days
|
||||
|
||||
_cache = {"data": None, "ts": 0.0}
|
||||
_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
def create_sso_token(username: str) -> str:
|
||||
exp = int(time.time()) + COOKIE_MAX_AGE
|
||||
payload = f"{username}:{exp}"
|
||||
sig = hmac.new(AUTH_SECRET, payload.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
return f"{payload}:{sig}"
|
||||
|
||||
|
||||
def verify_sso_token(token: str) -> tuple[bool, str]:
|
||||
if not token or token.count(":") != 2:
|
||||
return False, ""
|
||||
try:
|
||||
username, exp_str, sig = token.split(":", 2)
|
||||
if int(exp_str) < int(time.time()):
|
||||
return False, ""
|
||||
payload = f"{username}:{exp_str}"
|
||||
expected = hmac.new(AUTH_SECRET, payload.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
if hmac.compare_digest(sig, expected):
|
||||
return True, username
|
||||
except Exception:
|
||||
pass
|
||||
return False, ""
|
||||
|
||||
|
||||
def run_cmd(cmd: str, timeout: float = 2.0) -> str:
|
||||
try:
|
||||
env = os.environ.copy()
|
||||
@@ -48,7 +89,6 @@ def check_tcp(host: str, port: int, timeout: float = 0.8) -> tuple[bool, int]:
|
||||
def get_mac_mini_stats() -> dict:
|
||||
# 1. CPU Load & Core count
|
||||
load_str = run_cmd("sysctl -n vm.loadavg")
|
||||
# Format: "{ 1.42 1.18 1.05 }"
|
||||
loads = [float(x) for x in re.findall(r"[0-9]+\.[0-9]+", load_str)] or [0.0, 0.0, 0.0]
|
||||
cores = int(run_cmd("sysctl -n hw.ncpu") or "12")
|
||||
cpu_pct = min(100, round((loads[0] / max(1, cores)) * 100, 1))
|
||||
@@ -228,24 +268,134 @@ def get_network_and_edge_stats() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _codeg_call(endpoint: str, payload=None) -> object:
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
f"http://127.0.0.1:3080/api/{endpoint}",
|
||||
data=json.dumps(payload or {}).encode("utf-8"),
|
||||
headers={
|
||||
"Authorization": "Bearer homelab2026",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=2.0) as r:
|
||||
return json.loads(r.read().decode("utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_agents_and_git_overview() -> dict:
|
||||
codeg_ok, codeg_ms = check_tcp("127.0.0.1", 3080)
|
||||
gitea_ok, gitea_ms = check_tcp("127.0.0.1", 3005)
|
||||
|
||||
assistants = _codeg_call("assistant_list") if codeg_ok else []
|
||||
folders = _codeg_call("list_folders") if codeg_ok else []
|
||||
conversations = _codeg_call("list_conversations") if codeg_ok else []
|
||||
stats = _codeg_call("get_stats") if codeg_ok else {}
|
||||
|
||||
# Query local Gitea repositories
|
||||
repos = []
|
||||
if gitea_ok:
|
||||
try:
|
||||
with urllib.request.urlopen("http://127.0.0.1:3005/api/v1/repos/search?limit=10", timeout=2.0) as r:
|
||||
rdata = json.loads(r.read().decode("utf-8"))
|
||||
for item in rdata.get("data", []):
|
||||
repos.append(
|
||||
{
|
||||
"name": item.get("full_name"),
|
||||
"description": item.get("description", ""),
|
||||
"default_branch": item.get("default_branch", "main"),
|
||||
"updated_at": item.get("updated_at", ""),
|
||||
"size_kb": item.get("size", 0),
|
||||
"html_url": item.get("html_url", ""),
|
||||
"clone_url": item.get("clone_url", ""),
|
||||
"ssh_url": item.get("ssh_url", ""),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Query active tmux windows in 'agy' session
|
||||
tmux_windows = []
|
||||
try:
|
||||
out = run_cmd("/opt/homebrew/bin/tmux list-windows -t agy -F '#{window_index}|#{window_name}|#{window_active}|#{pane_current_command}'", timeout=1.5)
|
||||
for line in out.splitlines():
|
||||
parts = line.split("|")
|
||||
if len(parts) >= 4:
|
||||
tmux_windows.append(
|
||||
{
|
||||
"index": parts[0],
|
||||
"name": parts[1],
|
||||
"active": parts[2] == "1",
|
||||
"command": parts[3],
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Check ZSpace NAS Git Cold Storage bundles
|
||||
cold_dir = Path("/Users/ixvii0iv/nas/个人空间/Git_Cold_Storage/bundles")
|
||||
cold_bundles = []
|
||||
try:
|
||||
if cold_dir.exists():
|
||||
for f in sorted(cold_dir.glob("*.bundle")):
|
||||
st = f.stat()
|
||||
cold_bundles.append(
|
||||
{
|
||||
"name": f.name,
|
||||
"size_mb": round(st.st_size / (1024 * 1024), 2),
|
||||
"mtime": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(st.st_mtime)),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"codeg": {
|
||||
"online": codeg_ok,
|
||||
"latency_ms": codeg_ms,
|
||||
"version": "0.42.0",
|
||||
"assistants": assistants if isinstance(assistants, list) else [],
|
||||
"folders": folders if isinstance(folders, list) else [],
|
||||
"conversations": conversations if isinstance(conversations, list) else [],
|
||||
"stats": stats if isinstance(stats, dict) else {},
|
||||
},
|
||||
"gitea": {
|
||||
"online": gitea_ok,
|
||||
"latency_ms": gitea_ms,
|
||||
"version": "28.3.0",
|
||||
"hot_path": "/Users/ixvii0iv/homelab/stacks/git/data",
|
||||
"cold_path": "/Users/ixvii0iv/nas/个人空间/Git_Cold_Storage",
|
||||
"repos": repos,
|
||||
"cold_bundles": cold_bundles,
|
||||
},
|
||||
"tmux_windows": tmux_windows,
|
||||
}
|
||||
|
||||
|
||||
def collect_telemetry() -> dict:
|
||||
now = time.time()
|
||||
with _cache_lock:
|
||||
if _cache["data"] and (now - _cache["ts"] < 2.0):
|
||||
return _cache["data"]
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
with ThreadPoolExecutor(max_workers=6) as pool:
|
||||
f_mini = pool.submit(get_mac_mini_stats)
|
||||
f_nas = pool.submit(get_nas_stats)
|
||||
f_net = pool.submit(get_network_and_edge_stats)
|
||||
f_agy = pool.submit(check_tcp, "127.0.0.1", 7681)
|
||||
f_vw = pool.submit(check_tcp, "127.0.0.1", 8222)
|
||||
f_codeg = pool.submit(check_tcp, "127.0.0.1", 3080)
|
||||
f_gitea = pool.submit(check_tcp, "127.0.0.1", 3005)
|
||||
|
||||
mini = f_mini.result()
|
||||
nas = f_nas.result()
|
||||
net = f_net.result()
|
||||
agy_ok, _ = f_agy.result()
|
||||
vw_ok, _ = f_vw.result()
|
||||
codeg_ok, _ = f_codeg.result()
|
||||
gitea_ok, _ = f_gitea.result()
|
||||
|
||||
data = {
|
||||
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
@@ -254,12 +404,16 @@ def collect_telemetry() -> dict:
|
||||
"network": net,
|
||||
"services": {
|
||||
"agy": agy_ok,
|
||||
"codeg": codeg_ok,
|
||||
"gitea": gitea_ok,
|
||||
"vaultwarden": vw_ok,
|
||||
"nas": nas["online"],
|
||||
"clash": net["router_online"],
|
||||
"router": net["router_online"],
|
||||
"uptime": net["azure_online"],
|
||||
"clawbot": clawbot_mgr.is_connected(),
|
||||
},
|
||||
"clawbot": clawbot_mgr.get_public_status(),
|
||||
}
|
||||
with _cache_lock:
|
||||
_cache["data"] = data
|
||||
@@ -267,27 +421,115 @@ def collect_telemetry() -> dict:
|
||||
return data
|
||||
|
||||
|
||||
clawbot_mgr = ClawBotManager(telemetry_fn=collect_telemetry)
|
||||
|
||||
|
||||
class PortalHandler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
def _send_json(self, obj: dict, status: int = 200):
|
||||
def _get_cookie_token(self) -> str:
|
||||
raw_cookie = self.headers.get("Cookie", "")
|
||||
if not raw_cookie:
|
||||
return ""
|
||||
c = SimpleCookie()
|
||||
try:
|
||||
c.load(raw_cookie)
|
||||
if COOKIE_NAME in c:
|
||||
return c[COOKIE_NAME].value
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback regex parse
|
||||
m = re.search(rf"{COOKIE_NAME}=([^;\s]+)", raw_cookie)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
def _is_authenticated(self) -> tuple[bool, str]:
|
||||
token = self._get_cookie_token()
|
||||
return verify_sso_token(token)
|
||||
|
||||
def _send_json(self, obj: dict, status: int = 200, extra_headers=None):
|
||||
body = json.dumps(obj, ensure_ascii=False).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
if extra_headers:
|
||||
for k, v in extra_headers:
|
||||
self.send_header(k, v)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
# 1. Caddy forward_auth verification endpoint (/api/auth/verify)
|
||||
if self.path.startswith("/api/auth/verify"):
|
||||
ok, username = self._is_authenticated()
|
||||
if ok:
|
||||
self.send_response(200)
|
||||
self.send_header("X-Auth-User", username)
|
||||
self.send_header("Content-Length", "2")
|
||||
self.end_headers()
|
||||
self.wfile.write(b"OK")
|
||||
return
|
||||
|
||||
# Not authenticated -> Redirect to our custom-styled HomeLab Console Login Modal!
|
||||
fwd_proto = self.headers.get("X-Forwarded-Proto", "https")
|
||||
fwd_host = self.headers.get("X-Forwarded-Host") or self.headers.get("Host", "agy.961504.xyz")
|
||||
fwd_uri = self.headers.get("X-Forwarded-Uri", "/")
|
||||
target_url = f"{fwd_proto}://{fwd_host}{fwd_uri}"
|
||||
encoded_target = urllib.parse.quote(target_url, safe="")
|
||||
|
||||
if ".home.961504.xyz" in fwd_host or ":10443" in fwd_host:
|
||||
login_portal = f"https://home.961504.xyz:10443/?auth_redirect={encoded_target}"
|
||||
else:
|
||||
login_portal = f"https://nav.961504.xyz/?auth_redirect={encoded_target}"
|
||||
|
||||
self.send_response(302)
|
||||
self.send_header("Location", login_portal)
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", "0")
|
||||
self.end_headers()
|
||||
return
|
||||
|
||||
# 2. Check current SSO status (/api/auth/status)
|
||||
if self.path.startswith("/api/auth/status"):
|
||||
ok, username = self._is_authenticated()
|
||||
self._send_json({"authenticated": ok, "username": username if ok else None})
|
||||
return
|
||||
|
||||
# 3. Telemetry endpoint (/api/telemetry)
|
||||
if self.path.startswith("/api/telemetry"):
|
||||
self._send_json(collect_telemetry())
|
||||
return
|
||||
|
||||
# Serve SPA index.html for root and any path
|
||||
if INDEX_FILE.exists():
|
||||
body = INDEX_FILE.read_bytes()
|
||||
# 3b. WeChat ClawBot status endpoint (/api/clawbot/status)
|
||||
if self.path.startswith("/api/clawbot/status"):
|
||||
self._send_json(clawbot_mgr.get_public_status())
|
||||
return
|
||||
|
||||
# 3c. Agent Orchestrator & Gitea Cold/Hot Storage overview (/api/agents/overview)
|
||||
if self.path.startswith("/api/agents/overview"):
|
||||
self._send_json(get_agents_and_git_overview())
|
||||
return
|
||||
|
||||
# 4. Serve static assets from Vite build output (dist/) if available
|
||||
clean_path = self.path.split("?", 1)[0].lstrip("/")
|
||||
if clean_path and DIST_DIR.exists():
|
||||
asset_file = (DIST_DIR / clean_path).resolve()
|
||||
if str(asset_file).startswith(str(DIST_DIR.resolve())) and asset_file.is_file():
|
||||
body = asset_file.read_bytes()
|
||||
mime, _ = mimetypes.guess_type(str(asset_file))
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", mime or "application/octet-stream")
|
||||
self.send_header("Cache-Control", "public, max-age=86400")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
return
|
||||
|
||||
# 5. Serve SPA index.html from dist/index.html
|
||||
spa_index = (DIST_DIR / "index.html") if (DIST_DIR / "index.html").exists() else INDEX_FILE
|
||||
if spa_index.exists():
|
||||
body = spa_index.read_bytes()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
@@ -298,6 +540,71 @@ class PortalHandler(BaseHTTPRequestHandler):
|
||||
self.send_error(404, "index.html not found")
|
||||
|
||||
def do_POST(self):
|
||||
# 1. Unified Custom Login Endpoint (/api/auth/login)
|
||||
if self.path == "/api/auth/login":
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
raw = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
||||
try:
|
||||
body = json.loads(raw)
|
||||
except Exception:
|
||||
body = {}
|
||||
username = str(body.get("username", "")).strip()
|
||||
password = str(body.get("password", ""))
|
||||
|
||||
if username == AUTH_USER and password == AUTH_PASS:
|
||||
token = create_sso_token(username)
|
||||
host = self.headers.get("Host", "")
|
||||
headers = []
|
||||
# Set cross-subdomain cookie for *.961504.xyz (works for both nav.961504.xyz, agy.961504.xyz, *.home.961504.xyz)
|
||||
if "961504.xyz" in host:
|
||||
headers.append(
|
||||
(
|
||||
"Set-Cookie",
|
||||
f"{COOKIE_NAME}={token}; Domain=.961504.xyz; Path=/; Max-Age={COOKIE_MAX_AGE}; HttpOnly; Secure; SameSite=Lax",
|
||||
)
|
||||
)
|
||||
else:
|
||||
headers.append(
|
||||
(
|
||||
"Set-Cookie",
|
||||
f"{COOKIE_NAME}={token}; Path=/; Max-Age={COOKIE_MAX_AGE}; HttpOnly; SameSite=Lax",
|
||||
)
|
||||
)
|
||||
self._send_json({"ok": True, "username": username}, status=200, extra_headers=headers)
|
||||
else:
|
||||
self._send_json({"ok": False, "message": "用户名或访问密码不正确"}, status=401)
|
||||
return
|
||||
|
||||
# 2. Logout Endpoint (/api/auth/logout)
|
||||
if self.path == "/api/auth/logout":
|
||||
headers = [
|
||||
("Set-Cookie", f"{COOKIE_NAME}=; Domain=.961504.xyz; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax"),
|
||||
("Set-Cookie", f"{COOKIE_NAME}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax"),
|
||||
]
|
||||
self._send_json({"ok": True}, status=200, extra_headers=headers)
|
||||
return
|
||||
|
||||
# 2b. WeChat ClawBot Endpoints (/api/clawbot/*)
|
||||
if self.path == "/api/clawbot/qrcode":
|
||||
res = clawbot_mgr.start_qr_login()
|
||||
self._send_json(res, status=200 if res.get("ok") else 500)
|
||||
return
|
||||
if self.path == "/api/clawbot/verify":
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
raw = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
||||
try:
|
||||
body = json.loads(raw)
|
||||
except Exception:
|
||||
body = {}
|
||||
res = clawbot_mgr.submit_verify_code(str(body.get("code", "")))
|
||||
self._send_json(res)
|
||||
return
|
||||
if self.path == "/api/clawbot/disconnect":
|
||||
res = clawbot_mgr.disconnect()
|
||||
self._send_json(res)
|
||||
return
|
||||
|
||||
# 3. Operations Actions
|
||||
if self.path == "/api/action/backup_nas":
|
||||
out = run_cmd("/Users/ixvii0iv/homelab/scripts/backup-to-nas.sh", timeout=15.0)
|
||||
with _cache_lock:
|
||||
@@ -316,10 +623,17 @@ class PortalHandler(BaseHTTPRequestHandler):
|
||||
_cache["ts"] = 0.0
|
||||
self._send_json({"ok": True, "message": out or "Cloudflare DDNS 已刷新"})
|
||||
return
|
||||
if self.path == "/api/action/sync_git_nas":
|
||||
out = run_cmd("/Users/ixvii0iv/homelab/scripts/sync-git-to-nas.sh", timeout=20.0)
|
||||
self._send_json({"ok": True, "message": out or "Git 热层与极空间 20TB 冷备归档已同步"})
|
||||
return
|
||||
self._send_json({"ok": False, "message": "Unknown action"}, status=404)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
server = ThreadingHTTPServer(("0.0.0.0", PORT), PortalHandler)
|
||||
print(f"[HomeLab Portal] Listening on http://0.0.0.0:{PORT}")
|
||||
# 同时监听主入口 3002 (https://nav.961504.xyz) 与预览端口 3004
|
||||
srv_preview = ThreadingHTTPServer(("0.0.0.0", 3004), PortalHandler)
|
||||
threading.Thread(target=srv_preview.serve_forever, daemon=True).start()
|
||||
server = ThreadingHTTPServer(("0.0.0.0", 3002), PortalHandler)
|
||||
print("[HomeLab Portal V3] Listening on http://0.0.0.0:3002 and :3004")
|
||||
server.serve_forever()
|
||||
@@ -0,0 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>HomeLab Console · 家庭云边中控台</title>
|
||||
<link rel="icon" href="https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/apple.png" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body class="min-h-screen">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+3676
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "homelab-console",
|
||||
"private": true,
|
||||
"version": "3.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.3.1",
|
||||
"@radix-ui/react-label": "^2.1.17",
|
||||
"@radix-ui/react-separator": "^1.2.0",
|
||||
"@radix-ui/react-slot": "^1.5.0",
|
||||
"@radix-ui/react-switch": "^1.4.1",
|
||||
"@radix-ui/react-tabs": "^1.2.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^0.475.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^3.0.1",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"tailwindcss": "^4.0.6",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { usePortalStore } from './store/usePortalStore';
|
||||
import { TopRibbon } from './components/TopRibbon';
|
||||
import { ServiceCard } from './components/ServiceCard';
|
||||
import { SshShelf } from './components/SshShelf';
|
||||
import { CommandPalette } from './components/CommandPalette';
|
||||
import { AgyTerminalModal } from './components/AgyTerminalModal';
|
||||
import { AuthGateCard } from './components/AuthGateModal';
|
||||
import { WechatClawModal } from './components/WechatClawModal';
|
||||
import { StorybookWorkbench } from './components/StorybookWorkbench';
|
||||
import { AgentOrchestratorWorkbench } from './components/AgentOrchestratorWorkbench';
|
||||
import { LAYOUT_PRESETS } from './design-system/tokens';
|
||||
import type { ServiceGroup } from './types/telemetry';
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const {
|
||||
telemetry,
|
||||
toast,
|
||||
viewMode,
|
||||
layoutPreset,
|
||||
fetchTelemetry,
|
||||
setShowCmdPalette,
|
||||
getEffectiveMode,
|
||||
routeMode,
|
||||
} = usePortalStore();
|
||||
|
||||
const [authRedirect, setAuthRedirect] = useState<string | null>(() => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get('auth_redirect');
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchTelemetry();
|
||||
const timer = setInterval(fetchTelemetry, 3000);
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault();
|
||||
setShowCmdPalette(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [fetchTelemetry, setShowCmdPalette]);
|
||||
|
||||
const activeModeLabel = useMemo(() => {
|
||||
const m = getEffectiveMode();
|
||||
if (m === 'lan') return 'LAN 局域网极速直连 (192.168.31.x)';
|
||||
if (m === 'home10443') return '10443 家宽公网 HTTPS 直连';
|
||||
return '443 免端口 HTTPS 直达 (*.961504.xyz)';
|
||||
}, [getEffectiveMode, routeMode]);
|
||||
|
||||
const gridClass =
|
||||
LAYOUT_PRESETS[layoutPreset]?.gridClass || LAYOUT_PRESETS['homepage-3col'].gridClass;
|
||||
|
||||
const serviceGroups = useMemo<ServiceGroup[]>(() => {
|
||||
const t = telemetry;
|
||||
return [
|
||||
{
|
||||
title: '核心工作台、多智能体编排与私有代码库',
|
||||
subtitle: 'AI Agent Orchestration, Code Hub & Storage',
|
||||
showRouteHint: true,
|
||||
items: [
|
||||
{
|
||||
id: 'codeg',
|
||||
title: 'Codeg 多智能体协同工作台',
|
||||
subtitle: 'codeg.961504.xyz · ACP 20+ CLI 底座',
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/github-copilot.png',
|
||||
online: t.services.codeg ?? true,
|
||||
desc: '原生聚合 Google Antigravity (agy)、Claude Code、Codex 等 20 种 CLI,支持 4 大分工角色单聊、@点名群聊委派、Git Worktree 待办与无限画布。',
|
||||
edgeUrl: 'https://codeg.961504.xyz',
|
||||
homeUrl: 'https://codeg.home.961504.xyz:10443',
|
||||
lanUrl: 'http://192.168.31.164:3080',
|
||||
stats: [
|
||||
{ label: '预设分工角色', value: '4 角色就绪', highlight: true },
|
||||
{ label: '底座引擎', value: 'Codeg v0.42.0' },
|
||||
{ label: 'SSO 免密', value: 'Proxy Auth 已注入' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'gitea',
|
||||
title: 'Gitea 私有代码与冷备中枢',
|
||||
subtitle: 'git.961504.xyz · Mac SSD 热层 + NAS 冷层',
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/gitea.png',
|
||||
online: t.services.gitea ?? true,
|
||||
desc: '自托管私有 Git 代码仓库,数据库与活跃索引跑在 M6 Mac mini 本地 SSD,Git LFS 大文件与 .bundle 全量冷备归档于极空间 20TB RAID。',
|
||||
edgeUrl: 'https://git.961504.xyz',
|
||||
homeUrl: 'https://git.home.961504.xyz:10443',
|
||||
lanUrl: 'http://192.168.31.164:3005',
|
||||
stats: [
|
||||
{ label: '主仓库', value: 'admin/homelab', highlight: true },
|
||||
{ label: '热层介质', value: 'M6 NVMe SSD' },
|
||||
{ label: '冷存储池', value: '极空间 20TB NAS' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'agy',
|
||||
title: 'AGY 云端工作台',
|
||||
subtitle: 'agy.961504.xyz · ttyd + tmux',
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/google-gemini.png',
|
||||
online: t.services.agy,
|
||||
actionType: 'drawer',
|
||||
desc: 'M6 Mac mini 上的 24 小时常驻 AI 编程终端,已同步 Google 登录态并锁定新加坡出口,关掉网页后台继续运行。',
|
||||
edgeUrl: 'https://agy.961504.xyz',
|
||||
homeUrl: 'https://agy.home.961504.xyz:10443',
|
||||
lanUrl: 'http://192.168.31.164:7681',
|
||||
stats: [
|
||||
{ label: 'M6 CPU 负载', value: `${t.mini.cpu_pct}%` },
|
||||
{ label: '已用内存', value: `${t.mini.mem_used_gb} / 24 GB` },
|
||||
{
|
||||
label: 'tmux 会话',
|
||||
value: t.mini.agy_tmux ? '常驻运行中' : '待机',
|
||||
highlight: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'zspace',
|
||||
title: '极空间 Z4 私有云',
|
||||
subtitle: '192.168.31.185 · 20TB RAID 存储池',
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/synology-drive.png',
|
||||
online: t.services.nas,
|
||||
desc: '家庭 20TB 大容量存储中心、极影视蓝光影院与手机相册备份,已自动 SMB 挂载至 Mac mini ~/nas。',
|
||||
edgeUrl: 'https://z4.home.961504.xyz:10443',
|
||||
homeUrl: 'https://z4.home.961504.xyz:10443',
|
||||
lanUrl: 'http://192.168.31.185:5055',
|
||||
stats: [
|
||||
{ label: '剩余可用', value: `${t.nas.free_tb} TB`, highlight: true },
|
||||
{ label: '已用容量', value: `${t.nas.used_tb} TB (${t.nas.pct}%)` },
|
||||
{ label: 'SMB 挂载', value: t.nas.mounted ? '~/nas 已挂载' : '重连中' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'vaultwarden',
|
||||
title: 'Vaultwarden 密码保险箱',
|
||||
subtitle: 'vw.961504.xyz · Bitwarden 兼容',
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/vaultwarden.png',
|
||||
online: t.services.vaultwarden,
|
||||
actionType: 'backup',
|
||||
desc: '自托管全平台加密密码库,支持 iOS / Android / 浏览器插件自动填充,每天凌晨 03:30 自动打包灾备至极空间。',
|
||||
edgeUrl: 'https://vw.961504.xyz',
|
||||
homeUrl: 'https://vw.home.961504.xyz:10443',
|
||||
lanUrl: 'http://192.168.31.164:8222',
|
||||
stats: [
|
||||
{ label: '容器状态', value: 'Healthy', highlight: true },
|
||||
{ label: '最近极空间灾备', value: t.nas.latest_backup },
|
||||
{ label: 'Mac SSD 占用', value: `${t.mini.disk_pct}%` },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '网络网关、AI 专线与外部哨兵',
|
||||
subtitle: 'Network Gateway & Cloud Edge',
|
||||
showRouteHint: false,
|
||||
items: [
|
||||
{
|
||||
id: 'clash',
|
||||
title: 'Mihomo 路由分流面板',
|
||||
subtitle: '192.168.31.1:9999 · ShellCrash',
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/clash.png',
|
||||
online: t.services.clash,
|
||||
desc: '小米主路由器透明网关控制台,实时监控全屋设备流量、分流规则与狮城 AI 算力专线锁定状态。',
|
||||
edgeUrl: 'https://clash.home.961504.xyz:10443/ui/',
|
||||
homeUrl: 'https://clash.home.961504.xyz:10443/ui/',
|
||||
lanUrl: 'http://192.168.31.1:9999/ui/',
|
||||
stats: [
|
||||
{ label: 'AI 锁定策略', value: '狮城节点', highlight: true },
|
||||
{
|
||||
label: '新加坡延迟',
|
||||
value: t.network.sg_delay_ms > 0 ? `${t.network.sg_delay_ms} ms` : '直连',
|
||||
},
|
||||
{ label: '固定出口 IP', value: t.network.sg_egress_ip },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'router',
|
||||
title: '小米主路由器后台',
|
||||
subtitle: '192.168.31.1 · PPPoE 公网网关',
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/openwrt.png',
|
||||
online: t.services.router,
|
||||
desc: '管理家庭联通公网宽带拨号、DHCP 静态 IP 绑定、10443 与 41641 端口转发及全屋内网 Split-DNS。',
|
||||
edgeUrl: 'https://router.home.961504.xyz:10443',
|
||||
homeUrl: 'https://router.home.961504.xyz:10443',
|
||||
lanUrl: 'http://192.168.31.1',
|
||||
stats: [
|
||||
{ label: '联通公网 IPv4', value: t.network.home_wan_ip, highlight: true },
|
||||
{ label: '泛域名 DDNS', value: '*.home.961504.xyz' },
|
||||
{ label: '内网网关', value: '192.168.31.1' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'uptime',
|
||||
title: 'Uptime Kuma 外部哨兵',
|
||||
subtitle: 'status.961504.xyz · SG Azure',
|
||||
iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/uptime-kuma.png',
|
||||
online: t.services.uptime,
|
||||
desc: '独立部署在新加坡 Azure 公有云上的 24 小时外部监控哨兵,通过 Tailscale P2P 专线实时探测家庭服务。',
|
||||
edgeUrl: 'https://status.961504.xyz',
|
||||
homeUrl: 'https://status.961504.xyz',
|
||||
lanUrl: 'https://status.961504.xyz',
|
||||
stats: [
|
||||
{ label: '云端公网 IP', value: '104.43.105.243' },
|
||||
{ label: 'Tailscale 专线', value: '100.115.204.51' },
|
||||
{ label: '外部探测状态', value: '100% 在线', highlight: true },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}, [telemetry]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Toast Notification */}
|
||||
{toast && (
|
||||
<div className="fixed bottom-5 right-5 z-50 hl-card px-4 py-3 flex items-center gap-2.5 shadow-2xl">
|
||||
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: 'var(--success)' }} />
|
||||
<span className="text-xs font-medium">{toast}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 1. Standard Two-Tier AppShell Header & Telemetry Subheader */}
|
||||
<TopRibbon />
|
||||
|
||||
{/* 2. View Switch: Dashboard vs Agent Orchestrator vs Storybook Workbench */}
|
||||
{viewMode === 'storybook' ? (
|
||||
<StorybookWorkbench />
|
||||
) : viewMode === 'agents' ? (
|
||||
<AgentOrchestratorWorkbench />
|
||||
) : (
|
||||
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-7">
|
||||
{serviceGroups.map((group) => (
|
||||
<section key={group.title} className="space-y-3">
|
||||
<div className="flex items-center justify-between px-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2
|
||||
className="text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{group.title}
|
||||
</h2>
|
||||
<span
|
||||
className="text-[11px] font-mono-num"
|
||||
style={{ color: 'var(--text-subtle)' }}
|
||||
>
|
||||
· {group.subtitle}
|
||||
</span>
|
||||
</div>
|
||||
{group.showRouteHint && (
|
||||
<span
|
||||
className="text-[11px] font-mono-num hidden sm:inline"
|
||||
style={{ color: 'var(--text-subtle)' }}
|
||||
>
|
||||
当前路由模式: {activeModeLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`grid ${gridClass}`}>
|
||||
{group.items.map((card) => (
|
||||
<ServiceCard key={card.id} card={card} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
|
||||
{/* 3. Bottom SSH Quick Copy & Maintenance Shelf */}
|
||||
<SshShelf />
|
||||
</main>
|
||||
)}
|
||||
|
||||
{/* 4. Global Cmd+K Command Palette */}
|
||||
<CommandPalette />
|
||||
|
||||
{/* 5. Embedded AGY Cloud Terminal Modal */}
|
||||
<AgyTerminalModal />
|
||||
|
||||
{/* 5b. WeChat ClawBot QR Code & Live Status Modal */}
|
||||
<WechatClawModal />
|
||||
|
||||
{/* 6. Custom SSO Auth Gate Modal (Replaces Browser Native Basic Auth Popup!) */}
|
||||
{authRedirect && (
|
||||
<div className="fixed inset-0 z-50 bg-black/75 backdrop-blur-sm flex items-center justify-center p-4">
|
||||
<AuthGateCard
|
||||
targetUrl={authRedirect}
|
||||
onSuccess={() => {
|
||||
window.location.href = authRedirect;
|
||||
}}
|
||||
onCancel={() => {
|
||||
setAuthRedirect(null);
|
||||
window.history.replaceState({}, '', '/');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,531 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Archive,
|
||||
Bot,
|
||||
Copy,
|
||||
ExternalLink,
|
||||
FolderGit2,
|
||||
GitBranch,
|
||||
HardDrive,
|
||||
Layers,
|
||||
MessageSquare,
|
||||
RefreshCw,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { usePortalStore } from '../store/usePortalStore';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card';
|
||||
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
|
||||
|
||||
interface AssistantItem {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
agent_type: string;
|
||||
instructions: string;
|
||||
avatar?: { kind: string; color?: string };
|
||||
}
|
||||
|
||||
interface RepoItem {
|
||||
name: string;
|
||||
description: string;
|
||||
default_branch: string;
|
||||
updated_at: string;
|
||||
size_kb: number;
|
||||
html_url: string;
|
||||
clone_url: string;
|
||||
ssh_url: string;
|
||||
}
|
||||
|
||||
interface ColdBundleItem {
|
||||
name: string;
|
||||
size_mb: number;
|
||||
mtime: string;
|
||||
}
|
||||
|
||||
interface OverviewData {
|
||||
codeg: {
|
||||
online: boolean;
|
||||
latency_ms: number;
|
||||
version: string;
|
||||
assistants: AssistantItem[];
|
||||
folders: Array<{ id: number; name: string; path: string }>;
|
||||
conversations: Array<{ id: number; title: string; agent_type: string; status: string }>;
|
||||
};
|
||||
gitea: {
|
||||
online: boolean;
|
||||
latency_ms: number;
|
||||
version: string;
|
||||
hot_path: string;
|
||||
cold_path: string;
|
||||
repos: RepoItem[];
|
||||
cold_bundles: ColdBundleItem[];
|
||||
};
|
||||
tmux_windows: Array<{ index: string; name: string; active: boolean; command: string }>;
|
||||
}
|
||||
|
||||
const COLOR_MAP: Record<string, string> = {
|
||||
blue: '#3b82f6',
|
||||
violet: '#8b5cf6',
|
||||
teal: '#14b8a6',
|
||||
amber: '#f59e0b',
|
||||
};
|
||||
|
||||
export const AgentOrchestratorWorkbench: React.FC = () => {
|
||||
const { getEffectiveMode, routeMode, copyCmd, showToast, setShowAgyModal, setShowWechatModal } =
|
||||
usePortalStore();
|
||||
const [subTab, setSubTab] = useState<'native' | 'embedded'>('native');
|
||||
const [data, setData] = useState<OverviewData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncingCold, setSyncingCold] = useState(false);
|
||||
|
||||
const effectiveMode = useMemo(() => getEffectiveMode(), [getEffectiveMode, routeMode]);
|
||||
|
||||
const codegUrl = useMemo(() => {
|
||||
if (effectiveMode === 'lan') return 'http://192.168.31.164:3080';
|
||||
if (effectiveMode === 'home10443') return 'https://codeg.home.961504.xyz:10443';
|
||||
return 'https://codeg.961504.xyz';
|
||||
}, [effectiveMode]);
|
||||
|
||||
const giteaUrl = useMemo(() => {
|
||||
if (effectiveMode === 'lan') return 'http://192.168.31.164:3005';
|
||||
if (effectiveMode === 'home10443') return 'https://git.home.961504.xyz:10443';
|
||||
return 'https://git.961504.xyz';
|
||||
}, [effectiveMode]);
|
||||
|
||||
const fetchOverview = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch('/api/agents/overview');
|
||||
if (res.ok) {
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
}
|
||||
} catch {
|
||||
// ignore transient network errors
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchOverview();
|
||||
const timer = setInterval(fetchOverview, 6000);
|
||||
return () => clearInterval(timer);
|
||||
}, [fetchOverview]);
|
||||
|
||||
const handleSyncCold = async () => {
|
||||
setSyncingCold(true);
|
||||
showToast('正在将 Gitea 热层仓库与 LFS 大文件归档至极空间 20TB NAS...');
|
||||
try {
|
||||
const res = await fetch('/api/action/sync_git_nas', { method: 'POST' });
|
||||
const d = await res.json();
|
||||
showToast(d.message || '极空间 Git 冷备同步完成');
|
||||
await fetchOverview();
|
||||
} catch {
|
||||
showToast('冷备同步请求失败');
|
||||
} finally {
|
||||
setSyncingCold(false);
|
||||
}
|
||||
};
|
||||
|
||||
const assistants = data?.codeg.assistants || [];
|
||||
const repos = data?.gitea.repos || [];
|
||||
const bundles = data?.gitea.cold_bundles || [];
|
||||
const tmuxWindows = data?.tmux_windows || [];
|
||||
|
||||
return (
|
||||
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
|
||||
{/* Top Banner: Architecture Philosophy & Quick Switcher */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 pb-2 border-b hl-border">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h1 className="text-lg font-semibold tracking-tight flex items-center gap-2">
|
||||
<Bot className="w-5 h-5" style={{ color: 'var(--accent)' }} />
|
||||
<span>Multi-Agent 协同编排与私有 Git 中枢</span>
|
||||
</h1>
|
||||
<Badge variant="success">Codeg v0.42 + Gitea v28.3</Badge>
|
||||
</div>
|
||||
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||
以开源 Codeg (ACP 协议) 为底座、Gitea (Mac SSD 热层 + 极空间 20TB 冷层) 为代码仓库,支持单聊分工角色与{' '}
|
||||
<code className="font-mono-num px-1 py-0.5 rounded hl-muted">@点名</code> 跨智能体群聊协作。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tabs value={subTab} onValueChange={(v) => setSubTab(v as 'native' | 'embedded')}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="native">
|
||||
<Layers className="w-3.5 h-3.5" />
|
||||
<span>自研编排 & Git 大盘</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="embedded">
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<span>内嵌 Codeg 完整画布</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(codegUrl, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
<span>全屏打开 Codeg</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(giteaUrl, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
<FolderGit2 className="w-3.5 h-3.5" />
|
||||
<span>打开 Gitea</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{subTab === 'embedded' ? (
|
||||
<Card className="overflow-hidden">
|
||||
<div
|
||||
className="px-4 py-2.5 border-b hl-border flex items-center justify-between text-xs"
|
||||
style={{ backgroundColor: 'var(--bg-muted)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: 'var(--success)' }} />
|
||||
<span className="font-medium">Codeg 原生多智能体工作台 (已注入 SSO 免密凭证)</span>
|
||||
<span className="font-mono-num" style={{ color: 'var(--text-muted)' }}>
|
||||
{codegUrl}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(codegUrl, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
<span>新标签页全屏</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<iframe
|
||||
src={codegUrl}
|
||||
title="Codeg Multi-Agent Workspace"
|
||||
className="w-full border-0"
|
||||
style={{ height: 'calc(100vh - 220px)', minHeight: '640px' }}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* Section 1: 4 Specialist Role Assistants (Single Chat + @Mention Squad) */}
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4" style={{ color: 'var(--accent)' }} />
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider" style={{ color: 'var(--text-muted)' }}>
|
||||
分工角色会话与 @群聊战队 · Specialist Role Squad (`Google Antigravity` 底座)
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={fetchOverview} disabled={loading}>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||
<span>刷新状态</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setShowWechatModal(true)}>
|
||||
<MessageSquare className="w-3.5 h-3.5" />
|
||||
<span>微信入口绑定</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{assistants.map((a) => {
|
||||
const badgeColor = COLOR_MAP[a.avatar?.color || 'blue'] || '#3b82f6';
|
||||
return (
|
||||
<Card key={a.id} className="p-4 flex flex-col justify-between gap-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center text-white font-semibold text-sm shrink-0"
|
||||
style={{ backgroundColor: badgeColor }}
|
||||
>
|
||||
{a.name.slice(0, 1)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-sm flex items-center gap-1.5">
|
||||
<span>@{a.name}</span>
|
||||
</div>
|
||||
<div className="text-[11px] font-mono-num" style={{ color: 'var(--text-muted)' }}>
|
||||
CLI: {a.agent_type === 'antigravity' ? 'Google Antigravity (agy)' : a.agent_type}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="success">就绪</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-xs leading-relaxed" style={{ color: 'var(--text-muted)' }}>
|
||||
{a.description}
|
||||
</p>
|
||||
|
||||
<div
|
||||
className="p-2 rounded-md text-[11px] font-mono-num line-clamp-2 hl-muted"
|
||||
style={{ color: 'var(--text-subtle)' }}
|
||||
title={a.instructions}
|
||||
>
|
||||
Prompt: {a.instructions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t hl-border flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={() => setSubTab('embedded')}
|
||||
>
|
||||
<span>发起单聊</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyCmd(`@${a.name} `, `群聊点名 @${a.name}`)}
|
||||
title="复制 @点名 指令,可在主会话或群聊中直接委派该角色"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
<span>@点名</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Section 2: Self-Hosted Gitea (Mac SSD Hot Tier + ZSpace 20TB Cold Tier) & Active AGY Sessions */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||
{/* Left 2 Columns: Gitea Hot/Cold Tier Architecture */}
|
||||
<Card className="lg:col-span-2 p-5 space-y-4">
|
||||
<CardHeader className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 pb-3 border-b hl-border">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<FolderGit2 className="w-4 h-4" style={{ color: 'var(--accent)' }} />
|
||||
<span>私有 Git 代码中枢 (Gitea 热冷分层存储架构)</span>
|
||||
<Badge variant={data?.gitea.online ? 'success' : 'warning'}>
|
||||
{data?.gitea.online ? `在线 · ${data.gitea.latency_ms}ms` : '检测中'}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
服务端与活跃索引常驻 M6 Mac mini NVMe SSD,LFS 大文件与全量 <code className="font-mono-num">.bundle</code> 镜像实时归档至极空间 Z4 20TB RAID。
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSyncCold}
|
||||
disabled={syncingCold}
|
||||
>
|
||||
<Archive className={`w-3.5 h-3.5 ${syncingCold ? 'animate-spin' : ''}`} />
|
||||
<span>{syncingCold ? '归档中...' : '立即同步至极空间冷存'}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => window.open(giteaUrl, '_blank', 'noopener,noreferrer')}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
<span>Gitea 控制台</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Hot vs Cold Tier Storage Boxes */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div className="p-3 rounded-lg border hl-border hl-muted space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-semibold flex items-center gap-1.5">
|
||||
🔥 Mac mini 热存储层 (NVMe SSD)
|
||||
</span>
|
||||
<Badge variant="default">毫秒级读写</Badge>
|
||||
</div>
|
||||
<div className="text-[11px] font-mono-num truncate" style={{ color: 'var(--text-muted)' }}>
|
||||
{data?.gitea.hot_path || '/Users/ixvii0iv/homelab/stacks/git/data'}
|
||||
</div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--text-subtle)' }}>
|
||||
承载 SQLite 元数据、活跃 Git 裸仓库与 Agent Worktree 并发合并
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-lg border hl-border hl-muted space-y-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="font-semibold flex items-center gap-1.5">
|
||||
<HardDrive className="w-3.5 h-3.5" style={{ color: 'var(--success)' }} />
|
||||
🧊 极空间 Z4 冷存储层 (20TB RAID)
|
||||
</span>
|
||||
<Badge variant="success">{bundles.length} 个全量冷备包</Badge>
|
||||
</div>
|
||||
<div className="text-[11px] font-mono-num truncate" style={{ color: 'var(--text-muted)' }}>
|
||||
{data?.gitea.cold_path || '/Users/ixvii0iv/nas/个人空间/Git_Cold_Storage'}
|
||||
</div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--text-subtle)' }}>
|
||||
承载 Git LFS 大文件、Release 制品包与自包含 .bundle 离线灾备包
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hosted Repositories List */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium" style={{ color: 'var(--text-muted)' }}>
|
||||
当前托管仓库 ({repos.length})
|
||||
</div>
|
||||
{repos.map((r) => (
|
||||
<div
|
||||
key={r.name}
|
||||
className="p-3 rounded-lg border hl-border flex flex-col sm:flex-row sm:items-center justify-between gap-3"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="w-3.5 h-3.5" style={{ color: 'var(--accent)' }} />
|
||||
<span className="font-semibold text-sm font-mono-num">{r.name}</span>
|
||||
<Badge variant="default">{r.default_branch}</Badge>
|
||||
{bundles.some((b) => b.name.includes(r.name.replace('/', '_'))) && (
|
||||
<Badge variant="success">已冷备至 NAS</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||
{r.description || 'HomeLab 核心基础设施与前端工程仓库'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
copyCmd(`git clone ${giteaUrl}/${r.name}.git`, `克隆 ${r.name}`)
|
||||
}
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
<span>复制 Clone</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
window.open(`${giteaUrl}/${r.name}`, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
<span>查看代码</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Right Column: Active AGY Tmux & Workspace Sessions */}
|
||||
<Card className="p-5 flex flex-col justify-between space-y-4">
|
||||
<div className="space-y-4">
|
||||
<CardHeader className="pb-3 border-b hl-border">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4" style={{ color: 'var(--accent)' }} />
|
||||
<span>AGY 原生常驻会话流</span>
|
||||
</span>
|
||||
<Badge variant="success">{tmuxWindows.length || 1} 窗口活跃</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
M6 Mac mini 本机 <code className="font-mono-num">tmux (agy)</code> 与 Codeg 工作区实时状态。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<div className="text-xs font-medium" style={{ color: 'var(--text-muted)' }}>
|
||||
已挂载 Codeg 工作区目录
|
||||
</div>
|
||||
{(data?.codeg.folders || []).map((f) => (
|
||||
<div
|
||||
key={f.id}
|
||||
className="p-2.5 rounded-lg border hl-border hl-muted flex items-center justify-between text-xs font-mono-num"
|
||||
>
|
||||
<span className="truncate">{f.path}</span>
|
||||
<Badge variant="success">已绑定</Badge>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="text-xs font-medium pt-2" style={{ color: 'var(--text-muted)' }}>
|
||||
Mac mini Tmux 会话窗格
|
||||
</div>
|
||||
{tmuxWindows.length > 0 ? (
|
||||
tmuxWindows.map((w) => (
|
||||
<div
|
||||
key={w.index}
|
||||
className="p-2.5 rounded-lg border hl-border flex items-center justify-between text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2 font-mono-num">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{
|
||||
backgroundColor: w.active ? 'var(--success)' : 'var(--text-muted)',
|
||||
}}
|
||||
/>
|
||||
<span className="font-medium">
|
||||
#{w.index} {w.name}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="default">{w.command}</Badge>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-2.5 rounded-lg border hl-border text-xs font-mono-num">
|
||||
#0 agy (Google Antigravity 常驻守护)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bundles.length > 0 && (
|
||||
<div className="pt-2 space-y-1.5">
|
||||
<div className="text-xs font-medium" style={{ color: 'var(--text-muted)' }}>
|
||||
最近极空间 NAS 冷备快照
|
||||
</div>
|
||||
{bundles.map((b) => (
|
||||
<div
|
||||
key={b.name}
|
||||
className="p-2 rounded-md hl-muted flex items-center justify-between text-[11px] font-mono-num"
|
||||
>
|
||||
<span className="truncate">{b.name}</span>
|
||||
<span style={{ color: 'var(--success)' }}>
|
||||
{b.size_mb} MB · {b.mtime.slice(5, 16)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t hl-border flex items-center gap-2">
|
||||
<Button className="flex-1" onClick={() => setShowAgyModal(true)}>
|
||||
<Terminal className="w-3.5 h-3.5" />
|
||||
<span>唤起 AGY 终端</span>
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setSubTab('embedded')}>
|
||||
<Sparkles className="w-3.5 h-3.5" />
|
||||
<span>进入画布</span>
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { usePortalStore } from '../store/usePortalStore';
|
||||
import { AuthGateCard } from './AuthGateModal';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog';
|
||||
|
||||
export const AgyTerminalModal: React.FC = () => {
|
||||
const { showAgyModal, setShowAgyModal, getEffectiveMode } = usePortalStore();
|
||||
const [authChecked, setAuthChecked] = useState(false);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
|
||||
const mode = getEffectiveMode();
|
||||
const url =
|
||||
mode === 'lan'
|
||||
? 'http://192.168.31.164:7681'
|
||||
: mode === 'home10443'
|
||||
? 'https://agy.home.961504.xyz:10443'
|
||||
: 'https://agy.961504.xyz';
|
||||
|
||||
useEffect(() => {
|
||||
if (!showAgyModal) return;
|
||||
if (mode === 'lan') {
|
||||
setAuthenticated(true);
|
||||
setAuthChecked(true);
|
||||
return;
|
||||
}
|
||||
setAuthChecked(false);
|
||||
fetch('/api/auth/status', { credentials: 'include' })
|
||||
.then((r) => r.json())
|
||||
.then((d) => {
|
||||
setAuthenticated(Boolean(d.authenticated));
|
||||
setAuthChecked(true);
|
||||
})
|
||||
.catch(() => {
|
||||
setAuthenticated(false);
|
||||
setAuthChecked(true);
|
||||
});
|
||||
}, [showAgyModal, mode]);
|
||||
|
||||
return (
|
||||
<Dialog open={showAgyModal} onOpenChange={setShowAgyModal}>
|
||||
<DialogContent className="w-[95vw] max-w-6xl h-[88vh] flex flex-col overflow-hidden p-0">
|
||||
<DialogTitle className="sr-only">AGY 云端工作台</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
M6 Mac mini 上的 24 小时 tmux 常驻 AI 编程终端
|
||||
</DialogDescription>
|
||||
|
||||
<div className="px-4 py-2.5 border-b hl-border flex items-center justify-between hl-muted shrink-0">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<img
|
||||
src="https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/google-gemini.png"
|
||||
alt="AGY"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-xs font-mono-num font-medium">
|
||||
AGY 云端工作台 · tmux 持久会话 (关窗不断连)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono-num"
|
||||
>
|
||||
新标签页全屏 ↗
|
||||
</a>
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setShowAgyModal(false)}>
|
||||
收起终端
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!authChecked ? (
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center text-xs font-mono-num"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
正在校验 HomeLab SSO 通行证...
|
||||
</div>
|
||||
) : !authenticated ? (
|
||||
<div
|
||||
className="flex-1 flex items-center justify-center p-4"
|
||||
style={{ backgroundColor: 'var(--bg-page)' }}
|
||||
>
|
||||
<AuthGateCard
|
||||
targetUrl={url}
|
||||
onSuccess={() => setAuthenticated(true)}
|
||||
onCancel={() => setShowAgyModal(false)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
src={url}
|
||||
title="AGY Cloud Workbench"
|
||||
className="w-full flex-1 bg-black border-0"
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Eye, EyeOff, Lock, ShieldCheck, Terminal, User } from 'lucide-react';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
import { InputGroup } from './ui/input';
|
||||
import { Switch } from './ui/switch';
|
||||
|
||||
interface AuthGateCardProps {
|
||||
targetUrl?: string;
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export const AuthGateCard: React.FC<AuthGateCardProps> = ({
|
||||
targetUrl = 'https://agy.961504.xyz',
|
||||
onSuccess,
|
||||
onCancel,
|
||||
embedded = false,
|
||||
}) => {
|
||||
const [username, setUsername] = useState('ixvii0iv');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPass, setShowPass] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(true);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username.trim() || !password) {
|
||||
setErrorMsg('请输入用户名与访问密钥');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setErrorMsg(null);
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ username: username.trim(), password, remember: rememberMe }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data.ok) {
|
||||
onSuccess?.();
|
||||
} else {
|
||||
setErrorMsg(data.message || '认证未通过,请检查密码');
|
||||
}
|
||||
} catch {
|
||||
setErrorMsg('网络请求异常,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`w-full max-w-md p-6 space-y-5 ${embedded ? '' : 'shadow-2xl'}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 border-b hl-border pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center border hl-border shrink-0"
|
||||
style={{ backgroundColor: 'var(--accent-soft)', color: 'var(--accent)' }}
|
||||
>
|
||||
<ShieldCheck className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-bold tracking-tight">HomeLab 统一安全通行证</h3>
|
||||
<Badge variant="success">SSO 2.0</Badge>
|
||||
</div>
|
||||
<p
|
||||
className="text-xs font-mono-num mt-0.5 truncate max-w-[260px]"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{targetUrl}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-semibold" style={{ color: 'var(--text-muted)' }}>
|
||||
管理员账号 (Username)
|
||||
</label>
|
||||
<InputGroup>
|
||||
<User className="w-4 h-4 shrink-0" style={{ color: 'var(--text-subtle)' }} />
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="输入管理员账号 (如 ixvii0iv)"
|
||||
className="font-mono-num"
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-xs font-semibold" style={{ color: 'var(--text-muted)' }}>
|
||||
访问通行密钥 (Access Key)
|
||||
</label>
|
||||
<span className="text-[10px] font-mono-num" style={{ color: 'var(--text-subtle)' }}>
|
||||
与 Mac mini 密码一致
|
||||
</span>
|
||||
</div>
|
||||
<InputGroup>
|
||||
<Lock className="w-4 h-4 shrink-0" style={{ color: 'var(--text-subtle)' }} />
|
||||
<input
|
||||
type={showPass ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="输入访问密钥..."
|
||||
className="font-mono-num"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPass(!showPass)}
|
||||
className="p-1 rounded hover:opacity-80 cursor-pointer"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
title={showPass ? '隐藏密码' : '显示密码'}
|
||||
>
|
||||
{showPass ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
</InputGroup>
|
||||
</div>
|
||||
|
||||
{/* Official @radix-ui/react-switch Toggle */}
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<span className="text-xs select-none" style={{ color: 'var(--text-muted)' }}>
|
||||
在此设备保持 30 天免密通行 (*.961504.xyz)
|
||||
</span>
|
||||
<Switch checked={rememberMe} onCheckedChange={setRememberMe} />
|
||||
</div>
|
||||
|
||||
{errorMsg && (
|
||||
<div
|
||||
className="px-3 py-2 rounded-lg text-xs font-medium border"
|
||||
style={{
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
borderColor: 'rgba(239, 68, 68, 0.3)',
|
||||
color: '#ef4444',
|
||||
}}
|
||||
>
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2.5 pt-2">
|
||||
{onCancel && (
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" disabled={loading} className="px-4">
|
||||
<Terminal className="w-3.5 h-3.5" />
|
||||
<span>{loading ? '正在验证签名...' : '解锁并进入工作台'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,185 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { usePortalStore } from '../store/usePortalStore';
|
||||
import { Badge } from './ui/badge';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from './ui/command';
|
||||
|
||||
interface CmdItem {
|
||||
id: string;
|
||||
title: string;
|
||||
hint: string;
|
||||
type: string;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
export const CommandPalette: React.FC = () => {
|
||||
const {
|
||||
showCmdPalette,
|
||||
setShowCmdPalette,
|
||||
setShowAgyModal,
|
||||
setTheme,
|
||||
triggerAction,
|
||||
copyCmd,
|
||||
getEffectiveMode,
|
||||
} = usePortalStore();
|
||||
|
||||
const commands = useMemo<CmdItem[]>(() => {
|
||||
const mode = getEffectiveMode();
|
||||
const resolve = (edge: string, home: string, lan: string) =>
|
||||
mode === 'lan' ? lan : mode === 'home10443' ? home : edge;
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'open-agy-drawer',
|
||||
title: '在页内唤起 AGY 云端终端',
|
||||
hint: 'tmux attach -t agy',
|
||||
type: '终端',
|
||||
run: () => setShowAgyModal(true),
|
||||
},
|
||||
{
|
||||
id: 'open-agy',
|
||||
title: '新窗口打开 AGY 云端工作台',
|
||||
hint: 'https://agy.961504.xyz',
|
||||
type: '跳转',
|
||||
run: () =>
|
||||
window.open(
|
||||
resolve(
|
||||
'https://agy.961504.xyz',
|
||||
'https://agy.home.961504.xyz:10443',
|
||||
'http://192.168.31.164:7681'
|
||||
),
|
||||
'_blank'
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'open-z4',
|
||||
title: '打开 极空间 Z4 私有云 (20TB)',
|
||||
hint: '192.168.31.185:5055',
|
||||
type: '跳转',
|
||||
run: () =>
|
||||
window.open(
|
||||
resolve(
|
||||
'https://z4.home.961504.xyz:10443',
|
||||
'https://z4.home.961504.xyz:10443',
|
||||
'http://192.168.31.185:5055'
|
||||
),
|
||||
'_blank'
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'open-vw',
|
||||
title: '打开 Vaultwarden 密码保险箱',
|
||||
hint: 'https://vw.961504.xyz',
|
||||
type: '跳转',
|
||||
run: () =>
|
||||
window.open(
|
||||
resolve(
|
||||
'https://vw.961504.xyz',
|
||||
'https://vw.home.961504.xyz:10443',
|
||||
'http://192.168.31.164:8222'
|
||||
),
|
||||
'_blank'
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'open-clash',
|
||||
title: '打开 Mihomo 路由分流面板',
|
||||
hint: '192.168.31.1:9999',
|
||||
type: '跳转',
|
||||
run: () =>
|
||||
window.open(
|
||||
resolve(
|
||||
'https://clash.home.961504.xyz:10443/ui/',
|
||||
'https://clash.home.961504.xyz:10443/ui/',
|
||||
'http://192.168.31.1:9999/ui/'
|
||||
),
|
||||
'_blank'
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'act-backup',
|
||||
title: '立即打包备份核心数据至极空间 Z4',
|
||||
hint: 'backup-to-nas.sh',
|
||||
type: '运维',
|
||||
run: () => triggerAction('backup_nas', '正在打包备份至极空间 Z4...'),
|
||||
},
|
||||
{
|
||||
id: 'act-ddns',
|
||||
title: '立即刷新 Cloudflare DDNS 解析',
|
||||
hint: 'cloudflare-ddns.sh',
|
||||
type: '运维',
|
||||
run: () => triggerAction('refresh_ddns', '正在同步 Cloudflare DDNS...'),
|
||||
},
|
||||
{
|
||||
id: 'ssh-mini',
|
||||
title: '复制 SSH 连接 Mac mini 指令',
|
||||
hint: 'ssh mini',
|
||||
type: 'SSH',
|
||||
run: () => copyCmd('ssh mini', 'SSH Mac mini'),
|
||||
},
|
||||
{
|
||||
id: 'ssh-agy',
|
||||
title: '复制 终端接力 AGY 会话指令',
|
||||
hint: 'ssh mini -t "tmux attach -t agy"',
|
||||
type: 'SSH',
|
||||
run: () => copyCmd('ssh mini -t "tmux attach -t agy"', '接力 AGY'),
|
||||
},
|
||||
{
|
||||
id: 'theme-system',
|
||||
title: '主题模式: 跟随设备系统深浅色自动切换',
|
||||
hint: 'system',
|
||||
type: '主题',
|
||||
run: () => setTheme('system'),
|
||||
},
|
||||
{
|
||||
id: 'theme-light',
|
||||
title: '切换主题: 浅色纯净 (Light)',
|
||||
hint: 'light',
|
||||
type: '主题',
|
||||
run: () => setTheme('light'),
|
||||
},
|
||||
{
|
||||
id: 'theme-dark',
|
||||
title: '切换主题: 深色极客 (Dark)',
|
||||
hint: 'dark',
|
||||
type: '主题',
|
||||
run: () => setTheme('dark'),
|
||||
},
|
||||
];
|
||||
}, [getEffectiveMode, setShowAgyModal, setTheme, triggerAction, copyCmd]);
|
||||
|
||||
return (
|
||||
<CommandDialog open={showCmdPalette} onOpenChange={setShowCmdPalette}>
|
||||
<CommandInput placeholder="输入服务名称、SSH 指令、路由模式或主题..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>未找到匹配的指令或服务</CommandEmpty>
|
||||
<CommandGroup heading="快速操作与服务直达 (cmdk + Radix Dialog)">
|
||||
{commands.map((cmd) => (
|
||||
<CommandItem
|
||||
key={cmd.id}
|
||||
value={`${cmd.title} ${cmd.hint} ${cmd.type}`}
|
||||
onSelect={() => {
|
||||
setShowCmdPalette(false);
|
||||
cmd.run();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="font-medium">{cmd.title}</span>
|
||||
<span className="font-mono-num text-[11px] text-[var(--text-muted)]">
|
||||
{cmd.hint}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="secondary">{cmd.type}</Badge>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import { usePortalStore } from '../store/usePortalStore';
|
||||
import type { ServiceCardItem } from '../types/telemetry';
|
||||
|
||||
interface Props {
|
||||
card: ServiceCardItem;
|
||||
}
|
||||
|
||||
export const ServiceCard: React.FC<Props> = ({ card }) => {
|
||||
const { getEffectiveMode, setShowAgyModal, triggerAction } = usePortalStore();
|
||||
const mode = getEffectiveMode();
|
||||
|
||||
const resolvedUrl =
|
||||
mode === 'lan' ? card.lanUrl : mode === 'home10443' ? card.homeUrl : card.edgeUrl;
|
||||
|
||||
const badgeLabel =
|
||||
mode === 'lan' ? 'LAN 直连' : mode === 'home10443' ? '10443' : '443 免端口';
|
||||
|
||||
return (
|
||||
<a
|
||||
href={resolvedUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hl-card hl-card-interactive flex flex-col justify-between overflow-hidden group cursor-pointer"
|
||||
>
|
||||
{/* Card Top: Authentic Brand Icon + Title + Status Dot + Description */}
|
||||
<div className="p-4 sm:p-5 space-y-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3.5 min-w-0">
|
||||
<div className="w-10 h-10 rounded-xl hl-muted border hl-border p-1.5 flex items-center justify-center shrink-0">
|
||||
<img src={card.iconUrl} alt={card.title} className="w-full h-full object-contain" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold truncate group-hover:underline">
|
||||
{card.title}
|
||||
</h3>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full shrink-0"
|
||||
style={{
|
||||
backgroundColor: card.online !== false ? 'var(--success)' : '#ef4444',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className="text-[11px] font-mono-num truncate mt-0.5"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{card.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Pill or Route Badge */}
|
||||
{card.actionType === 'drawer' ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setShowAgyModal(true);
|
||||
}}
|
||||
className="px-2 py-1 rounded-md text-[11px] font-medium shrink-0 transition-opacity hover:opacity-80 cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-soft)',
|
||||
color: 'var(--accent)',
|
||||
}}
|
||||
>
|
||||
页内终端
|
||||
</button>
|
||||
) : card.actionType === 'backup' ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
triggerAction('backup_nas', '正在打包备份至极空间 Z4...');
|
||||
}}
|
||||
className="px-2 py-1 rounded-md text-[11px] font-medium shrink-0 transition-opacity hover:opacity-80 cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: 'var(--success-soft)',
|
||||
color: 'var(--success)',
|
||||
}}
|
||||
>
|
||||
立即备份
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="text-xs font-mono-num px-2 py-0.5 rounded hl-muted shrink-0"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{badgeLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p
|
||||
className="text-xs leading-relaxed line-clamp-2"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{card.desc}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Card Footer: gethomepage.dev Signature 3-Cell Inline Telemetry Widget */}
|
||||
<div className="hl-widget-footer grid grid-cols-3 divide-x hl-border text-center">
|
||||
{card.stats.map((stat, idx) => (
|
||||
<div key={idx} className="py-2.5 px-2 flex flex-col items-center justify-center">
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-wider truncate max-w-full"
|
||||
style={{ color: 'var(--text-subtle)' }}
|
||||
>
|
||||
{stat.label}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs font-semibold font-mono-num mt-0.5 truncate max-w-full"
|
||||
style={{
|
||||
color: stat.highlight ? 'var(--success)' : 'var(--text-main)',
|
||||
}}
|
||||
>
|
||||
{stat.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import React from 'react';
|
||||
import { usePortalStore } from '../store/usePortalStore';
|
||||
|
||||
const SSH_LIST = [
|
||||
{ label: 'SSH 连接 Mac mini', cmd: 'ssh mini' },
|
||||
{ label: '终端接力 AGY 会话', cmd: 'ssh mini -t "tmux attach -t agy"' },
|
||||
{ label: 'SSH 连接新加坡 Azure', cmd: 'ssh azure' },
|
||||
{ label: 'SSH 连接小米主路由', cmd: 'ssh root@192.168.31.1' },
|
||||
];
|
||||
|
||||
export const SshShelf: React.FC = () => {
|
||||
const { copyCmd, triggerAction } = usePortalStore();
|
||||
|
||||
return (
|
||||
<section className="space-y-3 pt-1">
|
||||
<div className="flex items-center justify-between px-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2
|
||||
className="text-xs font-semibold uppercase tracking-wider"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
终端快捷指令与备用直连书签
|
||||
</h2>
|
||||
<span className="text-[11px] font-mono-num" style={{ color: 'var(--text-subtle)' }}>
|
||||
· 点击指令即复制
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs">
|
||||
<button
|
||||
onClick={() => triggerAction('refresh_ddns', '正在同步 Cloudflare DDNS...')}
|
||||
className="hover:underline font-medium cursor-pointer"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
>
|
||||
刷新 DDNS 解析
|
||||
</button>
|
||||
<button
|
||||
onClick={() => triggerAction('mount_nas', '正在巡检极空间 20TB SMB 挂载...')}
|
||||
className="hover:underline font-medium cursor-pointer"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
>
|
||||
巡检极空间挂载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{SSH_LIST.map((item) => (
|
||||
<button
|
||||
key={item.cmd}
|
||||
onClick={() => copyCmd(item.cmd, item.label)}
|
||||
className="hl-card hl-card-interactive px-4 py-3 text-left flex items-center justify-between gap-2 cursor-pointer"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold">{item.label}</div>
|
||||
<div
|
||||
className="text-[11px] font-mono-num truncate mt-0.5"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
>
|
||||
{item.cmd}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="text-[10px] font-mono-num px-1.5 py-0.5 rounded hl-muted shrink-0"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
COPY
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,516 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
BookOpen,
|
||||
Check,
|
||||
KeyRound,
|
||||
Layers,
|
||||
Layout,
|
||||
Palette,
|
||||
Search,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
import { LAYOUT_PRESETS, THEME_PALETTES, type LayoutPresetId } from '../design-system/tokens';
|
||||
import { usePortalStore } from '../store/usePortalStore';
|
||||
import { AuthGateCard } from './AuthGateModal';
|
||||
import { ServiceCard } from './ServiceCard';
|
||||
import { SshShelf } from './SshShelf';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
import { Input, InputGroup } from './ui/input';
|
||||
import { Switch } from './ui/switch';
|
||||
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
|
||||
|
||||
type StoryId =
|
||||
| 'layout-architecture'
|
||||
| 'theme-tokens'
|
||||
| 'form-inputs'
|
||||
| 'service-card'
|
||||
| 'ssh-shelf';
|
||||
|
||||
export const StorybookWorkbench: React.FC = () => {
|
||||
const [activeStory, setActiveStory] = useState<StoryId>('form-inputs');
|
||||
const {
|
||||
theme,
|
||||
resolvedTheme,
|
||||
layoutPreset,
|
||||
setTheme,
|
||||
setLayoutPreset,
|
||||
setViewMode,
|
||||
setShowCmdPalette,
|
||||
telemetry,
|
||||
showToast,
|
||||
} = usePortalStore();
|
||||
|
||||
// Interactive Playground Props for ServiceCard story
|
||||
const [demoOnline, setDemoOnline] = useState(true);
|
||||
const [demoAction, setDemoAction] = useState<'drawer' | 'backup' | undefined>('drawer');
|
||||
|
||||
// Interactive Playground Props for Form Inputs story
|
||||
const [demoSearch, setDemoSearch] = useState('');
|
||||
const [demoHost, setDemoHost] = useState('192.168.31.164');
|
||||
const [demoSwitch, setDemoSwitch] = useState(true);
|
||||
|
||||
const stories: Array<{ id: StoryId; group: string; title: string; icon: React.ReactNode }> = [
|
||||
{
|
||||
id: 'layout-architecture',
|
||||
group: 'FOUNDATIONS / 架构与布局',
|
||||
title: '01. AppShell 双层顶栏与网格布局',
|
||||
icon: <Layout className="w-3.5 h-3.5" />,
|
||||
},
|
||||
{
|
||||
id: 'theme-tokens',
|
||||
group: 'FOUNDATIONS / 架构与布局',
|
||||
title: '02. 双主题色板与自动切换规范',
|
||||
icon: <Palette className="w-3.5 h-3.5" />,
|
||||
},
|
||||
{
|
||||
id: 'form-inputs',
|
||||
group: 'SHADCN / RADIX UI 组件',
|
||||
title: '03. shadcn/ui + Radix 原语与认证弹窗',
|
||||
icon: <KeyRound className="w-3.5 h-3.5" />,
|
||||
},
|
||||
{
|
||||
id: 'service-card',
|
||||
group: 'SHADCN / RADIX UI 组件',
|
||||
title: '04. ServiceCard 内联遥测卡片',
|
||||
icon: <Layers className="w-3.5 h-3.5" />,
|
||||
},
|
||||
{
|
||||
id: 'ssh-shelf',
|
||||
group: 'SHADCN / RADIX UI 组件',
|
||||
title: '05. SshShelf 快捷指令栏',
|
||||
icon: <Terminal className="w-3.5 h-3.5" />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6 items-start">
|
||||
{/* Left Sidebar: Storybook Navigation Tree */}
|
||||
<Card className="p-4 space-y-4 lg:sticky lg:top-20">
|
||||
<div className="flex items-center justify-between border-b hl-border pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4" style={{ color: 'var(--accent)' }} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider">shadcn/ui 组件库</span>
|
||||
</div>
|
||||
<Badge variant="accent">Radix + cmdk</Badge>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{stories.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setActiveStory(s.id)}
|
||||
className="w-full px-3 py-2 rounded-lg text-left text-xs font-medium flex items-center gap-2.5 transition-all cursor-pointer"
|
||||
style={
|
||||
activeStory === s.id
|
||||
? {
|
||||
backgroundColor: 'var(--accent-soft)',
|
||||
color: 'var(--accent)',
|
||||
}
|
||||
: { color: 'var(--text-muted)' }
|
||||
}
|
||||
>
|
||||
{s.icon}
|
||||
<span className="truncate">{s.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t hl-border space-y-2 text-xs">
|
||||
<div className="text-[11px] font-semibold" style={{ color: 'var(--text-muted)' }}>
|
||||
全局外观状态预览 (@radix-ui/react-tabs)
|
||||
</div>
|
||||
<Tabs value={theme} onValueChange={(v) => setTheme(v as 'system' | 'light' | 'dark')}>
|
||||
<TabsList className="w-full grid grid-cols-3">
|
||||
<TabsTrigger value="system">自动</TabsTrigger>
|
||||
<TabsTrigger value="light">浅色</TabsTrigger>
|
||||
<TabsTrigger value="dark">深色</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button onClick={() => setViewMode('dashboard')} className="w-full mt-2">
|
||||
返回中控台主页
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Right Canvas: Interactive Story Preview & Controls */}
|
||||
<div className="lg:col-span-3 space-y-6">
|
||||
{/* STORY 1: Layout Architecture */}
|
||||
{activeStory === 'layout-architecture' && (
|
||||
<div className="space-y-6">
|
||||
<Card className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between border-b hl-border pb-4">
|
||||
<div>
|
||||
<span
|
||||
className="text-[11px] font-mono-num uppercase tracking-wider"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
>
|
||||
Foundations / AppShell Layout
|
||||
</span>
|
||||
<h2 className="text-lg font-bold mt-0.5">
|
||||
标准双层顶栏架构 (Two-Tier AppShell) & 响应式网格切换
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs leading-relaxed" style={{ color: 'var(--text-muted)' }}>
|
||||
参考 <strong>Vercel Geist / shadcn/ui Dashboard-01 / GitHub AppShell</strong>{' '}
|
||||
标准规范,将原本容易挤压折行的单层顶栏拆分为职责清晰的双层结构:
|
||||
</p>
|
||||
|
||||
{/* Visual Blueprint Diagram of Two-Tier Header */}
|
||||
<div className="p-4 rounded-xl hl-muted border hl-border space-y-2.5 font-mono-num text-xs">
|
||||
<div className="p-3 rounded-lg hl-card flex items-center justify-between">
|
||||
<span>[左] 🍎 HomeLab · Radix Tabs 切换 (中控台 / Storybook)</span>
|
||||
<span style={{ color: 'var(--accent)' }}>
|
||||
[最右固定] 🔍 搜索 ⌘K · >_ AGY 终端 · ☀️/🌙 主题切换
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3 rounded-lg hl-card flex items-center justify-between">
|
||||
<span style={{ color: 'var(--success)' }}>
|
||||
[左] 实时硬件遥测条 (M6 CPU · RAM · Z4 20TB · SG 专线)
|
||||
</span>
|
||||
<span>[右对齐] Radix Tabs 路由切换器 (智能路由 / 443 / 10443 / LAN)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interactive Grid Preset Selector */}
|
||||
<div className="pt-2 space-y-3">
|
||||
<div className="text-xs font-semibold">
|
||||
选择主页卡片网格布局预设(点击立即应用到主页):
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3.5">
|
||||
{(Object.keys(LAYOUT_PRESETS) as LayoutPresetId[]).map((key) => {
|
||||
const preset = LAYOUT_PRESETS[key];
|
||||
const active = layoutPreset === key;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
onClick={() => setLayoutPreset(key)}
|
||||
className="hl-card hl-card-interactive p-4 cursor-pointer space-y-2"
|
||||
style={active ? { borderColor: 'var(--accent)' } : {}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-bold">{preset.name}</span>
|
||||
{active && (
|
||||
<span
|
||||
className="w-4 h-4 rounded-full flex items-center justify-center"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent)',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
<Check className="w-2.5 h-2.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px]" style={{ color: 'var(--text-muted)' }}>
|
||||
{preset.description}
|
||||
</p>
|
||||
<code
|
||||
className="text-[10px] font-mono-num block pt-1"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
>
|
||||
{preset.gridClass}
|
||||
</code>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* STORY 2: Theme Tokens */}
|
||||
{activeStory === 'theme-tokens' && (
|
||||
<Card className="p-6 space-y-6">
|
||||
<div className="border-b hl-border pb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<span
|
||||
className="text-[11px] font-mono-num uppercase tracking-wider"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
>
|
||||
Foundations / Color System
|
||||
</span>
|
||||
<h2 className="text-lg font-bold mt-0.5">
|
||||
主流双主题色板规范(当前生效:{resolvedTheme === 'dark' ? '深色极客' : '浅色纯净'})
|
||||
</h2>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
模式: {theme === 'system' ? '跟随设备自动切换' : '手动锁定'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{(['light', 'dark'] as const).map((modeKey) => {
|
||||
const p = THEME_PALETTES[modeKey];
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="rounded-xl p-5 space-y-4 border"
|
||||
style={{
|
||||
backgroundColor: p.tokens['--bg-page'],
|
||||
color: p.tokens['--text-main'],
|
||||
borderColor: p.tokens['--border-color'],
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between border-b pb-3"
|
||||
style={{ borderColor: p.tokens['--border-color'] }}
|
||||
>
|
||||
<div>
|
||||
<div className="text-sm font-bold">{p.name}</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: p.tokens['--text-muted'] }}>
|
||||
{p.description}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setTheme(modeKey)}
|
||||
className="px-2.5 py-1 rounded-md text-xs font-mono-num font-semibold cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: p.tokens['--primary'],
|
||||
color: p.tokens['--primary-fg'],
|
||||
}}
|
||||
>
|
||||
预览此主题
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(p.tokens).map(([tokenName, hex]) => (
|
||||
<div
|
||||
key={tokenName}
|
||||
className="p-2 rounded-lg border flex items-center gap-2"
|
||||
style={{
|
||||
backgroundColor: p.tokens['--bg-card'],
|
||||
borderColor: p.tokens['--border-color'],
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-5 h-5 rounded border shrink-0"
|
||||
style={{
|
||||
backgroundColor: hex,
|
||||
borderColor: p.tokens['--border-hover'],
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-mono-num font-semibold truncate">
|
||||
{tokenName}
|
||||
</div>
|
||||
<div
|
||||
className="text-[10px] font-mono-num truncate"
|
||||
style={{ color: p.tokens['--text-muted'] }}
|
||||
>
|
||||
{hex}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* STORY 3: Official shadcn/ui + Radix UI Primitives & AuthGate Card */}
|
||||
{activeStory === 'form-inputs' && (
|
||||
<Card className="p-6 space-y-6">
|
||||
<div className="border-b hl-border pb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<span
|
||||
className="text-[11px] font-mono-num uppercase tracking-wider"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
>
|
||||
src/components/ui/* · Official shadcn/ui + Radix Primitives + cmdk
|
||||
</span>
|
||||
<h2 className="text-lg font-bold mt-0.5">
|
||||
开源 `shadcn/ui` 组件库(`Input` / `Button` / `Switch` / `Badge` / `CommandDialog` / `AuthGateCard`)
|
||||
</h2>
|
||||
</div>
|
||||
<KeyRound className="w-4 h-4" style={{ color: 'var(--accent)' }} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
|
||||
{/* Left: shadcn/ui Primitives Showcase */}
|
||||
<div className="p-5 rounded-xl hl-muted border hl-border space-y-4">
|
||||
<div
|
||||
className="text-xs font-bold uppercase tracking-wider"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
1. `src/components/ui/*` 标准原语展示
|
||||
</div>
|
||||
|
||||
{/* Button Variants */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium" style={{ color: 'var(--text-muted)' }}>
|
||||
Button 变体 (`cva` + `@radix-ui/react-slot`)
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="default">Primary 主按钮</Button>
|
||||
<Button variant="outline">Outline 描边</Button>
|
||||
<Button variant="secondary">Secondary 次级</Button>
|
||||
<Button variant="accent">Accent 高亮</Button>
|
||||
<Button variant="ghost" onClick={() => setShowCmdPalette(true)}>
|
||||
唤起 cmdk 面板 ⌘K
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badge Variants & Radix Switch */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium" style={{ color: 'var(--text-muted)' }}>
|
||||
Badge 标签与 `@radix-ui/react-switch` 开关
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 p-2.5 rounded-lg hl-card">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge variant="default">Default</Badge>
|
||||
<Badge variant="accent">Radix UI</Badge>
|
||||
<Badge variant="success">Online</Badge>
|
||||
<Badge variant="outline">v3.2</Badge>
|
||||
</div>
|
||||
<Switch checked={demoSwitch} onCheckedChange={setDemoSwitch} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input & InputGroup */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium" style={{ color: 'var(--text-muted)' }}>
|
||||
`InputGroup` 组合输入框
|
||||
</label>
|
||||
<InputGroup>
|
||||
<Search className="w-4 h-4 shrink-0" style={{ color: 'var(--text-subtle)' }} />
|
||||
<input
|
||||
type="text"
|
||||
value={demoSearch}
|
||||
onChange={(e) => setDemoSearch(e.target.value)}
|
||||
placeholder="搜索服务、SSH 指令或节点..."
|
||||
/>
|
||||
<kbd
|
||||
className="px-1.5 py-0.5 text-[10px] font-mono-num rounded hl-card border hl-border"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
⌘K
|
||||
</kbd>
|
||||
</InputGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-medium" style={{ color: 'var(--text-muted)' }}>
|
||||
`Input` 等宽字体输入框
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={demoHost}
|
||||
onChange={(e) => setDemoHost(e.target.value)}
|
||||
className="font-mono-num"
|
||||
placeholder="192.168.31.164"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Live AuthGateCard Preview */}
|
||||
<div className="flex flex-col items-center">
|
||||
<AuthGateCard
|
||||
embedded
|
||||
targetUrl="https://agy.961504.xyz"
|
||||
onSuccess={() => showToast('SSO 认证成功!已签发 30 天全域名通行 Cookie')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* STORY 4: ServiceCard Interactive Playground */}
|
||||
{activeStory === 'service-card' && (
|
||||
<Card className="p-6 space-y-6">
|
||||
<div className="border-b hl-border pb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<span
|
||||
className="text-[11px] font-mono-num uppercase tracking-wider"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
>
|
||||
Components / ServiceCard
|
||||
</span>
|
||||
<h2 className="text-lg font-bold mt-0.5">
|
||||
三段式内联遥测卡片 (ServiceCard Interactive Story)
|
||||
</h2>
|
||||
</div>
|
||||
<Sparkles className="w-4 h-4" style={{ color: 'var(--accent)' }} />
|
||||
</div>
|
||||
|
||||
{/* Interactive Controls */}
|
||||
<div className="p-4 rounded-xl hl-muted border hl-border flex flex-wrap items-center gap-6 text-xs">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="font-semibold">在线状态 (online):</span>
|
||||
<Switch checked={demoOnline} onCheckedChange={setDemoOnline} />
|
||||
<span className="font-mono-num">{demoOnline ? 'true' : 'false'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">右上角动作按钮 (actionType):</span>
|
||||
{(['drawer', 'backup', undefined] as const).map((act, i) => (
|
||||
<Button
|
||||
key={i}
|
||||
variant={demoAction === act ? 'accent' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setDemoAction(act)}
|
||||
className="font-mono-num"
|
||||
>
|
||||
{act || 'route-badge'}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Live Component Canvas */}
|
||||
<div className="max-w-md mx-auto py-4">
|
||||
<ServiceCard
|
||||
card={{
|
||||
id: 'agy',
|
||||
title: 'AGY 云端工作台',
|
||||
subtitle: 'agy.961504.xyz · ttyd + tmux',
|
||||
iconUrl:
|
||||
'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/google-gemini.png',
|
||||
online: demoOnline,
|
||||
actionType: demoAction,
|
||||
desc: 'M6 Mac mini 上的 24 小时常驻 AI 编程终端,已同步 Google 登录态并锁定新加坡出口,关掉网页后台继续运行。',
|
||||
edgeUrl: 'https://agy.961504.xyz',
|
||||
homeUrl: 'https://agy.home.961504.xyz:10443',
|
||||
lanUrl: 'http://192.168.31.164:7681',
|
||||
stats: [
|
||||
{ label: 'M6 CPU 负载', value: `${telemetry.mini.cpu_pct}%` },
|
||||
{ label: '已用内存', value: `${telemetry.mini.mem_used_gb} / 24 GB` },
|
||||
{ label: 'tmux 会话', value: '常驻运行中', highlight: true },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* STORY 5: SshShelf */}
|
||||
{activeStory === 'ssh-shelf' && (
|
||||
<Card className="p-6 space-y-6">
|
||||
<div className="border-b hl-border pb-4">
|
||||
<span
|
||||
className="text-[11px] font-mono-num uppercase tracking-wider"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
>
|
||||
Components / SshShelf
|
||||
</span>
|
||||
<h2 className="text-lg font-bold mt-0.5">底部 SSH 快捷复制与运维操作栏</h2>
|
||||
</div>
|
||||
<SshShelf />
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,241 @@
|
||||
import React from 'react';
|
||||
import { BookOpen, Bot, LayoutGrid, MessageSquare, Moon, Search, Sun, Terminal } from 'lucide-react';
|
||||
import { usePortalStore } from '../store/usePortalStore';
|
||||
import type { RouteModeId } from '../types/telemetry';
|
||||
import { Button } from './ui/button';
|
||||
import { Tabs, TabsList, TabsTrigger } from './ui/tabs';
|
||||
|
||||
const MODES: Array<{ id: RouteModeId; label: string }> = [
|
||||
{ id: 'auto', label: '智能路由' },
|
||||
{ id: 'edge443', label: '443 免端口' },
|
||||
{ id: 'home10443', label: '10443 家宽' },
|
||||
{ id: 'lan', label: 'LAN 局域网' },
|
||||
];
|
||||
|
||||
export const TopRibbon: React.FC = () => {
|
||||
const {
|
||||
theme,
|
||||
resolvedTheme,
|
||||
routeMode,
|
||||
viewMode,
|
||||
telemetry,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
setRouteMode,
|
||||
setViewMode,
|
||||
setShowAgyModal,
|
||||
setShowWechatModal,
|
||||
setShowCmdPalette,
|
||||
} = usePortalStore();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* =====================================================================
|
||||
Tier 1: Standard AppShell Global Top Navbar (h-14, NEVER wraps!)
|
||||
Left: Brand + Radix Tabs (中控台 / Storybook)
|
||||
Far Right: Search (Cmd+K) + AGY Terminal + Theme Switcher
|
||||
===================================================================== */}
|
||||
<header
|
||||
className="border-b hl-border sticky top-0 z-30 backdrop-blur-md"
|
||||
style={{ backgroundColor: 'color-mix(in srgb, var(--bg-card) 88%, transparent)' }}
|
||||
>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-14 flex items-center justify-between gap-4">
|
||||
{/* Left Group: Brand Logo + Official Radix Navigation Tabs */}
|
||||
<div className="flex items-center gap-4 shrink-0">
|
||||
<div
|
||||
onClick={() => setViewMode('dashboard')}
|
||||
className="flex items-center gap-2.5 cursor-pointer"
|
||||
>
|
||||
<img
|
||||
src="https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/apple.png"
|
||||
alt="Mac mini"
|
||||
className="w-6 h-6 rounded"
|
||||
/>
|
||||
<span className="font-semibold text-sm tracking-tight">HomeLab</span>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: 'var(--success)' }}
|
||||
title="全屋 4 节点在线"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-4 w-[1px] hl-muted" />
|
||||
|
||||
{/* View Switcher Tabs via @radix-ui/react-tabs */}
|
||||
<Tabs
|
||||
value={viewMode}
|
||||
onValueChange={(v) => setViewMode(v as 'dashboard' | 'agents' | 'storybook')}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="dashboard">
|
||||
<LayoutGrid className="w-3.5 h-3.5" />
|
||||
<span>中控台</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="agents">
|
||||
<Bot className="w-3.5 h-3.5" />
|
||||
<span>Agent 编排</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="storybook">
|
||||
<BookOpen className="w-3.5 h-3.5" />
|
||||
<span>Storybook 设计规范</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Far Right Group: Pinned to Right Edge (Search + AGY + Theme Switcher) */}
|
||||
<div className="flex items-center gap-2.5 shrink-0 ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCmdPalette(true)}
|
||||
className="gap-3"
|
||||
title="按 Cmd+K 唤起全局命令面板"
|
||||
>
|
||||
<span className="flex items-center gap-1.5" style={{ color: 'var(--text-muted)' }}>
|
||||
<Search className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">搜索服务或指令...</span>
|
||||
</span>
|
||||
<kbd
|
||||
className="px-1.5 py-0.5 text-[10px] font-mono-num rounded hl-muted"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
⌘K
|
||||
</kbd>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowWechatModal(true)}
|
||||
title="连接微信 ClawBot,在微信内直接收发消息与控制 Mac mini"
|
||||
>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{
|
||||
backgroundColor: telemetry.services.clawbot ? 'var(--success)' : 'var(--accent)',
|
||||
}}
|
||||
/>
|
||||
<MessageSquare className="w-3.5 h-3.5" />
|
||||
<span>微信 ClawBot</span>
|
||||
</Button>
|
||||
|
||||
<Button onClick={() => setShowAgyModal(true)}>
|
||||
<Terminal className="w-3.5 h-3.5" />
|
||||
<span>AGY 终端</span>
|
||||
</Button>
|
||||
|
||||
{/* Top-Right Theme Toggle (Pinned at Far Right Corner!) */}
|
||||
<div className="flex items-center p-0.5 rounded-lg hl-muted border hl-border text-xs">
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="px-2.5 py-1 rounded-md font-medium transition-all flex items-center gap-1.5 cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: 'var(--bg-card)',
|
||||
color: 'var(--text-main)',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.08)',
|
||||
}}
|
||||
title="点击切换深色 / 浅色主题(默认跟随系统)"
|
||||
>
|
||||
{resolvedTheme === 'dark' ? (
|
||||
<>
|
||||
<Moon className="w-3.5 h-3.5" />
|
||||
<span>深色</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sun className="w-3.5 h-3.5" />
|
||||
<span>浅色</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{theme !== 'system' && (
|
||||
<button
|
||||
onClick={() => setTheme('system')}
|
||||
className="px-2 py-1 rounded-md text-[11px] transition-all cursor-pointer"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
title="恢复跟随设备系统深浅色自动切换"
|
||||
>
|
||||
自动
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* =====================================================================
|
||||
Tier 2: Subheader Telemetry & Route Mode Bar (Aligned with Grid)
|
||||
Left: 4 Hardware & Network Gauges
|
||||
Right: Route Mode Segmented Switcher via @radix-ui/react-tabs
|
||||
===================================================================== */}
|
||||
{viewMode === 'dashboard' && (
|
||||
<div className="border-b hl-border" style={{ backgroundColor: 'var(--bg-card)' }}>
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-2.5 flex flex-wrap items-center justify-between gap-3">
|
||||
{/* Left: System Telemetry Strip */}
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs font-mono-num">
|
||||
<div className="hl-pill px-2.5 py-1 flex items-center gap-2">
|
||||
<span style={{ color: 'var(--text-muted)' }}>M6 CPU</span>
|
||||
<div className="w-12 h-1.5 rounded-full hl-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full hl-bar-fill"
|
||||
style={{ width: `${Math.max(5, telemetry.mini.cpu_pct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-medium">{telemetry.mini.cpu_pct}%</span>
|
||||
</div>
|
||||
|
||||
<div className="hl-pill px-2.5 py-1 flex items-center gap-2">
|
||||
<span style={{ color: 'var(--text-muted)' }}>RAM</span>
|
||||
<div className="w-12 h-1.5 rounded-full hl-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full hl-bar-fill"
|
||||
style={{ width: `${Math.max(5, telemetry.mini.mem_pct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-medium">{telemetry.mini.mem_used_gb}G</span>
|
||||
</div>
|
||||
|
||||
<div className="hl-pill px-2.5 py-1 flex items-center gap-2">
|
||||
<span style={{ color: 'var(--text-muted)' }}>NAS 余量</span>
|
||||
<div className="w-12 h-1.5 rounded-full hl-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full hl-bar-fill-green"
|
||||
style={{ width: `${Math.max(5, telemetry.nas.pct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="font-medium" style={{ color: 'var(--success)' }}>
|
||||
{telemetry.nas.free_tb} TB
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="hl-pill px-2.5 py-1 flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full"
|
||||
style={{ backgroundColor: 'var(--success)' }}
|
||||
/>
|
||||
<span style={{ color: 'var(--text-muted)' }}>SG 专线</span>
|
||||
<span className="font-medium">
|
||||
{telemetry.network.sg_delay_ms > 0 ? `${telemetry.network.sg_delay_ms}ms` : '直连'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Route Mode Switcher via @radix-ui/react-tabs */}
|
||||
<Tabs
|
||||
value={routeMode}
|
||||
onValueChange={(v) => setRouteMode(v as RouteModeId)}
|
||||
className="ml-auto"
|
||||
>
|
||||
<TabsList>
|
||||
{MODES.map((m) => (
|
||||
<TabsTrigger key={m.id} value={m.id}>
|
||||
{m.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,301 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import {
|
||||
CheckCircle2,
|
||||
MessageSquare,
|
||||
QrCode,
|
||||
RefreshCw,
|
||||
Send,
|
||||
Smartphone,
|
||||
Unplug,
|
||||
} from 'lucide-react';
|
||||
import { usePortalStore } from '../store/usePortalStore';
|
||||
import type { ClawbotPublicStatus } from '../types/telemetry';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
export const WechatClawModal: React.FC = () => {
|
||||
const { showWechatModal, setShowWechatModal, showToast, fetchTelemetry } = usePortalStore();
|
||||
const [statusData, setStatusData] = useState<ClawbotPublicStatus | null>(null);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string>('');
|
||||
const [loadingQr, setLoadingQr] = useState(false);
|
||||
const [verifyCode, setVerifyCode] = useState('');
|
||||
const [submittingCode, setSubmittingCode] = useState(false);
|
||||
|
||||
const generateNewQr = async () => {
|
||||
setLoadingQr(true);
|
||||
try {
|
||||
const res = await fetch('/api/clawbot/qrcode', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
setStatusData(data);
|
||||
const url = data?.login?.qrcode_url;
|
||||
if (url) {
|
||||
const svgUrl = await QRCode.toDataURL(url, {
|
||||
width: 240,
|
||||
margin: 2,
|
||||
color: { dark: '#09090b', light: '#ffffff' },
|
||||
});
|
||||
setQrDataUrl(svgUrl);
|
||||
}
|
||||
} catch {
|
||||
showToast('获取微信 ClawBot 二维码失败');
|
||||
} finally {
|
||||
setLoadingQr(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial load & polling while modal is open
|
||||
useEffect(() => {
|
||||
if (!showWechatModal) return;
|
||||
|
||||
let cancelled = false;
|
||||
const init = async () => {
|
||||
try {
|
||||
const r = await fetch('/api/clawbot/status');
|
||||
const d: ClawbotPublicStatus = await r.json();
|
||||
if (cancelled) return;
|
||||
setStatusData(d);
|
||||
if (!d.connected && (!d.login?.active || !d.login?.qrcode_url)) {
|
||||
await generateNewQr();
|
||||
} else if (d.login?.qrcode_url) {
|
||||
const svgUrl = await QRCode.toDataURL(d.login.qrcode_url, {
|
||||
width: 240,
|
||||
margin: 2,
|
||||
color: { dark: '#09090b', light: '#ffffff' },
|
||||
});
|
||||
if (!cancelled) setQrDataUrl(svgUrl);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
init();
|
||||
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/clawbot/status');
|
||||
if (!r.ok) return;
|
||||
const d: ClawbotPublicStatus = await r.json();
|
||||
if (cancelled) return;
|
||||
setStatusData(d);
|
||||
if (d.login?.qrcode_url) {
|
||||
const svgUrl = await QRCode.toDataURL(d.login.qrcode_url, {
|
||||
width: 240,
|
||||
margin: 2,
|
||||
color: { dark: '#09090b', light: '#ffffff' },
|
||||
});
|
||||
if (!cancelled) setQrDataUrl(svgUrl);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [showWechatModal]);
|
||||
|
||||
const handleVerifySubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!verifyCode.trim()) return;
|
||||
setSubmittingCode(true);
|
||||
try {
|
||||
const res = await fetch('/api/clawbot/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: verifyCode.trim() }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setStatusData(data);
|
||||
setVerifyCode('');
|
||||
showToast('配对码已提交,正在等待微信验证...');
|
||||
} catch {
|
||||
showToast('配对码提交失败');
|
||||
} finally {
|
||||
setSubmittingCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/clawbot/disconnect', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
setStatusData(data);
|
||||
setQrDataUrl('');
|
||||
fetchTelemetry();
|
||||
showToast('已断开微信 ClawBot 连接');
|
||||
} catch {
|
||||
showToast('断开失败');
|
||||
}
|
||||
};
|
||||
|
||||
const connected = Boolean(statusData?.connected);
|
||||
const loginState = statusData?.login;
|
||||
|
||||
return (
|
||||
<Dialog open={showWechatModal} onOpenChange={setShowWechatModal}>
|
||||
<DialogContent className="max-w-lg p-6 space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-3 border-b hl-border pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-10 h-10 rounded-xl flex items-center justify-center border hl-border shrink-0"
|
||||
style={{ backgroundColor: 'rgba(16, 185, 129, 0.14)', color: 'var(--success)' }}
|
||||
>
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<DialogTitle className="text-sm font-bold">
|
||||
微信 ClawBot · M6 Mac mini 直连通道
|
||||
</DialogTitle>
|
||||
<Badge variant={connected ? 'success' : 'accent'}>
|
||||
{connected ? '已连接在线' : '扫码绑定'}
|
||||
</Badge>
|
||||
</div>
|
||||
<DialogDescription className="text-xs font-mono-num mt-0.5">
|
||||
@tencent-weixin/openclaw-weixin · ilinkai.weixin.qq.com
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body: Connected vs QR Login */}
|
||||
{connected ? (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="p-4 rounded-xl border flex items-center justify-between"
|
||||
style={{
|
||||
backgroundColor: 'var(--success-soft)',
|
||||
borderColor: 'rgba(16, 185, 129, 0.3)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="w-5 h-5 shrink-0" style={{ color: 'var(--success)' }} />
|
||||
<div>
|
||||
<div className="text-xs font-bold">微信 ClawBot 长轮询守护线程运行中</div>
|
||||
<div className="text-[11px] font-mono-num mt-0.5" style={{ color: 'var(--text-muted)' }}>
|
||||
Bot ID: {statusData?.bot_id} · 累计收发: {statusData?.msg_count ?? 0} 条
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleDisconnect}>
|
||||
<Unplug className="w-3.5 h-3.5" />
|
||||
<span>重新绑定</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-xl hl-muted border hl-border space-y-2.5 text-xs">
|
||||
<div className="font-semibold">📱 现在打开手机微信「微信 ClawBot」可直接发送:</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 font-mono-num text-[11px]">
|
||||
<div className="p-2 rounded hl-card">
|
||||
<span style={{ color: 'var(--accent)' }}>状态</span> · 查看 M6 & 极空间遥测
|
||||
</div>
|
||||
<div className="p-2 rounded hl-card">
|
||||
<span style={{ color: 'var(--accent)' }}>备份</span> · 立即打包备份至 20TB NAS
|
||||
</div>
|
||||
<div className="p-2 rounded hl-card">
|
||||
<span style={{ color: 'var(--accent)' }}>屏幕</span> · 截取 AGY 终端实时画面
|
||||
</div>
|
||||
<div className="p-2 rounded hl-card">
|
||||
<span style={{ color: 'var(--accent)' }}>$ df -h</span> · 执行任意 Mac mini 命令
|
||||
</div>
|
||||
<div className="p-2 rounded hl-card sm:col-span-2">
|
||||
<span style={{ color: 'var(--accent)' }}>> 你好</span> 或直接发任意问题 · 注入 AGY 终端或调用 AI 回答
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col items-center justify-center p-5 rounded-xl hl-muted border hl-border space-y-3">
|
||||
{qrDataUrl ? (
|
||||
<div className="p-3 bg-white rounded-xl shadow-md border">
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="WeChat ClawBot QR Code"
|
||||
className="w-48 h-48 block"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-48 h-48 rounded-xl hl-card flex items-center justify-center text-xs font-mono-num">
|
||||
{loadingQr ? '正在向微信申请二维码...' : '点击下方按钮生成二维码'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-center">
|
||||
<Smartphone className="w-4 h-4 shrink-0" style={{ color: 'var(--accent)' }} />
|
||||
<span>{loginState?.message || '请使用手机微信扫描上方二维码启用 ClawBot'}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={generateNewQr}
|
||||
disabled={loadingQr}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
<span>{loadingQr ? '刷新中...' : '刷新二维码'}</span>
|
||||
</Button>
|
||||
{loginState?.qrcode_url && (
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<a
|
||||
href={loginState.qrcode_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono-num"
|
||||
>
|
||||
<QrCode className="w-3.5 h-3.5" />
|
||||
<span>打开备用链接 ↗</span>
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verify Code Input (if WeChat prompts for pairing digits) */}
|
||||
{loginState?.status === 'need_verifycode' && (
|
||||
<form
|
||||
onSubmit={handleVerifySubmit}
|
||||
className="p-4 rounded-xl border space-y-2.5"
|
||||
style={{
|
||||
backgroundColor: 'var(--accent-soft)',
|
||||
borderColor: 'var(--accent)',
|
||||
}}
|
||||
>
|
||||
<label className="block text-xs font-bold">
|
||||
🔢 手机微信上显示了配对数字,请在下方输入:
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={verifyCode}
|
||||
onChange={(e) => setVerifyCode(e.target.value)}
|
||||
placeholder="输入手机微信显示的 6 位数字..."
|
||||
className="font-mono-num"
|
||||
autoFocus
|
||||
/>
|
||||
<Button type="submit" disabled={submittingCode}>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
<span>验证</span>
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end pt-1">
|
||||
<Button variant="outline" onClick={() => setShowWechatModal(false)}>
|
||||
关闭窗口
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] font-mono-num font-medium transition-colors shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-[var(--primary)] text-[var(--primary-fg)]',
|
||||
secondary: 'hl-muted text-[var(--text-muted)] border hl-border',
|
||||
outline: 'border hl-border text-[var(--text-muted)] bg-[var(--bg-card)]',
|
||||
accent: 'bg-[var(--accent-soft)] text-[var(--accent)] font-semibold',
|
||||
success: 'bg-[var(--success-soft)] text-[var(--success)] font-semibold',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'secondary',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,52 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-lg text-xs font-medium transition-all cursor-pointer select-none disabled:pointer-events-none disabled:opacity-50 shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'hl-btn-primary shadow-xs',
|
||||
outline: 'hl-btn-outline',
|
||||
secondary: 'hl-muted border hl-border hover:opacity-90',
|
||||
ghost: 'hover:bg-[var(--bg-muted)]',
|
||||
accent:
|
||||
'border border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)] font-semibold hover:opacity-90',
|
||||
},
|
||||
size: {
|
||||
default: 'h-8 px-3 py-1.5',
|
||||
sm: 'h-7 px-2.5 text-[11px] rounded-md',
|
||||
lg: 'h-9 px-4 text-xs',
|
||||
icon: 'h-8 w-8 p-0',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('hl-card', className)} {...props} />
|
||||
)
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
|
||||
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-5', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-sm font-bold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
CardTitle.displayName = 'CardTitle';
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-xs text-[var(--text-muted)]', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = 'CardDescription';
|
||||
|
||||
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-5 pt-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('flex items-center p-5 pt-0', className)} {...props} />
|
||||
)
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
@@ -0,0 +1,121 @@
|
||||
import * as React from 'react';
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
import { Search } from 'lucide-react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from './dialog';
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col overflow-hidden rounded-xl bg-[var(--bg-card)] text-[var(--text-main)]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
interface CommandDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> {}
|
||||
|
||||
const CommandDialog: React.FC<CommandDialogProps> = ({ children, ...props }) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="max-w-xl overflow-hidden p-0 shadow-2xl top-[22%] translate-y-0">
|
||||
<DialogTitle className="sr-only">全局命令与服务搜索面板</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
使用键盘上下方向键选择服务或操作,回车立即执行
|
||||
</DialogDescription>
|
||||
<Command>{children}</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b hl-border px-4 py-3 gap-3" cmdk-input-wrapper="">
|
||||
<Search className="h-4 w-4 shrink-0 text-[var(--text-muted)]" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex w-full bg-transparent text-sm outline-none placeholder:text-[var(--text-subtle)] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<kbd className="px-1.5 py-0.5 text-[10px] font-mono-num rounded hl-muted text-[var(--text-muted)] shrink-0">
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
));
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn('max-h-80 overflow-y-auto overflow-x-hidden p-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-xs text-[var(--text-muted)]"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'overflow-hidden p-1 text-[var(--text-main)] [&_[cmdk-group-heading]]:px-2.5 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-[10px] [&_[cmdk-group-heading]]:font-semibold [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wider [&_[cmdk-group-heading]]:text-[var(--text-subtle)]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ComponentRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-pointer select-none items-center justify-between rounded-lg px-3 py-2 text-xs outline-none transition-colors data-[selected=true]:bg-[var(--bg-muted)] data-[selected=true]:text-[var(--text-main)] data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/70 backdrop-blur-sm transition-opacity',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 w-full max-w-lg translate-x-[-50%] translate-y-[-50%] hl-card shadow-2xl duration-200 outline-none',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col space-y-1.5 text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
DialogHeader.displayName = 'DialogHeader';
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-sm font-bold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-xs text-[var(--text-muted)]', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn('hl-input', className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export interface InputGroupProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
const InputGroup = React.forwardRef<HTMLDivElement, InputGroupProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return <div ref={ref} className={cn('hl-input-group', className)} {...props} />;
|
||||
}
|
||||
);
|
||||
InputGroup.displayName = 'InputGroup';
|
||||
|
||||
export { Input, InputGroup };
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ComponentRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border transition-colors disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-[var(--accent)] data-[state=checked]:border-[var(--accent)] data-[state=unchecked]:bg-[var(--bg-muted)] data-[state=unchecked]:border-[var(--border-color)]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from 'react';
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||
import { cn } from '../../lib/utils';
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ComponentRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-lg hl-muted p-1 border hl-border text-[var(--text-muted)]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md px-2.5 py-1 text-xs font-medium transition-all cursor-pointer select-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-[var(--bg-card)] data-[state=active]:text-[var(--text-main)] data-[state=active]:shadow-xs',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ComponentRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn('mt-2 outline-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -0,0 +1,135 @@
|
||||
import React from 'react';
|
||||
import { THEME_PALETTES, LAYOUT_PRESETS } from './tokens';
|
||||
|
||||
export const DesignTokensShowcase: React.FC = () => {
|
||||
return (
|
||||
<div className="p-6 sm:p-8 space-y-10 max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="space-y-2 border-b hl-border pb-5">
|
||||
<div className="text-xs font-mono-num uppercase tracking-widest" style={{ color: 'var(--accent)' }}>
|
||||
HomeLab Design System · Storybook v3.0
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight">主题色彩、排版与布局规范 (Design Tokens)</h1>
|
||||
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>
|
||||
全系统仅保留两套高标准主流主题(浅色纯净 Light & 深色极客 Dark),默认通过{' '}
|
||||
<code className="font-mono-num px-1.5 py-0.5 rounded hl-muted">prefers-color-scheme</code>{' '}
|
||||
自动跟随设备切换,并支持右上角一键手动覆盖。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Side-by-Side Theme Palettes */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-base font-semibold">1. 双主题色彩变量对照表 (Dual Mainstream Palettes)</h2>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{(['light', 'dark'] as const).map((key) => {
|
||||
const p = THEME_PALETTES[key];
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="rounded-xl p-5 space-y-4 border"
|
||||
style={{
|
||||
backgroundColor: p.tokens['--bg-page'],
|
||||
color: p.tokens['--text-main'],
|
||||
borderColor: p.tokens['--border-color'],
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b pb-3" style={{ borderColor: p.tokens['--border-color'] }}>
|
||||
<div>
|
||||
<div className="text-sm font-bold">{p.name}</div>
|
||||
<div className="text-xs mt-0.5" style={{ color: p.tokens['--text-muted'] }}>
|
||||
{p.description}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className="px-2.5 py-1 rounded-md text-xs font-mono-num font-semibold"
|
||||
style={{
|
||||
backgroundColor: p.tokens['--primary'],
|
||||
color: p.tokens['--primary-fg'],
|
||||
}}
|
||||
>
|
||||
{p.id.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2.5">
|
||||
{Object.entries(p.tokens).map(([tokenName, hex]) => (
|
||||
<div
|
||||
key={tokenName}
|
||||
className="p-2.5 rounded-lg border flex items-center gap-2.5"
|
||||
style={{
|
||||
backgroundColor: p.tokens['--bg-card'],
|
||||
borderColor: p.tokens['--border-color'],
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="w-6 h-6 rounded-md border shrink-0"
|
||||
style={{
|
||||
backgroundColor: hex,
|
||||
borderColor: p.tokens['--border-hover'],
|
||||
}}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-mono-num font-semibold truncate">{tokenName}</div>
|
||||
<div className="text-[10px] font-mono-num truncate" style={{ color: p.tokens['--text-muted'] }}>
|
||||
{hex}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Typography Scale */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-base font-semibold">2. 字体与等宽数字规范 (Typography & Tabular Numbers)</h2>
|
||||
<div className="hl-card p-5 space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-mono-num uppercase" style={{ color: 'var(--text-muted)' }}>
|
||||
UI Sans Font · Inter / SF Pro Display
|
||||
</div>
|
||||
<div className="text-xl font-bold">AGY 云端工作台 · 极空间 Z4 私有云</div>
|
||||
<p className="text-xs leading-relaxed" style={{ color: 'var(--text-muted)' }}>
|
||||
正文描述统一采用 12px (0.75rem) 搭配 1.625 行高,限制最最多 2 行截断 (line-clamp-2),确保所有卡片高度严格对齐。
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-mono-num uppercase" style={{ color: 'var(--text-muted)' }}>
|
||||
Telemetry Mono Font · JetBrains Mono (tabular-nums)
|
||||
</div>
|
||||
<div className="text-xl font-bold font-mono-num" style={{ color: 'var(--success)' }}>
|
||||
16.58 TB FREE · 68 ms · 104.43.105.243
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed" style={{ color: 'var(--text-muted)' }}>
|
||||
所有实时跳动的 CPU 百分比、内存、存储余量、网络延迟强制启用{' '}
|
||||
<code className="font-mono-num">font-variant-numeric: tabular-nums</code>,杜绝每 3 秒刷新时产生像素抖动。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Layout Grid Presets */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-base font-semibold">3. 响应式布局网格预设 (Layout Grid Presets)</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{Object.values(LAYOUT_PRESETS).map((lp) => (
|
||||
<div key={lp.id} className="hl-card p-4 space-y-2">
|
||||
<div className="text-sm font-semibold">{lp.name}</div>
|
||||
<code className="text-[11px] font-mono-num block" style={{ color: 'var(--accent)' }}>
|
||||
{lp.gridClass}
|
||||
</code>
|
||||
<p className="text-xs" style={{ color: 'var(--text-muted)' }}>
|
||||
{lp.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
export interface ThemePalette {
|
||||
id: 'light' | 'dark';
|
||||
name: string;
|
||||
description: string;
|
||||
tokens: {
|
||||
'--bg-page': string;
|
||||
'--bg-card': string;
|
||||
'--bg-widget': string;
|
||||
'--bg-muted': string;
|
||||
'--bg-hover': string;
|
||||
'--text-main': string;
|
||||
'--text-muted': string;
|
||||
'--text-subtle': string;
|
||||
'--border-color': string;
|
||||
'--border-hover': string;
|
||||
'--primary': string;
|
||||
'--primary-fg': string;
|
||||
'--accent': string;
|
||||
'--accent-soft': string;
|
||||
'--success': string;
|
||||
'--success-soft': string;
|
||||
};
|
||||
}
|
||||
|
||||
export const THEME_PALETTES: Record<'light' | 'dark', ThemePalette> = {
|
||||
light: {
|
||||
id: 'light',
|
||||
name: 'Shadcn Clean Light (浅色纯净)',
|
||||
description: '对标 Apple / Stripe / Linear 浅色日光模式,高对比深灰文字配纯净白卡片与冷灰微边框。',
|
||||
tokens: {
|
||||
'--bg-page': '#f8fafc',
|
||||
'--bg-card': '#ffffff',
|
||||
'--bg-widget': '#f8fafc',
|
||||
'--bg-muted': '#f1f5f9',
|
||||
'--bg-hover': '#ffffff',
|
||||
'--text-main': '#0f172a',
|
||||
'--text-muted': '#64748b',
|
||||
'--text-subtle': '#94a3b8',
|
||||
'--border-color': '#e2e8f0',
|
||||
'--border-hover': '#cbd5e1',
|
||||
'--primary': '#0f172a',
|
||||
'--primary-fg': '#ffffff',
|
||||
'--accent': '#2563eb',
|
||||
'--accent-soft': 'rgba(37, 99, 235, 0.08)',
|
||||
'--success': '#10b981',
|
||||
'--success-soft': 'rgba(16, 185, 129, 0.10)',
|
||||
},
|
||||
},
|
||||
dark: {
|
||||
id: 'dark',
|
||||
name: 'Catppuccin / Linear Dark (深色极客)',
|
||||
description: '融合 Catppuccin Mocha 与 Linear 暗黑规范,柔和护眼的深邃蓝黑基底配高辨识度状态色。',
|
||||
tokens: {
|
||||
'--bg-page': '#11111b',
|
||||
'--bg-card': '#1e1e2e',
|
||||
'--bg-widget': '#181825',
|
||||
'--bg-muted': '#313244',
|
||||
'--bg-hover': '#242438',
|
||||
'--text-main': '#cdd6f4',
|
||||
'--text-muted': '#a6adc8',
|
||||
'--text-subtle': '#6c7086',
|
||||
'--border-color': '#313244',
|
||||
'--border-hover': '#45475a',
|
||||
'--primary': '#cba6f7',
|
||||
'--primary-fg': '#11111b',
|
||||
'--accent': '#89b4fa',
|
||||
'--accent-soft': 'rgba(137, 180, 250, 0.15)',
|
||||
'--success': '#a6e3a1',
|
||||
'--success-soft': 'rgba(166, 227, 161, 0.15)',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type LayoutPresetId = 'homepage-3col' | 'bento-split' | 'compact-2col';
|
||||
|
||||
export interface LayoutPreset {
|
||||
id: LayoutPresetId;
|
||||
name: string;
|
||||
description: string;
|
||||
gridClass: string;
|
||||
}
|
||||
|
||||
export const LAYOUT_PRESETS: Record<LayoutPresetId, LayoutPreset> = {
|
||||
'homepage-3col': {
|
||||
id: 'homepage-3col',
|
||||
name: 'Homepage 经典三列矩阵 (3-Column Grid)',
|
||||
description: 'gethomepage.dev 标志性对称 3 列分组卡片,底部内嵌 3 格实时遥测单元。',
|
||||
gridClass: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4',
|
||||
},
|
||||
'bento-split': {
|
||||
id: 'bento-split',
|
||||
name: 'Shadcn 双列宽屏卡片 (2-Column Wide Bento)',
|
||||
description: '适合大字号阅读的 2 列宽体卡片布局,留白更充裕。',
|
||||
gridClass: 'grid-cols-1 lg:grid-cols-2 gap-5',
|
||||
},
|
||||
'compact-2col': {
|
||||
id: 'compact-2col',
|
||||
name: '高密度四列紧凑矩阵 (4-Column Dense)',
|
||||
description: '适合超宽显示器的一屏高密度 4 列布局。',
|
||||
gridClass: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3.5',
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,219 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* =========================================================================
|
||||
HomeLab Console V3 (React + Vite + TypeScript + Zustand + Tailwind v4)
|
||||
Architecture: gethomepage.dev (Two-Tier AppShell + 3-Col Rows + Inline Widgets)
|
||||
Design System: shadcn/ui + Catppuccin/Linear + Custom Form Controls & Auth Gate
|
||||
========================================================================= */
|
||||
:root,
|
||||
[data-theme="dark"] {
|
||||
--bg-page: #09090b;
|
||||
--bg-card: #121215;
|
||||
--bg-widget: #16161a;
|
||||
--bg-muted: #18181b;
|
||||
--bg-hover: #1c1c21;
|
||||
--text-main: #f4f4f5;
|
||||
--text-muted: #a1a1aa;
|
||||
--text-subtle: #71717a;
|
||||
--border-color: #27272a;
|
||||
--border-subtle: rgba(255, 255, 255, 0.06);
|
||||
--border-hover: #3f3f46;
|
||||
--primary: #fafafa;
|
||||
--primary-fg: #09090b;
|
||||
--accent: #3b82f6;
|
||||
--accent-soft: rgba(59, 130, 246, 0.16);
|
||||
--success: #10b981;
|
||||
--success-soft: rgba(16, 185, 129, 0.14);
|
||||
--card-shadow: 0 4px 16px -4px rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg-page: #f8fafc;
|
||||
--bg-card: #ffffff;
|
||||
--bg-widget: #f8fafc;
|
||||
--bg-muted: #f1f5f9;
|
||||
--bg-hover: #ffffff;
|
||||
--text-main: #0f172a;
|
||||
--text-muted: #64748b;
|
||||
--text-subtle: #94a3b8;
|
||||
--border-color: #e2e8f0;
|
||||
--border-subtle: #f1f5f9;
|
||||
--border-hover: #cbd5e1;
|
||||
--primary: #0f172a;
|
||||
--primary-fg: #ffffff;
|
||||
--accent: #2563eb;
|
||||
--accent-soft: rgba(37, 99, 235, 0.12);
|
||||
--success: #10b981;
|
||||
--success-soft: rgba(16, 185, 129, 0.1);
|
||||
--card-shadow: 0 1px 3px 0 rgba(15, 23, 42, 0.04), 0 1px 2px -1px rgba(15, 23, 42, 0.03);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background-color: var(--bg-page);
|
||||
color: var(--text-main);
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
Global Browser Default Input Reset & Custom Input System (.hl-input)
|
||||
彻底禁用浏览器原生丑陋的 input 边框、外发光与黄色自动填充背景
|
||||
========================================================================= */
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
button {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
input:-webkit-autofill,
|
||||
input:-webkit-autofill:hover,
|
||||
input:-webkit-autofill:focus,
|
||||
textarea:-webkit-autofill {
|
||||
-webkit-text-fill-color: var(--text-main) !important;
|
||||
-webkit-box-shadow: 0 0 0px 1000px var(--bg-muted) inset !important;
|
||||
transition: background-color 5000s ease-in-out 0s;
|
||||
}
|
||||
|
||||
.hl-input {
|
||||
width: 100%;
|
||||
background-color: var(--bg-muted);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.625rem;
|
||||
padding: 0.55rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.25rem;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.hl-input::placeholder {
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.hl-input:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.hl-input:focus,
|
||||
.hl-input:focus-visible {
|
||||
background-color: var(--bg-card);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.hl-input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
background-color: var(--bg-muted);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.625rem;
|
||||
padding: 0 0.75rem;
|
||||
height: 2.375rem;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.hl-input-group:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.hl-input-group:focus-within {
|
||||
background-color: var(--bg-card);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
|
||||
.hl-input-group input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text-main);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.hl-input-group input::placeholder {
|
||||
color: var(--text-subtle);
|
||||
}
|
||||
|
||||
.font-mono-num {
|
||||
font-family: "JetBrains Mono", "SF Mono", Menlo, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.hl-card {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: var(--card-shadow);
|
||||
border-radius: 0.75rem;
|
||||
transition: border-color 0.16s ease, transform 0.16s ease, box-shadow 0.16s ease;
|
||||
}
|
||||
|
||||
.hl-card-interactive:hover {
|
||||
border-color: var(--border-hover);
|
||||
background-color: var(--bg-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.hl-widget-footer {
|
||||
background-color: var(--bg-widget);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.hl-pill {
|
||||
background-color: var(--bg-card);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.hl-muted {
|
||||
background-color: var(--bg-muted);
|
||||
}
|
||||
|
||||
.hl-border {
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.hl-btn-primary {
|
||||
background-color: var(--primary);
|
||||
color: var(--primary-fg);
|
||||
font-weight: 500;
|
||||
border-radius: 0.5rem;
|
||||
transition: opacity 0.15s ease, transform 0.1s ease;
|
||||
}
|
||||
.hl-btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.hl-btn-primary:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.hl-btn-outline {
|
||||
background-color: var(--bg-card);
|
||||
color: var(--text-main);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
transition: background-color 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.hl-btn-outline:hover {
|
||||
background-color: var(--bg-muted);
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.hl-bar-fill {
|
||||
background-color: var(--accent);
|
||||
transition: width 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.hl-bar-fill-green {
|
||||
background-color: var(--success);
|
||||
transition: width 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,220 @@
|
||||
import { create } from 'zustand';
|
||||
import type { RouteModeId, TelemetryData, ThemeId } from '../types/telemetry';
|
||||
import type { LayoutPresetId } from '../design-system/tokens';
|
||||
|
||||
interface PortalState {
|
||||
theme: ThemeId;
|
||||
resolvedTheme: 'light' | 'dark';
|
||||
routeMode: RouteModeId;
|
||||
layoutPreset: LayoutPresetId;
|
||||
viewMode: 'dashboard' | 'agents' | 'storybook';
|
||||
showAgyModal: boolean;
|
||||
showWechatModal: boolean;
|
||||
showCmdPalette: boolean;
|
||||
toast: string;
|
||||
actionLoading: boolean;
|
||||
telemetry: TelemetryData;
|
||||
setTheme: (theme: ThemeId) => void;
|
||||
toggleTheme: () => void;
|
||||
setRouteMode: (mode: RouteModeId) => void;
|
||||
setLayoutPreset: (preset: LayoutPresetId) => void;
|
||||
setViewMode: (view: 'dashboard' | 'agents' | 'storybook') => void;
|
||||
setShowAgyModal: (open: boolean) => void;
|
||||
setShowWechatModal: (open: boolean) => void;
|
||||
setShowCmdPalette: (open: boolean) => void;
|
||||
showToast: (msg: string) => void;
|
||||
fetchTelemetry: () => Promise<void>;
|
||||
triggerAction: (action: string, startMsg: string) => Promise<void>;
|
||||
copyCmd: (cmd: string, label: string) => Promise<void>;
|
||||
getEffectiveMode: () => Exclude<RouteModeId, 'auto'>;
|
||||
}
|
||||
|
||||
const getSystemDark = () =>
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
const applyDomTheme = (mode: ThemeId): 'light' | 'dark' => {
|
||||
const isDark = mode === 'system' ? getSystemDark() : mode === 'dark';
|
||||
const domTheme = isDark ? 'dark' : 'light';
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.setAttribute('data-theme', domTheme);
|
||||
}
|
||||
return domTheme;
|
||||
};
|
||||
|
||||
const storedTheme = localStorage.getItem('hl_theme_v2') as ThemeId | null;
|
||||
const initialTheme: ThemeId =
|
||||
storedTheme === 'light' || storedTheme === 'dark' || storedTheme === 'system'
|
||||
? storedTheme
|
||||
: 'system';
|
||||
const initialResolved = applyDomTheme(initialTheme);
|
||||
const initialRouteMode = (localStorage.getItem('hl_route_mode') as RouteModeId) || 'auto';
|
||||
const initialLayout =
|
||||
(localStorage.getItem('hl_layout_preset') as LayoutPresetId) || 'homepage-3col';
|
||||
const initialView =
|
||||
typeof window !== 'undefined' && window.location.pathname.startsWith('/storybook')
|
||||
? 'storybook'
|
||||
: typeof window !== 'undefined' && window.location.pathname.startsWith('/agents')
|
||||
? 'agents'
|
||||
: 'dashboard';
|
||||
|
||||
export const usePortalStore = create<PortalState>((set, get) => ({
|
||||
theme: initialTheme,
|
||||
resolvedTheme: initialResolved,
|
||||
routeMode: initialRouteMode,
|
||||
layoutPreset: initialLayout,
|
||||
viewMode: initialView,
|
||||
showAgyModal: false,
|
||||
showWechatModal: false,
|
||||
showCmdPalette: false,
|
||||
toast: '',
|
||||
actionLoading: false,
|
||||
telemetry: {
|
||||
mini: {
|
||||
chip: 'Apple M6 (12核)',
|
||||
ip: '192.168.31.164',
|
||||
tailscale_ip: '100.108.205.40',
|
||||
cpu_pct: 6.5,
|
||||
load_1m: 0.85,
|
||||
mem_used_gb: 11.8,
|
||||
mem_total_gb: 24.0,
|
||||
mem_pct: 49.2,
|
||||
disk_used_gb: 59.9,
|
||||
disk_total_gb: 460.4,
|
||||
disk_pct: 13.0,
|
||||
uptime: '运行中',
|
||||
agy_tmux: true,
|
||||
},
|
||||
nas: {
|
||||
ip: '192.168.31.185',
|
||||
online: true,
|
||||
mounted: true,
|
||||
total_tb: 19.94,
|
||||
used_tb: 3.36,
|
||||
free_tb: 16.58,
|
||||
pct: 16.8,
|
||||
latest_backup: '11-06 23:17',
|
||||
},
|
||||
network: {
|
||||
home_wan_ip: '124.90.142.21',
|
||||
ai_policy: 'Ai平台 → 狮城节点',
|
||||
sg_node: '233boy-sea',
|
||||
sg_egress_ip: '104.43.105.243',
|
||||
sg_delay_ms: 68,
|
||||
},
|
||||
services: {
|
||||
agy: true,
|
||||
vaultwarden: true,
|
||||
nas: true,
|
||||
clash: true,
|
||||
router: true,
|
||||
uptime: true,
|
||||
clawbot: false,
|
||||
},
|
||||
},
|
||||
|
||||
setTheme: (theme) => {
|
||||
localStorage.setItem('hl_theme_v2', theme);
|
||||
const resolvedTheme = applyDomTheme(theme);
|
||||
set({ theme, resolvedTheme });
|
||||
},
|
||||
|
||||
toggleTheme: () => {
|
||||
const { resolvedTheme, setTheme } = get();
|
||||
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
||||
},
|
||||
|
||||
setRouteMode: (routeMode) => {
|
||||
localStorage.setItem('hl_route_mode', routeMode);
|
||||
set({ routeMode });
|
||||
},
|
||||
|
||||
setLayoutPreset: (layoutPreset) => {
|
||||
localStorage.setItem('hl_layout_preset', layoutPreset);
|
||||
set({ layoutPreset });
|
||||
},
|
||||
|
||||
setViewMode: (viewMode) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const nextUrl =
|
||||
viewMode === 'storybook' ? '/storybook' : viewMode === 'agents' ? '/agents' : '/';
|
||||
window.history.pushState({}, '', nextUrl);
|
||||
}
|
||||
set({ viewMode });
|
||||
},
|
||||
|
||||
setShowAgyModal: (showAgyModal) => set({ showAgyModal }),
|
||||
setShowWechatModal: (showWechatModal) => set({ showWechatModal }),
|
||||
setShowCmdPalette: (showCmdPalette) => set({ showCmdPalette }),
|
||||
|
||||
showToast: (msg) => {
|
||||
set({ toast: msg });
|
||||
setTimeout(() => {
|
||||
if (get().toast === msg) {
|
||||
set({ toast: '' });
|
||||
}
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
fetchTelemetry: async () => {
|
||||
try {
|
||||
const res = await fetch('/api/telemetry');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
set({ telemetry: data });
|
||||
}
|
||||
} catch {
|
||||
// ignore transient network errors
|
||||
}
|
||||
},
|
||||
|
||||
triggerAction: async (action, startMsg) => {
|
||||
const { showToast, fetchTelemetry } = get();
|
||||
set({ actionLoading: true });
|
||||
showToast(startMsg);
|
||||
try {
|
||||
const res = await fetch(`/api/action/${action}`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
showToast(data.message || '操作已完成');
|
||||
await fetchTelemetry();
|
||||
} catch {
|
||||
showToast('请求失败,请稍后重试');
|
||||
} finally {
|
||||
set({ actionLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
copyCmd: async (cmd, label) => {
|
||||
const { showToast } = get();
|
||||
try {
|
||||
await navigator.clipboard.writeText(cmd);
|
||||
showToast(`已复制「${label}」: ${cmd}`);
|
||||
} catch {
|
||||
showToast(cmd);
|
||||
}
|
||||
},
|
||||
|
||||
getEffectiveMode: () => {
|
||||
const { routeMode } = get();
|
||||
if (routeMode !== 'auto') return routeMode;
|
||||
const host = window.location.hostname;
|
||||
if (host.startsWith('192.168.') || host.startsWith('100.') || host === 'localhost') {
|
||||
return 'lan';
|
||||
}
|
||||
if (host.includes('.home.961504.xyz') || window.location.port === '10443') {
|
||||
return 'home10443';
|
||||
}
|
||||
return 'edge443';
|
||||
},
|
||||
}));
|
||||
|
||||
// Listen to device OS dark/light mode changes automatically
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
|
||||
const state = usePortalStore.getState();
|
||||
if (state.theme === 'system') {
|
||||
state.setTheme('system');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
export type ThemeId = 'system' | 'light' | 'dark';
|
||||
export type RouteModeId = 'auto' | 'edge443' | 'home10443' | 'lan';
|
||||
|
||||
export interface ClawbotPublicStatus {
|
||||
connected: boolean;
|
||||
bot_id?: string;
|
||||
user_id?: string;
|
||||
base_url?: string;
|
||||
connected_at?: string;
|
||||
msg_count?: number;
|
||||
last_inbound_text?: string;
|
||||
last_reply_text?: string;
|
||||
login?: {
|
||||
active: boolean;
|
||||
qrcode_url: string;
|
||||
status: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TelemetryData {
|
||||
timestamp?: string;
|
||||
mini: {
|
||||
chip: string;
|
||||
ip: string;
|
||||
tailscale_ip: string;
|
||||
cpu_pct: number;
|
||||
load_1m: number;
|
||||
cores?: number;
|
||||
mem_used_gb: number;
|
||||
mem_total_gb: number;
|
||||
mem_pct: number;
|
||||
disk_used_gb: number;
|
||||
disk_total_gb: number;
|
||||
disk_pct: number;
|
||||
uptime: string;
|
||||
containers?: Array<{ name: string; status: string; image: string }>;
|
||||
agy_tmux: boolean;
|
||||
};
|
||||
nas: {
|
||||
name?: string;
|
||||
ip: string;
|
||||
online: boolean;
|
||||
latency_ms?: number;
|
||||
mounted: boolean;
|
||||
total_tb: number;
|
||||
used_tb: number;
|
||||
free_tb: number;
|
||||
pct: number;
|
||||
latest_backup: string;
|
||||
backup_count?: number;
|
||||
};
|
||||
network: {
|
||||
home_wan_ip: string;
|
||||
router_ip?: string;
|
||||
router_online?: boolean;
|
||||
router_ms?: number;
|
||||
ai_policy: string;
|
||||
sg_node: string;
|
||||
sg_egress_ip: string;
|
||||
sg_delay_ms: number;
|
||||
azure_tailscale_ip?: string;
|
||||
azure_online?: boolean;
|
||||
azure_ms?: number;
|
||||
};
|
||||
services: {
|
||||
agy: boolean;
|
||||
codeg?: boolean;
|
||||
gitea?: boolean;
|
||||
vaultwarden: boolean;
|
||||
nas: boolean;
|
||||
clash: boolean;
|
||||
router: boolean;
|
||||
uptime: boolean;
|
||||
clawbot?: boolean;
|
||||
};
|
||||
clawbot?: ClawbotPublicStatus;
|
||||
}
|
||||
|
||||
export interface StatCell {
|
||||
label: string;
|
||||
value: string;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
export interface ServiceCardItem {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
iconUrl: string;
|
||||
online: boolean;
|
||||
actionType?: 'drawer' | 'backup' | 'clawbot';
|
||||
desc: string;
|
||||
edgeUrl: string;
|
||||
homeUrl: string;
|
||||
lanUrl: string;
|
||||
stats: StatCell[];
|
||||
}
|
||||
|
||||
export interface ServiceGroup {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
showRouteHint?: boolean;
|
||||
items: ServiceCardItem[];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
build: {
|
||||
outDir: '../dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://192.168.31.164:3002',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user