add prod docker

This commit is contained in:
王性驊 2026-06-25 17:49:25 +08:00
parent b7125abeac
commit e820bc0368
23 changed files with 2158 additions and 80 deletions

View File

@ -0,0 +1,9 @@
.run
.git
web/node_modules
web/dist
worker/node_modules
**/*_test.go
**/.DS_Store
*.md
!deploy/**

File diff suppressed because it is too large Load Diff

View File

@ -7,3 +7,10 @@
➜ Local: http://localhost:5173/
➜ Network: use --host to expose
5:37:52 PM [vite] (client) hmr update /src/index.css
5:37:55 PM [vite] (client) hmr update /src/index.css
5:37:58 PM [vite] (client) hmr update /src/index.css
5:37:58 PM [vite] (client) hmr update /src/index.css
5:39:34 PM [vite] (client) hmr update /src/index.css
5:41:23 PM [vite] (client) hmr update /src/index.css
5:42:28 PM [vite] (client) hmr update /src/index.css

View File

@ -75,3 +75,15 @@ node-worker-style-8d: ## 啟動 Node 8D 爬蟲 worker
cd .. && npm run worker:style-8d
check: fmt test ## 格式化並測試
prod: ## 一鍵啟動 production DockerAPI + Web + workers分身數見 deploy/.env
bash scripts/prod-up.sh
prod-down: ## 停止 production Docker stack
bash scripts/prod-down.sh
prod-logs: ## 追蹤 production logs可傳 service 名make prod-logs ARGS=api
bash scripts/prod-logs.sh $(ARGS)
prod-build: ## 只建置 production images不啟動
cd deploy && docker compose -f docker-compose.prod.yml build

View File

@ -0,0 +1,43 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"haixun-backend/internal/config"
"haixun-backend/internal/svc"
"github.com/zeromicro/go-zero/core/conf"
)
var configFile = flag.String("f", "etc/gateway.worker.yaml", "config file")
func main() {
flag.Parse()
var c config.Config
conf.MustLoad(*configFile, &c)
if !c.JobWorker.Enabled {
fmt.Fprintln(os.Stderr, "[worker] JobWorker.Enabled must be true")
os.Exit(1)
}
sc := svc.NewServiceContext(c)
defer sc.Close(context.Background())
fmt.Printf(
"[worker] started type=%s (scheduler=%v reaper=%v)\n",
c.JobWorker.WorkerType,
c.JobScheduler.Enabled,
c.JobReaper.Enabled,
)
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
<-ch
fmt.Println("[worker] shutting down")
}

View File

@ -0,0 +1,30 @@
# 複製為 deploy/.env 後再啟動cp deploy/.env.example deploy/.env
# ── 對外埠 ──
HAIXUN_WEB_PORT=8080
# ── Worker 分身數make prod 會帶入 docker compose --scale──
GO_WORKER_REPLICAS=1
NODE_STYLE8D_WORKER_REPLICAS=1
# ── Mongo / Redis容器內預設通常不用改──
HAIXUN_MONGO_URI=mongodb://mongo:27017
HAIXUN_MONGO_DATABASE=haixun
HAIXUN_REDIS_ADDR=redis:6379
# ── 安全金鑰(正式環境務必更換)──
HAIXUN_AUTH_ACCESS_SECRET=change-me-access-secret
HAIXUN_AUTH_REFRESH_SECRET=change-me-refresh-secret
HAIXUN_WORKER_SECRET=change-me-worker-secret
# ── 首次初始化管理員make prod 會自動跑 init已存在則跳過建立──
INIT_TENANT_ID=default
INIT_ADMIN_EMAIL=admin@haixun.local
INIT_ADMIN_PASSWORD=Admin-Pass-1!
# ── Node 8D worker 選項 ──
# HAIXUN_NODE_WORKER_ID=custom-node-worker-1
# HAIXUN_WORKER_POLL_MS=3000
# ── 略過自動 init已有資料庫時可設 1──
# HAIXUN_SKIP_INIT=1

View File

@ -0,0 +1,22 @@
# syntax=docker/dockerfile:1
FROM golang:1.22-bookworm AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/haixun-api .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/haixun-worker ./cmd/worker
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/haixun-tool ./cmd/tool
FROM debian:bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates gettext-base curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /out/haixun-api /out/haixun-worker /out/haixun-tool /app/
COPY deploy/config/gateway.runtime.yaml.tpl deploy/config/gateway.worker.runtime.yaml.tpl /app/deploy/config/
COPY deploy/docker/entrypoint-api.sh deploy/docker/entrypoint-worker.sh deploy/docker/entrypoint-init.sh /app/deploy/docker/
RUN chmod +x /app/deploy/docker/entrypoint-api.sh /app/deploy/docker/entrypoint-worker.sh /app/deploy/docker/entrypoint-init.sh
EXPOSE 8890
ENTRYPOINT ["/app/deploy/docker/entrypoint-api.sh"]

View File

@ -0,0 +1,9 @@
# syntax=docker/dockerfile:1
FROM mcr.microsoft.com/playwright:v1.49.1-noble AS base
WORKDIR /app
COPY worker/package.json ./
RUN npm install
COPY worker/ ./
ENV NODE_ENV=production
CMD ["npx", "tsx", "style-8d-worker.ts"]

View File

@ -0,0 +1,13 @@
# syntax=docker/dockerfile:1
FROM node:22-bookworm AS web-builder
WORKDIR /src/web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
FROM nginx:1.27-alpine
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=web-builder /src/web/dist /usr/share/nginx/html
EXPOSE 80

View File

@ -0,0 +1,35 @@
Name: haixun-backend
Host: 0.0.0.0
Port: 8890
Timeout: 120000
Mongo:
URI: ${HAIXUN_MONGO_URI}
Database: ${HAIXUN_MONGO_DATABASE}
TimeoutSeconds: 10
Redis:
Addr: ${HAIXUN_REDIS_ADDR}
DB: 0
Auth:
AccessSecret: ${HAIXUN_AUTH_ACCESS_SECRET}
RefreshSecret: ${HAIXUN_AUTH_REFRESH_SECRET}
AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000
DevHeaderFallback: false
InternalWorker:
Secret: ${HAIXUN_WORKER_SECRET}
JobWorker:
Enabled: false
WorkerType: go
JobScheduler:
Enabled: true
IntervalSeconds: 60
JobReaper:
Enabled: true
IntervalSeconds: 30

View File

@ -0,0 +1,35 @@
Name: haixun-worker
Host: 0.0.0.0
Port: 8891
Timeout: 120000
Mongo:
URI: ${HAIXUN_MONGO_URI}
Database: ${HAIXUN_MONGO_DATABASE}
TimeoutSeconds: 10
Redis:
Addr: ${HAIXUN_REDIS_ADDR}
DB: 0
Auth:
AccessSecret: ${HAIXUN_AUTH_ACCESS_SECRET}
RefreshSecret: ${HAIXUN_AUTH_REFRESH_SECRET}
AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000
DevHeaderFallback: false
InternalWorker:
Secret: ${HAIXUN_WORKER_SECRET}
JobWorker:
Enabled: true
WorkerType: go
JobScheduler:
Enabled: false
IntervalSeconds: 60
JobReaper:
Enabled: false
IntervalSeconds: 30

View File

@ -0,0 +1,108 @@
name: haixun-prod
services:
mongo:
image: mongo:7
restart: unless-stopped
environment:
MONGO_INITDB_DATABASE: haixun
volumes:
- mongo_data:/data/db
healthcheck:
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
interval: 5s
timeout: 5s
retries: 12
start_period: 15s
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 12
api:
build:
context: ..
dockerfile: deploy/Dockerfile.api
restart: unless-stopped
env_file:
- .env
depends_on:
mongo:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8890/api/v1/health >/dev/null || exit 1"]
interval: 10s
timeout: 5s
retries: 12
start_period: 20s
go-worker:
build:
context: ..
dockerfile: deploy/Dockerfile.api
restart: unless-stopped
entrypoint: ["/app/deploy/docker/entrypoint-worker.sh"]
env_file:
- .env
depends_on:
mongo:
condition: service_healthy
redis:
condition: service_healthy
api:
condition: service_healthy
node-worker-style-8d:
build:
context: ..
dockerfile: deploy/Dockerfile.node-worker
restart: unless-stopped
env_file:
- .env
environment:
HAIXUN_BACKEND_URL: http://api:8890
HAIXUN_WORKER_SECRET: ${HAIXUN_WORKER_SECRET}
HAIXUN_NODE_WORKER_ID: ${HAIXUN_NODE_WORKER_ID:-}
HAIXUN_WORKER_POLL_MS: ${HAIXUN_WORKER_POLL_MS:-3000}
depends_on:
api:
condition: service_healthy
web:
build:
context: ..
dockerfile: deploy/Dockerfile.web
restart: unless-stopped
ports:
- "${HAIXUN_WEB_PORT:-8080}:80"
depends_on:
api:
condition: service_healthy
init:
profiles: ["init"]
build:
context: ..
dockerfile: deploy/Dockerfile.api
entrypoint: ["/app/deploy/docker/entrypoint-init.sh"]
env_file:
- .env
depends_on:
mongo:
condition: service_healthy
redis:
condition: service_healthy
volumes:
mongo_data:
redis_data:

View File

@ -0,0 +1,15 @@
#!/bin/sh
set -eu
: "${HAIXUN_MONGO_URI:=mongodb://mongo:27017}"
: "${HAIXUN_MONGO_DATABASE:=haixun}"
: "${HAIXUN_REDIS_ADDR:=redis:6379}"
: "${HAIXUN_AUTH_ACCESS_SECRET:?HAIXUN_AUTH_ACCESS_SECRET is required}"
: "${HAIXUN_AUTH_REFRESH_SECRET:?HAIXUN_AUTH_REFRESH_SECRET is required}"
: "${HAIXUN_WORKER_SECRET:?HAIXUN_WORKER_SECRET is required}"
export HAIXUN_MONGO_URI HAIXUN_MONGO_DATABASE HAIXUN_REDIS_ADDR
export HAIXUN_AUTH_ACCESS_SECRET HAIXUN_AUTH_REFRESH_SECRET HAIXUN_WORKER_SECRET
envsubst < /app/deploy/config/gateway.runtime.yaml.tpl > /tmp/gateway.runtime.yaml
exec /app/haixun-api -f /tmp/gateway.runtime.yaml

View File

@ -0,0 +1,22 @@
#!/bin/sh
set -eu
: "${HAIXUN_MONGO_URI:=mongodb://mongo:27017}"
: "${HAIXUN_MONGO_DATABASE:=haixun}"
: "${HAIXUN_REDIS_ADDR:=redis:6379}"
: "${HAIXUN_AUTH_ACCESS_SECRET:?HAIXUN_AUTH_ACCESS_SECRET is required}"
: "${HAIXUN_AUTH_REFRESH_SECRET:?HAIXUN_AUTH_REFRESH_SECRET is required}"
: "${HAIXUN_WORKER_SECRET:?HAIXUN_WORKER_SECRET is required}"
: "${INIT_TENANT_ID:=default}"
: "${INIT_ADMIN_EMAIL:=admin@haixun.local}"
: "${INIT_ADMIN_PASSWORD:?INIT_ADMIN_PASSWORD is required}"
export HAIXUN_MONGO_URI HAIXUN_MONGO_DATABASE HAIXUN_REDIS_ADDR
export HAIXUN_AUTH_ACCESS_SECRET HAIXUN_AUTH_REFRESH_SECRET HAIXUN_WORKER_SECRET
envsubst < /app/deploy/config/gateway.runtime.yaml.tpl > /tmp/gateway.runtime.yaml
exec /app/haixun-tool init \
-f /tmp/gateway.runtime.yaml \
-tenant "$INIT_TENANT_ID" \
-email "$INIT_ADMIN_EMAIL" \
-password "$INIT_ADMIN_PASSWORD"

View File

@ -0,0 +1,15 @@
#!/bin/sh
set -eu
: "${HAIXUN_MONGO_URI:=mongodb://mongo:27017}"
: "${HAIXUN_MONGO_DATABASE:=haixun}"
: "${HAIXUN_REDIS_ADDR:=redis:6379}"
: "${HAIXUN_AUTH_ACCESS_SECRET:?HAIXUN_AUTH_ACCESS_SECRET is required}"
: "${HAIXUN_AUTH_REFRESH_SECRET:?HAIXUN_AUTH_REFRESH_SECRET is required}"
: "${HAIXUN_WORKER_SECRET:?HAIXUN_WORKER_SECRET is required}"
export HAIXUN_MONGO_URI HAIXUN_MONGO_DATABASE HAIXUN_REDIS_ADDR
export HAIXUN_AUTH_ACCESS_SECRET HAIXUN_AUTH_REFRESH_SECRET HAIXUN_WORKER_SECRET
envsubst < /app/deploy/config/gateway.worker.runtime.yaml.tpl > /tmp/gateway.worker.runtime.yaml
exec /app/haixun-worker -f /tmp/gateway.worker.runtime.yaml

View File

@ -0,0 +1,25 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
location /api/ {
proxy_pass http://api:8890;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location / {
try_files $uri $uri/ /index.html;
}
}

View File

@ -0,0 +1,35 @@
Name: haixun-backend
Host: 0.0.0.0
Port: 8890
Timeout: 120000
Mongo:
URI: mongodb://mongo:27017
Database: haixun
TimeoutSeconds: 10
Redis:
Addr: redis:6379
DB: 0
Auth:
AccessSecret: change-me-in-prod
RefreshSecret: change-me-in-prod-too
AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000
DevHeaderFallback: false
InternalWorker:
Secret: change-me-worker-secret
JobWorker:
Enabled: false
WorkerType: go
JobScheduler:
Enabled: true
IntervalSeconds: 60
JobReaper:
Enabled: true
IntervalSeconds: 30

View File

@ -0,0 +1,35 @@
Name: haixun-worker
Host: 0.0.0.0
Port: 8891
Timeout: 120000
Mongo:
URI: mongodb://mongo:27017
Database: haixun
TimeoutSeconds: 10
Redis:
Addr: redis:6379
DB: 0
Auth:
AccessSecret: change-me-in-prod
RefreshSecret: change-me-in-prod-too
AccessExpireSeconds: 900
RefreshExpireSeconds: 2592000
DevHeaderFallback: false
InternalWorker:
Secret: change-me-worker-secret
JobWorker:
Enabled: true
WorkerType: go
JobScheduler:
Enabled: false
IntervalSeconds: 60
JobReaper:
Enabled: false
IntervalSeconds: 30

View File

@ -0,0 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
BACKEND_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE_FILE="$BACKEND_DIR/deploy/docker-compose.prod.yml"
cd "$BACKEND_DIR/deploy"
docker compose -f "$COMPOSE_FILE" down
echo "[prod] stopped"

View File

@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
BACKEND_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE_FILE="$BACKEND_DIR/deploy/docker-compose.prod.yml"
cd "$BACKEND_DIR/deploy"
docker compose -f "$COMPOSE_FILE" logs -f --tail=200 "${@:-}"

View File

@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -euo pipefail
BACKEND_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DEPLOY_DIR="$BACKEND_DIR/deploy"
COMPOSE_FILE="$DEPLOY_DIR/docker-compose.prod.yml"
ENV_FILE="$DEPLOY_DIR/.env"
ENV_EXAMPLE="$DEPLOY_DIR/.env.example"
if [[ ! -f "$ENV_FILE" ]]; then
if [[ -f "$ENV_EXAMPLE" ]]; then
cp "$ENV_EXAMPLE" "$ENV_FILE"
echo "[prod] created $ENV_FILE from .env.example — 請先修改密鑰與管理員密碼"
else
echo "[prod] missing $ENV_FILE" >&2
exit 1
fi
fi
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
GO_REPLICAS="${GO_WORKER_REPLICAS:-1}"
NODE_REPLICAS="${NODE_STYLE8D_WORKER_REPLICAS:-1}"
WEB_PORT="${HAIXUN_WEB_PORT:-8080}"
if ! command -v docker >/dev/null 2>&1; then
echo "[prod] docker is required" >&2
exit 1
fi
cd "$DEPLOY_DIR"
echo "[prod] building images..."
docker compose -f "$COMPOSE_FILE" build
echo "[prod] starting mongo + redis..."
docker compose -f "$COMPOSE_FILE" up -d mongo redis
echo "[prod] waiting for mongo/redis..."
for _ in $(seq 1 90); do
mongo_ok=$(docker compose -f "$COMPOSE_FILE" ps --format json mongo 2>/dev/null | grep -o '"Health":"[^"]*"' | head -1 | cut -d'"' -f4 || true)
redis_ok=$(docker compose -f "$COMPOSE_FILE" ps --format json redis 2>/dev/null | grep -o '"Health":"[^"]*"' | head -1 | cut -d'"' -f4 || true)
if [[ "$mongo_ok" == "healthy" && "$redis_ok" == "healthy" ]]; then
break
fi
sleep 1
done
if [[ "${HAIXUN_SKIP_INIT:-0}" != "1" ]]; then
echo "[prod] running bootstrap init..."
docker compose -f "$COMPOSE_FILE" --profile init run --rm init
else
echo "[prod] skip init (HAIXUN_SKIP_INIT=1)"
fi
echo "[prod] starting api, web, workers (go=${GO_REPLICAS}, node-style-8d=${NODE_REPLICAS})..."
docker compose -f "$COMPOSE_FILE" up -d \
--scale "go-worker=${GO_REPLICAS}" \
--scale "node-worker-style-8d=${NODE_REPLICAS}" \
api web go-worker node-worker-style-8d
echo "[prod] waiting for API health..."
for _ in $(seq 1 60); do
if curl -fsS "http://127.0.0.1:${WEB_PORT}/api/v1/health" >/dev/null 2>&1; then
break
fi
sleep 1
done
echo ""
echo "[prod] stack is up"
echo " Web: http://127.0.0.1:${WEB_PORT}"
echo " API: http://127.0.0.1:${WEB_PORT}/api/v1/health (via nginx)"
echo " Go worker: ${GO_REPLICAS} replica(s)"
echo " Node 8D: ${NODE_REPLICAS} replica(s)"
echo " Env: ${ENV_FILE}"
echo " Stop: make -C haixun-backend prod-down"
echo " Logs: make -C haixun-backend prod-logs"

View File

@ -2,84 +2,163 @@
@import "taipei-sans-tc/dist/Regular/TaipeiSansTCBeta-Regular.css";
@import "taipei-sans-tc/dist/Bold/TaipeiSansTCBeta-Bold.css";
/* ══ 淺色:沉穩田園色(低飽和、無貼圖感 ══ */
/* ══ Pokémon palette — light薄荷綠灰藍 ══ */
:root,
[data-theme="light"] {
color-scheme: light;
--hx-canvas: #d8e2e8;
--hx-canvas-grass: #d4dfd4;
--hx-surface: #faf7f2;
--hx-surface-muted: #f1ece4;
--hx-ink: #3a3530;
--hx-ink-secondary: #5a554e;
--hx-muted: #5a6578;
--hx-subtle: #8a847c;
--hx-wood: #c4a882;
--hx-wood-dark: #9a7d5c;
--hx-wood-deep: #6f5a44;
--hx-line: #ddd4c8;
--hx-brand: #5a8f7b;
--hx-brand-hover: #4d7f6c;
--hx-brand-shadow: #3f6b59;
--hx-brand-soft: #e6f0eb;
--hx-glow: #dce8ee;
--hx-glow-alt: #ebe4d8;
--hx-accent: #6a8fa0;
--hx-accent-hover: #5a7f90;
--hx-accent-soft: #e8eff3;
--hx-device: #6a9488;
--hx-device-dark: #4f7568;
--hx-success: #4a7f5e;
--hx-success-soft: #e4efe8;
--hx-warning: #9a7340;
--hx-warning-soft: #f2ead8;
--hx-danger: #b05a50;
--hx-danger-soft: #f5e6e4;
--hx-shadow-soft: 0 8px 28px -8px rgb(58 53 48 / 0.14);
--hx-shadow-card: 0 1px 2px rgb(58 53 48 / 0.06), 0 6px 20px -4px rgb(58 53 48 / 0.1);
--hx-hero-gradient: linear-gradient(165deg, #eef3f0 0%, #faf7f2 60%, #f1ece4 100%);
--background: #fcfdfc;
--foreground: #15281f;
--card: #ffffff;
--card-foreground: #15281f;
--popover: #ffffff;
--popover-foreground: #15281f;
--primary: #bdcdc5;
--primary-foreground: #0a0a0a;
--secondary: #7b94a4;
--secondary-foreground: #0a0a0a;
--muted: #f2f5f7;
--muted-foreground: #627784;
--accent: #4a628b;
--accent-foreground: #ffffff;
--destructive: #ef4444;
--destructive-foreground: #ffffff;
--border: #e0ebe6;
--input: #e0ebe6;
--ring: #99b2a6;
--chart-1: #bdcdc5;
--chart-2: #7b94a4;
--chart-3: #4a628b;
--chart-4: #bdcdc5;
--chart-5: #7b94a4;
--sidebar: #ffffff;
--sidebar-foreground: #15281f;
--sidebar-primary: #bdcdc5;
--sidebar-primary-foreground: #0a0a0a;
--sidebar-accent: #f2f5f7;
--sidebar-accent-foreground: #15281f;
--sidebar-border: #e0ebe6;
--sidebar-ring: #99b2a6;
/* hx semantic — accent 作 CTA導覽主色primary 作柔和底色 */
--hx-canvas: #c5d4d8;
--hx-canvas-grass: #b8cdc4;
--hx-surface: var(--card);
--hx-surface-muted: var(--muted);
--hx-ink: var(--foreground);
--hx-ink-secondary: #2a4035;
--hx-muted: var(--muted-foreground);
--hx-subtle: #8a9aa4;
--hx-wood: color-mix(in srgb, var(--primary) 45%, var(--border) 55%);
--hx-wood-dark: color-mix(in srgb, var(--accent) 40%, var(--secondary) 60%);
--hx-wood-deep: #2d3f52;
--hx-line: var(--border);
--hx-brand: var(--accent);
--hx-brand-hover: #3d5275;
--hx-brand-shadow: #32486a;
--hx-brand-soft: color-mix(in srgb, var(--primary) 55%, var(--background) 45%);
--hx-on-brand: var(--accent-foreground);
--hx-glow: color-mix(in srgb, var(--primary) 50%, var(--background) 50%);
--hx-glow-alt: color-mix(in srgb, var(--secondary) 24%, var(--muted) 76%);
--hx-accent: var(--secondary);
--hx-accent-hover: #6a8494;
--hx-accent-soft: color-mix(in srgb, var(--secondary) 16%, var(--muted) 84%);
--hx-device: var(--accent);
--hx-device-dark: var(--hx-brand-shadow);
--hx-success: #4a8a72;
--hx-success-soft: color-mix(in srgb, var(--primary) 35%, var(--muted) 65%);
--hx-warning: #c4923a;
--hx-warning-soft: #f8f0e4;
--hx-danger: var(--destructive);
--hx-danger-soft: #fde8e8;
--hx-shadow-soft: 0 8px 28px -8px rgb(21 40 31 / 0.14);
--hx-shadow-card: 0 1px 2px rgb(21 40 31 / 0.06), 0 6px 20px -4px rgb(21 40 31 / 0.1);
--hx-hero-gradient: linear-gradient(
165deg,
color-mix(in srgb, var(--primary) 42%, var(--background) 58%) 0%,
var(--card) 58%,
var(--muted) 100%
);
--pocket-width: 28rem;
--sidebar-width: 15.5rem;
--pocket-screen-height: min(50rem, calc(100dvh - 6.5rem));
}
/* ══ 深色:黃昏低對比 ══ */
/* ══ Pokémon palette — dark薄荷綠灰藍 ══ */
[data-theme="dark"] {
color-scheme: dark;
--hx-canvas: #1e2a32;
--hx-canvas-grass: #243028;
--hx-surface: #2c363c;
--hx-surface-muted: #354048;
--hx-ink: #ece6dc;
--hx-ink-secondary: #c8c0b4;
--hx-muted: #b8c4d6;
--hx-subtle: #8a8278;
--hx-wood: #6a5a48;
--hx-wood-dark: #524438;
--hx-wood-deep: #3a3028;
--hx-line: #4a544c;
--hx-brand: #7aab96;
--hx-brand-hover: #8abba6;
--hx-brand-shadow: #4f7568;
--hx-brand-soft: #2a3c34;
--hx-glow: #2a3840;
--hx-glow-alt: #3a342c;
--hx-accent: #7a9aa8;
--hx-accent-hover: #8aaab8;
--hx-accent-soft: #2a3840;
--hx-device: #5a8070;
--hx-device-dark: #3f5a50;
--hx-success: #7aab96;
--hx-success-soft: #2a3c34;
--hx-warning: #c8a060;
--hx-warning-soft: #3a3428;
--hx-danger: #c89088;
--hx-danger-soft: #3c2c2c;
--hx-shadow-soft: 0 8px 28px -8px rgb(0 0 0 / 0.4);
--hx-shadow-card: 0 1px 2px rgb(0 0 0 / 0.2), 0 6px 20px -4px rgb(0 0 0 / 0.32);
--hx-hero-gradient: linear-gradient(165deg, #2a3840 0%, #2c363c 60%, #354048 100%);
--background: #141a17;
--foreground: #f4f6f5;
--card: #1b2721;
--card-foreground: #f4f6f5;
--popover: #1b2721;
--popover-foreground: #f4f6f5;
--primary: #bccdc4;
--primary-foreground: #0a0a0a;
--secondary: #7b94a3;
--secondary-foreground: #0a0a0a;
--muted: #253037;
--muted-foreground: #98a9b3;
--accent: #6983b0;
--accent-foreground: #ffffff;
--destructive: #dc2626;
--destructive-foreground: #ffffff;
--border: #2d4338;
--input: #2d4338;
--ring: #a8bdb3;
--chart-1: #bdcdc5;
--chart-2: #7b94a4;
--chart-3: #4a628b;
--chart-4: #bdcdc5;
--chart-5: #7b94a4;
--sidebar: #1b2721;
--sidebar-foreground: #f4f6f5;
--sidebar-primary: #bccdc4;
--sidebar-primary-foreground: #0a0a0a;
--sidebar-accent: #253037;
--sidebar-accent-foreground: #f4f6f5;
--sidebar-border: #2d4338;
--sidebar-ring: #a8bdb3;
--hx-canvas: var(--background);
--hx-canvas-grass: color-mix(in srgb, var(--muted) 68%, var(--background) 32%);
--hx-surface: var(--card);
--hx-surface-muted: var(--muted);
--hx-ink: var(--foreground);
--hx-ink-secondary: #c5d0cc;
--hx-muted: var(--muted-foreground);
--hx-subtle: #6f858f;
--hx-wood: color-mix(in srgb, var(--border) 65%, var(--secondary) 35%);
--hx-wood-dark: color-mix(in srgb, var(--accent) 38%, var(--background) 62%);
--hx-wood-deep: #1a2a38;
--hx-line: var(--border);
--hx-brand: var(--accent);
--hx-brand-hover: #7a94be;
--hx-brand-shadow: #4e6890;
--hx-brand-soft: color-mix(in srgb, var(--primary) 18%, var(--muted) 82%);
--hx-on-brand: var(--accent-foreground);
--hx-glow: color-mix(in srgb, var(--accent) 14%, var(--background) 86%);
--hx-glow-alt: color-mix(in srgb, var(--muted) 52%, var(--background) 48%);
--hx-accent: var(--secondary);
--hx-accent-hover: #8fa8b8;
--hx-accent-soft: color-mix(in srgb, var(--secondary) 14%, var(--muted) 86%);
--hx-device: var(--accent);
--hx-device-dark: var(--hx-brand-shadow);
--hx-success: #6a9a88;
--hx-success-soft: color-mix(in srgb, var(--primary) 14%, var(--muted) 86%);
--hx-warning: #d4a84a;
--hx-warning-soft: color-mix(in srgb, #d4a84a 14%, var(--muted) 86%);
--hx-danger: var(--destructive);
--hx-danger-soft: color-mix(in srgb, var(--destructive) 18%, var(--muted) 82%);
--hx-shadow-soft: 0 8px 28px -8px rgb(0 0 0 / 0.42);
--hx-shadow-card: 0 1px 2px rgb(0 0 0 / 0.22), 0 6px 20px -4px rgb(0 0 0 / 0.34);
--hx-hero-gradient: linear-gradient(
165deg,
color-mix(in srgb, var(--accent) 14%, var(--card) 86%) 0%,
var(--card) 55%,
var(--muted) 100%
);
}
/*
@ -1656,7 +1735,7 @@ h4 {
}
.hx-source-chip__avatar {
--hx-source-hue: 160;
--hx-source-hue: 168;
display: inline-flex;
flex-shrink: 0;
align-items: center;
@ -1747,7 +1826,7 @@ h4 {
}
.hx-source-preview__avatar {
--hx-source-hue: 160;
--hx-source-hue: 168;
display: inline-flex;
flex-shrink: 0;
align-items: center;
@ -2452,7 +2531,7 @@ th {
background: color-mix(in srgb, var(--hx-surface) 90%, transparent 10%);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
box-shadow: 4px 0 24px -12px rgb(58 53 48 / 0.12);
box-shadow: 4px 0 24px -12px rgb(21 40 31 / 0.12);
}
@media (min-width: 1024px) {
@ -2680,7 +2759,7 @@ th {
border: 1px solid var(--hx-brand-shadow);
background: linear-gradient(180deg, var(--hx-device) 0%, var(--hx-device-dark) 100%);
padding: 0.7rem 1.1rem;
color: #faf7f2;
color: var(--hx-on-brand);
font-family: var(--font-zh);
font-weight: 700;
font-size: 1.125rem;
@ -3045,8 +3124,8 @@ th {
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 0.22),
inset 0 -2px 4px rgb(0 0 0 / 0.12),
0 14px 36px -10px rgb(58 53 48 / 0.28),
0 4px 14px rgb(58 53 48 / 0.12);
0 14px 36px -10px rgb(21 40 31 / 0.28),
0 4px 14px rgb(21 40 31 / 0.12);
}
.ac-pocket-device::before {
@ -3084,7 +3163,7 @@ th {
border: 1px solid color-mix(in srgb, var(--hx-line) 85%, var(--hx-device-dark) 15%);
background: var(--hx-surface);
box-shadow:
inset 0 2px 10px rgb(58 53 48 / 0.07),
inset 0 2px 10px rgb(21 40 31 / 0.07),
inset 0 0 0 1px rgb(255 255 255 / 0.55);
}
@ -3259,9 +3338,9 @@ th {
.ac-btn-primary {
border: 1px solid var(--hx-brand-shadow);
background: var(--hx-brand);
color: #faf7f2;
color: var(--hx-on-brand);
font-weight: 600;
box-shadow: 0 1px 2px rgb(58 53 48 / 0.12);
box-shadow: 0 1px 2px rgb(21 40 31 / 0.12);
}
.ac-btn-primary:hover {
@ -3288,7 +3367,7 @@ th {
.ac-btn-danger {
border: 1px solid #8a4840;
background: var(--hx-danger);
color: #faf7f2;
color: var(--hx-on-brand);
font-weight: 600;
}
@ -3333,7 +3412,7 @@ th {
.ac-dock {
background: var(--hx-surface);
border-top: 2px solid var(--hx-line);
box-shadow: 0 -4px 16px rgb(58 53 48 / 0.08);
box-shadow: 0 -4px 16px rgb(21 40 31 / 0.08);
}
[data-theme="dark"] .ac-dock {
@ -3496,7 +3575,7 @@ th {
font-weight: 800;
letter-spacing: 0.04em;
line-height: 1.2;
color: #faf7f2;
color: var(--hx-on-brand);
white-space: nowrap;
}
@ -3552,7 +3631,7 @@ th {
font-size: 0.625rem;
font-weight: 800;
letter-spacing: 0.04em;
color: #faf7f2;
color: var(--hx-on-brand);
box-shadow: var(--hx-shadow-soft);
pointer-events: none;
white-space: nowrap;
@ -3567,7 +3646,7 @@ th {
font-weight: 800;
letter-spacing: 0.02em;
line-height: 1.2;
color: #faf7f2;
color: var(--hx-on-brand);
white-space: nowrap;
box-shadow: var(--hx-shadow-soft);
}

View File

@ -0,0 +1,15 @@
{
"name": "haixun-node-worker",
"private": true,
"type": "module",
"scripts": {
"style-8d": "tsx style-8d-worker.ts"
},
"dependencies": {
"playwright": "^1.49.1"
},
"devDependencies": {
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}