diff --git a/.claude/skills/ui-ux-pro-max/scripts/__pycache__/core.cpython-314.pyc b/.claude/skills/ui-ux-pro-max/scripts/__pycache__/core.cpython-314.pyc index 4a96c66..73acfc9 100644 Binary files a/.claude/skills/ui-ux-pro-max/scripts/__pycache__/core.cpython-314.pyc and b/.claude/skills/ui-ux-pro-max/scripts/__pycache__/core.cpython-314.pyc differ diff --git a/.claude/skills/ui-ux-pro-max/scripts/__pycache__/design_system.cpython-314.pyc b/.claude/skills/ui-ux-pro-max/scripts/__pycache__/design_system.cpython-314.pyc index 8598028..ae7a755 100644 Binary files a/.claude/skills/ui-ux-pro-max/scripts/__pycache__/design_system.cpython-314.pyc and b/.claude/skills/ui-ux-pro-max/scripts/__pycache__/design_system.cpython-314.pyc differ diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..80b14ca --- /dev/null +++ b/.dockerignore @@ -0,0 +1,93 @@ +# ============================================================ +# ZCLAW SaaS Backend - Docker Ignore +# ============================================================ + +# Build artifacts +target/ + +# Frontend applications (not needed for SaaS backend) +desktop/ +admin/ +design-system/ + +# Node.js +node_modules/ +.pnpm-store/ +bun.lock +pnpm-lock.yaml +package.json +package-lock.json + +# Git +.git/ +.gitignore + +# IDE and editor +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Docker +.docker/ +docker-compose*.yml +Dockerfile +.dockerignore + +# Documentation +docs/ +*.md +!saas-config.toml +CLAUDE.md +CLAUDE*.md + +# Environment files (secrets) +.env +.env.* +saas-env.example + +# Data files +saas-data/ +saas-data.db +saas-data.db-shm +saas-data.db-wal +*.db +*.db-shm +*.db-wal + +# Test artifacts +tests/ +test-results/ +test.rs +*.log + +# Temporary files +tmp-screenshot.png +tmp/ +temp/ +*.tmp + +# Claude worktree metadata +.claude/ +plans/ +pipelines/ +scripts/ +hands/ +skills/ +plugins/ +config/ +extract.js +extract_models.js +extract_privacy.js +start-all.ps1 +start.ps1 +start.sh +Makefile +PROGRESS.md +CHANGELOG.md +pencil-new.pen diff --git a/Cargo.lock b/Cargo.lock index 04d1761..66c6c57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2800,6 +2800,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -4148,6 +4158,41 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "shellexpand", + "syn 2.0.117", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + [[package]] name = "rustc-hash" version = "2.1.1" @@ -4617,6 +4662,15 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs", +] + [[package]] name = "shlex" version = "1.3.0" @@ -4795,6 +4849,7 @@ dependencies = [ "atoi", "byteorder", "bytes", + "chrono", "crc", "crossbeam-queue", "either", @@ -4855,6 +4910,7 @@ dependencies = [ "sha2", "sqlx-core", "sqlx-mysql", + "sqlx-postgres", "sqlx-sqlite", "syn 1.0.109", "tempfile", @@ -4873,6 +4929,7 @@ dependencies = [ "bitflags 2.11.0", "byteorder", "bytes", + "chrono", "crc", "digest", "dotenvy", @@ -4914,6 +4971,7 @@ dependencies = [ "base64 0.21.7", "bitflags 2.11.0", "byteorder", + "chrono", "crc", "dotenvy", "etcetera", @@ -4949,6 +5007,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa" dependencies = [ "atoi", + "chrono", "flume", "futures-channel", "futures-core", @@ -5989,6 +6048,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -6099,6 +6164,70 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utoipa" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_json", + "utoipa-gen 4.3.1", +] + +[[package]] +name = "utoipa" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +dependencies = [ + "indexmap 2.13.0", + "serde", + "serde_json", + "utoipa-gen 5.4.0", +] + +[[package]] +name = "utoipa-gen" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c24e8ab68ff9ee746aad22d39b5535601e6416d1b0feeabf78be986a5c4392" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.117", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f839caa8e09dddc3ff1c3112a91ef7da0601075ba5025d9f33ae99c4cb9b6e51" +dependencies = [ + "axum", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "utoipa 4.2.3", + "zip 0.6.6", +] + [[package]] name = "uuid" version = "1.22.0" @@ -7338,7 +7467,7 @@ dependencies = [ "zclaw-runtime", "zclaw-skills", "zclaw-types", - "zip", + "zip 2.4.2", ] [[package]] @@ -7432,17 +7561,19 @@ dependencies = [ name = "zclaw-saas" version = "0.1.0" dependencies = [ + "aes-gcm", "anyhow", "argon2", + "async-stream", "axum", "axum-extra", + "bytes", "chrono", "dashmap", "data-encoding", "futures", "hex", "jsonwebtoken", - "libsqlite3-sys", "rand 0.8.5", "reqwest 0.12.28", "secrecy", @@ -7461,8 +7592,9 @@ dependencies = [ "tracing-subscriber", "url", "urlencoding", + "utoipa 5.4.0", + "utoipa-swagger-ui", "uuid", - "zclaw-types", ] [[package]] @@ -7572,6 +7704,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "byteorder", + "crc32fast", + "crossbeam-utils", + "flate2", +] + [[package]] name = "zip" version = "2.4.2" diff --git a/Cargo.toml b/Cargo.toml index 833b7e4..707d17b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,7 +57,7 @@ chrono = { version = "0.4", features = ["serde"] } uuid = { version = "1", features = ["v4", "v5", "serde"] } # Database -sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] } +sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "chrono"] } libsqlite3-sys = { version = "0.27", features = ["bundled"] } # HTTP client (for LLM drivers) diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7ccac52 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,83 @@ +# ============================================================ +# ZCLAW SaaS Backend - Multi-stage Docker Build +# ============================================================ + +# ---- Stage 1: Builder ---- +FROM rust:1.75-bookworm AS builder + +# Install build dependencies for sqlx (postgres) and libsqlite3-sys (bundled) +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config \ + libssl-dev \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Copy workspace manifests first to leverage Docker layer caching +COPY Cargo.toml Cargo.lock ./ + +# Create stub source files so cargo can resolve and cache dependencies +# This avoids rebuilding dependencies when only application code changes +RUN mkdir -p crates/zclaw-saas/src \ + && echo 'fn main() {}' > crates/zclaw-saas/src/main.rs \ + && for crate in zclaw-types zclaw-memory zclaw-runtime zclaw-kernel \ + zclaw-skills zclaw-hands zclaw-channels zclaw-protocols \ + zclaw-pipeline zclaw-growth; do \ + mkdir -p crates/$crate/src && echo '' > crates/$crate/src/lib.rs; \ + done \ + && mkdir -p desktop/src-tauri/src && echo 'fn main() {}' > desktop/src-tauri/src/main.rs + +# Pre-build dependencies (release profile with caching) +RUN cargo build --release --package zclaw-saas 2>/dev/null || true + +# Copy actual source code (invalidates stubs, triggers recompile of app code only) +COPY crates/ crates/ +COPY desktop/ desktop/ + +# Touch source files to invalidate the stub timestamps +RUN touch crates/zclaw-saas/src/main.rs \ + && for crate in zclaw-types zclaw-memory zclaw-runtime zclaw-kernel \ + zclaw-skills zclaw-hands zclaw-channels zclaw-protocols \ + zclaw-pipeline zclaw-growth; do \ + touch crates/$crate/src/lib.rs 2>/dev/null || true; \ + done \ + && touch desktop/src-tauri/src/main.rs 2>/dev/null || true + +# Build the actual binary +RUN cargo build --release --package zclaw-saas + +# ---- Stage 2: Runtime ---- +FROM debian:bookworm-slim AS runtime + +# Install runtime dependencies (ca-certificates for HTTPS, libgcc for Rust runtime) +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + libgcc-s \ + && rm -rf /var/lib/apt/lists/* \ + && update-ca-certificates + +# Create non-root user for security +RUN groupadd --gid 1000 zclaw \ + && useradd --uid 1000 --gid zclaw --shell /bin/false zclaw + +WORKDIR /app + +# Copy binary from builder +COPY --from=builder /app/target/release/zclaw-saas /app/zclaw-saas + +# Copy configuration file +COPY saas-config.toml /app/saas-config.toml + +# Ensure the non-root user owns the application files +RUN chown -R zclaw:zclaw /app + +USER zclaw + +# Expose the SaaS API port +EXPOSE 8080 + +# Health check endpoint (matches the saas-config.toml port) +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD ["/app/zclaw-saas", "--healthcheck"] || exit 1 + +ENTRYPOINT ["/app/zclaw-saas"] diff --git a/Makefile b/Makefile index d03df20..8122786 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,9 @@ # ZCLAW Makefile # Cross-platform task runner -.PHONY: help start start-dev start-no-browser desktop desktop-build setup test clean +.PHONY: help start start-dev start-no-browser desktop desktop-build setup test clean \ + saas-build saas-run saas-test saas-test-integration saas-clippy saas-migrate \ + saas-docker-up saas-docker-down saas-docker-build help: ## Show this help message @echo "ZCLAW - AI Agent Desktop Client" @@ -71,3 +73,32 @@ clean-deep: clean ## Deep clean (including pnpm cache) @rm -rf desktop/pnpm-lock.yaml @rm -rf pnpm-lock.yaml @echo "Deep clean complete. Run 'pnpm install' to reinstall." + +# === SaaS Backend === + +saas-build: ## Build zclaw-saas crate + @cargo build -p zclaw-saas + +saas-run: ## Start SaaS backend (cargo run) + @cargo run -p zclaw-saas + +saas-test: ## Run SaaS unit tests + @cargo test -p zclaw-saas -- --test-threads=1 + +saas-test-integration: ## Run SaaS integration tests (requires PostgreSQL) + @cargo test -p zclaw-saas -- --ignored --test-threads=1 + +saas-clippy: ## Run clippy on zclaw-saas + @cargo clippy -p zclaw-saas -- -D warnings + +saas-migrate: ## Run database migrations + @cargo run -p zclaw-saas -- --migrate + +saas-docker-up: ## Start SaaS services (PostgreSQL + backend) + @docker compose up -d + +saas-docker-down: ## Stop SaaS services + @docker compose down + +saas-docker-build: ## Build SaaS Docker images + @docker compose build diff --git a/admin/.gitignore b/admin/.gitignore index 5b3ad33..5258b3b 100644 --- a/admin/.gitignore +++ b/admin/.gitignore @@ -1,2 +1,4 @@ .next/ node_modules/ +.env.local +.env*.local diff --git a/admin/next.config.js b/admin/next.config.js index 767719f..c6a7683 100644 --- a/admin/next.config.js +++ b/admin/next.config.js @@ -1,4 +1,44 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {} +const nextConfig = { + async headers() { + return [ + { + source: '/(.*)', + headers: [ + { + key: 'X-Frame-Options', + value: 'DENY', + }, + { + key: 'X-Content-Type-Options', + value: 'nosniff', + }, + { + key: 'Referrer-Policy', + value: 'strict-origin-when-cross-origin', + }, + { + key: 'Content-Security-Policy', + value: [ + "default-src 'self'", + "script-src 'self' 'unsafe-eval' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com", + "font-src 'self' https://fonts.gstatic.com", + "img-src 'self' data: blob:", + "connect-src 'self'", + "frame-ancestors 'none'", + "base-uri 'self'", + "form-action 'self'", + ].join('; '), + }, + { + key: 'Permissions-Policy', + value: 'camera=(), microphone=(), geolocation=()', + }, + ], + }, + ] + }, +} module.exports = nextConfig diff --git a/admin/package.json b/admin/package.json index c33c6ed..59b8191 100644 --- a/admin/package.json +++ b/admin/package.json @@ -11,10 +11,10 @@ "dependencies": { "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.7", - "@radix-ui/react-separator": "^1.1.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.484.0", @@ -22,6 +22,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "recharts": "^2.15.3", + "sonner": "^2.0.7", "tailwind-merge": "^3.0.2" }, "devDependencies": { diff --git a/admin/pnpm-lock.yaml b/admin/pnpm-lock.yaml index 2f8b4ef..356cbaa 100644 --- a/admin/pnpm-lock.yaml +++ b/admin/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: recharts: specifier: ^2.15.3 version: 2.15.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) tailwind-merge: specifier: ^3.0.2 version: 3.5.0 @@ -1063,6 +1066,12 @@ packages: scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2052,6 +2061,11 @@ snapshots: dependencies: loose-envify: 1.4.0 + sonner@2.0.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + source-map-js@1.2.1: {} streamsearch@1.1.0: {} diff --git a/admin/src/app/(dashboard)/accounts/page.tsx b/admin/src/app/(dashboard)/accounts/page.tsx index 91e1a01..8106a0d 100644 --- a/admin/src/app/(dashboard)/accounts/page.tsx +++ b/admin/src/app/(dashboard)/accounts/page.tsx @@ -68,6 +68,13 @@ export default function AccountsPage() { const [total, setTotal] = useState(0) const [page, setPage] = useState(1) const [search, setSearch] = useState('') + + // 搜索 debounce: 输入后 300ms 再触发请求 + const [debouncedSearchState, setDebouncedSearchState] = useState('') + useEffect(() => { + const timer = setTimeout(() => setDebouncedSearchState(search), 300) + return () => clearTimeout(timer) + }, [search]) const [roleFilter, setRoleFilter] = useState('all') const [statusFilter, setStatusFilter] = useState('all') const [loading, setLoading] = useState(true) @@ -87,7 +94,7 @@ export default function AccountsPage() { setError('') try { const params: Record = { page, page_size: PAGE_SIZE } - if (search.trim()) params.search = search.trim() + if (debouncedSearchState.trim()) params.search = debouncedSearchState.trim() if (roleFilter !== 'all') params.role = roleFilter if (statusFilter !== 'all') params.status = statusFilter @@ -103,7 +110,7 @@ export default function AccountsPage() { } finally { setLoading(false) } - }, [page, search, roleFilter, statusFilter]) + }, [page, debouncedSearchState, roleFilter, statusFilter]) useEffect(() => { fetchAccounts() diff --git a/admin/src/app/(dashboard)/config/page.tsx b/admin/src/app/(dashboard)/config/page.tsx index 204d257..0fc44e4 100644 --- a/admin/src/app/(dashboard)/config/page.tsx +++ b/admin/src/app/(dashboard)/config/page.tsx @@ -88,6 +88,19 @@ export default function ConfigPage() { async function handleSave() { if (!editTarget) return + // 表单验证 + if (editValue.trim() === '') { + setError('配置值不能为空') + return + } + if (editTarget.value_type === 'number' && isNaN(Number(editValue))) { + setError('请输入有效的数字') + return + } + if (editTarget.value_type === 'boolean' && editValue !== 'true' && editValue !== 'false') { + setError('布尔值只能为 true 或 false') + return + } setSaving(true) try { let parsedValue: string | number | boolean = editValue @@ -96,7 +109,7 @@ export default function ConfigPage() { } else if (editTarget.value_type === 'boolean') { parsedValue = editValue === 'true' } - await api.config.update(editTarget.id, { value: parsedValue }) + await api.config.update(editTarget.id, { current_value: parsedValue }) setEditTarget(null) fetchConfigs(activeTab) } catch (err) { diff --git a/admin/src/app/(dashboard)/devices/page.tsx b/admin/src/app/(dashboard)/devices/page.tsx new file mode 100644 index 0000000..9ab97a8 --- /dev/null +++ b/admin/src/app/(dashboard)/devices/page.tsx @@ -0,0 +1,125 @@ +'use client' + +import { useEffect, useState } from 'react' +import { Monitor, Loader2, RefreshCw } from 'lucide-react' +import { Badge } from '@/components/ui/badge' +import { + Table, TableBody, TableCell, TableHead, TableHeader, TableRow, +} from '@/components/ui/table' +import { api } from '@/lib/api-client' +import { ApiRequestError } from '@/lib/api-client' +import type { DeviceInfo } from '@/lib/types' + +function formatRelativeTime(dateStr: string): string { + const now = Date.now() + const then = new Date(dateStr).getTime() + const diffMs = now - then + const diffMin = Math.floor(diffMs / 60000) + const diffHour = Math.floor(diffMs / 3600000) + const diffDay = Math.floor(diffMs / 86400000) + + if (diffMin < 1) return '刚刚' + if (diffMin < 60) return `${diffMin} 分钟前` + if (diffHour < 24) return `${diffHour} 小时前` + return `${diffDay} 天前` +} + +function isOnline(lastSeen: string): boolean { + return Date.now() - new Date(lastSeen).getTime() < 5 * 60 * 1000 +} + +export default function DevicesPage() { + const [devices, setDevices] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState('') + + async function fetchDevices() { + setLoading(true) + setError('') + try { + const res = await api.devices.list() + setDevices(res) + } catch (err) { + if (err instanceof ApiRequestError) setError(err.body.message) + else setError('加载失败') + } finally { + setLoading(false) + } + } + + useEffect(() => { fetchDevices() }, []) + + return ( +
+
+

