From 8fdec02a1c27b9b005611f27395b3c3ee81d0cd4 Mon Sep 17 00:00:00 2001 From: Jack Date: Wed, 21 Oct 2026 10:13:38 +0800 Subject: [PATCH] add a foreground dev launcher and close the Arco token gaps The CYBER skin left a class of defects that unit tests could not see: Arco components kept their own light-theme appearance because the token bridge never covered the neutral, gray, tooltip, mask and border-radius families, and four override rules targeted class names that do not exist in Arco, so they silently matched nothing. Bridge the missing families and point the dead selectors at the real ones (.arco-switch-handle, .arco-checkbox-icon, .arco-select-dropdown, .arco-slider-bar), so switches stop rendering a white knob and checkboxes finally glow. Add start.sh so a dev run can be started without a second terminal: it frees the ports left over from a previous run, refusing to kill processes that do not belong to this project, then starts the API server and the Vite client in the foreground. --- packages/client/src/styles/global.scss | 125 ++++++++++++++- start.sh | 203 +++++++++++++++++++++++++ 2 files changed, 325 insertions(+), 3 deletions(-) create mode 100755 start.sh diff --git a/packages/client/src/styles/global.scss b/packages/client/src/styles/global.scss index 30c35dc..ec5d1b6 100644 --- a/packages/client/src/styles/global.scss +++ b/packages/client/src/styles/global.scss @@ -131,9 +131,59 @@ body[arco-theme='dark'], --color-fill-2-hover: oklch(0.255 0.018 247); --color-fill-3-hover: oklch(0.285 0.018 246); + // Arco 的 neutral / gray 族:边框线、树连接线、空状态图标都走这两族。 + // 不桥接就会漏出 Arco 自带的中性灰(暗色下 rgb(72,72,73) 之类),与 CYBER 的发丝线体系脱节。 + --gray-1: 28, 30, 36; + --gray-2: 40, 43, 51; + --gray-3: 52, 56, 66; + --gray-4: 66, 70, 82; + --gray-5: 92, 97, 110; + --gray-6: 118, 123, 136; + --gray-7: 144, 149, 162; + --gray-8: 170, 175, 188; + --gray-9: 196, 201, 214; + --gray-10: 226, 231, 240; + + --color-neutral-1: rgb(var(--gray-1)); + --color-neutral-2: rgb(var(--gray-2)); + --color-neutral-3: rgb(var(--gray-3)); + --color-neutral-4: rgb(var(--gray-4)); + --color-neutral-5: rgb(var(--gray-5)); + --color-neutral-6: rgb(var(--gray-6)); + --color-neutral-7: rgb(var(--gray-7)); + --color-neutral-8: rgb(var(--gray-8)); + --color-neutral-9: rgb(var(--gray-9)); + --color-neutral-10: rgb(var(--gray-10)); + + --color-neutral-1-hover: rgb(var(--gray-2)); + --color-neutral-2-hover: rgb(var(--gray-3)); + --color-neutral-3-hover: rgb(var(--gray-4)); + --color-neutral-4-hover: rgb(var(--gray-5)); + + // 次级按钮的半透明底:跟灰阶走,保持低对比 + --color-secondary-1: rgba(var(--gray-9), 0.08); + --color-secondary-2: rgba(var(--gray-9), 0.12); + --color-secondary-3: rgba(var(--gray-9), 0.16); + --color-secondary-4: rgba(var(--gray-9), 0.20); + + // 浮层与遮罩 + --color-bg-popup: oklch(0.185 0.014 248); + --color-tooltip-bg: oklch(0.225 0.016 246); + --color-bg-white: oklch(0.945 0.006 200); + --color-mask-bg: var(--overlay-scrim); + --color-spin-layer-bg: var(--overlay-scrim); + --color-menu-dark-bg: oklch(0.185 0.014 248); + --color-menu-light-bg: oklch(0.185 0.014 248); + + // Arco 的圆角族名与 CYBER 的 --radius-* 不同,两族都要给, + // 否则 Arco 组件会继续用它的 2/4/8px,和我们声明的 4/6/8px 对不上。 --radius-small: 4px; --radius-medium: 6px; --radius-large: 8px; + --border-radius-none: 0px; + --border-radius-small: 4px; + --border-radius-medium: 6px; + --border-radius-large: 8px; --font-size-body-1: 14px; --font-size-body-2: 13px; @@ -535,21 +585,43 @@ $neon-hv: var(--accent-hover); box-shadow: 0 0 10px rgba(var(--accent-primary-rgb), 0.3); } -.arco-switch-checked .arco-switch-slider { +// 开关手柄。Arco 的类名是 .arco-switch-handle(不是 .arco-switch-slider,后者不存在), +// 底色默认走 --color-bg-white,暗色下是一块亮白,必须接管。 +.arco-switch-handle { + background-color: var(--text-primary); + box-shadow: 0 0 8px rgba(var(--accent-primary-rgb), 0.45); +} + +.arco-switch-checked .arco-switch-handle { background-color: var(--accent-primary); box-shadow: 0 0 10px rgba(var(--accent-primary-rgb), 0.7); } -.arco-checkbox-checked .arco-checkbox-inner { +// 复选框图标。Arco 的类名是 .arco-checkbox-icon(不是 .arco-checkbox-inner,后者不存在), +// 不写对类名,选中态的外发光就完全不生效。 +.arco-checkbox-checked .arco-checkbox-icon { background-color: var(--accent-primary); border-color: var(--accent-primary); box-shadow: 0 0 8px rgba(var(--accent-primary-rgb), 0.5); } +// 单选按钮组:选中态给霓虹管壁,和按钮同一套语言 +.arco-radio-button.arco-radio-checked { + color: var(--accent-primary); + background-color: rgba(var(--accent-primary-rgb), 0.12); + border-color: rgba(var(--accent-primary-rgb), 0.5); + box-shadow: inset 0 0 10px rgba(var(--accent-primary-rgb), 0.14); +} + +.arco-radio-button:not(:first-of-type)::before { + border-color: var(--border-light); +} + // 下拉 / 弹层的浮层背景。浮层要浮在内容之上,用最亮的一层底。 .arco-trigger-popup, .arco-select-popup, -.arco-picker-dropdown, +.arco-select-dropdown, +.arco-picker-container, .arco-dropdown, .arco-modal, .arco-drawer { @@ -558,6 +630,53 @@ $neon-hv: var(--accent-hover); box-shadow: var(--shadow-lg); } +// 浮层内部:Arco 把下拉面板挂在 body 下,不在 .arco-trigger-popup 内, +// 所以 message / notification / popover / popconfirm 都要单独接管。 +// 它们用 --color-bg-popup,桥接后与 modal 同为 --bg-card 一档。 +.arco-message, +.arco-notification, +.arco-popover-popup-content, +.arco-popconfirm-popup-content { + background-color: var(--bg-card); + border: 1px solid var(--border-color); + color: var(--text-primary); +} + +.arco-message .arco-message-content, +.arco-notification .arco-notification-content, +.arco-popover-popup-content, +.arco-popconfirm-popup-content { + color: var(--text-primary); +} + +// 气泡确认框的操作区要压暗一档,否则和内容区糊在一起 +.arco-popconfirm-popup-footer { + border-top: 1px solid var(--border-light); +} + +// Tooltip:Arco 的提示框用 --color-tooltip-bg,箭头和内容框同色。 +// 这里让它比弹层亮一档,和弹层区分开。 +.arco-tooltip-content { + background-color: var(--color-tooltip-bg); + color: var(--text-primary); + font-family: $font-code; +} + +.arco-tooltip-popup-arrow::before { + background-color: var(--color-tooltip-bg); +} + +// 抽屉 / 弹窗头尾的分隔线 +.arco-drawer-header, +.arco-modal-header { + border-color: var(--border-light); +} + +.arco-drawer-footer, +.arco-modal-footer { + border-color: var(--border-light); +} + // 表格:去掉亮色分隔线的突兀感,表头沉下去 .arco-table-th { background-color: var(--bg-secondary); diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..67c23e2 --- /dev/null +++ b/start.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# +# Local development launcher for hermes-ui. +# +# Starts the Koa API server and the Vite client together in the foreground, +# so you can watch the logs and Ctrl-C to stop everything. +# +# ./start.sh +# +# Then open http://localhost:8649/ and sign in with admin / 123456. +# +# The API server listens on 8647 and the Vite dev server proxies /api and +# /socket.io to it. Both ports are overridable: +# +# API_PORT=9000 CLIENT_PORT=9001 ./start.sh +# +# Ports left over from a previous run are cleaned up automatically. +# +set -uo pipefail + +cd "$(dirname "$0")" + +API_PORT="${API_PORT:-8647}" +CLIENT_PORT="${CLIENT_PORT:-8649}" + +bold() { printf '\033[1m%s\033[0m\n' "$1"; } +info() { printf '\033[36m›\033[0m %s\n' "$1"; } +warn() { printf '\033[33m!\033[0m %s\n' "$1"; } +die() { printf '\033[31m✗\033[0m %s\n' "$1" >&2; exit 1; } + +# --- Preflight ----------------------------------------------------------- + +command -v node >/dev/null 2>&1 || die "node not found. Install Node.js >= 23." + +node_version="$(node -v)" +node_major="${node_version#v}" +node_major="${node_major%%.*}" +if [ "$node_major" -lt 23 ]; then + die "Node.js >= 23 required, found $node_version." +fi +info "node $node_version" + +if [ ! -d node_modules ]; then + warn "node_modules missing — installing (this takes a few minutes)." + npm ci --ignore-scripts || die "npm ci failed." + # node-pty's install script is what places the loadable native module. + # `npm ci --ignore-scripts` skips it, which silently disables the terminal. + info "rebuilding node-pty" + npm rebuild node-pty || warn "node-pty rebuild failed — the terminal may not work." +elif ! node -e "require('node-pty')" >/dev/null 2>&1; then + warn "node-pty native module missing — rebuilding (needed for the terminal)." + npm rebuild node-pty || warn "node-pty rebuild failed — the terminal may not work." +fi + +# --- Port cleanup -------------------------------------------------------- +# +# A previous run can leave vite, the ts-node server, and their supervisor +# behind. The Vite client runs with --strictPort, so it refuses to fall back +# to another port and dies instead. Clean all of it before starting. + +# PIDs listening on a TCP port, one per line. Always exits 0 — `lsof` returns +# non-zero when nothing matches, which under `set -e` would abort the script. +pids_on_port() { + lsof -nP -iTCP:"$1" -sTCP:LISTEN -t 2>/dev/null | sort -u || true +} + +port_in_use() { + local pids + pids="$(pids_on_port "$1")" + [ -n "$pids" ] +} + +# Whether a PID belongs to this project. Guards against killing an unrelated +# process that merely happens to hold the port. +pid_is_ours() { + local pid="$1" cmd cwd + cmd="$(ps -o command= -p "$pid" 2>/dev/null || true)" + case "$cmd" in + *hermes-ui*|*"packages/server/src/index.ts"*|*node_modules/.bin/vite*) return 0 ;; + esac + # A plain `node` process running from inside the project directory is a + # leftover dev server too — e.g. one started by hand from this repo. + case "$cmd" in + node*) ;; + *) return 1 ;; + esac + cwd="$(lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1 || true)" + case "$cwd" in + "$PWD"/*|"$PWD") return 0 ;; + esac + return 1 +} + +# Walk up from a PID to the topmost ancestor that still belongs to this +# project. Killing only the listener leaves its npm/nodemon parent alive to +# respawn it, which is how stale servers come back. +top_of_tree() { + local top="$1" parent parent_cmd + while true; do + parent="$(ps -o ppid= -p "$top" 2>/dev/null | tr -d ' ' || true)" + [ -n "$parent" ] || break + [ "$parent" = "1" ] && break + [ "$parent" = "$$" ] && break + parent_cmd="$(ps -o command= -p "$parent" 2>/dev/null || true)" + case "$parent_cmd" in + *hermes-ui*|*concurrently*|*cross-env*|*nodemon*|*"npm run dev"*) top="$parent" ;; + *) break ;; + esac + done + echo "$top" +} + +# Kill a PID once. The supervisor is reachable from several ports, so the +# same PID can be offered more than once — remember what we already killed. +# A plain string, not an associative array: macOS ships bash 3.2, which has +# no `declare -A`. +KILLED_PIDS=" " + +kill_pid() { + local pid="$1" signal="${2:-TERM}" cmd + cmd="$(ps -o command= -p "$pid" 2>/dev/null || true)" + [ -n "$cmd" ] || return 0 + case "$KILLED_PIDS" in + *" $pid "*) return 0 ;; + esac + KILLED_PIDS="$KILLED_PIDS$pid " + warn "killing pid $pid ($cmd)" + kill "-$signal" "$pid" 2>/dev/null || true +} + +free_port() { + local port="$1" pids pid top waited + + pids="$(pids_on_port "$port")" + [ -n "$pids" ] || return 0 + + for pid in $pids; do + if ! pid_is_ours "$pid"; then + die "port $port is held by a process that is not this project (pid $pid). Stop it yourself, or set $port to a free port." + fi + done + + for pid in $pids; do + top="$(top_of_tree "$pid")" + kill_pid "$top" + done + + # Give them a moment to release the socket. + waited=0 + while [ "$waited" -lt 30 ]; do + port_in_use "$port" || return 0 + sleep 0.2 + waited=$((waited + 1)) + done + + # Still held — SIGKILL the stragglers. + for pid in $(pids_on_port "$port"); do + kill_pid "$pid" KILL + done + sleep 0.5 +} + +# Kill a leftover `npm run dev` supervisor before taking the ports. If it +# stays alive it will respawn the children we just killed. +kill_stale_supervisor() { + local pid + for pid in $(pgrep -f 'npm run dev:server' 2>/dev/null || true); do + [ "$pid" = "$$" ] && continue + pid_is_ours "$pid" || continue + kill_pid "$pid" + done + for pid in $(pgrep -f 'npm run dev:client' 2>/dev/null || true); do + [ "$pid" = "$$" ] && continue + pid_is_ours "$pid" || continue + kill_pid "$pid" + done + for pid in $(pgrep -f 'npm run dev$' 2>/dev/null || true); do + [ "$pid" = "$$" ] && continue + pid_is_ours "$pid" || continue + kill_pid "$pid" + done + sleep 0.5 +} + +kill_stale_supervisor +free_port "$API_PORT" +free_port "$CLIENT_PORT" + +# --- Launch -------------------------------------------------------------- + +bold "hermes-ui — starting" +info "api http://localhost:$API_PORT" +info "client http://localhost:$CLIENT_PORT (login: admin / 123456)" +echo + +# `npm run dev` runs both halves under concurrently: the server through +# nodemon (hot restart on server edits) and the client through Vite. +# HERMES_WEB_UI_BACKEND_PORT tells Vite which port to proxy /api and +# /socket.io to; PORT tells the Koa server where to listen. +export PORT="$API_PORT" +export HERMES_WEB_UI_BACKEND_PORT="$API_PORT" + +npm run dev