diff --git a/.gitignore b/.gitignore index 06305b9..1abd7ba 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,8 @@ frontend/out/ *.spec.tsx __tests__/ WEBUI_DEVELOPMENT_GUIDE.md + +# Rust FFI build artifacts (v2 wreq-ffi) +wreq-ffi/target/ +wreq-ffi/include/wreq_ffi.h +wreq-ffi/Cargo.lock diff --git a/Dockerfile b/Dockerfile index 1a45606..a0321c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-bookworm AS frontend-builder +FROM --platform=$BUILDPLATFORM node:22-bookworm AS frontend-builder WORKDIR /frontend COPY frontend/package.json frontend/package-lock.json ./ @@ -6,11 +6,86 @@ RUN npm ci COPY frontend ./ RUN npm run build -FROM golang:1.22-bookworm AS builder +FROM --platform=$BUILDPLATFORM rust:1.86-bookworm AS rust-builder +ARG BUILDPLATFORM +ARG TARGETPLATFORM +ARG TARGETARCH +ARG TARGETOS=linux +WORKDIR /src + +RUN apt-get update -o Acquire::Retries=5 \ + && apt-get install -y -o Acquire::Retries=5 --no-install-recommends \ + cmake perl build-essential libclang-dev clang lld file \ + gcc-x86-64-linux-gnu g++-x86-64-linux-gnu \ + gcc-aarch64-linux-gnu g++-aarch64-linux-gnu \ + && rm -rf /var/lib/apt/lists/* + +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) RUST_TARGET=x86_64-unknown-linux-gnu ;; \ + arm64) RUST_TARGET=aarch64-unknown-linux-gnu ;; \ + *) echo "unsupported TARGETARCH=${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + rustup target add "${RUST_TARGET}"; \ + echo "${RUST_TARGET}" > /tmp/rust_target + +ENV CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc \ + CC_x86_64_unknown_linux_gnu=x86_64-linux-gnu-gcc \ + CXX_x86_64_unknown_linux_gnu=x86_64-linux-gnu-g++ \ + AR_x86_64_unknown_linux_gnu=x86_64-linux-gnu-ar \ + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \ + CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \ + CXX_aarch64_unknown_linux_gnu=aarch64-linux-gnu-g++ \ + AR_aarch64_unknown_linux_gnu=aarch64-linux-gnu-ar + +ENV CARGO_TARGET_DIR=/cargo-target + +COPY wreq-ffi ./wreq-ffi + +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=/cargo-target,id=cargo-target-${TARGETARCH},sharing=locked \ + set -eux; \ + RUST_TARGET=$(cat /tmp/rust_target); \ + case "${TARGETARCH}" in \ + amd64) CC=x86_64-linux-gnu-gcc; CXX=x86_64-linux-gnu-g++; AR=x86_64-linux-gnu-ar ;; \ + arm64) CC=aarch64-linux-gnu-gcc; CXX=aarch64-linux-gnu-g++; AR=aarch64-linux-gnu-ar ;; \ + *) echo "unsupported TARGETARCH=${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + export CC CXX AR; \ + echo "rust-builder toolchain: TARGETARCH=${TARGETARCH} RUST_TARGET=${RUST_TARGET} CC=${CC} CXX=${CXX} AR=${AR}"; \ + echo "rust-builder diag: BUILDPLATFORM=${BUILDPLATFORM} TARGETPLATFORM=${TARGETPLATFORM} TARGETARCH=${TARGETARCH} RUST_TARGET=${RUST_TARGET} host=$(uname -m)"; \ + cd wreq-ffi; \ + mkdir -p include; \ + touch src/lib.rs; \ + cargo build --release --target "${RUST_TARGET}"; \ + test -f include/wreq_ffi.h; \ + mkdir -p /out; \ + cp "${CARGO_TARGET_DIR}/${RUST_TARGET}/release/libwreq_ffi.a" /out/; \ + cp include/wreq_ffi.h /out/; \ + FIRST_MEMBER=$(ar t /out/libwreq_ffi.a | head -1); \ + AFILE=$(ar p /out/libwreq_ffi.a "$FIRST_MEMBER" | file -); \ + echo "rust-builder: first member ($FIRST_MEMBER) of /out/libwreq_ffi.a => ${AFILE}"; \ + case "${TARGETARCH}" in \ + amd64) echo "${AFILE}" | grep -q 'x86-64' || { echo "FATAL: /out/libwreq_ffi.a is not x86-64 (TARGETARCH=amd64). This usually means a cache mount got mixed up; try: docker buildx prune -af" >&2; exit 1; } ;; \ + arm64) echo "${AFILE}" | grep -q 'aarch64' || { echo "FATAL: /out/libwreq_ffi.a is not aarch64 (TARGETARCH=arm64). This usually means a cache mount got mixed up; try: docker buildx prune -af" >&2; exit 1; } ;; \ + esac; \ + echo "rust-builder: arch verified for TARGETARCH=${TARGETARCH}" + +FROM --platform=$BUILDPLATFORM golang:1.22-bookworm AS builder +ARG BUILDPLATFORM +ARG TARGETPLATFORM +ARG TARGETOS +ARG TARGETARCH + +RUN apt-get update -o Acquire::Retries=5 \ + && apt-get install -y -o Acquire::Retries=5 --no-install-recommends \ + file \ + gcc-x86-64-linux-gnu g++-x86-64-linux-gnu \ + gcc-aarch64-linux-gnu g++-aarch64-linux-gnu \ + && rm -rf /var/lib/apt/lists/* WORKDIR /src -ARG TARGETOS=linux -ARG TARGETARCH=amd64 COPY go.mod go.sum ./ RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ @@ -20,37 +95,47 @@ COPY cmd ./cmd COPY internal ./internal COPY static ./static COPY --from=frontend-builder /frontend/out /src/static/admin +COPY --from=rust-builder /out/libwreq_ffi.a /src/wreq-ffi/target/release/libwreq_ffi.a +COPY --from=rust-builder /out/wreq_ffi.h /src/wreq-ffi/include/wreq_ffi.h + RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ - test -f ./cmd/notion2api/main.go \ - && CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -v -trimpath -ldflags="-s -w" -o /out/notion2api ./cmd/notion2api + set -eux; \ + case "${TARGETARCH}" in \ + amd64) CC=x86_64-linux-gnu-gcc; CXX=x86_64-linux-gnu-g++ ;; \ + arm64) CC=aarch64-linux-gnu-gcc; CXX=aarch64-linux-gnu-g++ ;; \ + *) echo "unsupported TARGETARCH=${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + echo "go-builder diag: BUILDPLATFORM=${BUILDPLATFORM} TARGETPLATFORM=${TARGETPLATFORM} TARGETARCH=${TARGETARCH} CC=${CC} host=$(uname -m)"; \ + FIRST_MEMBER=$(ar t /src/wreq-ffi/target/release/libwreq_ffi.a | head -1); \ + AFILE=$(ar p /src/wreq-ffi/target/release/libwreq_ffi.a "$FIRST_MEMBER" | file -); \ + echo "go-builder: first member ($FIRST_MEMBER) of libwreq_ffi.a => ${AFILE}"; \ + case "${TARGETARCH}" in \ + amd64) echo "${AFILE}" | grep -q 'x86-64' || { echo "FATAL: libwreq_ffi.a in builder stage is not x86-64; rust-builder produced wrong arch or COPY layer is stale. Run: docker buildx prune -af" >&2; exit 1; } ;; \ + arm64) echo "${AFILE}" | grep -q 'aarch64' || { echo "FATAL: libwreq_ffi.a in builder stage is not aarch64; rust-builder produced wrong arch or COPY layer is stale. Run: docker buildx prune -af" >&2; exit 1; } ;; \ + esac; \ + test -f ./cmd/notion2api/main.go; \ + CGO_ENABLED=1 GOOS=${TARGETOS} GOARCH=${TARGETARCH} CC=${CC} CXX=${CXX} \ + go build -v -trimpath -tags wreq_ffi \ + -ldflags="-s -w" \ + -o /out/notion2api ./cmd/notion2api -FROM mcr.microsoft.com/playwright:v1.58.0-noble +FROM node:22-bookworm-slim ENV TZ=Asia/Shanghai -ENV PLAYWRIGHT_CHROME_BIN=/opt/playwright-helper/chrome -ENV CHROME_BIN=/opt/playwright-helper/chrome -ENV CHROMIUM_BIN=/opt/playwright-helper/chrome -ENV NODE_PATH=/opt/playwright-helper/node_modules +ENV NODE_PATH=/opt/notion2api-helper/node_modules +ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin WORKDIR /app RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates tzdata curl tini \ - && rm -rf /var/lib/apt/lists/* \ - && browser_bin="" \ - && for candidate in /ms-playwright/chromium-*/chrome-linux64/chrome /ms-playwright/chromium-*/chrome-linux/chrome; do \ - if [ -x "$candidate" ]; then browser_bin="$candidate"; break; fi; \ - done \ - && test -n "$browser_bin" \ - && mkdir -p /opt/playwright-helper /app/config /app/data/notion_accounts /app/static \ - && ln -sf "$browser_bin" "$PLAYWRIGHT_CHROME_BIN" \ - && "$PLAYWRIGHT_CHROME_BIN" --version - -RUN mkdir -p /opt/playwright-helper \ - && cd /opt/playwright-helper \ + && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*.deb \ + && mkdir -p /opt/notion2api-helper /app/config /app/data/notion_accounts /app/static + +RUN cd /opt/notion2api-helper \ && npm init -y >/dev/null 2>&1 \ - && npm install --omit=dev --no-package-lock playwright-core@1.58.0 \ - && test -d "$NODE_PATH/playwright-core" \ + && npm install --omit=dev --no-package-lock node-wreq@2.2.1 \ + && test -d "$NODE_PATH/node-wreq" \ && npm cache clean --force >/dev/null 2>&1 COPY --from=builder /out/notion2api /app/notion2api diff --git a/README.md b/README.md index fce7709..a789cf4 100644 --- a/README.md +++ b/README.md @@ -15,16 +15,14 @@ ### 本地运行 -```powershell -Set-Location 'E:\WorkSpace\sub2api\chatgpt_register\Nation2API' -& 'D:\Go\bin\go.exe' run .\cmd\notion2api --config .\config.example.json +```bash +go run ./cmd/notion2api --config ./config.example.json ``` ### 本地构建 -```powershell -Set-Location 'E:\WorkSpace\sub2api\chatgpt_register\Nation2API' -& 'D:\Go\bin\go.exe' build .\cmd\notion2api +```bash +go build ./cmd/notion2api ``` ## Docker 部署 @@ -47,17 +45,75 @@ docker compose -f docker-compose.prod.yml up -d --build - Health:`http://127.0.0.1:8787/healthz` - WebUI:`http://127.0.0.1:8787/admin` +## 代理与 Resin 粘性代理 + +### 代理模式 + +`proxy_mode` 支持: + +- `off`:关闭代理 +- `env`:从环境变量读取(优先 `N2A_*`) +- `http`:固定 HTTP 代理 +- `https`:按协议拆分 HTTP/HTTPS 代理 +- `socks5`:SOCKS5/SOCKS5H 代理 +- `resin_forward`:Resin 粘性代理转发 + +### 环境变量优先级(`proxy_mode=env`) + +HTTPS 请求优先顺序: + +1. `N2A_PROXY_HTTPS_URL` +2. `N2A_UPSTREAM_PROXY_HTTPS_URL` +3. `N2A_PROXY_URL` +4. `N2A_UPSTREAM_PROXY_URL` +5. `HTTPS_PROXY` / `https_proxy` +6. `ALL_PROXY` / `all_proxy` + +HTTP 请求优先顺序: + +1. `N2A_PROXY_HTTP_URL` +2. `N2A_UPSTREAM_PROXY_HTTP_URL` +3. `N2A_PROXY_URL` +4. `N2A_UPSTREAM_PROXY_URL` +5. `HTTP_PROXY` / `http_proxy` +6. `ALL_PROXY` / `all_proxy` + +也可以直接用环境变量覆盖配置文件中的代理字段: + +- `N2A_PROXY_MODE` +- `N2A_PROXY_URL` +- `N2A_PROXY_HTTP_URL` +- `N2A_PROXY_HTTPS_URL` +- `N2A_RESIN_ENABLED` +- `N2A_RESIN_URL` +- `N2A_RESIN_PLATFORM` +- `N2A_RESIN_MODE` + +### Resin 粘性代理(按账号隔离) + +每个账号都可以独立设置粘性身份: + +- `accounts[].sticky_proxy_account`:显式设置粘性账号名(推荐) +- 未设置时会回退到邮箱派生值 + +当启用 `resin_forward` 时: + +- 代理认证用户名格式:`.` +- 密码使用 `resin_url` 中 token +- 请求会附带 `X-Resin-Account` 头 + ## 配置说明 建议优先检查这些字段: - `api_key`:OpenAI 兼容接口密钥 - `admin.password`:WebUI 登录密码 -- `upstream_base_url`:上游站点地址 -- `upstream_origin`:上游请求 `Origin` -- `accounts`:账号池配置 -- `active_account`:默认激活账号 -- `storage.sqlite_path`:SQLite 数据库路径 +- `upstream_base_url` / `upstream_origin` +- `proxy_mode` / `proxy_url` / `proxy_http_url` / `proxy_https_url` +- `resin_enabled` / `resin_url` / `resin_platform` / `resin_mode` +- `accounts[*].sticky_proxy_account` +- `accounts` / `active_account` +- `storage.sqlite_path` 可直接参考: @@ -67,8 +123,8 @@ docker compose -f docker-compose.prod.yml up -d --build ## 使用建议 - 首次启动后先访问 `/admin`,确认账号、配置和连通性是否正常 -- 常规本地使用直接运行二进制或 `go run` 即可 -- 需要容器化部署时优先使用 Docker Compose +- 修改管理台前端后需执行 `npm --prefix ./frontend run build:static` +- 调整会话延续与存储时,建议同步检查 `internal/app/sqlite_store.go` 的 schema 与迁移兼容性 ## 开源协议 diff --git a/cmd/notion2api/main.go b/cmd/notion2api/main.go index e589a77..0ebdd15 100644 --- a/cmd/notion2api/main.go +++ b/cmd/notion2api/main.go @@ -1,7 +1,13 @@ package main -import "notion2api/internal/app" +import ( + "log" + + "notion2api/internal/app" + "notion2api/internal/wreq" +) func main() { + log.Printf("notion2api: wreq backend = %s", wreq.Version()) app.Main() } diff --git a/config.example.json b/config.example.json index b185c0e..aebb2b4 100644 --- a/config.example.json +++ b/config.example.json @@ -1,13 +1,24 @@ { - "probe_json": "C:\\Users\\GALIAIS\\AppData\\Local\\Temp\\TestAdminAccountManualImportCreatesSessionFiles722969220\\001\\manual_example_com\\probe.json", + "probe_json": "probe_files/default/probe.json", "host": "127.0.0.1", - "port": 8791, - "api_key": "notion2api-local-key", + "port": 8787, + "api_key": "change-me-openai-key", "upstream_base_url": "https://www.notion.so", "upstream_origin": "https://www.notion.so", + "upstream_host_header": "", + "upstream_tls_server_name": "", + "upstream_use_env_proxy": false, + "proxy_mode": "off", + "proxy_url": "", + "proxy_http_url": "", + "proxy_https_url": "", + "resin_enabled": false, + "resin_url": "", + "resin_platform": "Default", + "resin_mode": "forward", "model_id": "auto", "default_model": "auto", - "active_account": "manual@example.com", + "active_account": "", "timeout_sec": 180, "poll_interval_sec": 1.5, "poll_max_rounds": 40, @@ -60,24 +71,35 @@ }, "accounts": [ { - "email": "manual@example.com", - "probe_json": "C:\\Users\\GALIAIS\\AppData\\Local\\Temp\\TestAdminAccountManualImportCreatesSessionFiles722969220\\001\\manual_example_com\\probe.json", - "profile_dir": "C:\\Users\\GALIAIS\\AppData\\Local\\Temp\\TestAdminAccountManualImportCreatesSessionFiles722969220\\001\\manual_example_com", - "storage_state_path": "C:\\Users\\GALIAIS\\AppData\\Local\\Temp\\TestAdminAccountManualImportCreatesSessionFiles722969220\\001\\manual_example_com\\storage_state.json", - "pending_state_path": "C:\\Users\\GALIAIS\\AppData\\Local\\Temp\\TestAdminAccountManualImportCreatesSessionFiles722969220\\001\\manual_example_com\\pending_login.json", - "user_id": "user-1", - "user_name": "manual", - "space_id": "space-1", - "space_name": "manual's Space", - "client_version": "23.13.test", - "status": "expired", - "last_error": "open C:\\Users\\GALIAIS\\AppData\\Local\\Temp\\TestAdminAccountManualImportCreatesSessionFiles722969220\\001\\manual_example_com\\probe.json: The system cannot find the path specified.", - "last_login_at": "2026-03-22T14:30:44+08:00" + "email": "alice@example.com", + "probe_json": "probe_files/notion_accounts/alice_example_com/probe.json", + "profile_dir": "probe_files/notion_accounts/alice_example_com", + "storage_state_path": "probe_files/notion_accounts/alice_example_com/storage_state.json", + "pending_state_path": "probe_files/notion_accounts/alice_example_com/pending_login.json", + "proxy_mode": "resin_forward", + "sticky_proxy_account": "alice", + "resin_enabled": true, + "resin_url": "http://127.0.0.1:2260/your-resin-token", + "resin_platform": "Default", + "resin_mode": "forward", + "priority": 100, + "disabled": false + }, + { + "email": "bob@example.com", + "probe_json": "probe_files/notion_accounts/bob_example_com/probe.json", + "profile_dir": "probe_files/notion_accounts/bob_example_com", + "storage_state_path": "probe_files/notion_accounts/bob_example_com/storage_state.json", + "pending_state_path": "probe_files/notion_accounts/bob_example_com/pending_login.json", + "proxy_mode": "socks5", + "proxy_url": "socks5://127.0.0.1:1080", + "priority": 50, + "disabled": false } ], "model_aliases": { - "gpt52": "gpt-5.2", - "gpt54": "gpt-5.4" + "gpt54": "gpt-5.4", + "gpt52": "gpt-5.2" } } diff --git a/docker-compose.yml b/docker-compose.yml index 5514c3d..bd35f7f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,14 +2,27 @@ services: notion2api: build: context: . - dockerfile: Dockerfile - container_name: notion2api - restart: unless-stopped + dockerfile: ${N2A_DOCKERFILE:-Dockerfile} + image: ${N2A_IMAGE:-notion2api:latest} + container_name: ${N2A_CONTAINER_NAME:-notion2api} + restart: ${N2A_RESTART_POLICY:-unless-stopped} ports: - - "8787:8787" + - "${N2A_PORT:-8787}:8787" volumes: - - ./config.docker.json:/app/config/config.json - - ./data:/app/data + - ${N2A_CONFIG_FILE:-./config.docker.json}:/app/config/config.json:ro + - ${N2A_DATA_DIR:-./data}:/app/data environment: - TZ: Asia/Shanghai + TZ: ${TZ:-Asia/Shanghai} + N2A_PROXY_MODE: ${N2A_PROXY_MODE:-} + N2A_PROXY_URL: ${N2A_PROXY_URL:-} + N2A_PROXY_HTTP_URL: ${N2A_PROXY_HTTP_URL:-} + N2A_PROXY_HTTPS_URL: ${N2A_PROXY_HTTPS_URL:-} + N2A_RESIN_ENABLED: ${N2A_RESIN_ENABLED:-} + N2A_RESIN_URL: ${N2A_RESIN_URL:-} + N2A_RESIN_PLATFORM: ${N2A_RESIN_PLATFORM:-} + N2A_RESIN_MODE: ${N2A_RESIN_MODE:-} + HTTP_PROXY: ${HTTP_PROXY:-} + HTTPS_PROXY: ${HTTPS_PROXY:-} + ALL_PROXY: ${ALL_PROXY:-} + NO_PROXY: ${NO_PROXY:-} command: ["./notion2api", "--config", "/app/config/config.json"] diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 108784f..f3cff78 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -26,410 +26,357 @@ --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); - --font-sans: "Segoe UI Variable Text", "Segoe UI", "Noto Sans SC", "Microsoft YaHei UI", sans-serif; - --font-mono: "Cascadia Code", "Aptos Mono", Consolas, "JetBrains Mono", monospace; + --font-sans: var(--font-jakarta), "Plus Jakarta Sans", "Inter", "Noto Sans SC", "Microsoft YaHei UI", system-ui, sans-serif; + --font-mono: var(--font-jetbrains), "JetBrains Mono", "Cascadia Code", Consolas, monospace; } :root { - --radius: 0.875rem; - --background: oklch(0.985 0.004 304); - --foreground: oklch(0.236 0.014 286); - --card: oklch(0.995 0.003 300); - --card-foreground: oklch(0.236 0.014 286); - --popover: oklch(0.998 0.002 300); - --popover-foreground: oklch(0.236 0.014 286); - --primary: oklch(0.592 0.206 305.5); - --primary-foreground: oklch(0.985 0.002 300); - --secondary: oklch(0.968 0.008 300); - --secondary-foreground: oklch(0.278 0.013 286); - --muted: oklch(0.972 0.006 300); - --muted-foreground: oklch(0.505 0.016 286); - --accent: oklch(0.955 0.018 304); - --accent-foreground: oklch(0.266 0.014 286); - --destructive: oklch(0.622 0.226 26); - --border: oklch(0.9 0.01 296); - --input: oklch(0.895 0.011 296); - --ring: oklch(0.668 0.166 307); - --panel: oklch(0.992 0.004 300); - --panel-elevated: oklch(0.999 0.001 300); - --sidebar: oklch(0.976 0.008 304); - --sidebar-border: oklch(0.89 0.012 298); - --code-bg: oklch(0.221 0.019 282); - --code-fg: oklch(0.955 0.006 300); - --scrollbar-track: color-mix(in oklab, var(--muted) 78%, transparent); - --scrollbar-thumb: color-mix(in oklab, var(--foreground) 18%, transparent); - --scrollbar-thumb-strong: color-mix(in oklab, var(--primary) 28%, var(--foreground) 72%); + --radius: 0.75rem; + --topbar-height: 64px; + --content-max-width: 1360px; + --content-gutter: 1.75rem; + --section-gap: 1.5rem; + --rail-offset: 1rem; + --background: oklch(0.985 0.005 247); + --foreground: oklch(0.205 0.025 256); + --card: oklch(1 0 0); + --card-foreground: oklch(0.205 0.025 256); + --popover: oklch(0.998 0.002 247); + --popover-foreground: oklch(0.205 0.025 256); + --primary: oklch(0.512 0.221 277); + --primary-foreground: oklch(0.985 0.005 247); + --primary-rgb: 79 70 229; + --secondary: oklch(0.969 0.012 268); + --secondary-foreground: oklch(0.276 0.025 256); + --secondary-rgb: 124 58 237; + --muted: oklch(0.969 0.012 247); + --muted-foreground: oklch(0.512 0.026 247); + --accent: oklch(0.962 0.022 273); + --accent-foreground: oklch(0.296 0.046 273); + --destructive: oklch(0.602 0.224 27); + --border: oklch(0.918 0.014 247); + --input: oklch(0.918 0.014 247); + --ring: oklch(0.512 0.221 277); + --panel: oklch(1 0 0); + --panel-elevated: oklch(1 0 0); + --sidebar: oklch(1 0 0); + --sidebar-border: oklch(0.918 0.014 247); + --code-bg: oklch(0.196 0.026 256); + --code-fg: oklch(0.952 0.008 247); + --scrollbar-track: color-mix(in oklab, var(--muted) 70%, transparent); + --scrollbar-thumb: color-mix(in oklab, var(--primary) 24%, var(--muted-foreground) 76%); + --scrollbar-thumb-strong: color-mix(in oklab, var(--primary) 60%, var(--muted-foreground) 40%); + --gradient-brand: linear-gradient(135deg, #4F46E5 0%, #7C3AED 50%, #A855F7 100%); + --gradient-brand-soft: linear-gradient(135deg, rgba(79,70,229,0.10) 0%, rgba(124,58,237,0.10) 100%); + --shadow-soft: 0 4px 20px -2px rgba(79, 70, 229, 0.10); + --shadow-elevated: 0 12px 28px -10px rgba(79, 70, 229, 0.18), 0 6px 12px -8px rgba(124, 58, 237, 0.12); + --shadow-button: 0 6px 16px -4px rgba(79, 70, 229, 0.40); + --shadow-button-hover: 0 12px 24px -8px rgba(79, 70, 229, 0.55); + --shadow-glow: 0 0 24px rgba(79, 70, 229, 0.35); } .dark { - --background: oklch(0.188 0.012 286); - --foreground: oklch(0.952 0.004 300); - --card: oklch(0.224 0.013 286); - --card-foreground: oklch(0.952 0.004 300); - --popover: oklch(0.218 0.012 286); - --popover-foreground: oklch(0.952 0.004 300); - --primary: oklch(0.73 0.156 307); - --primary-foreground: oklch(0.206 0.014 286); - --secondary: oklch(0.258 0.013 286); - --secondary-foreground: oklch(0.948 0.004 300); - --muted: oklch(0.252 0.012 286); - --muted-foreground: oklch(0.752 0.018 300); - --accent: oklch(0.284 0.021 304); - --accent-foreground: oklch(0.958 0.004 300); - --destructive: oklch(0.704 0.191 22.216); - --border: oklch(0.325 0.014 286 / 0.92); - --input: oklch(0.298 0.013 286 / 0.95); - --ring: oklch(0.73 0.156 307); - --panel: oklch(0.208 0.012 286); - --panel-elevated: oklch(0.236 0.013 286); - --sidebar: oklch(0.2 0.011 286); - --sidebar-border: oklch(0.3 0.014 286); - --code-bg: oklch(0.168 0.01 280); - --code-fg: oklch(0.94 0.005 300); - --scrollbar-track: color-mix(in oklab, var(--muted) 54%, transparent); - --scrollbar-thumb: color-mix(in oklab, var(--foreground) 20%, transparent); - --scrollbar-thumb-strong: color-mix(in oklab, var(--primary) 34%, var(--foreground) 66%); + --background: oklch(0.158 0.018 256); + --foreground: oklch(0.962 0.008 247); + --card: oklch(0.214 0.022 256); + --card-foreground: oklch(0.962 0.008 247); + --popover: oklch(0.224 0.024 256); + --popover-foreground: oklch(0.962 0.008 247); + --primary: oklch(0.692 0.184 277); + --primary-foreground: oklch(0.158 0.018 256); + --primary-rgb: 129 140 248; + --secondary: oklch(0.262 0.024 256); + --secondary-foreground: oklch(0.948 0.008 247); + --secondary-rgb: 167 139 250; + --muted: oklch(0.252 0.022 256); + --muted-foreground: oklch(0.722 0.022 247); + --accent: oklch(0.302 0.04 273); + --accent-foreground: oklch(0.962 0.008 247); + --destructive: oklch(0.704 0.191 22); + --border: oklch(0.305 0.024 256 / 0.86); + --input: oklch(0.288 0.024 256); + --ring: oklch(0.692 0.184 277); + --panel: oklch(0.196 0.02 256); + --panel-elevated: oklch(0.226 0.024 256); + --sidebar: oklch(0.176 0.022 256); + --sidebar-border: oklch(0.286 0.024 256); + --code-bg: oklch(0.146 0.018 256); + --code-fg: oklch(0.948 0.008 247); + --scrollbar-track: color-mix(in oklab, var(--muted) 50%, transparent); + --scrollbar-thumb: color-mix(in oklab, var(--primary) 32%, var(--foreground) 68%); + --scrollbar-thumb-strong: color-mix(in oklab, var(--primary) 70%, var(--foreground) 30%); + --shadow-soft: 0 6px 24px -4px rgba(0, 0, 0, 0.55); + --shadow-elevated: 0 18px 40px -16px rgba(0, 0, 0, 0.7), 0 8px 16px -10px rgba(124, 58, 237, 0.25); + --shadow-button: 0 8px 22px -6px rgba(124, 58, 237, 0.55); + --shadow-button-hover: 0 14px 32px -10px rgba(124, 58, 237, 0.7); + --shadow-glow: 0 0 32px rgba(124, 58, 237, 0.45); } -:root[data-accent='violet'] { - --primary: oklch(0.592 0.206 305.5); - --ring: oklch(0.668 0.166 307); - --accent: oklch(0.955 0.018 304); - --accent-foreground: oklch(0.266 0.014 286); -} - -:root.dark[data-accent='violet'] { - --primary: oklch(0.73 0.156 307); - --ring: oklch(0.73 0.156 307); - --accent: oklch(0.284 0.021 304); - --accent-foreground: oklch(0.958 0.004 300); -} - -:root[data-accent='blue'] { - --primary: oklch(0.598 0.183 256); - --ring: oklch(0.676 0.143 252); - --accent: oklch(0.952 0.018 250); - --accent-foreground: oklch(0.252 0.02 258); -} - -:root.dark[data-accent='blue'] { - --primary: oklch(0.718 0.133 252); - --ring: oklch(0.718 0.133 252); - --accent: oklch(0.274 0.026 252); - --accent-foreground: oklch(0.956 0.005 300); -} - -:root[data-accent='teal'] { - --primary: oklch(0.612 0.118 196); - --ring: oklch(0.672 0.102 196); - --accent: oklch(0.953 0.017 192); - --accent-foreground: oklch(0.246 0.02 196); -} - -:root.dark[data-accent='teal'] { - --primary: oklch(0.732 0.094 190); - --ring: oklch(0.732 0.094 190); - --accent: oklch(0.276 0.018 192); - --accent-foreground: oklch(0.958 0.004 300); -} - -:root[data-accent='green'] { - --primary: oklch(0.622 0.154 145); - --ring: oklch(0.69 0.132 145); - --accent: oklch(0.955 0.02 145); - --accent-foreground: oklch(0.248 0.02 145); -} - -:root.dark[data-accent='green'] { - --primary: oklch(0.744 0.118 145); - --ring: oklch(0.744 0.118 145); - --accent: oklch(0.278 0.02 145); - --accent-foreground: oklch(0.958 0.004 300); -} - -:root[data-accent='amber'] { - --primary: oklch(0.676 0.158 72); - --ring: oklch(0.734 0.136 72); - --accent: oklch(0.958 0.018 78); - --accent-foreground: oklch(0.288 0.03 72); -} - -:root.dark[data-accent='amber'] { - --primary: oklch(0.792 0.124 78); - --ring: oklch(0.792 0.124 78); - --accent: oklch(0.29 0.026 72); - --accent-foreground: oklch(0.22 0.012 286); -} - -:root[data-accent='crimson'] { - --primary: oklch(0.612 0.214 18); - --ring: oklch(0.688 0.168 16); - --accent: oklch(0.954 0.018 16); - --accent-foreground: oklch(0.262 0.024 16); -} - -:root.dark[data-accent='crimson'] { - --primary: oklch(0.736 0.16 18); - --ring: oklch(0.736 0.16 18); - --accent: oklch(0.286 0.028 16); - --accent-foreground: oklch(0.958 0.004 300); -} +:root[data-accent='violet'] { --primary: oklch(0.512 0.221 277); --ring: oklch(0.512 0.221 277); --accent: oklch(0.962 0.022 273); --primary-rgb: 79 70 229; } +:root.dark[data-accent='violet'] { --primary: oklch(0.692 0.184 277); --ring: oklch(0.692 0.184 277); --primary-rgb: 129 140 248; } +:root[data-accent='blue'] { --primary: oklch(0.546 0.196 261); --ring: oklch(0.546 0.196 261); --accent: oklch(0.954 0.024 252); --primary-rgb: 37 99 235; } +:root.dark[data-accent='blue'] { --primary: oklch(0.706 0.156 252); --ring: oklch(0.706 0.156 252); --primary-rgb: 96 165 250; } +:root[data-accent='teal'] { --primary: oklch(0.612 0.118 196); --ring: oklch(0.612 0.118 196); --accent: oklch(0.953 0.017 192); --primary-rgb: 13 148 136; } +:root.dark[data-accent='teal'] { --primary: oklch(0.732 0.094 190); --ring: oklch(0.732 0.094 190); --primary-rgb: 45 212 191; } +:root[data-accent='green'] { --primary: oklch(0.622 0.154 145); --ring: oklch(0.622 0.154 145); --accent: oklch(0.955 0.02 145); --primary-rgb: 22 163 74; } +:root.dark[data-accent='green'] { --primary: oklch(0.744 0.118 145); --ring: oklch(0.744 0.118 145); --primary-rgb: 74 222 128; } +:root[data-accent='amber'] { --primary: oklch(0.676 0.158 72); --ring: oklch(0.676 0.158 72); --accent: oklch(0.958 0.018 78); --primary-rgb: 217 119 6; } +:root.dark[data-accent='amber'] { --primary: oklch(0.792 0.124 78); --ring: oklch(0.792 0.124 78); --primary-rgb: 251 191 36; } +:root[data-accent='crimson'] { --primary: oklch(0.612 0.214 18); --ring: oklch(0.612 0.214 18); --accent: oklch(0.954 0.018 16); --primary-rgb: 220 38 38; } +:root.dark[data-accent='crimson'] { --primary: oklch(0.736 0.16 18); --ring: oklch(0.736 0.16 18); --primary-rgb: 248 113 113; } @layer base { - * { - @apply border-border outline-ring/50; - } - - html { - scroll-behavior: smooth; - } - + * { @apply border-border outline-ring/40; } + html { scroll-behavior: smooth; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } body { @apply bg-background text-foreground font-sans antialiased; min-height: 100vh; position: relative; - background-image: - radial-gradient(circle at 0% 0%, color-mix(in oklab, var(--primary) 14%, transparent), transparent 34%), - radial-gradient(circle at 100% 0%, color-mix(in oklab, var(--primary) 8%, transparent), transparent 28%), - linear-gradient(180deg, color-mix(in oklab, var(--background) 98%, white 2%), var(--background)); - } - - ::selection { - background: color-mix(in oklab, var(--primary) 78%, white 22%); - color: var(--primary-foreground); + font-feature-settings: "cv11", "ss01", "ss03"; + letter-spacing: -0.005em; } + ::selection { background: rgb(var(--primary-rgb) / 0.28); color: var(--foreground); } + h1, h2, h3, h4, h5, h6 { letter-spacing: -0.02em; font-feature-settings: "cv11", "ss01"; } + [data-anchor] { scroll-margin-top: calc(var(--topbar-height) + 1.25rem); } } @layer components { - .hide-scrollbar::-webkit-scrollbar { - display: none; - } - - .hide-scrollbar { - -ms-overflow-style: none; - scrollbar-width: none; - } + .hide-scrollbar::-webkit-scrollbar { display: none; } + .hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } + .pretty-scroll { scrollbar-width: thin; scrollbar-color: var(--scrollbar-thumb) transparent; } + .pretty-scroll::-webkit-scrollbar { width: 10px; height: 10px; } + .pretty-scroll::-webkit-scrollbar-thumb { border: 2px solid transparent; border-radius: 999px; background-clip: padding-box; background-color: var(--scrollbar-thumb); } + .pretty-scroll:hover::-webkit-scrollbar-thumb { background-color: var(--scrollbar-thumb-strong); } + .pretty-scroll::-webkit-scrollbar-track { background: transparent; } + .sidebar-scroll::-webkit-scrollbar { width: 6px; } .console-surface { @apply min-h-screen text-foreground; background: - linear-gradient(180deg, color-mix(in oklab, var(--background) 96%, transparent), color-mix(in oklab, var(--background) 100%, transparent)), - repeating-linear-gradient( - 90deg, - color-mix(in oklab, var(--border) 18%, transparent) 0, - color-mix(in oklab, var(--border) 18%, transparent) 1px, - transparent 1px, - transparent 48px - ); - background-size: auto, 100% 100%; + radial-gradient(ellipse 80% 60% at 8% -5%, rgb(var(--primary-rgb) / 0.18), transparent 55%), + radial-gradient(ellipse 70% 50% at 100% 0%, rgb(var(--secondary-rgb) / 0.14), transparent 52%), + radial-gradient(ellipse 60% 40% at 50% 110%, rgb(var(--primary-rgb) / 0.10), transparent 55%), + var(--background); + background-attachment: fixed; + } + .dark .console-surface { + background: + radial-gradient(ellipse 80% 60% at 8% -5%, rgb(var(--primary-rgb) / 0.10), transparent 55%), + radial-gradient(ellipse 70% 50% at 100% 0%, rgb(var(--secondary-rgb) / 0.10), transparent 52%), + radial-gradient(ellipse 60% 40% at 50% 110%, rgb(var(--primary-rgb) / 0.06), transparent 55%), + var(--background); + background-attachment: fixed; } .sidebar-shell { - border-color: color-mix(in oklab, var(--sidebar-border) 96%, transparent); - background: linear-gradient(180deg, color-mix(in oklab, var(--sidebar) 92%, white 8%), color-mix(in oklab, var(--background) 88%, transparent)); + border-color: var(--sidebar-border); + background: color-mix(in oklab, var(--sidebar) 92%, transparent); + backdrop-filter: blur(20px) saturate(140%); + -webkit-backdrop-filter: blur(20px) saturate(140%); } - .topbar-shell { - border-color: color-mix(in oklab, var(--border) 92%, transparent); - background: color-mix(in oklab, var(--background) 80%, transparent); - backdrop-filter: blur(18px); + border-color: color-mix(in oklab, var(--border) 80%, transparent); + background: color-mix(in oklab, var(--background) 72%, transparent); + backdrop-filter: blur(18px) saturate(140%); + -webkit-backdrop-filter: blur(18px) saturate(140%); + } + .topbar-inner { + @apply mx-auto flex w-full flex-wrap items-center gap-3 px-4 md:px-7; + min-height: var(--topbar-height); + } + .topbar-dot { + @apply inline-block size-1 rounded-full; + background: color-mix(in oklab, var(--muted-foreground) 45%, transparent); + } + + .console-content { + @apply mx-auto w-full min-w-0 px-4 py-6 md:px-7 lg:py-8 pb-12; + } + .console-content[data-fullwidth='true'] { max-width: 100%; } + .console-content:not([data-fullwidth='true']) { max-width: var(--content-max-width); } + .panel-stack > * + * { margin-top: var(--section-gap); } + + .brand-badge { + @apply inline-flex items-center justify-center font-bold text-white; + background: var(--gradient-brand); + box-shadow: var(--shadow-glow), inset 0 1px 0 rgba(255,255,255,0.18); + border-radius: calc(var(--radius) - 2px); } .panel-card { @apply min-w-0 border text-card-foreground; border-color: color-mix(in oklab, var(--border) 92%, transparent); - background: linear-gradient(180deg, color-mix(in oklab, var(--panel-elevated) 96%, white 4%), color-mix(in oklab, var(--card) 100%, transparent)); - box-shadow: - 0 22px 48px -34px rgba(18, 24, 38, 0.34), - 0 1px 0 rgba(255, 255, 255, 0.68) inset; + background: var(--card); + box-shadow: var(--shadow-soft); + border-radius: var(--radius); + transition: box-shadow 0.22s ease, border-color 0.18s ease, transform 0.18s ease; } - - .dark .panel-card { - box-shadow: - 0 24px 54px -34px rgba(0, 0, 0, 0.64), - 0 1px 0 rgba(255, 255, 255, 0.05) inset; + .panel-card:hover { + box-shadow: var(--shadow-elevated); + border-color: color-mix(in oklab, var(--primary) 12%, var(--border) 88%); + } + .panel-card.elevated { + box-shadow: var(--shadow-elevated); } - .surface-subtle { @apply border text-foreground; - border-color: color-mix(in oklab, var(--border) 90%, transparent); - background: linear-gradient(180deg, color-mix(in oklab, var(--panel) 90%, white 10%), color-mix(in oklab, var(--muted) 84%, transparent)); + border-color: color-mix(in oklab, var(--border) 88%, transparent); + background: color-mix(in oklab, var(--muted) 56%, var(--card) 44%); + border-radius: calc(var(--radius) - 2px); + } + .surface-tinted { + @apply border text-foreground; + border-color: color-mix(in oklab, var(--primary) 18%, var(--border) 82%); + background: linear-gradient(180deg, rgb(var(--primary-rgb) / 0.05), rgb(var(--secondary-rgb) / 0.04)); + border-radius: calc(var(--radius) - 2px); + } + .divider-soft { + height: 1px; + background: linear-gradient(90deg, transparent, color-mix(in oklab, var(--border) 80%, transparent), transparent); + border: 0; } + .text-gradient-brand { + background: var(--gradient-brand); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + color: transparent; + } .section-eyebrow { - @apply text-[11px] font-semibold uppercase tracking-[0.2em] text-muted-foreground; + @apply text-[11px] font-bold uppercase tracking-[0.2em]; + color: color-mix(in oklab, var(--primary) 78%, var(--muted-foreground) 22%); } - .metric-value { - @apply text-2xl font-semibold tracking-tight md:text-3xl; + @apply text-3xl font-bold tracking-tight md:text-[2rem]; word-break: break-word; + line-height: 1.08; + font-feature-settings: "tnum", "cv11", "ss01"; + font-variant-numeric: tabular-nums; } .value-box { - @apply overflow-auto rounded-md border px-3 py-2 text-xs whitespace-pre-wrap break-words; - max-height: 8.75rem; - border-color: color-mix(in oklab, var(--border) 84%, transparent); - background: color-mix(in oklab, var(--background) 78%, var(--muted) 22%); - } - - .pretty-scroll { - scrollbar-width: thin; - scrollbar-color: var(--scrollbar-thumb) transparent; - scrollbar-gutter: stable both-edges; - } - - .pretty-scroll::-webkit-scrollbar { - width: 12px; - height: 12px; + @apply overflow-auto whitespace-pre-wrap break-words text-xs leading-6; + max-height: 9rem; + padding: 0.55rem 0.8rem; + border: 1px solid color-mix(in oklab, var(--border) 80%, transparent); + background: color-mix(in oklab, var(--background) 72%, var(--muted) 28%); + border-radius: calc(var(--radius) - 4px); + font-feature-settings: "tnum"; } - - .pretty-scroll::-webkit-scrollbar-thumb { - min-height: 2.5rem; - border: 3px solid transparent; - border-radius: 999px; - background-clip: padding-box; - background-image: linear-gradient( - 180deg, - color-mix(in oklab, var(--scrollbar-thumb) 84%, white 16%), - var(--scrollbar-thumb-strong) - ); - } - - .pretty-scroll:hover::-webkit-scrollbar-thumb { - background-image: linear-gradient( - 180deg, - color-mix(in oklab, var(--scrollbar-thumb-strong) 82%, white 18%), - color-mix(in oklab, var(--scrollbar-thumb-strong) 92%, var(--primary) 8%) - ); - } - - .pretty-scroll::-webkit-scrollbar-track { + .status-chip { + @apply inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium leading-none; + border: 1px solid color-mix(in oklab, var(--border) 90%, transparent); + background: color-mix(in oklab, var(--muted) 60%, var(--card) 40%); + color: var(--muted-foreground); border-radius: 999px; - background: linear-gradient( - 180deg, - color-mix(in oklab, var(--scrollbar-track) 88%, transparent), - color-mix(in oklab, var(--scrollbar-track) 46%, transparent) - ); - } - - .sidebar-scroll::-webkit-scrollbar { - width: 9px; } - - .sidebar-scroll::-webkit-scrollbar-track { - background: color-mix(in oklab, var(--scrollbar-track) 72%, transparent); - } - - .status-chip { - @apply inline-flex items-center rounded-md border px-3 py-1.5 text-xs font-medium text-muted-foreground; - border-color: color-mix(in oklab, var(--border) 94%, transparent); - background: color-mix(in oklab, var(--muted) 72%, transparent); + .status-chip.tone-primary { + border-color: color-mix(in oklab, var(--primary) 30%, transparent); + background: color-mix(in oklab, var(--primary) 12%, var(--card) 88%); + color: color-mix(in oklab, var(--primary) 70%, var(--foreground) 30%); } .code-surface { - border-color: color-mix(in oklab, var(--border) 66%, transparent); - background: linear-gradient(180deg, color-mix(in oklab, var(--code-bg) 100%, black 0%), color-mix(in oklab, var(--code-bg) 92%, black 8%)); + border-color: color-mix(in oklab, var(--border) 60%, transparent); + background: linear-gradient(180deg, var(--code-bg), color-mix(in oklab, var(--code-bg) 92%, black 8%)); color: var(--code-fg); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); + border-radius: calc(var(--radius) - 4px); } .console-list-scroll { - height: min(78vh, 1040px); + height: clamp(28rem, calc(100vh - var(--topbar-height) - 12rem), 64rem); } - .console-detail-scroll { - height: min(76vh, 1020px); + height: clamp(26rem, calc(100vh - var(--topbar-height) - 14rem), 62rem); } - .rail-sticky { position: sticky; - top: 1.5rem; + top: calc(var(--topbar-height) + var(--rail-offset)); align-self: start; } .auth-shell { - background-image: - radial-gradient(circle at top, color-mix(in oklab, var(--primary) 16%, transparent), transparent 34%), - linear-gradient(180deg, color-mix(in oklab, var(--background) 94%, white 6%), color-mix(in oklab, var(--background) 100%, transparent)); + background: + radial-gradient(circle at 22% 18%, rgb(var(--primary-rgb) / 0.30), transparent 45%), + radial-gradient(circle at 78% 14%, rgb(var(--secondary-rgb) / 0.26), transparent 45%), + radial-gradient(circle at 50% 96%, rgb(var(--primary-rgb) / 0.18), transparent 50%), + var(--background); + } + + .orb { + position: absolute; + border-radius: 9999px; + filter: blur(80px); + pointer-events: none; + opacity: 0.55; + } + .orb.orb-indigo { background: rgb(var(--primary-rgb) / 0.55); } + .orb.orb-violet { background: rgb(var(--secondary-rgb) / 0.50); } + @keyframes orbFloat { + 0%, 100% { transform: translate3d(0, 0, 0) scale(1); } + 50% { transform: translate3d(0, -10px, 0) scale(1.04); } + } + .orb.orb-animate { animation: orbFloat 9s ease-in-out infinite; } + @media (prefers-reduced-motion: reduce) { + .orb.orb-animate { animation: none; } + [data-slot='button'] { transition: none !important; } + .panel-card { transition: none; } } [data-slot='card'] { - border-radius: calc(var(--radius) + 2px) !important; + border-radius: var(--radius) !important; overflow: hidden; } - [data-slot='button'] { - border-radius: var(--radius) !important; - box-shadow: none !important; + border-radius: calc(var(--radius) - 4px) !important; transition: transform 0.18s ease, - box-shadow 0.18s ease, + box-shadow 0.22s ease, border-color 0.18s ease, background-color 0.18s ease, color 0.18s ease; } - - [data-slot='button']:not(:disabled):active { - transform: translateY(1px); - } + [data-slot='button']:not(:disabled):active { transform: translateY(0); } [data-slot='input'], [data-slot='textarea'], [data-slot='select-trigger'] { - border-radius: var(--radius) !important; - border-color: color-mix(in oklab, var(--border) 94%, transparent); - background: color-mix(in oklab, var(--background) 82%, var(--panel-elevated) 18%); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.32); - } - - .dark [data-slot='input'], - .dark [data-slot='textarea'], - .dark [data-slot='select-trigger'] { - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); + border-radius: calc(var(--radius) - 4px) !important; + border-color: var(--border); + background: var(--card); + transition: border-color 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease; } - [data-slot='input']:hover, [data-slot='textarea']:hover, [data-slot='select-trigger']:hover { - border-color: color-mix(in oklab, var(--primary) 24%, var(--border) 76%); + border-color: color-mix(in oklab, var(--primary) 32%, var(--border) 68%); + } + [data-slot='input']:focus-visible, + [data-slot='textarea']:focus-visible, + [data-slot='select-trigger']:focus-visible { + border-color: var(--primary) !important; + box-shadow: 0 0 0 3px rgb(var(--primary-rgb) / 0.18) !important; } [data-slot='select-content'] { border-color: color-mix(in oklab, var(--border) 92%, transparent); - background: color-mix(in oklab, var(--popover) 96%, white 4%); - box-shadow: 0 20px 44px -28px rgba(18, 24, 38, 0.36); - } - - .dark [data-slot='select-content'] { - box-shadow: 0 24px 56px -28px rgba(0, 0, 0, 0.62); - } - - [data-slot='scroll-area-scrollbar'] { - padding: 2px; - } - - [data-slot='scroll-area-scrollbar'][data-orientation='vertical'] { - width: 12px; - } - - [data-slot='scroll-area-scrollbar'][data-orientation='horizontal'] { - height: 12px; + background: var(--popover); + box-shadow: var(--shadow-elevated); + border-radius: calc(var(--radius) - 2px); } + [data-slot='scroll-area-scrollbar'] { padding: 2px; } + [data-slot='scroll-area-scrollbar'][data-orientation='vertical'] { width: 10px; } + [data-slot='scroll-area-scrollbar'][data-orientation='horizontal'] { height: 10px; } [data-slot='scroll-area-thumb'] { border-radius: 999px; - background: linear-gradient( - 180deg, - color-mix(in oklab, var(--scrollbar-thumb) 84%, white 16%), - var(--scrollbar-thumb-strong) - ); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.18); + background: var(--scrollbar-thumb); } - .surface-subtle, - .value-box, - .code-surface, - .status-chip { - border-radius: var(--radius) !important; + .surface-subtle, .value-box, .code-surface, .status-chip, .surface-tinted { + border-radius: calc(var(--radius) - 4px) !important; } + .status-chip { border-radius: 999px !important; } } diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index c4aeb7d..b39dad2 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -1,10 +1,24 @@ import type { Metadata } from 'next'; import type { ReactNode } from 'react'; +import { Plus_Jakarta_Sans, JetBrains_Mono } from 'next/font/google'; import { ThemeProvider } from '@/components/layout/theme-provider'; import { Toaster } from '@/components/ui/sonner'; -import '@radix-ui/themes/styles.css'; import './globals.css'; +const jakarta = Plus_Jakarta_Sans({ + subsets: ['latin'], + weight: ['400', '500', '600', '700', '800'], + variable: '--font-jakarta', + display: 'swap', +}); + +const jetbrains = JetBrains_Mono({ + subsets: ['latin'], + weight: ['400', '500', '600'], + variable: '--font-jetbrains', + display: 'swap', +}); + export const metadata: Metadata = { title: { default: 'Nation2API Console', @@ -19,7 +33,7 @@ export default function RootLayout({ children: ReactNode; }>) { return ( - + {children} diff --git a/frontend/components/admin/accounts-panel.tsx b/frontend/components/admin/accounts-panel.tsx index 43124c3..855c7bd 100644 --- a/frontend/components/admin/accounts-panel.tsx +++ b/frontend/components/admin/accounts-panel.tsx @@ -11,7 +11,6 @@ import { ShieldCheck, Trash2, WandSparkles, - type LucideIcon, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -25,9 +24,11 @@ import { InfoCard, JsonPreview, KeyValueGrid, + MetaTile, PanelHeader, StatCard, StatusPill, + Subsection, formatMaybeDate, } from '@/components/admin/shared'; import type { AccountItem, AccountsPayload, JsonResult, ModelItem } from '@/lib/services/admin/types'; @@ -35,6 +36,7 @@ import type { AccountItem, AccountsPayload, JsonResult, ModelItem } from '@/lib/ interface AccountEditState { priority: number; hourlyQuota: number; + maxConcurrency: number; disabled: boolean; } @@ -57,11 +59,8 @@ interface ProbeDraft { client_version?: string; } -const SURFACE_CARD_CLASS = 'surface-subtle min-w-0 p-4'; -const FIELD_CLASS = 'h-10 rounded-md bg-transparent'; -const TEXTAREA_CLASS = 'rounded-md bg-transparent'; -const TOGGLE_PANEL_CLASS = 'surface-subtle min-w-0 px-4 py-4'; -const META_TILE_CLASS = 'surface-subtle min-w-0 p-3'; +const FIELD_CLASS = 'h-10 rounded-lg bg-transparent'; +const TEXTAREA_CLASS = 'rounded-lg bg-transparent'; const PROBE_TEXTAREA_CLASS = 'pretty-scroll h-[360px] min-h-[360px] resize-none !rounded-none !border-0 !bg-transparent px-4 py-3 font-mono text-[12px] leading-6 !shadow-none focus-visible:!ring-0 lg:h-[440px] lg:min-h-[440px]'; @@ -83,6 +82,7 @@ function buildAccountEditMap(items: AccountItem[]): Record -
-
- -
-
-
{title}
-

