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