设备管理

+ +
+ + {error && ( +
+ {error} +
+ )} + + {loading && !devices.length ? ( +
+ +
+ ) : devices.length === 0 ? ( +
+ +

暂无已注册设备

+
+ ) : ( +
+ + + + 设备名称 + 平台 + 版本 + 状态 + 最后活跃 + 注册时间 + + + + {devices.map((d) => ( + + + {d.device_name || d.device_id} + + + {d.platform || 'unknown'} + + + {d.app_version || '-'} + + + + {isOnline(d.last_seen_at) ? '在线' : '离线'} + + + + {formatRelativeTime(d.last_seen_at)} + + + {new Date(d.created_at).toLocaleString('zh-CN')} + + + ))} + +
+
+ )} +
+ ) +} diff --git a/admin/src/app/(dashboard)/layout.tsx b/admin/src/app/(dashboard)/layout.tsx index 5c847c6..830f2b0 100644 --- a/admin/src/app/(dashboard)/layout.tsx +++ b/admin/src/app/(dashboard)/layout.tsx @@ -1,6 +1,6 @@ 'use client' -import { useState, type ReactNode } from 'react' +import { useState, useEffect, type ReactNode } from 'react' import Link from 'next/link' import { usePathname, useRouter } from 'next/navigation' import { @@ -17,46 +17,71 @@ import { ChevronLeft, Menu, Bell, + UserCog, + ShieldCheck, + Monitor, } from 'lucide-react' import { AuthGuard, useAuth } from '@/components/auth-guard' import { logout } from '@/lib/auth' import { cn } from '@/lib/utils' const navItems = [ - { href: '/', label: '仪表盘', icon: LayoutDashboard }, - { href: '/accounts', label: '账号管理', icon: Users }, - { href: '/providers', label: '服务商', icon: Server }, - { href: '/models', label: '模型管理', icon: Cpu }, - { href: '/api-keys', label: 'API 密钥', icon: Key }, - { href: '/usage', label: '用量统计', icon: BarChart3 }, - { href: '/relay', label: '中转任务', icon: ArrowLeftRight }, - { href: '/config', label: '系统配置', icon: Settings }, - { href: '/logs', label: '操作日志', icon: FileText }, + { href: '/', label: '仪表盘', icon: LayoutDashboard, permission: null }, + { href: '/accounts', label: '账号管理', icon: Users, permission: 'account:admin' }, + { href: '/providers', label: '服务商', icon: Server, permission: 'model:admin' }, + { href: '/models', label: '模型管理', icon: Cpu, permission: 'model:admin' }, + { href: '/api-keys', label: 'API 密钥', icon: Key, permission: null }, + { href: '/usage', label: '用量统计', icon: BarChart3, permission: null }, + { href: '/relay', label: '中转任务', icon: ArrowLeftRight, permission: 'relay:admin' }, + { href: '/config', label: '系统配置', icon: Settings, permission: 'admin:full' }, + { href: '/logs', label: '操作日志', icon: FileText, permission: 'admin:full' }, + { href: '/profile', label: '个人设置', icon: UserCog, permission: null }, + { href: '/security', label: '安全设置', icon: ShieldCheck, permission: null }, + { href: '/devices', label: '设备管理', icon: Monitor, permission: null }, ] function Sidebar({ collapsed, onToggle, + mobileOpen, + onMobileClose, }: { collapsed: boolean onToggle: () => void + mobileOpen: boolean + onMobileClose: () => void }) { const pathname = usePathname() const router = useRouter() const { account } = useAuth() + // 路由变化时关闭移动端菜单 + useEffect(() => { + onMobileClose() + }, [pathname, onMobileClose]) + function handleLogout() { logout() router.replace('/login') } return ( -