{description}

-
-
-
{children}
- - ); -} - function DetailField({ label, hint, @@ -143,7 +116,7 @@ function DetailField({ return (
- + {hint ?

{hint}

: null}
{children} @@ -165,8 +138,10 @@ function AccountListItem({ type="button" onClick={onSelect} className={[ - 'w-full rounded-lg border px-4 py-4 text-left transition-colors', - selected ? 'border-primary/40 bg-primary/5 shadow-sm' : 'border-border/70 bg-background hover:bg-muted/40', + 'w-full rounded-lg border px-4 py-4 text-left transition-all', + selected + ? 'border-primary/40 bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))] shadow-soft' + : 'border-border/70 bg-card hover:border-primary/20 hover:bg-muted/40', ].join(' ')} >
@@ -298,8 +273,8 @@ export function AccountsPanel({ const modelOptions = useMemo(() => models.filter((item) => item.id), [models]); const selectedEdit = selectedAccount?.email - ? accountEdits[selectedAccount.email] || { priority: 0, hourlyQuota: 0, disabled: false } - : { priority: 0, hourlyQuota: 0, disabled: false }; + ? accountEdits[selectedAccount.email] || { priority: 0, hourlyQuota: 0, maxConcurrency: 1, disabled: false } + : { priority: 0, hourlyQuota: 0, maxConcurrency: 1, disabled: false }; const summaryCards = [ { @@ -347,6 +322,7 @@ export function AccountsPanel({ [email]: { priority: current[email]?.priority ?? 0, hourlyQuota: current[email]?.hourlyQuota ?? 0, + maxConcurrency: current[email]?.maxConcurrency ?? 1, disabled: current[email]?.disabled ?? false, ...patch, }, @@ -383,6 +359,7 @@ export function AccountsPanel({ email, priority: edit.priority, hourly_quota: edit.hourlyQuota, + max_concurrency: edit.maxConcurrency, disabled: edit.disabled, }); toast.success(`已保存 ${email}`); @@ -411,6 +388,47 @@ export function AccountsPanel({ } } + async function performManualImport() { + setManualBusy(true); + setManualHint('导入中...'); + try { + let probe: ProbeDraft | null = null; + if (manual.probeJsonText.trim()) { + probe = safeParseProbeJSON(manual.probeJsonText); + } + const email = manual.email.trim() || probe?.email?.trim() || ''; + if (!email && !manual.cookieHeader.trim() && !manual.probeJsonText.trim()) { + throw new Error('请至少提供 cookie_header、Probe JSON 或邮箱'); + } + const payload = await onImportAccount({ + email, + user_id: manual.userId.trim(), + user_name: manual.userName.trim(), + space_id: manual.spaceId.trim(), + space_name: manual.spaceName.trim(), + client_version: manual.clientVersion.trim(), + cookie_header: manual.cookieHeader.trim(), + probe_json_text: manual.probeJsonText.trim(), + active: manual.active, + }); + const importedEmail = String( + (payload as { account?: { email?: string }; status?: { email?: string } }).account?.email || + (payload as { status?: { email?: string } }).status?.email || + email, + ); + populateEmail(importedEmail); + setManual((current) => ({ ...current, email: importedEmail })); + setManualHint(`账号 ${importedEmail} 已导入`); + toast.success('手动导入成功'); + } catch (error) { + const message = error instanceof Error ? error.message : '手动导入失败'; + setManualHint(message); + toast.error(message); + } finally { + setManualBusy(false); + } + } + return (
-
+
{ @@ -469,26 +487,28 @@ export function AccountsPanel({ } }} > - - - setStartEmail(event.target.value)} - placeholder="name@example.com" - className={FIELD_CLASS} - /> - -
- -

- {startMessage || '收到验证码后直接提交。'} -

+ +
+ + setStartEmail(event.target.value)} + placeholder="name@example.com" + className={FIELD_CLASS} + /> + +
+ +

+ {startMessage || '收到验证码后直接提交。'} +

+
- +
- -
- - setVerifyEmail(event.target.value)} - placeholder="与左侧邮箱一致" - className={FIELD_CLASS} - /> - - - setVerifyCode(event.target.value)} - placeholder="六位验证码" - className={[FIELD_CLASS, 'tracking-[0.32em]'].join(' ')} - /> - -
-
- -

- {verifyMessage || '错误时会显示服务端返回信息。'} -

+ +
+
+ + setVerifyEmail(event.target.value)} + placeholder="与左侧邮箱一致" + className={FIELD_CLASS} + /> + + + setVerifyCode(event.target.value)} + placeholder="六位验证码" + className={[FIELD_CLASS, 'tracking-[0.32em]'].join(' ')} + /> + +
+
+ +

+ {verifyMessage || '错误时会显示服务端返回信息。'} +

+
- +
+ {manualHint || '只填 token_v2 会尝试补齐其他字段。'} +
+ } > -
-
-
- - setManual((current) => ({ ...current, email: event.target.value }))} className={FIELD_CLASS} /> - - - setManual((current) => ({ ...current, userId: event.target.value }))} className={FIELD_CLASS} /> - - - setManual((current) => ({ ...current, spaceId: event.target.value }))} className={FIELD_CLASS} /> - - - setManual((current) => ({ ...current, clientVersion: event.target.value }))} className={FIELD_CLASS} /> - - - setManual((current) => ({ ...current, userName: event.target.value }))} className={FIELD_CLASS} /> - - - setManual((current) => ({ ...current, spaceName: event.target.value }))} className={FIELD_CLASS} /> - -
+
+ +
+
+ + setManual((current) => ({ ...current, email: event.target.value }))} className={FIELD_CLASS} /> + + + setManual((current) => ({ ...current, userId: event.target.value }))} className={FIELD_CLASS} /> + + + setManual((current) => ({ ...current, spaceId: event.target.value }))} className={FIELD_CLASS} /> + + + setManual((current) => ({ ...current, clientVersion: event.target.value }))} className={FIELD_CLASS} /> + + + setManual((current) => ({ ...current, userName: event.target.value }))} className={FIELD_CLASS} /> + + + setManual((current) => ({ ...current, spaceName: event.target.value }))} className={FIELD_CLASS} /> + +
- -