From 44256a511cdcc3b5026852d08c57b04c5ae12340 Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 31 Mar 2026 00:12:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BASaaS=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E5=8A=9F=E8=83=BD=E4=B8=8E=E5=AE=89=E5=85=A8=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit refactor: 重构数据库连接使用PostgreSQL替代SQLite feat(auth): 增加JWT验证的audience和issuer检查 feat(crypto): 添加AES-256-GCM字段加密支持 feat(api): 集成utoipa实现OpenAPI文档 fix(admin): 修复配置项表单验证逻辑 style: 统一代码格式与类型定义 docs: 更新技术栈文档说明PostgreSQL --- .../scripts/__pycache__/core.cpython-314.pyc | Bin 12619 -> 12606 bytes .../__pycache__/design_system.cpython-314.pyc | Bin 62939 -> 62926 bytes .dockerignore | 93 ++ Cargo.lock | 150 +- Cargo.toml | 2 +- Dockerfile | 83 + Makefile | 33 +- admin/.gitignore | 2 + admin/next.config.js | 42 +- admin/package.json | 3 +- admin/pnpm-lock.yaml | 14 + admin/src/app/(dashboard)/accounts/page.tsx | 11 +- admin/src/app/(dashboard)/config/page.tsx | 15 +- admin/src/app/(dashboard)/devices/page.tsx | 125 ++ admin/src/app/(dashboard)/layout.tsx | 133 +- admin/src/app/(dashboard)/models/page.tsx | 4 +- admin/src/app/(dashboard)/page.tsx | 22 +- admin/src/app/(dashboard)/profile/page.tsx | 154 ++ admin/src/app/(dashboard)/providers/page.tsx | 19 +- admin/src/app/(dashboard)/relay/page.tsx | 37 +- admin/src/app/(dashboard)/security/page.tsx | 203 +++ admin/src/app/(dashboard)/usage/page.tsx | 33 +- admin/src/app/layout.tsx | 2 + admin/src/app/login/page.tsx | 59 +- admin/src/components/auth-guard.tsx | 77 +- admin/src/lib/api-client.ts | 135 +- admin/src/lib/auth.ts | 177 ++- admin/src/lib/types.ts | 44 +- crates/zclaw-saas/Cargo.toml | 8 +- crates/zclaw-saas/src/account/handlers.rs | 142 +- crates/zclaw-saas/src/account/service.rs | 84 +- crates/zclaw-saas/src/account/types.rs | 56 +- crates/zclaw-saas/src/auth/handlers.rs | 168 +- crates/zclaw-saas/src/auth/jwt.rs | 13 +- crates/zclaw-saas/src/auth/mod.rs | 30 +- crates/zclaw-saas/src/auth/totp.rs | 92 +- crates/zclaw-saas/src/auth/types.rs | 11 +- crates/zclaw-saas/src/config.rs | 121 +- crates/zclaw-saas/src/crypto.rs | 277 ++++ crates/zclaw-saas/src/csrf.rs | 243 +++ crates/zclaw-saas/src/db.rs | 234 +-- crates/zclaw-saas/src/error.rs | 59 + crates/zclaw-saas/src/lib.rs | 3 + crates/zclaw-saas/src/main.rs | 93 +- crates/zclaw-saas/src/middleware.rs | 257 +++- crates/zclaw-saas/src/migration/handlers.rs | 24 +- crates/zclaw-saas/src/migration/service.rs | 241 ++- crates/zclaw-saas/src/migration/types.rs | 22 +- .../zclaw-saas/src/model_config/handlers.rs | 10 +- crates/zclaw-saas/src/model_config/service.rs | 269 +++- crates/zclaw-saas/src/model_config/types.rs | 45 +- crates/zclaw-saas/src/openapi.rs | 790 ++++++++++ crates/zclaw-saas/src/relay/handlers.rs | 229 ++- crates/zclaw-saas/src/relay/service.rs | 411 ++++- crates/zclaw-saas/src/relay/types.rs | 31 +- crates/zclaw-saas/src/state.rs | 11 +- crates/zclaw-saas/tests/integration_test.rs | 45 +- design-system/zclaw-admin/MASTER.md | 206 +++ .../components/SaaS/ConfigMigrationWizard.tsx | 8 +- desktop/src/components/SaaS/SaaSStatus.tsx | 2 +- desktop/src/components/Settings/ModelsAPI.tsx | 17 +- desktop/src/lib/api-key-storage.ts | 18 +- desktop/src/lib/gateway-client.ts | 9 +- desktop/src/lib/gateway-storage.ts | 36 +- desktop/src/lib/saas-client.ts | 237 ++- desktop/src/store/connectionStore.ts | 17 +- desktop/src/store/saasStore.ts | 85 +- desktop/src/types/config.ts | 32 + desktop/src/types/index.ts | 6 + docker-compose.yml | 76 + docs/deployment/saas-deployment.md | 193 +++ .../08-saas-platform/00-saas-overview.md | 4 +- .../specs/2026-03-27-saas-backend-design.md | 90 +- saas-config.toml | 10 +- saas-data.db-shm | Bin 0 -> 32768 bytes saas-data.db-wal | 0 saas-env.example | 37 + start-all.ps1 | 71 +- target/.future-incompat-report.json | 1 + target/.rustc_info.json | 2 +- target/.rustdoc_fingerprint.json | 1 + target/doc/.lock | 0 target/doc/crates.js | 2 + target/doc/help.html | 1 + target/doc/search.index/0b449907ad24.js | 1 + target/doc/search.index/116b0dd6356a.js | 1 + target/doc/search.index/19a76830551e.js | 1 + target/doc/search.index/1b2c2c8a42e3.js | 1 + target/doc/search.index/208c1ed46b11.js | 1 + target/doc/search.index/46fc818fb975.js | 1 + target/doc/search.index/64ed127e80c8.js | 1 + target/doc/search.index/7daee033b6b2.js | 1 + target/doc/search.index/alias/a762150c532c.js | 1 + .../search.index/crateNames/352366c807c4.js | 1 + target/doc/search.index/desc/2a3395df5205.js | 1 + target/doc/search.index/entry/4c1ae371e7a7.js | 1 + .../doc/search.index/function/1244c21298d2.js | 1 + .../generic_inverted_index/e8aeac81ba01.js | 1 + target/doc/search.index/name/cca90cb0da88.js | 1 + .../normalizedName/9b07dd5710b7.js | 1 + target/doc/search.index/path/c28d476a7d44.js | 1 + target/doc/search.index/root.js | 1 + target/doc/search.index/type/899b061fe283.js | 1 + target/doc/settings.html | 1 + target/doc/src-files.js | 2 + .../doc/src/totp_rs/custom_providers.rs.html | 63 + target/doc/src/totp_rs/lib.rs.html | 1369 +++++++++++++++++ target/doc/src/totp_rs/rfc.rs.html | 412 +++++ target/doc/src/totp_rs/secret.rs.html | 283 ++++ target/doc/src/totp_rs/url_error.rs.html | 248 +++ .../doc/static.files/COPYRIGHT-7fb11f4e.txt | 71 + .../FiraMono-Medium-86f75c8c.woff2 | Bin 0 -> 64572 bytes .../FiraMono-Regular-87c26294.woff2 | Bin 0 -> 64868 bytes .../FiraSans-Italic-81dc35de.woff2 | Bin 0 -> 136300 bytes .../FiraSans-LICENSE-05ab6dbd.txt | 98 ++ .../FiraSans-Medium-e1aa3f0a.woff2 | Bin 0 -> 132780 bytes .../FiraSans-MediumItalic-ccf7e434.woff2 | Bin 0 -> 140588 bytes .../FiraSans-Regular-0fe48ade.woff2 | Bin 0 -> 129188 bytes .../static.files/LICENSE-APACHE-a60eea81.txt | 201 +++ .../doc/static.files/LICENSE-MIT-23f18e03.txt | 23 + .../NanumBarunGothic-13b3dcba.ttf.woff2 | Bin 0 -> 399468 bytes .../NanumBarunGothic-LICENSE-a37d393b.txt | 103 ++ .../SourceCodePro-It-fc8b9304.ttf.woff2 | Bin 0 -> 44896 bytes .../SourceCodePro-LICENSE-67f54ca7.txt | 97 ++ .../SourceCodePro-Regular-8badfe75.ttf.woff2 | Bin 0 -> 52228 bytes .../SourceCodePro-Semibold-aa29a496.ttf.woff2 | Bin 0 -> 52348 bytes .../SourceSerif4-Bold-6d4fd4c0.ttf.woff2 | Bin 0 -> 81540 bytes .../SourceSerif4-It-ca3b17ed.ttf.woff2 | Bin 0 -> 59716 bytes .../SourceSerif4-LICENSE-a2cfd9d5.md | 98 ++ .../SourceSerif4-Regular-6b053e98.ttf.woff2 | Bin 0 -> 76260 bytes .../SourceSerif4-Semibold-457a13ac.ttf.woff2 | Bin 0 -> 80732 bytes target/doc/static.files/favicon-044be391.svg | 24 + .../static.files/favicon-32x32-eab170b8.png | Bin 0 -> 690 bytes target/doc/static.files/main-a410ff4d.js | 24 + .../doc/static.files/normalize-9960930a.css | 2 + target/doc/static.files/noscript-263c88ec.css | 1 + .../doc/static.files/rust-logo-9a9549ea.svg | 61 + target/doc/static.files/rustdoc-ca0dd0c4.css | 86 ++ .../static.files/scrape-examples-2bbcccac.js | 1 + target/doc/static.files/search-9e2438ea.js | 5 + target/doc/static.files/settings-c38705f0.js | 17 + .../doc/static.files/src-script-813739b1.js | 1 + target/doc/static.files/storage-e2aeef58.js | 27 + target/doc/static.files/stringdex-a3946164.js | 2 + target/doc/totp_rs/all.html | 1 + target/doc/totp_rs/enum.Algorithm.html | 26 + target/doc/totp_rs/enum.Rfc6238Error.html | 19 + target/doc/totp_rs/enum.Secret.html | 26 + target/doc/totp_rs/enum.SecretParseError.html | 19 + target/doc/totp_rs/enum.TotpUrlError.html | 41 + target/doc/totp_rs/index.html | 35 + target/doc/totp_rs/rfc/enum.Rfc6238Error.html | 11 + target/doc/totp_rs/rfc/struct.Rfc6238.html | 11 + target/doc/totp_rs/secret/enum.Secret.html | 11 + .../totp_rs/secret/enum.SecretParseError.html | 11 + target/doc/totp_rs/sidebar-items.js | 1 + target/doc/totp_rs/struct.Rfc6238.html | 26 + target/doc/totp_rs/struct.TOTP.html | 77 + .../totp_rs/url_error/enum.TotpUrlError.html | 11 + .../doc/trait.impl/core/clone/trait.Clone.js | 9 + target/doc/trait.impl/core/cmp/trait.Eq.js | 9 + .../trait.impl/core/cmp/trait.PartialEq.js | 9 + .../doc/trait.impl/core/convert/trait.From.js | 9 + .../trait.impl/core/convert/trait.TryFrom.js | 9 + .../trait.impl/core/default/trait.Default.js | 9 + .../doc/trait.impl/core/error/trait.Error.js | 9 + target/doc/trait.impl/core/fmt/trait.Debug.js | 9 + .../doc/trait.impl/core/fmt/trait.Display.js | 9 + .../doc/trait.impl/core/marker/trait.Copy.js | 9 + .../trait.impl/core/marker/trait.Freeze.js | 9 + .../doc/trait.impl/core/marker/trait.Send.js | 9 + .../core/marker/trait.StructuralPartialEq.js | 9 + .../doc/trait.impl/core/marker/trait.Sync.js | 9 + .../doc/trait.impl/core/marker/trait.Unpin.js | 9 + .../core/marker/trait.UnsafeUnpin.js | 9 + .../panic/unwind_safe/trait.RefUnwindSafe.js | 9 + .../panic/unwind_safe/trait.UnwindSafe.js | 9 + 177 files changed, 9731 insertions(+), 948 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 admin/src/app/(dashboard)/devices/page.tsx create mode 100644 admin/src/app/(dashboard)/profile/page.tsx create mode 100644 admin/src/app/(dashboard)/security/page.tsx create mode 100644 crates/zclaw-saas/src/crypto.rs create mode 100644 crates/zclaw-saas/src/csrf.rs create mode 100644 crates/zclaw-saas/src/openapi.rs create mode 100644 design-system/zclaw-admin/MASTER.md create mode 100644 docker-compose.yml create mode 100644 docs/deployment/saas-deployment.md create mode 100644 saas-data.db-shm create mode 100644 saas-data.db-wal create mode 100644 saas-env.example create mode 100644 target/.future-incompat-report.json create mode 100644 target/.rustdoc_fingerprint.json create mode 100644 target/doc/.lock create mode 100644 target/doc/crates.js create mode 100644 target/doc/help.html create mode 100644 target/doc/search.index/0b449907ad24.js create mode 100644 target/doc/search.index/116b0dd6356a.js create mode 100644 target/doc/search.index/19a76830551e.js create mode 100644 target/doc/search.index/1b2c2c8a42e3.js create mode 100644 target/doc/search.index/208c1ed46b11.js create mode 100644 target/doc/search.index/46fc818fb975.js create mode 100644 target/doc/search.index/64ed127e80c8.js create mode 100644 target/doc/search.index/7daee033b6b2.js create mode 100644 target/doc/search.index/alias/a762150c532c.js create mode 100644 target/doc/search.index/crateNames/352366c807c4.js create mode 100644 target/doc/search.index/desc/2a3395df5205.js create mode 100644 target/doc/search.index/entry/4c1ae371e7a7.js create mode 100644 target/doc/search.index/function/1244c21298d2.js create mode 100644 target/doc/search.index/generic_inverted_index/e8aeac81ba01.js create mode 100644 target/doc/search.index/name/cca90cb0da88.js create mode 100644 target/doc/search.index/normalizedName/9b07dd5710b7.js create mode 100644 target/doc/search.index/path/c28d476a7d44.js create mode 100644 target/doc/search.index/root.js create mode 100644 target/doc/search.index/type/899b061fe283.js create mode 100644 target/doc/settings.html create mode 100644 target/doc/src-files.js create mode 100644 target/doc/src/totp_rs/custom_providers.rs.html create mode 100644 target/doc/src/totp_rs/lib.rs.html create mode 100644 target/doc/src/totp_rs/rfc.rs.html create mode 100644 target/doc/src/totp_rs/secret.rs.html create mode 100644 target/doc/src/totp_rs/url_error.rs.html create mode 100644 target/doc/static.files/COPYRIGHT-7fb11f4e.txt create mode 100644 target/doc/static.files/FiraMono-Medium-86f75c8c.woff2 create mode 100644 target/doc/static.files/FiraMono-Regular-87c26294.woff2 create mode 100644 target/doc/static.files/FiraSans-Italic-81dc35de.woff2 create mode 100644 target/doc/static.files/FiraSans-LICENSE-05ab6dbd.txt create mode 100644 target/doc/static.files/FiraSans-Medium-e1aa3f0a.woff2 create mode 100644 target/doc/static.files/FiraSans-MediumItalic-ccf7e434.woff2 create mode 100644 target/doc/static.files/FiraSans-Regular-0fe48ade.woff2 create mode 100644 target/doc/static.files/LICENSE-APACHE-a60eea81.txt create mode 100644 target/doc/static.files/LICENSE-MIT-23f18e03.txt create mode 100644 target/doc/static.files/NanumBarunGothic-13b3dcba.ttf.woff2 create mode 100644 target/doc/static.files/NanumBarunGothic-LICENSE-a37d393b.txt create mode 100644 target/doc/static.files/SourceCodePro-It-fc8b9304.ttf.woff2 create mode 100644 target/doc/static.files/SourceCodePro-LICENSE-67f54ca7.txt create mode 100644 target/doc/static.files/SourceCodePro-Regular-8badfe75.ttf.woff2 create mode 100644 target/doc/static.files/SourceCodePro-Semibold-aa29a496.ttf.woff2 create mode 100644 target/doc/static.files/SourceSerif4-Bold-6d4fd4c0.ttf.woff2 create mode 100644 target/doc/static.files/SourceSerif4-It-ca3b17ed.ttf.woff2 create mode 100644 target/doc/static.files/SourceSerif4-LICENSE-a2cfd9d5.md create mode 100644 target/doc/static.files/SourceSerif4-Regular-6b053e98.ttf.woff2 create mode 100644 target/doc/static.files/SourceSerif4-Semibold-457a13ac.ttf.woff2 create mode 100644 target/doc/static.files/favicon-044be391.svg create mode 100644 target/doc/static.files/favicon-32x32-eab170b8.png create mode 100644 target/doc/static.files/main-a410ff4d.js create mode 100644 target/doc/static.files/normalize-9960930a.css create mode 100644 target/doc/static.files/noscript-263c88ec.css create mode 100644 target/doc/static.files/rust-logo-9a9549ea.svg create mode 100644 target/doc/static.files/rustdoc-ca0dd0c4.css create mode 100644 target/doc/static.files/scrape-examples-2bbcccac.js create mode 100644 target/doc/static.files/search-9e2438ea.js create mode 100644 target/doc/static.files/settings-c38705f0.js create mode 100644 target/doc/static.files/src-script-813739b1.js create mode 100644 target/doc/static.files/storage-e2aeef58.js create mode 100644 target/doc/static.files/stringdex-a3946164.js create mode 100644 target/doc/totp_rs/all.html create mode 100644 target/doc/totp_rs/enum.Algorithm.html create mode 100644 target/doc/totp_rs/enum.Rfc6238Error.html create mode 100644 target/doc/totp_rs/enum.Secret.html create mode 100644 target/doc/totp_rs/enum.SecretParseError.html create mode 100644 target/doc/totp_rs/enum.TotpUrlError.html create mode 100644 target/doc/totp_rs/index.html create mode 100644 target/doc/totp_rs/rfc/enum.Rfc6238Error.html create mode 100644 target/doc/totp_rs/rfc/struct.Rfc6238.html create mode 100644 target/doc/totp_rs/secret/enum.Secret.html create mode 100644 target/doc/totp_rs/secret/enum.SecretParseError.html create mode 100644 target/doc/totp_rs/sidebar-items.js create mode 100644 target/doc/totp_rs/struct.Rfc6238.html create mode 100644 target/doc/totp_rs/struct.TOTP.html create mode 100644 target/doc/totp_rs/url_error/enum.TotpUrlError.html create mode 100644 target/doc/trait.impl/core/clone/trait.Clone.js create mode 100644 target/doc/trait.impl/core/cmp/trait.Eq.js create mode 100644 target/doc/trait.impl/core/cmp/trait.PartialEq.js create mode 100644 target/doc/trait.impl/core/convert/trait.From.js create mode 100644 target/doc/trait.impl/core/convert/trait.TryFrom.js create mode 100644 target/doc/trait.impl/core/default/trait.Default.js create mode 100644 target/doc/trait.impl/core/error/trait.Error.js create mode 100644 target/doc/trait.impl/core/fmt/trait.Debug.js create mode 100644 target/doc/trait.impl/core/fmt/trait.Display.js create mode 100644 target/doc/trait.impl/core/marker/trait.Copy.js create mode 100644 target/doc/trait.impl/core/marker/trait.Freeze.js create mode 100644 target/doc/trait.impl/core/marker/trait.Send.js create mode 100644 target/doc/trait.impl/core/marker/trait.StructuralPartialEq.js create mode 100644 target/doc/trait.impl/core/marker/trait.Sync.js create mode 100644 target/doc/trait.impl/core/marker/trait.Unpin.js create mode 100644 target/doc/trait.impl/core/marker/trait.UnsafeUnpin.js create mode 100644 target/doc/trait.impl/core/panic/unwind_safe/trait.RefUnwindSafe.js create mode 100644 target/doc/trait.impl/core/panic/unwind_safe/trait.UnwindSafe.js 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 4a96c66828576d02476df35f396fe50b4e1acb9e..73acfc9b8f3a88e78118d92b9fbfcf5f6bcd663f 100644 GIT binary patch delta 116 zcmX?|v@eNIn~#@^0SKx&j%9w(*vKcr9v~Uyz!YmYA0wqnDhMSelX= zQ=VUxT~d^qS{zfHm{_cvl$e~InwJ8XF3!%($tjL0&D1Te&@Cv+*Ue3=h$&7k$}A`; Lj@jJBeozwtEyF6m delta 129 zcmdm&bUKMon~#@^0SMmN6lH!^-^eGyo|2^>TAW%`tY4OyT2fM!m{+0ilAm0fo0?Zr zte>2pl9`vTpO;!uqMMUimYSoRRGOKSl3Ju+nyFh_p<7UtubZ1#pb8sOC79*|}~bUjbWqn!8m@lygpEd3=6BYF=7mUV4mPa!z7t zN@`4beo=NwQEF;&OmSjjv2Idga&~H73S7E4J2NMzIHojHx3ogHpeSEAH?bn7IJqdZ Nprklv^CPyO?*Q+tEy@4@ delta 158 zcmY++K?=e!5I|9(P(-j7kvJD{=@r}>YBH3;Gz~LJNmt&-op=uqP{9inibt?p|MD&V zYbxGTQSGYjX06I!nF*k2xxbJvwzs 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 ( -