Compare commits

...

24 Commits

Author SHA1 Message Date
dependabot[bot]
9e61b687cf chore(deps): bump jsdom from 26.1.0 to 30.0.1 in /web
Bumps [jsdom](https://github.com/jsdom/jsdom) from 26.1.0 to 30.0.1.
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v26.1.0...v30.0.1)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 30.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-23 19:25:27 +00:00
Wu Qing
95b27af600 Merge pull request #129 from Awuqing/dependabot/npm_and_yarn/web/react-router-dom-7.0.0
chore(deps): bump react-router-dom from 6.30.4 to 7.0.0 in /web
2026-08-17 18:03:34 +08:00
Wu Qing
254b013631 Merge pull request #122 from Awuqing/dependabot/npm_and_yarn/web/vitest-4.1.10
chore(deps): bump vitest from 3.2.7 to 4.1.10 in /web
2026-08-17 18:02:52 +08:00
Wu Qing
bcf921a590 Merge pull request #128 from Awuqing/dependabot/go_modules/server/golang.org/x/crypto-0.55.0
chore(deps): bump golang.org/x/crypto from 0.54.0 to 0.55.0 in /server
2026-08-17 18:02:49 +08:00
dependabot[bot]
a4e2ad2de0 chore(deps): bump vitest from 3.2.7 to 4.1.10 in /web
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 3.2.7 to 4.1.10.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.10/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.10
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 09:58:30 +00:00
dependabot[bot]
b7e4e67c2a chore(deps): bump react-router-dom from 6.30.4 to 7.0.0 in /web
Bumps [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) from 6.30.4 to 7.0.0.
- [Release notes](https://github.com/remix-run/react-router/releases)
- [Changelog](https://github.com/remix-run/react-router/blob/react-router-dom@7.0.0/packages/react-router-dom/CHANGELOG.md)
- [Commits](https://github.com/remix-run/react-router/commits/react-router-dom@7.0.0/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router-dom
  dependency-version: 7.0.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 09:58:18 +00:00
dependabot[bot]
b6d86caccf chore(deps): bump golang.org/x/crypto from 0.54.0 to 0.55.0 in /server
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.54.0 to 0.55.0.
- [Commits](https://github.com/golang/crypto/compare/v0.54.0...v0.55.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.55.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 09:58:18 +00:00
Wu Qing
6b5b9fcc2e Merge pull request #127 from Awuqing/dependabot/go_modules/server/go-minor-and-patch-9b498880f8
chore(deps): bump the go-minor-and-patch group across 1 directory with 12 updates
2026-08-17 17:56:13 +08:00
Wu Qing
83fe0b0c7b Merge pull request #110 from Awuqing/dependabot/npm_and_yarn/web/web-minor-and-patch-1ef1c8bb9f
chore(deps): bump the web-minor-and-patch group across 1 directory with 3 updates
2026-08-17 17:56:10 +08:00
Wu Qing
dfd349de6a Merge pull request #111 from Awuqing/dependabot/npm_and_yarn/docs-site/docs-minor-and-patch-afc90954ce
chore(deps): bump the docs-minor-and-patch group across 1 directory with 3 updates
2026-08-17 17:56:06 +08:00
Wu Qing
a65c643542 Merge pull request #109 from Awuqing/dependabot/docker/container-images-d0276e9342
chore(deps): bump the container-images group across 1 directory with 3 updates
2026-08-17 17:56:04 +08:00
Wu Qing
b13c3dc068 Merge pull request #115 from Awuqing/dependabot/github_actions/github-actions-bed44877b7
chore(deps): bump the github-actions group across 1 directory with 12 updates
2026-08-17 17:56:01 +08:00
dependabot[bot]
921ba24a94 chore(deps): bump the container-images group across 1 directory with 3 updates
Bumps the container-images group with 3 updates in the / directory: node, golang and alpine.


Updates `node` from 24-alpine to 26-alpine

Updates `golang` from 1.25-alpine to 1.26-alpine

Updates `alpine` from 3.21 to 3.24

---
updated-dependencies:
- dependency-name: alpine
  dependency-version: '3.24'
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: container-images
- dependency-name: golang
  dependency-version: 1.26-alpine
  dependency-type: direct:production
  dependency-group: container-images
- dependency-name: node
  dependency-version: 26-alpine
  dependency-type: direct:production
  dependency-group: container-images
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-16 19:43:00 +00:00
dependabot[bot]
30548a552b chore(deps): bump the docs-minor-and-patch group across 1 directory with 3 updates
Bumps the docs-minor-and-patch group with 3 updates in the /docs-site directory: [react](https://github.com/react/react/tree/HEAD/packages/react), [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react) and [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom).


Updates `react` from 19.2.5 to 19.2.8
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.2.8/packages/react)

Updates `@types/react` from 19.2.14 to 19.2.18
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `react-dom` from 19.2.5 to 19.2.8
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.2.8/packages/react-dom)

Updates `@types/react` from 19.2.14 to 19.2.18
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

---
updated-dependencies:
- dependency-name: "@types/react"
  dependency-version: 19.2.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: docs-minor-and-patch
- dependency-name: "@types/react"
  dependency-version: 19.2.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: docs-minor-and-patch
- dependency-name: react
  dependency-version: 19.2.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: docs-minor-and-patch
- dependency-name: react-dom
  dependency-version: 19.2.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: docs-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-16 19:33:35 +00:00
dependabot[bot]
6915a29b09 chore(deps): bump the web-minor-and-patch group across 1 directory with 3 updates
Bumps the web-minor-and-patch group with 3 updates in the /web directory: [@arco-design/web-react](https://github.com/arco-design/arco-design), [zustand](https://github.com/pmndrs/zustand) and [@testing-library/user-event](https://github.com/testing-library/user-event).


Updates `@arco-design/web-react` from 2.66.11 to 2.66.16
- [Release notes](https://github.com/arco-design/arco-design/releases)
- [Commits](https://github.com/arco-design/arco-design/compare/2.66.11...2.66.16)

Updates `zustand` from 5.0.11 to 5.0.15
- [Release notes](https://github.com/pmndrs/zustand/releases)
- [Commits](https://github.com/pmndrs/zustand/compare/v5.0.11...v5.0.15)

Updates `@testing-library/user-event` from 14.6.1 to 14.6.4
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.1...v14.6.4)

---
updated-dependencies:
- dependency-name: "@arco-design/web-react"
  dependency-version: 2.66.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-minor-and-patch
- dependency-name: "@testing-library/user-event"
  dependency-version: 14.6.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: web-minor-and-patch
- dependency-name: zustand
  dependency-version: 5.0.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: web-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-16 19:23:35 +00:00
dependabot[bot]
16d6eedc3c chore(deps): bump the github-actions group across 1 directory with 12 updates
Bumps the github-actions group with 12 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `4` | `7` |
| [actions/setup-go](https://github.com/actions/setup-go) | `5` | `7` |
| [actions/setup-node](https://github.com/actions/setup-node) | `4` | `7` |
| [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) | `3` | `5` |
| [actions/deploy-pages](https://github.com/actions/deploy-pages) | `4` | `5` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `4` | `7` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `4` | `8` |
| [softprops/action-gh-release](https://github.com/softprops/action-gh-release) | `2` | `3` |
| [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) | `3` | `4` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3` | `4` |
| [docker/login-action](https://github.com/docker/login-action) | `3` | `4` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `6` | `7` |



Updates `actions/checkout` from 4 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

Updates `actions/setup-go` from 5 to 7
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v5...v7)

Updates `actions/setup-node` from 4 to 7
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v7)

Updates `actions/upload-pages-artifact` from 3 to 5
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v5)

Updates `actions/deploy-pages` from 4 to 5
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/v4...v5)

Updates `actions/upload-artifact` from 4 to 7
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

Updates `actions/download-artifact` from 4 to 8
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

Updates `softprops/action-gh-release` from 2 to 3
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

Updates `docker/setup-qemu-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v3...v4)

Updates `docker/setup-buildx-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

Updates `docker/login-action` from 3 to 4
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

Updates `docker/build-push-action` from 6 to 7
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/deploy-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/setup-go
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/upload-pages-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: docker/setup-qemu-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-16 19:06:38 +00:00
dependabot[bot]
219282ab05 chore(deps): bump the go-minor-and-patch group across 1 directory with 12 updates
Bumps the go-minor-and-patch group with 8 updates in the /server directory:

| Package | From | To |
| --- | --- | --- |
| [github.com/gin-gonic/gin](https://github.com/gin-gonic/gin) | `1.10.1` | `1.12.0` |
| [github.com/golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt) | `5.3.0` | `5.3.1` |
| [github.com/klauspost/compress](https://github.com/klauspost/compress) | `1.18.1` | `1.19.2` |
| [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) | `1.23.2` | `1.24.1` |
| [github.com/rclone/rclone](https://github.com/rclone/rclone) | `1.73.5` | `1.75.0` |
| [github.com/spf13/viper](https://github.com/spf13/viper) | `1.20.0` | `1.21.0` |
| [go.uber.org/zap](https://github.com/uber-go/zap) | `1.27.0` | `1.28.0` |
| [gorm.io/gorm](https://github.com/go-gorm/gorm) | `1.25.12` | `1.31.2` |



Updates `github.com/gin-gonic/gin` from 1.10.1 to 1.12.0
- [Release notes](https://github.com/gin-gonic/gin/releases)
- [Changelog](https://github.com/gin-gonic/gin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gin-gonic/gin/compare/v1.10.1...v1.12.0)

Updates `github.com/golang-jwt/jwt/v5` from 5.3.0 to 5.3.1
- [Release notes](https://github.com/golang-jwt/jwt/releases)
- [Commits](https://github.com/golang-jwt/jwt/compare/v5.3.0...v5.3.1)

Updates `github.com/klauspost/compress` from 1.18.1 to 1.19.2
- [Release notes](https://github.com/klauspost/compress/releases)
- [Commits](https://github.com/klauspost/compress/compare/v1.18.1...v1.19.2)

Updates `github.com/prometheus/client_golang` from 1.23.2 to 1.24.1
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/v1.24.1/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.23.2...v1.24.1)

Updates `github.com/rclone/rclone` from 1.73.5 to 1.75.0
- [Release notes](https://github.com/rclone/rclone/releases)
- [Changelog](https://github.com/rclone/rclone/blob/master/RELEASE.md)
- [Commits](https://github.com/rclone/rclone/compare/v1.73.5...v1.75.0)

Updates `github.com/shirou/gopsutil/v4` from 4.25.10 to 4.26.6
- [Release notes](https://github.com/shirou/gopsutil/releases)
- [Commits](https://github.com/shirou/gopsutil/compare/v4.25.10...v4.26.6)

Updates `github.com/spf13/viper` from 1.20.0 to 1.21.0
- [Release notes](https://github.com/spf13/viper/releases)
- [Commits](https://github.com/spf13/viper/compare/v1.20.0...v1.21.0)

Updates `go.uber.org/zap` from 1.27.0 to 1.28.0
- [Release notes](https://github.com/uber-go/zap/releases)
- [Changelog](https://github.com/uber-go/zap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/uber-go/zap/compare/v1.27.0...v1.28.0)

Updates `golang.org/x/crypto` from 0.50.0 to 0.54.0
- [Commits](https://github.com/golang/crypto/compare/v0.50.0...v0.54.0)

Updates `golang.org/x/oauth2` from 0.34.0 to 0.36.0
- [Commits](https://github.com/golang/oauth2/compare/v0.34.0...v0.36.0)

Updates `google.golang.org/api` from 0.255.0 to 0.279.0
- [Release notes](https://github.com/googleapis/google-api-go-client/releases)
- [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md)
- [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.255.0...v0.279.0)

Updates `gorm.io/gorm` from 1.25.12 to 1.31.2
- [Release notes](https://github.com/go-gorm/gorm/releases)
- [Commits](https://github.com/go-gorm/gorm/compare/v1.25.12...v1.31.2)

---
updated-dependencies:
- dependency-name: github.com/gin-gonic/gin
  dependency-version: 1.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: github.com/golang-jwt/jwt/v5
  dependency-version: 5.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-minor-and-patch
- dependency-name: github.com/klauspost/compress
  dependency-version: 1.19.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: github.com/prometheus/client_golang
  dependency-version: 1.24.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: github.com/rclone/rclone
  dependency-version: 1.75.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: github.com/shirou/gopsutil/v4
  dependency-version: 4.26.6
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: github.com/spf13/viper
  dependency-version: 1.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: go.uber.org/zap
  dependency-version: 1.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: golang.org/x/crypto
  dependency-version: 0.54.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: golang.org/x/oauth2
  dependency-version: 0.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: google.golang.org/api
  dependency-version: 0.279.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
- dependency-name: gorm.io/gorm
  dependency-version: 1.31.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 08:09:13 +00:00
Wu Qing
74ee1f3694 Merge pull request #126 from Awuqing/feature/demo-showcase
Improve local demo and refresh documentation images
2026-08-11 16:05:58 +08:00
Awuqing
d68d336668 fix: preserve successful storage target updates 2026-08-11 16:00:58 +08:00
Awuqing
2e20c511b9 Merge main into feature/demo-showcase 2026-08-11 15:46:58 +08:00
Awuqing
3a181c7416 feat: improve local demo and documentation 2026-08-11 13:34:42 +08:00
Wu Qing
0aa3cc92df feat(admin): 优化访问管理布局与操作流程 (#125)
统一用户账号与 API Key 管理入口,增强筛选、状态反馈、审计联动及危险操作保护,并补充组件化布局与回归测试。
2026-08-11 11:42:55 +08:00
Wu Qing
0e615677db feat(docs): 升级文档站界面与导航 (#124)
重构文档站首页、导航与内容布局,统一 SVG 图标和双语文案,并完善响应式、深色模式与可访问性。
2026-08-09 22:32:27 +08:00
Wu Qing
9080a47703 chore(repo): 完善仓库维护与自动化 (#108)
统一 Node.js 24 LTS、CI、文档和发布工作流配置。

完善仓库维护规范、Dependabot 与贡献文档,清理生成产物并统一既有 Go 代码格式。
2026-08-09 21:18:19 +08:00
89 changed files with 4847 additions and 3798 deletions

25
.editorconfig Normal file
View File

@@ -0,0 +1,25 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{go,mod,sum}]
indent_style = tab
indent_size = 4
[Makefile]
indent_style = tab
[*.{js,jsx,ts,tsx,json,yml,yaml,css,scss,html,md,mdx}]
indent_style = space
indent_size = 2
[*.sh]
indent_style = space
indent_size = 4
[*.md]
trim_trailing_whitespace = false

15
.gitattributes vendored Normal file
View File

@@ -0,0 +1,15 @@
* text=auto eol=lf
*.bat text eol=crlf
*.cmd text eol=crlf
*.gif binary
*.ico binary
*.jpg binary
*.jpeg binary
*.png binary
*.webp binary
*.woff binary
*.woff2 binary
*.gz binary
*.zip binary

View File

@@ -1,14 +1,14 @@
---
name: "\U0001F41B Bug 报告"
about: "报告一个 Bug帮助我们改进 BackupX"
name: "Bug 报告"
about: "提交可复现的缺陷报告"
title: "[Bug] "
labels: ["bug"]
assignees: []
---
## 描述
## 问题描述
<!-- 清晰简洁地描述这个 Bug -->
<!-- 清晰说明发生了什么,以及它为什么是缺陷。 -->
## 复现步骤
@@ -18,26 +18,39 @@ assignees: []
## 期望行为
<!-- 描述你期望发生什么 -->
<!-- 描述正确结果。 -->
## 实际行为
<!-- 描述实际发生了什么 -->
<!-- 描述实际结果,包括稳定复现还是偶发。 -->
## 环境信息
- **OS**: <!-- 例如 Ubuntu 22.04 -->
- **Go 版本**: <!-- 例如 1.21 -->
- **Node.js 版本**: <!-- 例如 18.17 -->
- **浏览器**: <!-- 例如 Chrome 120 -->
- **BackupX 版本**: <!-- 例如 v1.0.0 -->
- BackupX 版本或提交:
- 部署方式Docker、裸机、集群
- 操作系统与架构:
- 数据库或备份类型:
- 浏览器版本(仅界面问题):
- 是否使用代理、私有 CA 或堡垒机:
## 最小配置
<!-- 仅保留复现所需字段。删除密码、Token、域名、IP 和存储凭据。 -->
```yaml
```
## 相关日志
```
<!-- 粘贴相关日志输出 -->
<!-- 附问题发生前后的最小日志片段,并先删除所有敏感信息。 -->
```text
```
## 截图
## 补充信息
<!-- 如有截图请附上 -->
<!-- 可附截图、相关 Issue、临时缓解方式或其他上下文。 -->
请勿提交数据库文件、完整配置、备份数据、API Key、Agent Token、安装命令或未脱敏日志。安全漏洞请使用私密漏洞报告入口。

View File

@@ -1,5 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: 💬 讨论区 / Discussions
url: https://github.com/Awuqing/GoogleDriverBackupEveryDay/discussions
about: 提问、讨论功能想法或分享使用经验
- name: 安全漏洞 / Security vulnerability
url: https://github.com/Awuqing/BackupX/security/advisories/new
about: 请通过私密渠道报告安全问题,不要创建公开 Issue。

View File

@@ -1,23 +1,36 @@
---
name: "\U0001F680 功能请求"
about: "建议一个新功能或改进"
name: "功能请求"
about: "建议一个可落地的新功能或改进"
title: "[Feature] "
labels: ["enhancement"]
assignees: []
---
## 需求描述
<!-- 清晰简洁地描述你希望的功能 -->
## 使用场景
<!-- 描述在什么场景下需要这个功能 -->
<!-- 描述在什么环境中遇到了什么问题。 -->
## 建议的解决方案
## 期望结果
<!-- 描述你认为应该如何实现(可选) -->
<!-- 说明用户最终需要完成的任务,不要只描述界面元素。 -->
## 建议方案
<!-- 可选:描述 API、配置、工作流或界面交互。 -->
## 替代方案
<!-- 说明当前绕过方式,以及它为什么不足。 -->
## 兼容性与风险
<!-- 是否影响现有配置、数据格式、部署方式、权限或集群节点。 -->
## 验收标准
- [ ]
- [ ]
## 补充信息
<!-- 任何其他相关信息、截图或上下文 -->
<!-- 可附相关文档、日志、截图或同类实现。不要附敏感信息。 -->

View File

@@ -1,29 +1,48 @@
## Pull Request
## 变更说明 / Summary
### 变更类型 / Type of Change
<!-- 说明做了什么、为什么需要,以及对用户或运维人员的影响。 -->
- [ ] 🐛 Bug 修复 (非破坏性变更)
- [ ] ✨ 新功能 (非破坏性变更)
- [ ] 💥 破坏性变更 (修复或功能导致现有功能变更)
- [ ] 📝 文档更新
- [ ] ♻️ 代码重构 (不影响功能)
- [ ] ⚡ 性能优化
## 变更类型 / Type of Change
### 描述 / Description
- [ ] 缺陷修复
- [ ] 新功能
- [ ] 破坏性变更
- [ ] 性能优化
- [ ] 重构或工程化
- [ ] 文档更新
<!-- 描述你做了什么更改以及为什么 -->
## 相关 Issue / Related Issue
### 相关 Issue / Related Issue
<!-- 例如Closes #123。没有关联 Issue 时写“无”。 -->
<!-- 关联的 Issue 编号,如 Fixes #123 -->
## 验证 / Validation
### 测试 / Testing
<!-- 只勾选实际执行过的项目,并在下方补充无法执行的原因。 -->
- [ ] 后端测试通过 (`go test ./...`)
- [ ] 前端测试通过 (`npm run test`)
- [ ] 前端构建成功 (`npm run build`)
- [ ] 已在本地环境手动测试
- [ ] 后端格式、静态检查与测试通过
- [ ] 前端测试与生产构建通过
- [ ] 文档类型检查与中英文生产构建通过
- [ ] 配置、迁移或部署兼容性已验证
- [ ] 已完成与变更范围相符的手动验证
### 截图 / Screenshots
验证命令与结果:
<!-- 如有 UI 变更请附上截图 -->
```text
```
## 风险与回滚 / Risk and Rollback
<!-- 说明兼容性风险、数据迁移、配置变化和回滚方法。无风险时写“无”。 -->
## 界面变更 / UI Changes
<!-- 有界面变更时附前后截图;无界面变更时写“无”。 -->
## 提交前检查 / Checklist
- [ ] 提交信息符合 Conventional Commits
- [ ] 未提交密钥、Token、真实配置、数据库或日志
- [ ] 新行为已补充测试和中英文文档
- [ ] 前端未使用 Emoji、渐变、阴影、独立字体或超过 4px 的圆角
- [ ] 没有夹带与本 PR 无关的格式化或重构

50
.github/SECURITY.md vendored
View File

@@ -1,34 +1,40 @@
# 安全漏洞披露政策 / Security Policy
# 安全策 / Security Policy
## 支持的版本 / Supported Versions
## 支持范围 / Supported Versions
| Version | Supported |
|---------|--------------------|
| latest | ✅ |
| < latest | ❌ |
安全修复面向最新稳定版本和 `main` 分支。旧版本不会单独维护安全补丁;升级前请先阅读对应 Release Notes 和升级恢复文档。
## 报告安全漏洞 / Reporting a Vulnerability
Security fixes target the latest stable release and the `main` branch. Older versions do not receive separate security patches. Review the release notes and upgrade documentation before updating.
如果您发现了安全漏洞,**请不要通过公开 Issue 报告**。
| Version | Status |
|---------|--------|
| Latest stable release | Supported |
| `main` | Development support |
| Older releases | Unsupported |
请发送邮件至项目维护者,包含以下信息:
## 报告漏洞 / Reporting a Vulnerability
1. 漏洞描述
2. 复现步骤
3. 受影响的版本
4. 可能的影响范围
请勿通过公开 Issue、Discussion 或 Pull Request 披露安全漏洞。使用 GitHub 的[私密漏洞报告入口](https://github.com/Awuqing/BackupX/security/advisories/new)提交报告。
我们会在 48 小时内确认收到并开始处理。
Do not disclose vulnerabilities in a public Issue, Discussion, or Pull Request. Submit the report through GitHub's [private vulnerability reporting form](https://github.com/Awuqing/BackupX/security/advisories/new).
---
报告应包含:
If you discover a security vulnerability, **please do NOT open a public issue**.
1. 受影响版本或提交;
2. 漏洞描述、攻击前提和影响范围;
3. 最小复现步骤或验证代码;
4. 已知缓解措施;
5. 希望使用的署名信息。
Instead, email the project maintainer with the following details:
不要上传真实密钥、Token、备份数据、数据库或包含客户信息的日志。必要时请先脱敏并使用最小化测试数据。
1. Description of the vulnerability
2. Steps to reproduce
3. Affected versions
4. Potential impact
Do not upload real credentials, tokens, backup data, databases, or logs containing customer information. Redact sensitive values and use minimal test data.
We will acknowledge receipt within 48 hours and begin working on a fix.
## 处理流程 / Response Process
- 维护者会尽快确认报告并进行初步分级;
- 修复期间请保持细节私密,避免影响仍未升级的部署;
- 修复发布后会在安全公告或 Release Notes 中说明受影响范围、缓解措施和升级版本;
- 披露时间由报告者与维护者协调确定。
The maintainer will acknowledge and triage the report as soon as practical. Details should remain private until a fix and coordinated disclosure are ready.

86
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,86 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
day: monday
time: "03:00"
timezone: Asia/Shanghai
open-pull-requests-limit: 5
commit-message:
prefix: chore(deps)
groups:
github-actions:
patterns:
- "*"
- package-ecosystem: gomod
directory: /server
schedule:
interval: weekly
day: monday
time: "03:10"
timezone: Asia/Shanghai
open-pull-requests-limit: 5
commit-message:
prefix: chore(deps)
groups:
go-minor-and-patch:
patterns:
- "*"
update-types:
- minor
- patch
- package-ecosystem: npm
directory: /web
schedule:
interval: weekly
day: monday
time: "03:20"
timezone: Asia/Shanghai
open-pull-requests-limit: 5
commit-message:
prefix: chore(deps)
groups:
web-minor-and-patch:
patterns:
- "*"
update-types:
- minor
- patch
- package-ecosystem: npm
directory: /docs-site
schedule:
interval: weekly
day: monday
time: "03:30"
timezone: Asia/Shanghai
open-pull-requests-limit: 5
commit-message:
prefix: chore(deps)
groups:
docs-minor-and-patch:
patterns:
- "*"
update-types:
- minor
- patch
- package-ecosystem: docker
directory: /
schedule:
interval: weekly
day: monday
time: "03:40"
timezone: Asia/Shanghai
open-pull-requests-limit: 5
commit-message:
prefix: chore(deps)
groups:
container-images:
patterns:
- "*"

View File

@@ -2,27 +2,50 @@ name: CI
on:
push:
branches: [main, master]
branches: [main]
pull_request:
branches: [main, master]
branches: [main]
workflow_dispatch:
# 最小权限:构建/测试仅需读取仓库内容,显式声明以收敛默认的可写令牌。
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
backend:
name: Go Build & Test
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v7
with:
go-version: '1.25'
cache-dependency-path: server/go.sum
- name: Verify modules
working-directory: server
run: go mod verify
- name: Check formatting
working-directory: server
run: |
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
printf '%s\n' "$unformatted"
exit 1
fi
- name: Vet
working-directory: server
run: go vet ./...
- name: Build
working-directory: server
run: go build ./...
@@ -34,13 +57,14 @@ jobs:
frontend:
name: React Build & Test
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '20'
node-version: '24'
cache: 'npm'
cache-dependency-path: web/package-lock.json
@@ -48,14 +72,10 @@ jobs:
working-directory: web
run: npm ci
- name: Type Check
working-directory: web
run: npx tsc --noEmit -p tsconfig.json
- name: Test
working-directory: web
run: npm run test
- name: Build
- name: Type Check & Build
working-directory: web
run: npm run build

View File

@@ -1,9 +1,16 @@
name: Deploy Docs
# 触发条件:
# - 推送 main 时,如果 docs-site/ 或站点相关 README 有变化
# - PR 修改 docs-site/ 时执行类型检查和构建
# - 推送 main 时构建并部署 GitHub Pages
# - 手动触发(在 Actions 页面)
on:
pull_request:
branches:
- main
paths:
- 'docs-site/**'
- '.github/workflows/docs.yml'
push:
branches:
- main
@@ -12,52 +19,62 @@ on:
- '.github/workflows/docs.yml'
workflow_dispatch:
# 允许写入 Pages用于发布到 github.com/Awuqing/BackupX 的 Pages 站点
# 默认只读Pages 写权限仅授予部署任务。
permissions:
contents: read
pages: write
id-token: write
# 同时只保留一个部署任务
# 同一分支只保留最新一次构建,避免旧提交覆盖新结果。
concurrency:
group: pages-${{ github.ref }}
group: docs-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build Docs
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: docs-site
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '20'
node-version: '24'
cache: 'npm'
cache-dependency-path: docs-site/package-lock.json
- name: Install dependencies
run: npm ci
- name: Type check
run: npm run typecheck
- name: Build site
run: npm run build
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
if: github.event_name != 'pull_request'
uses: actions/upload-pages-artifact@v5
with:
path: docs-site/build
deploy:
name: Deploy Docs
if: github.event_name != 'pull_request'
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5

View File

@@ -27,25 +27,85 @@ on:
type: string
permissions:
contents: write
packages: write
contents: read
# 统一版本号tag 推送取 ref_name手动触发取 inputs.version
env:
VERSION: ${{ github.event.inputs.version || github.ref_name }}
concurrency:
group: release-${{ github.ref }}-${{ github.event.inputs.version || 'tag' }}
cancel-in-progress: false
jobs:
# ─── Job 0: 校验版本与测试 ───
verify:
name: Validate Release
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v7
- name: Validate release input
shell: bash
env:
RELEASE_VERSION: ${{ env.VERSION }}
RELEASE_EVENT: ${{ github.event_name }}
RELEASE_REF: ${{ github.ref }}
run: |
if [[ ! "$RELEASE_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?$ ]]; then
echo "Version must be a SemVer tag such as v1.2.3 or v1.2.3-rc.1"
exit 1
fi
if [[ "$RELEASE_EVENT" == "workflow_dispatch" && "$RELEASE_REF" != "refs/heads/main" ]]; then
echo "Manual releases must run from the main branch"
exit 1
fi
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: '1.25'
cache-dependency-path: server/go.sum
- name: Verify backend
working-directory: server
run: |
go mod verify
unformatted="$(gofmt -l .)"
if [ -n "$unformatted" ]; then
printf '%s\n' "$unformatted"
exit 1
fi
go vet ./...
go test ./...
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: web/package-lock.json
- name: Verify frontend
working-directory: web
run: |
npm ci
npm run test
# ─── Job 1: 构建前端 ───
build-web:
name: Build Frontend
needs: verify
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v7
with:
node-version: '20'
node-version: '24'
cache: 'npm'
cache-dependency-path: web/package-lock.json
@@ -56,7 +116,7 @@ jobs:
npm run build
- name: Upload frontend artifact
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: web-dist
path: web/dist
@@ -67,6 +127,9 @@ jobs:
name: Build ${{ matrix.goarch }}
needs: build-web
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write
strategy:
matrix:
include:
@@ -75,16 +138,16 @@ jobs:
- goos: linux
goarch: arm64
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v7
with:
go-version: '1.25'
cache-dependency-path: server/go.sum
- name: Download frontend artifact
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: web-dist
path: web/dist
@@ -98,13 +161,13 @@ jobs:
run: |
go build \
-trimpath \
-ldflags "-s -w -X main.version=${{ env.VERSION }}" \
-ldflags "-s -w -X main.version=${VERSION}" \
-o ../backupx \
./cmd/backupx
- name: Package release
run: |
ARCHIVE_NAME="backupx-${{ env.VERSION }}-${{ matrix.goos }}-${{ matrix.goarch }}"
ARCHIVE_NAME="backupx-${VERSION}-${{ matrix.goos }}-${{ matrix.goarch }}"
mkdir -p "${ARCHIVE_NAME}"
cp backupx "${ARCHIVE_NAME}/"
cp -r web/dist "${ARCHIVE_NAME}/web"
@@ -122,7 +185,7 @@ jobs:
sha256sum "backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz" > "backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz.sha256"
- name: Upload to GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ env.VERSION }}
files: |
@@ -130,6 +193,7 @@ jobs:
backupx-${{ env.VERSION }}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz.sha256
backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz
backupx-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz.sha256
fail_on_unmatched_files: true
generate_release_notes: true
# ─── Job 3: Docker 多架构 → Docker Hub ───
@@ -137,23 +201,24 @@ jobs:
name: Build & Push Docker
needs: build-web
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build & Push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64

36
.gitignore vendored
View File

@@ -1,4 +1,38 @@
# Dependencies
web/node_modules/
docs-site/node_modules/
# Build and test artifacts
web/dist/
docs-site/build/
docs-site/.docusaurus/
server/bin/
.claude/
coverage/
*.out
*.tsbuildinfo
web/vite.config.js
web/vite.config.d.ts
# Runtime data and local configuration
data/
tmp/
temp/
*.db
*.db-shm
*.db-wal
*.log
.env
.env.*
!.env*.example
# Editors, operating systems, and local tools
.idea/
.vscode/
.DS_Store
Thumbs.db
desktop.ini
*.swp
*.swo
*~
.claude/
.codex/

1
.node-version Normal file
View File

@@ -0,0 +1 @@
24

View File

@@ -8,8 +8,8 @@ Thanks for your interest in contributing to BackupX! This guide covers how to se
### 依赖 / Prerequisites
- **Go** 1.25+(见 `server/go.mod`
- **Node.js** 20+CI 与 Docker 使用 Node 20
- **npm** 9+
- **Node.js** 24 LTS`.node-version`CI 与 Docker 使用同一主版本
- **npm** 11+
### 快速开始 / Quick Start
@@ -17,6 +17,8 @@ Thanks for your interest in contributing to BackupX! This guide covers how to se
```bash
git clone https://github.com/Awuqing/BackupX.git && cd BackupX
npm --prefix web ci
npm --prefix docs-site ci
# 终端 1 —— 后端(默认 http://localhost:8340
make dev-server
@@ -31,6 +33,7 @@ make dev-web
make build # 同时构建后端与前端
make build-server # 仅后端 → server/bin/backupx
make build-web # 仅前端 → web/dist
make build-docs # 仅文档站 → docs-site/build
make docker # 构建 Docker 镜像
make docker-cn # 国内镜像源加速构建
```
@@ -39,15 +42,17 @@ make docker-cn # 国内镜像源加速构建
## 测试 / Testing
提交前请确保测试通过
提交前应执行与 CI 一致的完整验证
```bash
make test # 后端 + 前端全部测试
make verify # 格式、依赖、静态检查、测试与三端构建
make test # 仅后端 + 前端测试
make test-server # 仅后端cd server && go test ./...
make test-web # 仅前端cd web && npm run testvitest
make check-docs # 文档类型检查 + 中英文站点构建
```
新增功能或修复缺陷时,请尽量补充对应测试。
新增功能或修复缺陷时,请补充对应测试。文档变更也必须通过严格断链检查,不能只依赖合并后的 Pages 部署结果。
## 提交信息规范 / Commit Messages
@@ -82,7 +87,7 @@ docs: 补充 CONTRIBUTING 指南
1. **Fork** 仓库并从最新的 `main` 切出特性分支;
2. **开发**功能或修复,必要时补充测试;
3. **自测**:确保 `make test` 通过;
3. **自测**:确保 `make verify` 通过;
4. **提交**:使用上述 Conventional Commits中文
5. **推送**并对着 `main` 发起 PR。
@@ -91,15 +96,17 @@ docs: 补充 CONTRIBUTING 指南
- 清晰说明本 PR 做了什么;
- 对新功能/修复,补充动机与背景;
- 关联相关 Issue`Closes #62`
- 纯文档 PR 可不附测试
- 纯文档 PR 至少应附上 `make check-docs` 的结果
> 请保持分支基于较新的 `main`:基线过旧的分支容易产生大范围冲突,难以评审与合入。
## 编码规范 / Coding Conventions
- **Go**所有错误必须处理(禁止 `_ = err`),日志使用项目已有库(`zap`,禁止 `fmt.Println`提交前执行 `gofmt`
- **前端**遵循项目 ESLint/Prettier/tsconfig 配置,不擅自引入新的 CSS 框架或 UI 库。
- **包管理**`web/` 使用 npm请提交对应的 `package-lock.json`
- **Go**遵循现有 handler → service → repository 分层;避免把连续业务流程拆成大量无复用价值的独立函数。所有错误必须处理,日志使用 `zap`,禁止 `fmt.Println`提交前执行 `gofmt``go vet`
- **前端组件**复用并组合现有组件,不在页面内复制同类交互逻辑,不擅自改变共享组件的基础样式,不引入新的 CSS 框架或 UI 库。
- **前端视觉**:不使用 Emoji、渐变和 `box-shadow`;图标统一使用项目 SVG 图标组件;圆角为 04px不引入独立字体不用加粗字体制造层级减少 Card不使用数据左侧装饰竖线
- **包管理**`web/``docs-site/` 均使用 npm依赖变更必须同步提交对应的 `package-lock.json`
- **文档**:英文源文件与 `zh-CN` 翻译应同步维护;新增页面必须加入侧边栏并通过两种语言的生产构建。
## License

View File

@@ -10,7 +10,7 @@ ARG USE_CHINA_MIRROR=false
# ---- Stage 1: Build frontend ----
FROM node:20-alpine AS web-builder
FROM node:26-alpine AS web-builder
ARG USE_CHINA_MIRROR
# 国内镜像npm 使用淘宝源
@@ -26,7 +26,7 @@ RUN npm run build
# ---- Stage 2: Build backend ----
FROM golang:1.25-alpine AS server-builder
FROM golang:1.26-alpine AS server-builder
ARG USE_CHINA_MIRROR
ARG VERSION=dev
@@ -43,7 +43,7 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}"
# ---- Stage 3: Production image ----
FROM alpine:3.21
FROM alpine:3.24
ARG USE_CHINA_MIRROR
# 国内镜像Alpine apk 使用阿里云源

View File

@@ -1,4 +1,6 @@
.PHONY: build dev test clean docker docker-cn
.PHONY: build build-server build-web build-docs dev-server dev-web \
test test-server test-web check-docs format-check vet-server \
verify verify-server verify-web verify-docs clean docker docker-cn
# 自动获取版本号(从 git tag 或 commit hash
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
@@ -12,6 +14,9 @@ build-server:
build-web:
cd web && npm run build
build-docs:
cd docs-site && npm run build
# ── 开发模式(分别在两个终端运行)──
dev-server:
cd server && go run ./cmd/backupx
@@ -28,6 +33,28 @@ test-server:
test-web:
cd web && npm run test
check-docs:
cd docs-site && npm run typecheck && npm run build
format-check:
@unformatted="$$(gofmt -l server)"; \
if [ -n "$$unformatted" ]; then \
printf '%s\n' "$$unformatted"; \
exit 1; \
fi
vet-server:
cd server && go mod verify && go vet ./...
# ── 提交前完整验证 ──
verify: verify-server verify-web verify-docs
verify-server: format-check vet-server test-server build-server
verify-web: test-web build-web
verify-docs: check-docs
# ── Docker 构建 ──
docker:
docker build --build-arg VERSION=$(VERSION) -t backupx:$(VERSION) -t backupx:latest .

View File

@@ -10,6 +10,8 @@
<p align="center">
<a href="https://github.com/Awuqing/BackupX/stargazers"><img src="https://img.shields.io/github/stars/Awuqing/BackupX?style=flat-square&color=f5c542" alt="Stars"></a>
<a href="https://github.com/Awuqing/BackupX/releases"><img src="https://img.shields.io/github/v/release/Awuqing/BackupX?style=flat-square&color=brightgreen" alt="Release"></a>
<a href="https://github.com/Awuqing/BackupX/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/Awuqing/BackupX/ci.yml?branch=main&style=flat-square" alt="CI"></a>
<a href="https://github.com/Awuqing/BackupX/actions/workflows/docs.yml"><img src="https://img.shields.io/github/actions/workflow/status/Awuqing/BackupX/docs.yml?branch=main&style=flat-square&label=docs" alt="Docs"></a>
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go" alt="Go">
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
<img src="https://img.shields.io/badge/SQLite-embedded-003B57?style=flat-square&logo=sqlite" alt="SQLite">
@@ -26,12 +28,12 @@
<table>
<tr>
<td width="50%"><img src="screenshots/dashboard.png" alt="Dashboard"></td>
<td width="50%"><img src="screenshots/backup-tasks.png" alt="Backup Tasks"></td>
<td width="50%"><img src="screenshots/dashboard.png" alt="BackupX dashboard with 30-day backup trends, storage distribution, and task health"><br><sub><strong>Dashboard</strong> — 30-day success and failure trends, storage distribution, task volume, and cluster health.</sub></td>
<td width="50%"><img src="screenshots/backup-tasks.png" alt="BackupX task list with schedules, targets, retention, tags, RPO, and verification status"><br><sub><strong>Backup tasks</strong> — schedules, multi-target policies, retention, tags, RPO goals, and recurring verification.</sub></td>
</tr>
<tr>
<td><img src="screenshots/storage-targets.png" alt="Storage Targets"></td>
<td><img src="screenshots/backup-records.png" alt="Backup Records"></td>
<td><img src="screenshots/storage-targets.png" alt="BackupX storage targets with connection health, capacity usage, and redundancy roles"><br><sub><strong>Storage targets</strong> — connection health, live capacity, favourites, and redundancy roles at a glance.</sub></td>
<td><img src="screenshots/backup-records.png" alt="BackupX backup history with success and failure states, checksums, destinations, and retention locks"><br><sub><strong>Backup records</strong> — success and failure states, checksums, destinations, execution logs, and retention locks.</sub></td>
</tr>
</table>

View File

@@ -10,6 +10,8 @@
<p align="center">
<a href="https://github.com/Awuqing/BackupX/stargazers"><img src="https://img.shields.io/github/stars/Awuqing/BackupX?style=flat-square&color=f5c542" alt="Stars"></a>
<a href="https://github.com/Awuqing/BackupX/releases"><img src="https://img.shields.io/github/v/release/Awuqing/BackupX?style=flat-square&color=brightgreen" alt="Release"></a>
<a href="https://github.com/Awuqing/BackupX/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/Awuqing/BackupX/ci.yml?branch=main&style=flat-square" alt="CI"></a>
<a href="https://github.com/Awuqing/BackupX/actions/workflows/docs.yml"><img src="https://img.shields.io/github/actions/workflow/status/Awuqing/BackupX/docs.yml?branch=main&style=flat-square&label=docs" alt="Docs"></a>
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go" alt="Go">
<img src="https://img.shields.io/badge/React-18-61DAFB?style=flat-square&logo=react" alt="React">
<img src="https://img.shields.io/badge/SQLite-embedded-003B57?style=flat-square&logo=sqlite" alt="SQLite">
@@ -26,12 +28,12 @@
<table>
<tr>
<td width="50%"><img src="screenshots/dashboard.png" alt="仪表盘"></td>
<td width="50%"><img src="screenshots/backup-tasks.png" alt="备份任务"></td>
<td width="50%"><img src="screenshots/dashboard.png" alt="BackupX 仪表盘,展示 30 天备份趋势、存储分布和任务健康度"><br><sub><strong>仪表盘</strong> — 30 天成功/失败趋势、存储分布、任务规模与集群健康度。</sub></td>
<td width="50%"><img src="screenshots/backup-tasks.png" alt="BackupX 备份任务列表展示调度、存储目标、保留策略、标签、RPO 和验证状态"><br><sub><strong>备份任务</strong> — 调度、双目标策略、保留规则、标签、RPO 与定时验证。</sub></td>
</tr>
<tr>
<td><img src="screenshots/storage-targets.png" alt="存储目标"></td>
<td><img src="screenshots/backup-records.png" alt="备份记录"></td>
<td><img src="screenshots/storage-targets.png" alt="BackupX 存储目标,展示连接状态、容量使用和冗余角色"><br><sub><strong>存储目标</strong> — 连接状态、实时容量、收藏及冗余角色一目了然。</sub></td>
<td><img src="screenshots/backup-records.png" alt="BackupX 备份历史,展示成功失败状态、校验和、存储位置及保留锁"><br><sub><strong>备份记录</strong> — 成功/失败状态、校验和、落盘位置、执行日志与保留锁定。</sub></td>
</tr>
</table>

View File

@@ -6,7 +6,7 @@ description: Get a BackupX dev environment running — backend, frontend, tests.
# Development Setup
**Requirements:** Go ≥ 1.25, Node.js ≥ 20, npm.
**Requirements:** Go ≥ 1.25, Node.js 24 LTS, npm 11 or later.
## Clone & install

View File

@@ -51,7 +51,7 @@ The installer:
## From source
Requires Go ≥ 1.25 and Node.js ≥ 20.
Requires Go ≥ 1.25, Node.js 24 LTS, and npm 11 or later.
```bash
git clone https://github.com/Awuqing/BackupX.git && cd BackupX

View File

@@ -76,19 +76,24 @@ const config: Config = {
label: 'Docs',
},
{
href: 'https://github.com/Awuqing/BackupX/releases',
label: 'Downloads',
to: '/docs/deployment/docker',
label: 'Deployment',
position: 'left',
},
{
to: '/docs/operations/monitoring',
label: 'Operations',
position: 'left',
},
{
to: '/docs/reference/api',
label: 'API',
position: 'left',
},
{
to: '/community',
label: 'Community',
position: 'left',
},
{
to: '/sponsors',
label: 'Sponsors',
position: 'left',
position: 'right',
},
{
type: 'localeDropdown',
@@ -105,44 +110,27 @@ const config: Config = {
style: 'dark',
links: [
{
title: 'Docs',
title: 'Documentation',
items: [
{label: 'Introduction', to: '/docs/intro'},
{label: 'Quick Start', to: '/docs/getting-started/quick-start'},
{label: 'Upgrade & Recovery', to: '/docs/operations/upgrade-recovery'},
{label: 'Configuration', to: '/docs/deployment/configuration'},
],
},
{
title: 'Features',
title: 'Operations',
items: [
{label: 'SAP HANA', to: '/docs/features/sap-hana'},
{label: 'Multi-Node Cluster', to: '/docs/features/multi-node'},
{label: 'API Reference', to: '/docs/reference/api'},
{label: 'Monitoring', to: '/docs/operations/monitoring'},
{label: 'Security', to: '/docs/operations/security'},
{label: 'Troubleshooting', to: '/docs/operations/troubleshooting'},
],
},
{
title: 'More',
title: 'Project',
items: [
{label: 'GitHub', href: 'https://github.com/Awuqing/BackupX'},
{label: 'Releases', href: 'https://github.com/Awuqing/BackupX/releases'},
{label: 'Docker Hub', href: 'https://hub.docker.com/r/awuqing/backupx'},
{label: 'Issues', href: 'https://github.com/Awuqing/BackupX/issues'},
],
},
{
title: 'Community',
items: [
{label: 'Contributors', href: 'https://github.com/Awuqing/BackupX/graphs/contributors'},
{label: 'Pull Requests', href: 'https://github.com/Awuqing/BackupX/pulls'},
{label: 'Sponsor', to: '/sponsors'},
],
},
{
title: 'Sponsors',
items: [
{label: 'Sponsor BackupX', href: 'https://github.com/sponsors/Awuqing'},
{label: 'Partnership', href: 'https://github.com/Awuqing/BackupX/issues/new/choose'},
{label: 'Sponsor tiers', to: '/sponsors'},
{label: 'Community', to: '/community'},
],
},
],

View File

@@ -1,160 +1,115 @@
{
"home.badge": {
"message": "开源备份控制平面 · v2.2.1",
"description": "Version badge on the hero"
},
"home.title.part1": {
"message": "面向自托管服务器的",
"description": "Hero title, first line"
},
"home.title.part2": {
"message": "备份编排平台。",
"description": "Hero title accent second line"
},
"home.tagline": {
"message": "在一个清爽控制台中管理文件、数据库、SAP HANA 和远程节点备份。控制平面自己掌握,存储后端灵活选择。",
"description": "Tagline on the home page"
},
"home.pageTitle": {
"message": "面向自托管服务器的备份编排",
"description": "Page <title> element on the home page"
},
"home.getStarted": {
"message": "快速开始",
"description": "Primary CTA on the home page"
},
"home.metric.backends": {
"message": "存储后端",
"description": "Hero metric label: storage backends"
},
"home.metric.backupTypes": {
"message": "远程执行",
"description": "Hero metric label: backup types"
},
"home.metric.license": {
"message": "开源协议",
"description": "Hero metric label: license"
},
"home.visual.eyebrow": {"message": "BackupX 控制台"},
"home.visual.title": {"message": "运维概览"},
"home.visual.status": {"message": "健康"},
"home.visual.success": {"message": "成功率"},
"home.visual.nodes": {"message": "活跃节点"},
"home.visual.targets": {"message": "存储目标"},
"home.visual.row1.title": {"message": "PostgreSQL 夜间备份"},
"home.visual.row1.desc": {"message": "加密归档已上传至 S3"},
"home.visual.row2.title": {"message": "SAP HANA 快照"},
"home.visual.row2.desc": {"message": "正在 agent-shanghai-02 上运行"},
"home.visual.row3.title": {"message": "保留策略清理"},
"home.visual.row3.desc": {"message": "下一次执行在 4 小时后"},
"home.command.title": {"message": "使用 Docker 启动"},
"home.badge": {"message": "BackupX 文档 · v2.2.1"},
"home.title.part1": {"message": "可靠地运维 BackupX"},
"home.title.part2": {"message": "每一步都有依据。"},
"home.tagline": {"message": "从部署控制平面、连接存储与远程 Agent到持续观测和验证恢复一套务实文档覆盖完整运维路径。"},
"home.pageTitle": {"message": "面向自托管服务器的备份编排"},
"home.getStarted": {"message": "使用 Docker 开始"},
"home.viewSource": {"message": "查看源码"},
"home.supported.label": {"message": "支持的运行环境"},
"home.supported.docker": {"message": "Docker"},
"home.supported.linux": {"message": "Linux"},
"home.supported.windows": {"message": "Windows Agent"},
"home.guide.label": {"message": "推荐文档路径"},
"home.guide.kicker": {"message": "从这里开始"},
"home.guide.hint": {"message": "为下一项工作选择指南"},
"home.guide.install.title": {"message": "安装 BackupX"},
"home.guide.install.desc": {"message": "Docker、Compose 或独立二进制"},
"home.guide.cluster.title": {"message": "连接远程节点"},
"home.guide.cluster.desc": {"message": "Agent、代理、私有 CA 与堡垒机"},
"home.guide.security.title": {"message": "加固运维环境"},
"home.guide.security.desc": {"message": "安全控制、监控与审计记录"},
"home.guide.recovery.title": {"message": "准备恢复方案"},
"home.guide.recovery.desc": {"message": "升级、回滚、恢复与故障排查"},
"section.features.tag": {
"message": "核心能力",
"description": "FEATURES section tag"
},
"section.features.title": {
"message": "该有的都有,多余的没有",
"description": "Features section title"
},
"section.features.subtitle": {
"message": "备份 Runner、存储 Provider、调度、集群 — 每一块都经过打磨。",
"description": "Features section subtitle"
},
"feat.types.title": {"message": "多种备份类型"},
"feat.types.desc": {"message": "文件与目录(支持多源路径),以及 MySQL、PostgreSQL、SQLite、SAP HANA 统一管理。"},
"feat.storage.title": {"message": "70+ 存储后端"},
"feat.storage.desc": {"message": "内置阿里云 OSS、腾讯云 COS、七牛、S3、Google Drive、WebDAV、FTP以及 SFTP、Azure Blob、Dropbox 等 rclone 后端。"},
"feat.scheduling.title": {"message": "调度与保留策略"},
"feat.scheduling.desc": {"message": "基于 Cron 的可视化调度编辑器,支持按天数/份数自动保留和空目录清理。"},
"feat.cluster.title": {"message": "多节点集群"},
"feat.cluster.desc": {"message": "Master-Agent 采用 Agent 主动出站轮询。支持代理、私有 CA 与 SSH 堡垒机,无需反向连通性。"},
"feat.security.title": {"message": "默认安全"},
"feat.security.desc": {"message": "JWT 认证、bcrypt、AES-256-GCM 加密配置、可选备份加密、完整审计日志。"},
"feat.deploy.title": {"message": "部署轻量"},
"feat.deploy.desc": {"message": "单个静态二进制 + 内嵌 SQLite。Docker 一键启动或裸机 — 零外部依赖。"},
"feat.learnMore": {"message": "了解更多"},
"section.features.tag": {"message": "文档路径"},
"section.features.title": {"message": "从部署到恢复,不依赖猜测"},
"section.features.subtitle": {"message": "每条路径都把产品能力与生产环境所需的配置、安全和运维决策直接关联。"},
"feat.install.title": {"message": "安装与升级"},
"feat.install.desc": {"message": "选择 Docker、Compose 或独立二进制,并保持部署过程可重复。"},
"feat.types.title": {"message": "保护文件与数据库"},
"feat.types.desc": {"message": "配置文件、MySQL、PostgreSQL、SQLite 和 SAP HANA 备份工作负载。"},
"feat.storage.title": {"message": "连接存储目标"},
"feat.storage.desc": {"message": "通过一致流程使用原生 Provider 或任意受支持的 rclone 后端。"},
"feat.cluster.title": {"message": "构建远程节点集群"},
"feat.cluster.desc": {"message": "通过代理、私有 CA 或 SSH 堡垒机部署仅出站连接的 Agent。"},
"feat.monitor.title": {"message": "监控日常运行"},
"feat.monitor.desc": {"message": "持续查看任务健康、存储容量、通知、日志和服务就绪状态。"},
"feat.recovery.title": {"message": "按验证过的方案恢复"},
"feat.recovery.desc": {"message": "预先准备升级、回滚点、恢复验证和事件故障排查。"},
"showcase.tag": {"message": "产品界面"},
"showcase.title": {"message": "精心打磨的控制台,而非 DIY 脚本"},
"showcase.subtitle": {"message": "每个页面都为运维而生 — 可观测优先,可配置次之。"},
"showcase.title": {"message": "部署前先了解完整操作流程"},
"showcase.subtitle": {"message": "每张产品截图都连接到对应指南,说明背后的任务、配置和运行模型。"},
"showcase.tabs.label": {"message": "BackupX 产品界面"},
"showcase.preview.label": {"message": "BackupX 控制台"},
"showcase.tab.dashboard": {"message": "仪表盘"},
"showcase.tab.tasks": {"message": "备份任务"},
"showcase.tab.storage": {"message": "存储目标"},
"showcase.tab.nodes": {"message": "多节点"},
"showcase.dashboard.title": {"message": "一眼掌握全局"},
"showcase.dashboard.desc": {"message": "备份成功率、存储使用量、最近执行记录、即将触发的计划 — 一页实时数据。"},
"showcase.tasks.title": {"message": "可视化任务编辑器"},
"showcase.tasks.desc": {"message": "文件、MySQL、PostgreSQL、SQLite、SAP HANA — 三步完成。Cron 编辑器、多目标分发、保留策略、压缩、加密 — 点击即用。"},
"showcase.storage.title": {"message": "70+ 后端,统一体验"},
"showcase.storage.desc": {"message": "阿里云 OSS、腾讯云 COS、S3、Google Drive、WebDAV — 加上每一种 rclone 后端。测试连接、收藏、查看实时容量。"},
"showcase.nodes.title": {"message": "几分钟搭起 Master-Agent"},
"showcase.nodes.desc": {"message": "创建节点、复制令牌、在任意远程主机启动 Agent。路由到节点的任务在本地执行并直接上传到存储 — 无需反向连通性。"},
"showcase.dashboard.alt": {"message": "BackupX 仪表盘,展示 30 天备份趋势、存储分布、任务规模和集群健康度"},
"showcase.dashboard.title": {"message": "一眼掌握运行态"},
"showcase.dashboard.desc": {"message": "在一个实时运维视图中掌握 30 天成功/失败趋势、存储分布、任务数量、数据量和最近执行记录。"},
"showcase.tasks.alt": {"message": "BackupX 任务列表展示调度、存储目标、保留策略、标签、RPO 目标和验证状态"},
"showcase.tasks.title": {"message": "策略状态清晰可扫"},
"showcase.tasks.desc": {"message": "集中查看调度、多目标策略、保留规则、标签、RPO 目标和验证状态,并可一键操作任意任务。"},
"showcase.storage.alt": {"message": "BackupX 存储目标,展示连接状态、实时容量、收藏和冗余角色"},
"showcase.storage.title": {"message": "每个目标,统一视图"},
"showcase.storage.desc": {"message": "统一比较本地磁盘与 70+ 远程后端的连接状态、实时容量、收藏及冗余角色。"},
"showcase.nodes.alt": {"message": "BackupX 节点列表展示健康度、Agent 版本、队列深度、标签和心跳时间"},
"showcase.nodes.title": {"message": "集群健康一屏掌握"},
"showcase.nodes.desc": {"message": "集中监控本地 Master 与每个远程节点的健康度、Agent 版本、队列深度、标签和心跳时间。"},
"showcase.cta": {"message": "开始阅读文档"},
"community.tag": {"message": "社区"},
"community.pageTitle": {"message": "社区、赞助与贡献者"},
"community.pageDescription": {"message": "赞助 BackupX了解贡献者并找到务实的参与方式。"},
"community.title": {"message": "开放协作,面向长期运维"},
"community.subtitle": {"message": "备份软件的信任来自透明发布、真实部署反馈,以及足够务实的贡献路径。"},
"community.sponsor.kicker": {"message": "赞助商"},
"community.sponsor.wallTitle": {"message": "赞助商"},
"community.sponsor.title": {"message": "支持你依赖的备份基础设施"},
"community.pageTitle": {"message": "社区、赞助与贡献者"},
"community.pageDescription": {"message": "反馈真实部署约束、完善文档,或为 BackupX 提交可验证的改进。"},
"community.title": {"message": "开放协作让运维知识持续完善"},
"community.subtitle": {"message": "反馈真实部署约束、完善运行手册,或通过可复现验证提交聚焦的改动。"},
"community.path.kicker": {"message": "贡献路径"},
"community.path.guide": {"message": "贡献指南"},
"community.path.issues.title": {"message": "反馈生产环境问题"},
"community.path.issues.desc": {"message": "提交日志、部署拓扑和恢复预期。"},
"community.path.docs.title": {"message": "完善文档与示例"},
"community.path.docs.desc": {"message": "贡献存储、Agent 和数据库部署指南。"},
"community.path.code.title": {"message": "提交聚焦的 Pull Request"},
"community.path.code.desc": {"message": "保持改动可测试、可审查,并贴合现有架构。"},
"community.contributor.kicker": {"message": "贡献者"},
"community.contributor.all": {"message": "查看全部"},
"community.contributor.source": {"message": "通过 GitHub contributors API 加载,并提供本地回退数据。"},
"community.contributor.botRole": {"message": "自动化贡献者"},
"community.contributor.githubRole": {"message": "GitHub 贡献者"},
"community.contributor.contributions": {"message": "{count} 次贡献"},
"community.sponsor.bandTitle": {"message": "支持长期维护"},
"community.sponsor.bandDesc": {"message": "支持兼容性测试、恢复工作和面向运维者的文档。"},
"community.sponsor.learnMore": {"message": "了解赞助方案"},
"community.sponsor.title": {"message": "支持可靠的备份基础设施"},
"community.sponsor.programDesc": {"message": "赞助将用于测试覆盖、恢复可信度、Provider 兼容性,以及运维人员可以直接应用的文档。"},
"community.sponsor.cta": {"message": "赞助 BackupX"},
"community.sponsor.openSlot": {"message": "赞助席位开放"},
"community.sponsor.logo.project": {"message": "项目赞助"},
"community.sponsor.logo.cloud": {"message": "云服务伙伴"},
"community.sponsor.logo.object": {"message": "对象存储"},
"community.sponsor.logo.cdn": {"message": "CDN 伙伴"},
"community.sponsor.logo.database": {"message": "数据库伙伴"},
"community.sponsor.logo.security": {"message": "安全审计"},
"community.sponsor.logo.agent": {"message": "远程节点实验室"},
"community.sponsor.logo.docs": {"message": "文档赞助"},
"community.sponsor.logo.release": {"message": "发布赞助"},
"community.sponsor.logo.s3": {"message": "S3 兼容"},
"community.sponsor.logo.webdav": {"message": "WebDAV 伙伴"},
"community.sponsor.logo.sftp": {"message": "SFTP 伙伴"},
"community.sponsor.logo.docker": {"message": "容器伙伴"},
"community.sponsor.logo.mirror": {"message": "镜像伙伴"},
"community.sponsor.logo.restore": {"message": "恢复演练"},
"community.sponsor.logo.qa": {"message": "测试实验室"},
"community.sponsor.logo.oss": {"message": "开源支持"},
"community.sponsor.logo.open": {"message": "赞助席位开放"},
"community.sponsor.infrastructure.label": {"message": "基础设施"},
"community.sponsor.infrastructure.title": {"message": "云与存储生态伙伴"},
"community.sponsor.infrastructure.desc": {"message": "帮助 BackupX 覆盖对象存储、WebDAV、SFTP 以及区域云平台的真实验证。"},
"community.sponsor.security.label": {"message": "安全"},
"community.sponsor.security.title": {"message": "审计与可靠性支持者"},
"community.sponsor.security.desc": {"message": "支持加密、恢复演练、发布签名和运维检查等强化工作。"},
"community.sponsor.infrastructure.title": {"message": "云与存储兼容性"},
"community.sponsor.infrastructure.desc": {"message": "支持对象存储、WebDAV、SFTP 区域云平台的真实验证。"},
"community.sponsor.security.label": {"message": "可靠性"},
"community.sponsor.security.title": {"message": "安全与恢复工作"},
"community.sponsor.security.desc": {"message": "支持加密审查、恢复演练、发布签名和运维检查。"},
"community.sponsor.community.label": {"message": "社区"},
"community.sponsor.community.title": {"message": "开源支持"},
"community.sponsor.community.desc": {"message": "支持文档、示例、平台测试和贡献者引导。"},
"community.sponsor.community.title": {"message": "文档与贡献者支持"},
"community.sponsor.community.desc": {"message": "改善指南、示例、平台测试和贡献者体验。"},
"community.sponsor.tier.title": {"message": "支持方式"},
"community.sponsor.tier.subtitle": {"message": "根据团队依赖 BackupX 的方式选择合适层级。"},
"community.sponsor.tier.backer.name": {"message": "Backer"},
"community.sponsor.tier.backer.amount": {"message": "适合个人与小团队"},
"community.sponsor.tier.backer.desc": {"message": "支持文档、Issue 分流、兼容性测试和小型体验改进。"},
"community.sponsor.tier.backer.desc": {"message": "支持文档、Issue 分流、兼容性测试和聚焦的易用性改进。"},
"community.sponsor.tier.partner.name": {"message": "Partner"},
"community.sponsor.tier.partner.amount": {"message": "适合存储与基础设施厂商"},
"community.sponsor.tier.partner.desc": {"message": "支持 Provider 验证、部署示例、基准说明和集成指南。"},
"community.sponsor.tier.enterprise.name": {"message": "Enterprise"},
"community.sponsor.tier.enterprise.amount": {"message": "适合生产环境使用方"},
"community.sponsor.tier.enterprise.desc": {"message": "赞助恢复演练、发布加固、审计和长期维护等可靠性工作。"},
"community.contributor.kicker": {"message": "贡献者"},
"community.contributor.all": {"message": "查看全部"},
"community.contributor.source": {"message": "浏览器端通过 GitHub contributors API 获取。"},
"community.contributor.botRole": {"message": "自动化贡献者"},
"community.contributor.githubRole": {"message": "GitHub 贡献者"},
"community.contributor.contributions": {"message": "{count} 次贡献"},
"community.path.kicker": {"message": "贡献路径"},
"community.path.issues.title": {"message": "反馈生产问题"},
"community.path.issues.desc": {"message": "提交日志、部署拓扑和恢复预期。"},
"community.path.docs.title": {"message": "完善文档与示例"},
"community.path.docs.desc": {"message": "贡献存储、Agent 和数据库部署指南。"},
"community.path.code.title": {"message": "提交聚焦的 PR"},
"community.path.code.desc": {"message": "保持改动小而可测,并贴合现有架构。"},
"sponsors.pageTitle": {"message": "赞助商"},
"sponsors.pageDescription": {"message": "赞助 BackupX 的可靠性、文档、存储兼容性和长期维护。"},
"sponsors.tag": {"message": "赞助商"},
"sponsors.title": {"message": "赞助 BackupX 生态"},
"sponsors.subtitle": {"message": "赞助帮助 BackupX 更贴近真实运维:经过验证的存储 Provider、可靠发布、恢复信心和更完善的文档。"}
"community.sponsor.tier.enterprise.desc": {"message": "支持恢复演练、发布加固、审计和长期维护。"},
"sponsors.pageTitle": {"message": "赞助 BackupX"},
"sponsors.pageDescription": {"message": "支持 BackupX 的可靠性、文档、存储兼容性和长期维护。"},
"sponsors.tag": {"message": "赞助"},
"sponsors.title": {"message": "让关键维护工作持续进行"},
"sponsors.subtitle": {"message": "赞助用于真实 Provider 验证、可靠发布、恢复演练和更完善的运维文档。"}
}

View File

@@ -6,7 +6,7 @@ description: 搭建 BackupX 本地开发环境 — 后端、前端、测试。
# 开发环境
**环境要求:** Go ≥ 1.25Node.js ≥ 20npm
**环境要求:** Go ≥ 1.25Node.js 24 LTS、npm 11 或更高版本
## 克隆与依赖

View File

@@ -51,7 +51,7 @@ sudo ./install.sh # 创建系统用户、安装到 /opt/backupx、配置
## 从源码构建
依赖Go ≥ 1.25Node.js ≥ 20
依赖Go ≥ 1.25Node.js 24 LTS、npm 11 或更高版本
```bash
git clone https://github.com/Awuqing/BackupX.git && cd BackupX

View File

@@ -1,23 +1,14 @@
{
"link.title.Docs": {"message": "文档"},
"link.title.Features": {"message": "功能"},
"link.title.More": {"message": "更多"},
"link.title.Community": {"message": "社区"},
"link.title.Sponsors": {"message": "赞助商"},
"link.title.Documentation": {"message": "文档"},
"link.title.Operations": {"message": "运维"},
"link.title.Project": {"message": "项目"},
"link.item.label.Introduction": {"message": "简介"},
"link.item.label.Quick Start": {"message": "快速开始"},
"link.item.label.Upgrade & Recovery": {"message": "升级与恢复"},
"link.item.label.SAP HANA": {"message": "SAP HANA"},
"link.item.label.Multi-Node Cluster": {"message": "多节点集群"},
"link.item.label.API Reference": {"message": "API 参考"},
"link.item.label.Configuration": {"message": "配置"},
"link.item.label.Monitoring": {"message": "监控"},
"link.item.label.Security": {"message": "安全"},
"link.item.label.Troubleshooting": {"message": "故障排查"},
"link.item.label.GitHub": {"message": "GitHub"},
"link.item.label.Releases": {"message": "Releases"},
"link.item.label.Docker Hub": {"message": "Docker Hub"},
"link.item.label.Issues": {"message": "Issues"},
"link.item.label.Contributors": {"message": "贡献者"},
"link.item.label.Pull Requests": {"message": "Pull Requests"},
"link.item.label.Sponsor": {"message": "赞助"},
"link.item.label.Sponsor BackupX": {"message": "赞助 BackupX"},
"link.item.label.Partnership": {"message": "合作伙伴"},
"link.item.label.Sponsor tiers": {"message": "赞助层级"}
"link.item.label.Releases": {"message": "发布版本"},
"link.item.label.Community": {"message": "社区"}
}

View File

@@ -1,22 +1,8 @@
{
"item.label.Docs": {
"message": "文档",
"description": "Navbar item: Docs"
},
"item.label.Downloads": {
"message": "下载",
"description": "Navbar item: Downloads"
},
"item.label.Community": {
"message": "社区",
"description": "Navbar item: Community"
},
"item.label.Sponsors": {
"message": "赞助商",
"description": "Navbar item: Sponsors"
},
"item.label.GitHub": {
"message": "GitHub",
"description": "Navbar item: GitHub"
}
"item.label.Docs": {"message": "文档"},
"item.label.Deployment": {"message": "部署"},
"item.label.Operations": {"message": "运维"},
"item.label.API": {"message": "API"},
"item.label.Community": {"message": "社区"},
"item.label.GitHub": {"message": "GitHub"}
}

View File

@@ -14,18 +14,18 @@
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.10.2",
"@docusaurus/tsconfig": "3.10.2",
"@docusaurus/types": "3.10.2",
"@types/react": "^19.0.0",
"@types/react": "^19.2.18",
"typescript": "~6.0.2"
},
"engines": {
"node": ">=20.0"
"node": ">=24.0"
}
},
"node_modules/@11ty/gray-matter": {
@@ -6293,9 +6293,9 @@
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -16429,24 +16429,24 @@
}
},
"node_modules/react": {
"version": "19.2.5",
"resolved": "https://registry.npmmirror.com/react/-/react-19.2.5.tgz",
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.5",
"resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.5.tgz",
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
"version": "19.2.8",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.5"
"react": "^19.2.8"
}
},
"node_modules/react-fast-compare": {

View File

@@ -21,14 +21,14 @@
"@mdx-js/react": "^3.0.0",
"clsx": "^2.0.0",
"prism-react-renderer": "^2.3.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.10.2",
"@docusaurus/tsconfig": "3.10.2",
"@docusaurus/types": "3.10.2",
"@types/react": "^19.0.0",
"@types/react": "^19.2.18",
"typescript": "~6.0.2"
},
"browserslist": {
@@ -44,6 +44,6 @@
]
},
"engines": {
"node": ">=20.0"
"node": ">=24.0"
}
}

View File

@@ -0,0 +1,172 @@
import type {ReactNode} from 'react';
export type DocIconName =
| 'arrowRight'
| 'bookOpen'
| 'box'
| 'check'
| 'clock'
| 'cloud'
| 'database'
| 'download'
| 'external'
| 'github'
| 'heart'
| 'monitor'
| 'network'
| 'restore'
| 'server'
| 'shield'
| 'storage'
| 'terminal'
| 'users'
| 'wrench';
type DocIconProps = {
name: DocIconName;
size?: number;
className?: string;
};
const ICON_PATHS: Record<Exclude<DocIconName, 'github'>, ReactNode> = {
arrowRight: (
<>
<path d="M5 12h14" />
<path d="m13 6 6 6-6 6" />
</>
),
bookOpen: (
<>
<path d="M3 5.5A3.5 3.5 0 0 1 6.5 2H11v17H6.5A3.5 3.5 0 0 0 3 22Z" />
<path d="M21 5.5A3.5 3.5 0 0 0 17.5 2H13v17h4.5A3.5 3.5 0 0 1 21 22Z" />
</>
),
box: (
<>
<path d="m12 3 8 4.5v9L12 21l-8-4.5v-9Z" />
<path d="m4.4 7.7 7.6 4.4 7.6-4.4" />
<path d="M12 12.1V21" />
</>
),
check: <path d="m5 12 4 4L19 6" />,
clock: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v5l3 2" />
</>
),
cloud: <path d="M17.5 19H7a5 5 0 0 1-.6-9.96A6.5 6.5 0 0 1 18.7 8.2 5.5 5.5 0 0 1 17.5 19Z" />,
database: (
<>
<ellipse cx="12" cy="5" rx="8" ry="3" />
<path d="M4 5v6c0 1.7 3.6 3 8 3s8-1.3 8-3V5" />
<path d="M4 11v6c0 1.7 3.6 3 8 3s8-1.3 8-3v-6" />
</>
),
download: (
<>
<path d="M12 3v12" />
<path d="m7 10 5 5 5-5" />
<path d="M4 20h16" />
</>
),
external: (
<>
<path d="M14 4h6v6" />
<path d="m20 4-9 9" />
<path d="M19 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1h6" />
</>
),
heart: <path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1.1-1.1a5.5 5.5 0 0 0-7.8 7.8l1.1 1.1L12 21l7.8-7.5 1.1-1.1a5.5 5.5 0 0 0-.1-7.8Z" />,
monitor: (
<>
<rect x="3" y="4" width="18" height="13" rx="1" />
<path d="M8 21h8" />
<path d="M12 17v4" />
</>
),
network: (
<>
<rect x="9" y="3" width="6" height="5" rx="1" />
<rect x="3" y="16" width="6" height="5" rx="1" />
<rect x="15" y="16" width="6" height="5" rx="1" />
<path d="M12 8v4M6 16v-4h12v4" />
</>
),
restore: (
<>
<path d="M4 7v5h5" />
<path d="M5.6 17a8 8 0 1 0 .4-10L4 9" />
<path d="M12 8v4l3 2" />
</>
),
server: (
<>
<rect x="3" y="4" width="18" height="6" rx="1" />
<rect x="3" y="14" width="18" height="6" rx="1" />
<path d="M7 7h.01M7 17h.01M11 7h7M11 17h7" />
</>
),
shield: (
<>
<path d="M12 3 20 6v6c0 5-3.3 8.2-8 9-4.7-.8-8-4-8-9V6Z" />
<path d="m8.5 12 2.2 2.2 4.8-5" />
</>
),
storage: (
<>
<path d="M4 6h16v12H4z" />
<path d="M8 10h8M8 14h5" />
</>
),
terminal: (
<>
<rect x="3" y="4" width="18" height="16" rx="1" />
<path d="m7 9 3 3-3 3M13 15h4" />
</>
),
users: (
<>
<circle cx="9" cy="8" r="3" />
<path d="M3 20a6 6 0 0 1 12 0" />
<path d="M16 5.2a3 3 0 0 1 0 5.6M17 14a5 5 0 0 1 4 4.9" />
</>
),
wrench: (
<>
<path d="M14.7 6.3a4 4 0 0 0-5-5L12 3.6 8.4 7.2 6.1 4.9a4 4 0 0 0 5 5L19 17.8a1.6 1.6 0 0 1-2.2 2.2l-7.9-7.9" />
</>
),
};
export default function DocIcon({name, size = 20, className}: DocIconProps): ReactNode {
if (name === 'github') {
return (
<svg
aria-hidden="true"
className={className}
width={size}
height={size}
viewBox="0 0 24 24"
fill="currentColor">
<path d="M12 2C6.48 2 2 6.59 2 12.25c0 4.53 2.87 8.37 6.84 9.73.5.1.68-.22.68-.49v-1.92c-2.78.62-3.37-1.21-3.37-1.21-.45-1.18-1.11-1.49-1.11-1.49-.91-.64.07-.63.07-.63 1 .08 1.53 1.06 1.53 1.06.89 1.57 2.34 1.11 2.91.85.09-.67.35-1.11.63-1.37-2.22-.26-4.56-1.14-4.56-5.07 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.71 0 0 .84-.28 2.75 1.05A9.3 9.3 0 0 1 12 6.95a9.3 9.3 0 0 1 2.5.35c1.91-1.33 2.75-1.05 2.75-1.05.55 1.41.2 2.45.1 2.71.64.72 1.03 1.63 1.03 2.75 0 3.94-2.34 4.8-4.57 5.06.36.32.68.94.68 1.9v2.82c0 .27.18.59.69.49A10.27 10.27 0 0 0 22 12.25C22 6.59 17.52 2 12 2Z" />
</svg>
);
}
return (
<svg
aria-hidden="true"
className={className}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round">
{ICON_PATHS[name]}
</svg>
);
}

View File

@@ -3,14 +3,9 @@ import {useEffect, useState} from 'react';
import Heading from '@theme/Heading';
import Translate from '@docusaurus/Translate';
import Link from '@docusaurus/Link';
import DocIcon, {type DocIconName} from '@site/src/components/DocIcon';
import styles from './styles.module.css';
type SponsorSlot = {
brand: ReactNode;
name: ReactNode;
href?: string;
};
type Contributor = {
login: string;
avatarUrl?: string;
@@ -28,86 +23,26 @@ type GitHubContributor = {
};
type CommunityPath = {
icon: DocIconName;
title: ReactNode;
description: ReactNode;
href: string;
};
const SPONSOR_SLOTS: SponsorSlot[] = [
{
brand: 'BackupX',
name: <Translate id="community.sponsor.logo.project">Project backer</Translate>,
href: 'https://github.com/sponsors/Awuqing',
},
{
brand: 'Cloud',
name: <Translate id="community.sponsor.logo.cloud">Cloud partner</Translate>,
},
{
brand: 'Object',
name: <Translate id="community.sponsor.logo.object">Object storage</Translate>,
},
{
brand: 'CDN',
name: <Translate id="community.sponsor.logo.cdn">CDN partner</Translate>,
},
{
brand: 'DB',
name: <Translate id="community.sponsor.logo.database">Database partner</Translate>,
},
{
brand: 'Security',
name: <Translate id="community.sponsor.logo.security">Security audit</Translate>,
},
{
brand: 'Agent',
name: <Translate id="community.sponsor.logo.agent">Remote node lab</Translate>,
},
{
brand: 'Docs',
name: <Translate id="community.sponsor.logo.docs">Docs sponsor</Translate>,
},
{
brand: 'Release',
name: <Translate id="community.sponsor.logo.release">Release sponsor</Translate>,
},
{
brand: 'S3',
name: <Translate id="community.sponsor.logo.s3">S3 compatible</Translate>,
},
{
brand: 'WebDAV',
name: <Translate id="community.sponsor.logo.webdav">WebDAV partner</Translate>,
},
{
brand: 'SFTP',
name: <Translate id="community.sponsor.logo.sftp">SFTP partner</Translate>,
},
{
brand: 'Docker',
name: <Translate id="community.sponsor.logo.docker">Container partner</Translate>,
},
{
brand: 'Mirror',
name: <Translate id="community.sponsor.logo.mirror">Mirror partner</Translate>,
},
{
brand: 'Restore',
name: <Translate id="community.sponsor.logo.restore">Restore drill</Translate>,
},
{
brand: 'QA',
name: <Translate id="community.sponsor.logo.qa">Test lab</Translate>,
},
{
brand: 'OSS',
name: <Translate id="community.sponsor.logo.oss">Open source</Translate>,
},
{
brand: 'Open Slot',
name: <Translate id="community.sponsor.logo.open">Sponsor slot open</Translate>,
},
];
type SponsorFocus = {
id: string;
icon: DocIconName;
label: ReactNode;
title: ReactNode;
description: ReactNode;
};
type SponsorTier = {
id: string;
name: ReactNode;
audience: ReactNode;
description: ReactNode;
};
const FALLBACK_CONTRIBUTORS: Contributor[] = [
{
@@ -126,30 +61,69 @@ const FALLBACK_CONTRIBUTORS: Contributor[] = [
const COMMUNITY_PATHS: CommunityPath[] = [
{
icon: 'wrench',
title: <Translate id="community.path.issues.title">Report production issues</Translate>,
description: <Translate id="community.path.issues.desc">Share logs, deployment topology and restore expectations.</Translate>,
description: <Translate id="community.path.issues.desc">Share logs, deployment topology, and restore expectations.</Translate>,
href: 'https://github.com/Awuqing/BackupX/issues',
},
{
icon: 'bookOpen',
title: <Translate id="community.path.docs.title">Improve docs and examples</Translate>,
description: <Translate id="community.path.docs.desc">Contribute deployment guides for storage, agents and databases.</Translate>,
description: <Translate id="community.path.docs.desc">Contribute deployment guides for storage, agents, and databases.</Translate>,
href: '/docs/development/contributing',
},
{
title: <Translate id="community.path.code.title">Ship focused PRs</Translate>,
description: <Translate id="community.path.code.desc">Keep changes small, tested and aligned with the existing architecture.</Translate>,
icon: 'github',
title: <Translate id="community.path.code.title">Ship focused pull requests</Translate>,
description: <Translate id="community.path.code.desc">Keep changes tested, reviewable, and aligned with the existing architecture.</Translate>,
href: 'https://github.com/Awuqing/BackupX/pulls',
},
];
function SponsorLogoCard({brand, name, href}: SponsorSlot) {
return (
<Link className={styles.sponsorLogoTile} to={href ?? 'https://github.com/sponsors/Awuqing'}>
<span className={styles.sponsorLogoMark}>{brand}</span>
<span className={styles.sponsorLogoName}>{name}</span>
</Link>
);
}
const SPONSOR_FOCUS: SponsorFocus[] = [
{
id: 'infrastructure',
icon: 'cloud',
label: <Translate id="community.sponsor.infrastructure.label">Infrastructure</Translate>,
title: <Translate id="community.sponsor.infrastructure.title">Cloud and storage compatibility</Translate>,
description: <Translate id="community.sponsor.infrastructure.desc">Support validation across object storage, WebDAV, SFTP, and regional cloud providers.</Translate>,
},
{
id: 'reliability',
icon: 'shield',
label: <Translate id="community.sponsor.security.label">Reliability</Translate>,
title: <Translate id="community.sponsor.security.title">Security and recovery work</Translate>,
description: <Translate id="community.sponsor.security.desc">Fund encryption reviews, restore drills, release signing, and operational checks.</Translate>,
},
{
id: 'community',
icon: 'users',
label: <Translate id="community.sponsor.community.label">Community</Translate>,
title: <Translate id="community.sponsor.community.title">Documentation and contributor support</Translate>,
description: <Translate id="community.sponsor.community.desc">Improve guides, examples, platform testing, and the contributor experience.</Translate>,
},
];
const SPONSOR_TIERS: SponsorTier[] = [
{
id: 'backer',
name: <Translate id="community.sponsor.tier.backer.name">Backer</Translate>,
audience: <Translate id="community.sponsor.tier.backer.amount">For individuals and small teams</Translate>,
description: <Translate id="community.sponsor.tier.backer.desc">Supports documentation, issue triage, compatibility testing, and focused usability work.</Translate>,
},
{
id: 'partner',
name: <Translate id="community.sponsor.tier.partner.name">Partner</Translate>,
audience: <Translate id="community.sponsor.tier.partner.amount">For storage and infrastructure vendors</Translate>,
description: <Translate id="community.sponsor.tier.partner.desc">Supports provider validation, deployment examples, benchmarks, and integration guides.</Translate>,
},
{
id: 'enterprise',
name: <Translate id="community.sponsor.tier.enterprise.name">Enterprise</Translate>,
audience: <Translate id="community.sponsor.tier.enterprise.amount">For production BackupX operators</Translate>,
description: <Translate id="community.sponsor.tier.enterprise.desc">Funds recovery drills, release hardening, audits, and long-term maintenance.</Translate>,
},
];
function getInitials(login: string): string {
return login
@@ -180,11 +154,9 @@ function useGitHubContributors(): Contributor[] {
useEffect(() => {
const controller = new AbortController();
fetch('https://api.github.com/repos/Awuqing/BackupX/contributors?per_page=12', {
fetch('https://api.github.com/repos/Awuqing/BackupX/contributors?per_page=8', {
signal: controller.signal,
headers: {
Accept: 'application/vnd.github+json',
},
headers: {Accept: 'application/vnd.github+json'},
})
.then(response => {
if (!response.ok) {
@@ -213,28 +185,28 @@ function useGitHubContributors(): Contributor[] {
return contributors;
}
function ContributorCard({login, avatarUrl, contributions, type, href}: Contributor) {
function ContributorRow({login, avatarUrl, contributions, type, href}: Contributor): ReactNode {
return (
<Link className={styles.contributorCard} to={href}>
<Link className={styles.contributorRow} to={href}>
{avatarUrl ? (
<img className={styles.avatarImage} src={avatarUrl} alt="" loading="lazy" />
) : (
<span className={styles.avatar} aria-hidden="true">{getInitials(login)}</span>
)}
<span className={styles.contributorBody}>
<strong>{login}</strong>
<span>
<span className={styles.contributorName}>{login}</span>
<span className={styles.contributorRole}>
{type === 'Bot' ? (
<Translate id="community.contributor.botRole">Automation contributor</Translate>
) : (
<Translate id="community.contributor.githubRole">GitHub contributor</Translate>
)}
</span>
<em>
<Translate id="community.contributor.contributions" values={{count: contributions}}>
{'{count} contributions'}
</Translate>
</em>
</span>
<span className={styles.contributionCount}>
<Translate id="community.contributor.contributions" values={{count: contributions}}>
{'{count} contributions'}
</Translate>
</span>
</Link>
);
@@ -242,22 +214,54 @@ function ContributorCard({login, avatarUrl, contributions, type, href}: Contribu
export function HomepageSponsors(): ReactNode {
return (
<div className={styles.sponsorWall}>
<div className={styles.sponsorWallHeader}>
<Heading as="h3" className={styles.sponsorWallTitle}>
<Translate id="community.sponsor.wallTitle">Sponsors</Translate>
</Heading>
<Link className={styles.sponsorWallAction} to="https://github.com/sponsors/Awuqing">
<div className={styles.sponsorProgram}>
<div className={styles.sponsorProgramHeader}>
<span className={styles.sponsorProgramIcon}><DocIcon name="heart" size={23} /></span>
<div className={styles.sponsorProgramCopy}>
<Heading as="h2" className={styles.sponsorProgramTitle}>
<Translate id="community.sponsor.title">Support reliable backup infrastructure</Translate>
</Heading>
<p>
<Translate id="community.sponsor.programDesc">
Sponsorship is directed toward test coverage, restore confidence, provider compatibility, and documentation that operators can apply directly.
</Translate>
</p>
</div>
<Link className={styles.sponsorAction} to="https://github.com/sponsors/Awuqing">
<DocIcon name="github" size={17} />
<Translate id="community.sponsor.cta">Sponsor BackupX</Translate>
<span aria-hidden="true">-&gt;</span>
<DocIcon name="external" size={15} />
</Link>
</div>
<div className={styles.sponsorLogoGrid}>
{SPONSOR_SLOTS.map((slot, index) => (
<SponsorLogoCard key={index} {...slot} />
<div className={styles.sponsorFocusList}>
{SPONSOR_FOCUS.map(focus => (
<div key={focus.id} className={styles.sponsorFocusItem}>
<span className={styles.sponsorFocusIcon}><DocIcon name={focus.icon} size={20} /></span>
<span className={styles.sponsorFocusBody}>
<span className={styles.sponsorFocusLabel}>{focus.label}</span>
<span className={styles.sponsorFocusTitle}>{focus.title}</span>
<span className={styles.sponsorFocusDescription}>{focus.description}</span>
</span>
</div>
))}
</div>
<div className={styles.tierSection}>
<div className={styles.tierSectionHeader}>
<Heading as="h3"><Translate id="community.sponsor.tier.title">Ways to support</Translate></Heading>
<p><Translate id="community.sponsor.tier.subtitle">Choose a level that matches how your team depends on BackupX.</Translate></p>
</div>
<div className={styles.tierGrid}>
{SPONSOR_TIERS.map(tier => (
<div key={tier.id} className={styles.tierItem}>
<span className={styles.tierName}>{tier.name}</span>
<span className={styles.tierAudience}>{tier.audience}</span>
<span className={styles.tierDescription}>{tier.description}</span>
</div>
))}
</div>
</div>
</div>
);
}
@@ -269,59 +273,74 @@ export default function HomepageCommunity(): ReactNode {
<section id="community" className={styles.section}>
<div className="container">
<div className={styles.sectionHead}>
<div className={styles.sectionTag}>
<Translate id="community.tag">COMMUNITY</Translate>
<div>
<div className={styles.sectionTag}>
<Translate id="community.tag">Community</Translate>
</div>
<Heading as="h2" className={styles.sectionTitle}>
<Translate id="community.title">Operational knowledge improves in the open</Translate>
</Heading>
</div>
<Heading as="h2" className={styles.sectionTitle}>
<Translate id="community.title">Built in the open, ready for long-term operators</Translate>
</Heading>
<p className={styles.sectionSubtitle}>
<Translate id="community.subtitle">
Backup software earns trust through transparent releases, real deployment feedback and a contributor path that stays practical.
Report real deployment constraints, improve the runbooks, or contribute a focused change with reproducible validation.
</Translate>
</p>
</div>
<HomepageSponsors />
<div className={styles.communityGrid}>
<div className={styles.panel}>
<div className={styles.communityShell}>
<div className={styles.pathPanel}>
<div className={styles.panelHeader}>
<span>
<Translate id="community.contributor.kicker">Contributors</Translate>
</span>
<Link to="https://github.com/Awuqing/BackupX/graphs/contributors">
<Translate id="community.contributor.all">View all</Translate>
<span><Translate id="community.path.kicker">Contribution paths</Translate></span>
<Link to="/docs/development/contributing">
<Translate id="community.path.guide">Contribution guide</Translate>
<DocIcon name="arrowRight" size={15} />
</Link>
</div>
<div className={styles.panelNote}>
<Translate id="community.contributor.source">Loaded from GitHub contributors API in the browser.</Translate>
</div>
<div className={styles.contributorList}>
{contributors.map(contributor => (
<ContributorCard key={contributor.login} {...contributor} />
<div className={styles.pathList}>
{COMMUNITY_PATHS.map((path, index) => (
<Link key={path.href} className={styles.pathItem} to={path.href}>
<span className={styles.pathIndex}>{String(index + 1).padStart(2, '0')}</span>
<span className={styles.pathIcon}><DocIcon name={path.icon} size={19} /></span>
<span className={styles.pathBody}>
<span className={styles.pathTitle}>{path.title}</span>
<span className={styles.pathDescription}>{path.description}</span>
</span>
<DocIcon name="arrowRight" size={17} className={styles.rowArrow} />
</Link>
))}
</div>
</div>
<div className={styles.panel}>
<div className={styles.contributorPanel}>
<div className={styles.panelHeader}>
<span>
<Translate id="community.path.kicker">Contributor paths</Translate>
</span>
<span><Translate id="community.contributor.kicker">Contributors</Translate></span>
<Link to="https://github.com/Awuqing/BackupX/graphs/contributors">
<Translate id="community.contributor.all">View all</Translate>
<DocIcon name="external" size={14} />
</Link>
</div>
<div className={styles.pathList}>
{COMMUNITY_PATHS.map((path, index) => (
<Link key={index} className={styles.pathItem} to={path.href}>
<span className={styles.pathIndex}>{String(index + 1).padStart(2, '0')}</span>
<span>
<strong>{path.title}</strong>
<em>{path.description}</em>
</span>
</Link>
<p className={styles.panelNote}>
<Translate id="community.contributor.source">Loaded from the GitHub contributors API with a local fallback.</Translate>
</p>
<div className={styles.contributorList}>
{contributors.slice(0, 5).map(contributor => (
<ContributorRow key={contributor.login} {...contributor} />
))}
</div>
</div>
<div className={styles.sponsorBand}>
<span className={styles.sponsorBandIcon}><DocIcon name="heart" size={20} /></span>
<span className={styles.sponsorBandBody}>
<span className={styles.sponsorBandTitle}><Translate id="community.sponsor.bandTitle">Support long-term maintenance</Translate></span>
<span className={styles.sponsorBandDescription}><Translate id="community.sponsor.bandDesc">Fund compatibility testing, recovery work, and operator-focused documentation.</Translate></span>
</span>
<Link to="/sponsors">
<Translate id="community.sponsor.learnMore">Sponsorship details</Translate>
<DocIcon name="arrowRight" size={16} />
</Link>
</div>
</div>
</div>
</section>

View File

@@ -1,367 +1,436 @@
.section {
padding: 5.5rem 0 6rem;
background:
linear-gradient(180deg, rgba(245, 247, 250, 0) 0%, rgba(245, 247, 250, 0.86) 100%),
var(--ifm-background-color);
}
[data-theme='dark'] .section {
background:
linear-gradient(180deg, rgba(15, 17, 21, 0) 0%, rgba(255, 255, 255, 0.03) 100%),
var(--ifm-background-color);
background: var(--ifm-background-color);
padding: 5rem 0 5.5rem;
}
.sectionHead {
max-width: 760px;
margin: 0 auto 2.5rem;
text-align: center;
align-items: end;
display: grid;
gap: 3rem;
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.7fr);
margin-bottom: 2.25rem;
}
.sectionTag {
display: inline-flex;
align-items: center;
min-height: 28px;
margin-bottom: 1rem;
padding: 4px 10px;
color: #00a870;
background: rgba(0, 180, 42, 0.1);
border: 1px solid rgba(0, 180, 42, 0.18);
border-radius: 8px;
font-size: 12px;
font-weight: 750;
letter-spacing: 0;
}
.sectionTitle {
margin: 0 0 1rem;
color: var(--ifm-heading-color);
font-size: 2.35rem;
font-weight: 750;
letter-spacing: 0;
line-height: 1.2;
}
.sectionSubtitle {
margin: 0;
color: var(--ifm-color-content-secondary);
font-size: 1.04rem;
line-height: 1.7;
}
.sponsorWall {
overflow: hidden;
margin-bottom: 1rem;
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
box-shadow: 0 12px 28px rgba(29, 33, 41, 0.06);
}
[data-theme='dark'] .sponsorWall {
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.08);
box-shadow: none;
}
.sponsorWallHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
min-height: 60px;
padding: 0 1.25rem;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .sponsorWallHeader {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.sponsorWallTitle {
position: relative;
margin: 0;
padding-left: 14px;
color: var(--ifm-heading-color);
font-size: 1.05rem;
font-weight: 750;
letter-spacing: 0;
}
.sponsorWallTitle::before {
position: absolute;
top: 50%;
left: 0;
width: 3px;
height: 18px;
content: "";
background: #52c41a;
border-radius: 3px;
transform: translateY(-50%);
}
.sponsorWallAction {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 36px;
padding: 0 12px;
color: #52c41a;
background: rgba(82, 196, 26, 0.08);
border: 1px solid rgba(82, 196, 26, 0.2);
border-radius: 8px;
font-size: 13px;
font-weight: 700;
text-decoration: none !important;
white-space: nowrap;
transition: background 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
}
.sponsorWallAction:hover,
.sponsorWallAction:focus-visible {
color: #389e0d;
background: rgba(82, 196, 26, 0.14);
border-color: #52c41a;
transform: translateY(-1px);
}
.sponsorLogoGrid {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
background: var(--ifm-color-emphasis-200);
gap: 1px;
padding: 1px;
}
[data-theme='dark'] .sponsorLogoGrid {
background: rgba(255, 255, 255, 0.08);
}
.sponsorLogoTile {
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
min-height: 106px;
padding: 14px 10px;
flex-direction: column;
color: inherit;
background: var(--ifm-background-color);
text-align: center;
text-decoration: none !important;
transition: background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
[data-theme='dark'] .sponsorLogoTile {
background: rgba(15, 17, 21, 0.78);
}
.sponsorLogoTile:hover,
.sponsorLogoTile:focus-visible {
z-index: 1;
color: inherit;
background: rgba(82, 196, 26, 0.04);
box-shadow: inset 0 0 0 1px rgba(82, 196, 26, 0.5);
transform: translateY(-1px);
}
.sponsorLogoMark {
display: block;
max-width: 100%;
overflow-wrap: anywhere;
color: var(--ifm-color-primary);
font-size: 1.45rem;
font-weight: 850;
letter-spacing: 0;
line-height: 1.1;
}
.sponsorLogoTile:nth-child(2n) .sponsorLogoMark {
color: #ff7d00;
}
.sponsorLogoTile:nth-child(3n) .sponsorLogoMark {
color: #14c9c9;
}
.sponsorLogoTile:nth-child(4n) .sponsorLogoMark {
color: #722ed1;
}
.sponsorLogoTile:nth-child(5n) .sponsorLogoMark {
color: #52c41a;
}
.sponsorLogoName {
display: block;
max-width: 100%;
margin-top: 10px;
color: var(--ifm-color-content-secondary);
font-size: 0.86rem;
font-weight: 600;
line-height: 1.35;
}
.panel {
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
box-shadow: 0 12px 28px rgba(29, 33, 41, 0.06);
}
[data-theme='dark'] .panel {
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.08);
box-shadow: none;
}
.communityGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.panel {
min-width: 0;
padding: 1.25rem;
}
.panelHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
color: var(--ifm-color-content-secondary);
font-size: 12px;
font-weight: 750;
letter-spacing: 0;
font-weight: 500;
letter-spacing: 0.06em;
margin-bottom: 0.75rem;
text-transform: uppercase;
}
.panelHeader a {
color: var(--ifm-color-primary);
text-decoration: none !important;
}
.panelNote {
margin: -0.35rem 0 1rem;
color: var(--ifm-color-content-secondary);
font-size: 0.82rem;
line-height: 1.5;
}
.contributorList,
.pathList {
display: grid;
gap: 10px;
}
.contributorCard,
.pathItem {
display: grid;
min-width: 0;
color: inherit;
background: var(--ifm-color-emphasis-100);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
text-decoration: none !important;
transition: border-color 0.2s ease, transform 0.2s ease, background 0.2s ease;
}
.contributorCard:hover,
.contributorCard:focus-visible,
.pathItem:hover,
.pathItem:focus-visible {
color: inherit;
background: var(--ifm-background-color);
border-color: var(--ifm-color-primary);
transform: translateY(-1px);
}
[data-theme='dark'] .contributorCard,
[data-theme='dark'] .pathItem {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.08);
}
.contributorCard {
grid-template-columns: auto minmax(0, 1fr);
gap: 12px;
align-items: center;
padding: 12px;
}
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
color: #fff;
background: #165dff;
border-radius: 8px;
font-size: 13px;
font-weight: 800;
}
.avatarImage {
width: 44px;
height: 44px;
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
object-fit: cover;
}
.contributorCard:nth-child(2) .avatar {
background: #00a870;
}
.contributorCard:nth-child(3) .avatar {
background: #ff7d00;
}
.contributorBody {
display: grid;
min-width: 0;
gap: 2px;
}
.contributorBody strong {
.sectionTitle {
color: var(--ifm-heading-color);
font-size: 0.98rem;
font-size: clamp(1.9rem, 4vw, 2.55rem);
font-weight: 500;
letter-spacing: -0.025em;
line-height: 1.18;
margin: 0;
max-width: 720px;
}
.contributorBody span {
color: var(--ifm-color-content);
font-size: 0.88rem;
}
.contributorBody em,
.pathItem em {
.sectionSubtitle {
color: var(--ifm-color-content-secondary);
font-size: 0.82rem;
font-style: normal;
line-height: 1.45;
font-size: 1rem;
line-height: 1.7;
margin: 0;
}
.communityShell,
.sponsorProgram {
border: 1px solid var(--bx-border);
border-radius: 4px;
overflow: hidden;
}
.communityShell {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(310px, 0.8fr);
}
.pathPanel,
.contributorPanel {
min-width: 0;
}
.pathPanel {
border-right: 1px solid var(--bx-border);
}
.panelHeader {
align-items: center;
background: var(--bx-surface-subtle);
border-bottom: 1px solid var(--bx-border);
display: flex;
gap: 1rem;
justify-content: space-between;
min-height: 50px;
padding: 10px 14px;
}
.panelHeader > span {
color: var(--ifm-heading-color);
font-size: 13px;
font-weight: 500;
}
.panelHeader a {
align-items: center;
color: var(--ifm-color-primary);
display: inline-flex;
font-size: 12px;
gap: 5px;
text-decoration: none !important;
}
.pathList,
.contributorList {
display: grid;
}
.pathItem {
grid-template-columns: auto minmax(0, 1fr);
gap: 12px;
padding: 14px;
align-items: center;
border-bottom: 1px solid var(--bx-border);
color: inherit;
display: grid;
gap: 11px;
grid-template-columns: auto auto minmax(0, 1fr) auto;
min-height: 100px;
padding: 13px 14px;
text-decoration: none !important;
}
.pathItem:last-child {
border-bottom: 0;
}
.pathItem:hover,
.pathItem:focus-visible,
.contributorRow:hover,
.contributorRow:focus-visible {
background: var(--bx-surface-selected);
color: inherit;
}
.pathIndex {
color: var(--ifm-color-primary);
color: var(--ifm-color-content-secondary);
font-family: var(--ifm-font-family-monospace);
font-size: 0.86rem;
font-weight: 800;
font-size: 11px;
}
.pathItem strong {
display: block;
margin-bottom: 4px;
.pathIcon,
.sponsorBandIcon,
.sponsorProgramIcon,
.sponsorFocusIcon {
align-items: center;
background: var(--bx-surface-selected);
border: 1px solid color-mix(in srgb, var(--ifm-color-primary) 25%, var(--bx-border));
border-radius: 3px;
color: var(--ifm-color-primary);
display: inline-flex;
justify-content: center;
}
.pathIcon {
height: 36px;
width: 36px;
}
.pathBody,
.contributorBody,
.sponsorBandBody,
.sponsorFocusBody {
display: grid;
min-width: 0;
}
.pathBody {
gap: 3px;
}
.pathTitle,
.contributorName,
.sponsorBandTitle,
.sponsorFocusTitle,
.tierName {
color: var(--ifm-heading-color);
font-size: 0.96rem;
font-weight: 500;
}
.pathTitle {
font-size: 14px;
}
.pathDescription {
color: var(--ifm-color-content-secondary);
font-size: 12px;
line-height: 1.55;
}
.rowArrow {
color: var(--ifm-color-content-secondary);
}
.pathItem:hover .rowArrow,
.pathItem:focus-visible .rowArrow {
color: var(--ifm-color-primary);
}
.panelNote {
border-bottom: 1px solid var(--bx-border);
color: var(--ifm-color-content-secondary);
font-size: 12px;
line-height: 1.5;
margin: 0;
padding: 10px 14px;
}
.contributorRow {
align-items: center;
border-bottom: 1px solid var(--bx-border);
color: inherit;
display: grid;
gap: 10px;
grid-template-columns: auto minmax(0, 1fr) auto;
min-height: 62px;
padding: 9px 14px;
text-decoration: none !important;
}
.contributorRow:last-child {
border-bottom: 0;
}
.avatar,
.avatarImage {
border-radius: 4px;
height: 34px;
width: 34px;
}
.avatar {
align-items: center;
background: var(--ifm-color-primary);
color: #ffffff;
display: inline-flex;
font-size: 11px;
justify-content: center;
}
.avatarImage {
border: 1px solid var(--bx-border);
object-fit: cover;
}
.contributorBody {
gap: 1px;
}
.contributorName {
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contributorRole,
.contributionCount {
color: var(--ifm-color-content-secondary);
font-size: 11px;
}
.contributionCount {
white-space: nowrap;
}
.sponsorBand {
align-items: center;
background: var(--bx-surface-subtle);
border-top: 1px solid var(--bx-border);
display: grid;
gap: 12px;
grid-column: 1 / -1;
grid-template-columns: auto minmax(0, 1fr) auto;
min-height: 82px;
padding: 13px 14px;
}
.sponsorBandIcon {
height: 38px;
width: 38px;
}
.sponsorBandBody {
gap: 2px;
}
.sponsorBandTitle {
font-size: 14px;
}
.sponsorBandDescription {
color: var(--ifm-color-content-secondary);
font-size: 12px;
line-height: 1.5;
}
.sponsorBand > a,
.sponsorAction {
align-items: center;
border: 1px solid var(--bx-border-strong);
border-radius: 4px;
color: var(--ifm-color-primary);
display: inline-flex;
font-size: 13px;
gap: 6px;
min-height: 38px;
padding: 0 11px;
text-decoration: none !important;
white-space: nowrap;
}
.sponsorBand > a:hover,
.sponsorBand > a:focus-visible,
.sponsorAction:hover,
.sponsorAction:focus-visible {
background: var(--bx-surface-selected);
border-color: var(--ifm-color-primary);
color: var(--ifm-color-primary);
}
.sponsorProgramHeader {
align-items: center;
background: var(--bx-surface-subtle);
border-bottom: 1px solid var(--bx-border);
display: grid;
gap: 14px;
grid-template-columns: auto minmax(0, 1fr) auto;
padding: 1.4rem;
}
.sponsorProgramIcon {
height: 44px;
width: 44px;
}
.sponsorProgramCopy {
min-width: 0;
}
.sponsorProgramTitle {
color: var(--ifm-heading-color);
font-size: 1.25rem;
font-weight: 500;
margin: 0 0 4px;
}
.sponsorProgramCopy p {
color: var(--ifm-color-content-secondary);
font-size: 0.9rem;
line-height: 1.55;
margin: 0;
max-width: 740px;
}
.sponsorFocusList {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.sponsorFocusItem {
display: grid;
gap: 11px;
grid-template-columns: auto minmax(0, 1fr);
min-height: 190px;
padding: 1.4rem;
}
.sponsorFocusItem + .sponsorFocusItem {
border-left: 1px solid var(--bx-border);
}
.sponsorFocusIcon {
height: 36px;
width: 36px;
}
.sponsorFocusBody {
align-content: start;
gap: 5px;
}
.sponsorFocusLabel {
color: var(--ifm-color-primary);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.sponsorFocusTitle {
font-size: 14px;
line-height: 1.4;
}
.sponsorFocusDescription,
.tierDescription {
color: var(--ifm-color-content-secondary);
font-size: 12px;
line-height: 1.6;
}
.tierSection {
border-top: 1px solid var(--bx-border);
}
.tierSectionHeader {
align-items: baseline;
background: var(--bx-surface-subtle);
border-bottom: 1px solid var(--bx-border);
display: flex;
gap: 14px;
justify-content: space-between;
padding: 14px;
}
.tierSectionHeader h3 {
color: var(--ifm-heading-color);
font-size: 14px;
font-weight: 500;
margin: 0;
}
.tierSectionHeader p {
color: var(--ifm-color-content-secondary);
font-size: 12px;
margin: 0;
text-align: right;
}
.tierGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.tierItem {
display: grid;
gap: 5px;
min-height: 150px;
padding: 1.25rem;
}
.tierItem + .tierItem {
border-left: 1px solid var(--bx-border);
}
.tierName {
font-size: 15px;
}
.tierAudience {
color: var(--ifm-color-primary);
font-size: 12px;
}
@media (max-width: 996px) {
@@ -369,20 +438,35 @@
padding: 4rem 0;
}
.sectionTitle {
font-size: 2rem;
}
.communityGrid {
.sectionHead {
align-items: start;
gap: 1rem;
grid-template-columns: 1fr;
}
.sponsorLogoGrid {
grid-template-columns: repeat(3, minmax(0, 1fr));
.communityShell {
grid-template-columns: 1fr;
}
.sponsorLogoTile {
min-height: 96px;
.pathPanel {
border-bottom: 1px solid var(--bx-border);
border-right: 0;
}
.sponsorFocusList,
.tierGrid {
grid-template-columns: 1fr;
}
.sponsorFocusItem,
.tierItem {
min-height: 0;
}
.sponsorFocusItem + .sponsorFocusItem,
.tierItem + .tierItem {
border-left: 0;
border-top: 1px solid var(--bx-border);
}
}
@@ -391,39 +475,35 @@
padding: 3.25rem 0;
}
.sectionTitle {
font-size: 1.75rem;
.pathItem {
grid-template-columns: auto minmax(0, 1fr) auto;
}
.sponsorWallHeader {
display: grid;
min-height: auto;
padding: 1rem;
.pathIndex,
.contributionCount {
display: none;
}
.sponsorWallAction {
.sponsorBand,
.sponsorProgramHeader {
align-items: start;
grid-template-columns: auto minmax(0, 1fr);
}
.sponsorBand > a,
.sponsorAction {
grid-column: 1 / -1;
justify-content: center;
width: 100%;
}
.sponsorLogoGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
.tierSectionHeader {
align-items: flex-start;
flex-direction: column;
gap: 2px;
}
.sponsorLogoMark {
font-size: 1.15rem;
}
.panel {
padding: 1rem;
}
}
@media (prefers-reduced-motion: reduce) {
.sponsorWallAction,
.sponsorLogoTile,
.contributorCard,
.pathItem {
transition: none;
.tierSectionHeader p {
text-align: left;
}
}

View File

@@ -1,169 +1,87 @@
import type {ReactNode} from 'react';
import Heading from '@theme/Heading';
import Translate from '@docusaurus/Translate';
import Link from '@docusaurus/Link';
import Translate from '@docusaurus/Translate';
import Heading from '@theme/Heading';
import DocIcon, {type DocIconName} from '@site/src/components/DocIcon';
import styles from './styles.module.css';
type FeatureItem = {
type DocumentationPath = {
icon: DocIconName;
title: ReactNode;
description: ReactNode;
icon: ReactNode;
link?: string;
to: string;
};
const DatabaseIcon = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M3 5v6c0 1.66 4 3 9 3s9-1.34 9-3V5" />
<path d="M3 11v6c0 1.66 4 3 9 3s9-1.34 9-3v-6" />
</svg>
);
const CloudIcon = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M18 10h-1.26A8 8 0 109 20h9a5 5 0 000-10z" />
</svg>
);
const ClockIcon = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
);
const NetworkIcon = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="9" y="2" width="6" height="6" rx="1" />
<rect x="2" y="16" width="6" height="6" rx="1" />
<rect x="16" y="16" width="6" height="6" rx="1" />
<path d="M12 8v4" />
<path d="M12 12H5v4" />
<path d="M12 12h7v4" />
</svg>
);
const ShieldIcon = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 2l9 4v6c0 5-3.5 9.5-9 10-5.5-.5-9-5-9-10V6l9-4z" />
<polyline points="9 12 11 14 15 10" />
</svg>
);
const RocketIcon = () => (
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 00-2.91-.09z" />
<path d="M12 15l-3-3a22 22 0 012-3.95A12.88 12.88 0 0122 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 01-4 2z" />
<path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" />
<path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" />
</svg>
);
const FEATURES: FeatureItem[] = [
const DOCUMENTATION_PATHS: DocumentationPath[] = [
{
title: <Translate id="feat.types.title">Many Backup Types</Translate>,
description: (
<Translate id="feat.types.desc">
Files and directories with multi-path sources, plus MySQL, PostgreSQL, SQLite, and SAP HANA all in one place.
</Translate>
),
icon: <DatabaseIcon />,
link: '/docs/features/backup-types',
icon: 'box',
title: <Translate id="feat.install.title">Install and upgrade</Translate>,
description: <Translate id="feat.install.desc">Choose Docker, Compose, or a standalone binary and keep the deployment repeatable.</Translate>,
to: '/docs/getting-started/installation',
},
{
title: <Translate id="feat.storage.title">70+ Storage Backends</Translate>,
description: (
<Translate id="feat.storage.desc">
Native Alibaba OSS, Tencent COS, Qiniu, S3, Google Drive, WebDAV, FTP plus SFTP, Azure Blob, Dropbox and more via rclone.
</Translate>
),
icon: <CloudIcon />,
link: '/docs/features/storage-backends',
icon: 'database',
title: <Translate id="feat.types.title">Protect files and databases</Translate>,
description: <Translate id="feat.types.desc">Configure file, MySQL, PostgreSQL, SQLite, and SAP HANA backup workloads.</Translate>,
to: '/docs/features/backup-types',
},
{
title: <Translate id="feat.scheduling.title">Scheduling & Retention</Translate>,
description: (
<Translate id="feat.scheduling.desc">
Cron-based schedules with a visual editor and auto-retention (by days or count), plus empty-directory cleanup.
</Translate>
),
icon: <ClockIcon />,
icon: 'storage',
title: <Translate id="feat.storage.title">Connect storage targets</Translate>,
description: <Translate id="feat.storage.desc">Use native providers or any supported rclone backend through one consistent flow.</Translate>,
to: '/docs/features/storage-backends',
},
{
title: <Translate id="feat.cluster.title">Multi-Node Cluster</Translate>,
description: (
<Translate id="feat.cluster.desc">
Outbound-only Master-Agent polling with proxy, private-CA, and SSH-bastion support. No reverse connectivity is required.
</Translate>
),
icon: <NetworkIcon />,
link: '/docs/features/multi-node',
icon: 'network',
title: <Translate id="feat.cluster.title">Build a remote-node cluster</Translate>,
description: <Translate id="feat.cluster.desc">Deploy outbound-only agents through proxies, private CAs, or SSH bastion hosts.</Translate>,
to: '/docs/features/multi-node',
},
{
title: <Translate id="feat.security.title">Secure by Default</Translate>,
description: (
<Translate id="feat.security.desc">
JWT auth, bcrypt passwords, AES-256-GCM encrypted config, optional backup encryption, and a full audit log.
</Translate>
),
icon: <ShieldIcon />,
icon: 'monitor',
title: <Translate id="feat.monitor.title">Monitor daily operations</Translate>,
description: <Translate id="feat.monitor.desc">Track task health, storage capacity, notifications, logs, and service readiness.</Translate>,
to: '/docs/operations/monitoring',
},
{
title: <Translate id="feat.deploy.title">Painless Deployment</Translate>,
description: (
<Translate id="feat.deploy.desc">
Single static binary with embedded SQLite. Docker one-click or bare-metal zero external dependencies.
</Translate>
),
icon: <RocketIcon />,
link: '/docs/getting-started/installation',
icon: 'restore',
title: <Translate id="feat.recovery.title">Recover with a tested plan</Translate>,
description: <Translate id="feat.recovery.desc">Prepare upgrades, rollback points, restore validation, and incident troubleshooting.</Translate>,
to: '/docs/operations/upgrade-recovery',
},
];
function Feature({title, description, icon, link}: FeatureItem) {
const content = (
<>
<div className={styles.iconWrap}>{icon}</div>
<Heading as="h3" className={styles.featureTitle}>{title}</Heading>
<p className={styles.featureDesc}>{description}</p>
{link && (
<span className={styles.featureLink}>
<Translate id="feat.learnMore">Learn more</Translate>
<span className={styles.featureArrow} aria-hidden="true">-&gt;</span>
</span>
)}
</>
);
if (link) {
return (
<Link to={link} className={styles.featureCardLink}>
{content}
</Link>
);
}
return <div className={styles.featureCard}>{content}</div>;
}
export default function HomepageFeatures(): ReactNode {
return (
<section className={styles.section}>
<div className="container">
<div className={styles.sectionHead}>
<div className={styles.sectionTag}>
<Translate id="section.features.tag">FEATURES</Translate>
<div>
<div className={styles.sectionTag}>
<Translate id="section.features.tag">Documentation paths</Translate>
</div>
<Heading as="h2" className={styles.sectionTitle}>
<Translate id="section.features.title">Move from deployment to recovery without guesswork</Translate>
</Heading>
</div>
<Heading as="h2" className={styles.sectionTitle}>
<Translate id="section.features.title">Everything you need, nothing you don't</Translate>
</Heading>
<p className={styles.sectionSubtitle}>
<Translate id="section.features.subtitle">
Battle-tested building blocks backup runners, storage providers, scheduling, and clustering.
Each path connects product capability to the configuration, security, and operational decisions required in production.
</Translate>
</p>
</div>
<div className={styles.grid}>
{FEATURES.map((feat, idx) => (
<Feature key={idx} {...feat} />
<div className={styles.pathGrid}>
{DOCUMENTATION_PATHS.map((path, index) => (
<Link key={path.to} to={path.to} className={styles.pathItem}>
<span className={styles.pathIndex}>{String(index + 1).padStart(2, '0')}</span>
<span className={styles.pathIcon}><DocIcon name={path.icon} size={21} /></span>
<span className={styles.pathBody}>
<span className={styles.pathTitle}>{path.title}</span>
<span className={styles.pathDescription}>{path.description}</span>
</span>
<DocIcon name="arrowRight" size={18} className={styles.pathArrow} />
</Link>
))}
</div>
</div>

View File

@@ -1,169 +1,171 @@
.section {
padding: 5.5rem 0 4.25rem;
background: var(--ifm-background-color);
padding: 5rem 0;
}
.sectionHead {
text-align: center;
max-width: 720px;
margin: 0 auto 3rem;
align-items: end;
display: grid;
gap: 3rem;
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.7fr);
margin-bottom: 2.25rem;
}
.sectionTag {
display: inline-flex;
align-items: center;
min-height: 28px;
font-size: 12px;
font-weight: 750;
letter-spacing: 0;
color: var(--ifm-color-primary);
padding: 4px 12px;
background: rgba(22, 93, 255, 0.08);
border: 1px solid rgba(22, 93, 255, 0.16);
border-radius: 8px;
margin-bottom: 1rem;
}
[data-theme='dark'] .sectionTag {
background: rgba(96, 126, 255, 0.18);
color: var(--ifm-color-primary-lighter);
font-size: 12px;
font-weight: 500;
letter-spacing: 0.06em;
margin-bottom: 0.75rem;
text-transform: uppercase;
}
.sectionTitle {
font-size: 2.35rem;
line-height: 1.2;
letter-spacing: 0;
font-weight: 750;
margin: 0 0 1rem;
color: var(--ifm-heading-color);
font-size: clamp(1.9rem, 4vw, 2.55rem);
font-weight: 500;
letter-spacing: -0.025em;
line-height: 1.18;
margin: 0;
max-width: 710px;
}
.sectionSubtitle {
font-size: 1.05rem;
line-height: 1.65;
color: var(--ifm-color-content-secondary);
font-size: 1rem;
line-height: 1.7;
margin: 0;
}
.grid {
.pathGrid {
border: 1px solid var(--bx-border);
border-radius: 4px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1.25rem;
grid-template-columns: repeat(2, minmax(0, 1fr));
overflow: hidden;
}
.pathItem {
align-items: start;
border-bottom: 1px solid var(--bx-border);
color: inherit;
display: grid;
gap: 12px;
grid-template-columns: auto auto minmax(0, 1fr) auto;
min-height: 154px;
padding: 1.4rem;
text-decoration: none !important;
}
.pathItem:nth-child(odd) {
border-right: 1px solid var(--bx-border);
}
.pathItem:nth-last-child(-n + 2) {
border-bottom: 0;
}
.pathItem:hover,
.pathItem:focus-visible {
background: var(--bx-surface-subtle);
color: inherit;
}
.pathIndex {
color: var(--ifm-color-content-secondary);
font-family: var(--ifm-font-family-monospace);
font-size: 11px;
line-height: 38px;
}
.pathIcon {
align-items: center;
background: var(--bx-surface-selected);
border: 1px solid color-mix(in srgb, var(--ifm-color-primary) 25%, var(--bx-border));
border-radius: 3px;
color: var(--ifm-color-primary);
display: inline-flex;
height: 38px;
justify-content: center;
width: 38px;
}
.pathBody {
display: grid;
gap: 7px;
min-width: 0;
padding-top: 7px;
}
.pathTitle {
color: var(--ifm-heading-color);
font-size: 1rem;
font-weight: 500;
line-height: 1.35;
}
.pathDescription {
color: var(--ifm-color-content-secondary);
font-size: 0.9rem;
line-height: 1.65;
}
.pathArrow {
color: var(--ifm-color-content-secondary);
margin-top: 10px;
}
.pathItem:hover .pathArrow,
.pathItem:focus-visible .pathArrow {
color: var(--ifm-color-primary);
}
@media (max-width: 996px) {
.section {
padding: 3.5rem 0 2rem;
padding: 4rem 0;
}
.sectionTitle {
font-size: 2rem;
}
.grid {
.sectionHead {
align-items: start;
gap: 1rem;
grid-template-columns: 1fr;
}
}
@media (min-width: 997px) and (max-width: 1200px) {
.grid {
grid-template-columns: repeat(2, 1fr);
@media (max-width: 760px) {
.pathGrid {
grid-template-columns: 1fr;
}
.pathItem,
.pathItem:nth-child(odd),
.pathItem:nth-last-child(-n + 2) {
border-bottom: 1px solid var(--bx-border);
border-right: 0;
}
.pathItem:last-child {
border-bottom: 0;
}
}
.featureCard,
.featureCardLink {
position: relative;
display: flex;
flex-direction: column;
padding: 1.75rem;
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
text-decoration: none !important;
color: inherit;
height: 100%;
}
@media (max-width: 520px) {
.section {
padding: 3.25rem 0;
}
.featureCardLink:hover {
transform: translateY(-2px);
border-color: var(--ifm-color-primary);
box-shadow: 0 12px 30px -8px rgba(22, 93, 255, 0.18);
color: inherit;
}
.pathItem {
gap: 10px;
grid-template-columns: auto minmax(0, 1fr) auto;
min-height: 0;
padding: 1rem;
}
[data-theme='dark'] .featureCard,
[data-theme='dark'] .featureCardLink {
background: rgba(255, 255, 255, 0.02);
border-color: rgba(255, 255, 255, 0.08);
}
.pathIndex {
display: none;
}
[data-theme='dark'] .featureCardLink:hover {
background: rgba(64, 128, 255, 0.05);
border-color: var(--ifm-color-primary);
box-shadow: 0 12px 30px -8px rgba(64, 128, 255, 0.25);
}
.iconWrap {
width: 48px;
height: 48px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(22, 93, 255, 0.1) 0%, rgba(20, 201, 201, 0.12) 100%);
color: var(--ifm-color-primary);
margin-bottom: 1.25rem;
}
[data-theme='dark'] .iconWrap {
background: linear-gradient(135deg, rgba(96, 126, 255, 0.15) 0%, rgba(20, 201, 201, 0.12) 100%);
color: var(--ifm-color-primary-lighter);
}
.featureTitle {
font-size: 1.15rem;
font-weight: 700;
margin: 0 0 0.6rem;
color: var(--ifm-heading-color);
letter-spacing: 0;
}
.featureDesc {
font-size: 0.95rem;
line-height: 1.65;
color: var(--ifm-color-content-secondary);
margin: 0;
flex: 1;
}
.featureLink {
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: 1rem;
font-size: 13px;
font-weight: 500;
color: var(--ifm-color-primary);
}
.featureArrow {
transition: transform 0.2s ease;
}
.featureCardLink:hover .featureArrow {
transform: translateX(4px);
}
@media (max-width: 640px) {
.sectionTitle {
font-size: 1.75rem;
}
}
@media (prefers-reduced-motion: reduce) {
.featureCard,
.featureCardLink,
.featureArrow {
transition: none;
.pathBody {
padding-top: 6px;
}
}

View File

@@ -0,0 +1,103 @@
import type {ReactNode} from 'react';
import Link from '@docusaurus/Link';
import Translate, {translate} from '@docusaurus/Translate';
import Heading from '@theme/Heading';
import DocIcon, {type DocIconName} from '@site/src/components/DocIcon';
import styles from './styles.module.css';
type GuideLink = {
icon: DocIconName;
title: ReactNode;
description: ReactNode;
to: string;
};
const GUIDE_LINKS: GuideLink[] = [
{
icon: 'box',
title: <Translate id="home.guide.install.title">Install BackupX</Translate>,
description: <Translate id="home.guide.install.desc">Docker, Compose, or a standalone binary</Translate>,
to: '/docs/getting-started/installation',
},
{
icon: 'network',
title: <Translate id="home.guide.cluster.title">Connect remote nodes</Translate>,
description: <Translate id="home.guide.cluster.desc">Agents, proxies, private CAs, and bastion hosts</Translate>,
to: '/docs/features/multi-node',
},
{
icon: 'shield',
title: <Translate id="home.guide.security.title">Harden operations</Translate>,
description: <Translate id="home.guide.security.desc">Security controls, monitoring, and audit trails</Translate>,
to: '/docs/operations/security',
},
{
icon: 'restore',
title: <Translate id="home.guide.recovery.title">Prepare recovery</Translate>,
description: <Translate id="home.guide.recovery.desc">Upgrade, rollback, restore, and troubleshoot</Translate>,
to: '/docs/operations/upgrade-recovery',
},
];
export default function HomepageHero(): ReactNode {
return (
<header className={styles.hero}>
<div className={`container ${styles.heroGrid}`}>
<div className={styles.heroContent}>
<div className={styles.badge}>
<DocIcon name="bookOpen" size={16} />
<Translate id="home.badge">BackupX documentation · v2.2.1</Translate>
</div>
<Heading as="h1" className={styles.heroTitle}>
<span className={styles.heroTitleLine}><Translate id="home.title.part1">Operate BackupX</Translate></span>
<span className={styles.heroTitleAccent}><Translate id="home.title.part2">with confidence.</Translate></span>
</Heading>
<p className={styles.heroSubtitle}>
<Translate id="home.tagline">
Deploy the control plane, connect storage and remote agents, then keep backups observable and recoverable with one practical guide set.
</Translate>
</p>
<div className={styles.actions}>
<Link className={styles.primaryAction} to="/docs/getting-started/quick-start">
<DocIcon name="terminal" size={18} />
<Translate id="home.getStarted">Start with Docker</Translate>
<DocIcon name="arrowRight" size={17} />
</Link>
<Link className={styles.secondaryAction} to="https://github.com/Awuqing/BackupX">
<DocIcon name="github" size={18} />
<Translate id="home.viewSource">View source</Translate>
</Link>
</div>
<div className={styles.supported} aria-label={translate({id: 'home.supported.label', message: 'Supported environments'})}>
<span><DocIcon name="check" size={16} /><Translate id="home.supported.docker">Docker</Translate></span>
<span><DocIcon name="check" size={16} /><Translate id="home.supported.linux">Linux</Translate></span>
<span><DocIcon name="check" size={16} /><Translate id="home.supported.windows">Windows agents</Translate></span>
</div>
</div>
<nav className={styles.guidePanel} aria-label={translate({id: 'home.guide.label', message: 'Recommended documentation paths'})}>
<div className={styles.guidePanelHeader}>
<span><Translate id="home.guide.kicker">Start here</Translate></span>
<span><Translate id="home.guide.hint">Choose a guide for the next task</Translate></span>
</div>
<div className={styles.guideList}>
{GUIDE_LINKS.map(guide => (
<Link key={guide.to} className={styles.guideLink} to={guide.to}>
<span className={styles.guideIcon}><DocIcon name={guide.icon} size={20} /></span>
<span className={styles.guideBody}>
<span className={styles.guideTitle}>{guide.title}</span>
<span className={styles.guideDescription}>{guide.description}</span>
</span>
<DocIcon name="arrowRight" size={17} className={styles.guideArrow} />
</Link>
))}
</div>
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,273 @@
.hero {
background: var(--bx-surface-subtle);
border-bottom: 1px solid var(--bx-border);
padding: 5.5rem 0 4.75rem;
}
.heroGrid {
align-items: center;
display: grid;
gap: 4.5rem;
grid-template-columns: minmax(0, 1fr) minmax(410px, 0.88fr);
}
.heroContent {
align-items: flex-start;
display: flex;
flex-direction: column;
}
.badge {
align-items: center;
background: var(--bx-surface-selected);
border: 1px solid color-mix(in srgb, var(--ifm-color-primary) 28%, var(--bx-border));
border-radius: 3px;
color: var(--ifm-color-primary);
display: inline-flex;
font-size: 13px;
font-weight: 400;
gap: 7px;
margin-bottom: 1.5rem;
min-height: 30px;
padding: 4px 9px;
}
.heroTitle {
color: var(--ifm-heading-color);
font-size: clamp(2.55rem, 5vw, 3.7rem);
font-weight: 500;
letter-spacing: -0.035em;
line-height: 1.06;
margin: 0;
max-width: 720px;
}
.heroTitleLine,
.heroTitleAccent {
display: block;
}
.heroTitleLine {
color: var(--ifm-heading-color);
}
.heroTitleAccent {
color: var(--ifm-color-primary);
}
.heroSubtitle {
color: var(--ifm-color-content-secondary);
font-size: 1.08rem;
line-height: 1.72;
margin: 1.5rem 0 0;
max-width: 650px;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 1.75rem;
}
.primaryAction,
.secondaryAction {
align-items: center;
border: 1px solid var(--ifm-color-primary);
border-radius: 4px;
display: inline-flex;
font-size: 14px;
font-weight: 400;
gap: 8px;
justify-content: center;
min-height: 44px;
padding: 0 15px;
text-decoration: none !important;
}
.primaryAction {
background: var(--ifm-color-primary);
color: #ffffff;
}
.primaryAction:hover,
.primaryAction:focus-visible {
background: var(--ifm-color-primary-dark);
border-color: var(--ifm-color-primary-dark);
color: #ffffff;
}
.secondaryAction {
background: var(--ifm-background-color);
border-color: var(--bx-border-strong);
color: var(--ifm-color-content);
}
.secondaryAction:hover,
.secondaryAction:focus-visible {
background: var(--bx-surface-selected);
border-color: var(--ifm-color-primary);
color: var(--ifm-color-primary);
}
.supported {
color: var(--ifm-color-content-secondary);
display: flex;
flex-wrap: wrap;
font-size: 13px;
gap: 10px 18px;
margin-top: 1.4rem;
}
.supported span {
align-items: center;
display: inline-flex;
gap: 5px;
}
.supported svg {
color: var(--bx-success);
}
.guidePanel {
background: var(--ifm-background-color);
border: 1px solid var(--bx-border-strong);
border-radius: 4px;
overflow: hidden;
}
.guidePanelHeader {
align-items: baseline;
border-bottom: 1px solid var(--bx-border);
display: flex;
gap: 12px;
justify-content: space-between;
padding: 14px 16px;
}
.guidePanelHeader span:first-child {
color: var(--ifm-heading-color);
font-size: 14px;
font-weight: 500;
}
.guidePanelHeader span:last-child {
color: var(--ifm-color-content-secondary);
font-size: 12px;
text-align: right;
}
.guideList {
display: grid;
}
.guideLink {
align-items: center;
border-bottom: 1px solid var(--bx-border);
color: inherit;
display: grid;
gap: 12px;
grid-template-columns: auto minmax(0, 1fr) auto;
min-height: 82px;
padding: 13px 16px;
text-decoration: none !important;
}
.guideLink:last-child {
border-bottom: 0;
}
.guideLink:hover,
.guideLink:focus-visible {
background: var(--bx-surface-selected);
color: inherit;
}
.guideIcon {
align-items: center;
background: var(--bx-surface-subtle);
border: 1px solid var(--bx-border);
border-radius: 3px;
color: var(--ifm-color-primary);
display: inline-flex;
height: 38px;
justify-content: center;
width: 38px;
}
.guideBody {
display: grid;
gap: 2px;
min-width: 0;
}
.guideTitle {
color: var(--ifm-heading-color);
font-size: 14px;
font-weight: 500;
}
.guideDescription {
color: var(--ifm-color-content-secondary);
font-size: 12px;
line-height: 1.5;
}
.guideArrow {
color: var(--ifm-color-content-secondary);
}
.guideLink:hover .guideArrow,
.guideLink:focus-visible .guideArrow {
color: var(--ifm-color-primary);
}
@media (max-width: 996px) {
.hero {
padding: 4.25rem 0 3.75rem;
}
.heroGrid {
gap: 2.75rem;
grid-template-columns: 1fr;
}
.heroContent {
max-width: 760px;
}
}
@media (max-width: 640px) {
.hero {
padding: 3.25rem 0 2.75rem;
}
.heroTitle {
font-size: 2.35rem;
}
.heroSubtitle {
font-size: 1rem;
}
.actions,
.primaryAction,
.secondaryAction {
width: 100%;
}
.guidePanelHeader {
align-items: flex-start;
flex-direction: column;
gap: 2px;
}
.guidePanelHeader span:last-child {
text-align: left;
}
.guideLink {
min-height: 78px;
padding: 12px;
}
}

View File

@@ -2,15 +2,18 @@ import type {ReactNode} from 'react';
import {useState} from 'react';
import clsx from 'clsx';
import Heading from '@theme/Heading';
import Translate from '@docusaurus/Translate';
import Translate, {translate} from '@docusaurus/Translate';
import useBaseUrl from '@docusaurus/useBaseUrl';
import Link from '@docusaurus/Link';
import DocIcon, {type DocIconName} from '@site/src/components/DocIcon';
import styles from './styles.module.css';
type Tab = {
id: string;
label: ReactNode;
icon: DocIconName;
image: string;
imageAlt: string;
title: ReactNode;
description: ReactNode;
};
@@ -20,44 +23,64 @@ function useTabs(): Tab[] {
{
id: 'dashboard',
label: <Translate id="showcase.tab.dashboard">Dashboard</Translate>,
icon: 'monitor',
image: useBaseUrl('/img/screenshots/dashboard.png'),
title: <Translate id="showcase.dashboard.title">Know at a glance</Translate>,
imageAlt: translate({
id: 'showcase.dashboard.alt',
message: 'BackupX dashboard showing 30-day backup trends, storage distribution, task volume and cluster health',
}),
title: <Translate id="showcase.dashboard.title">Operations at a glance</Translate>,
description: (
<Translate id="showcase.dashboard.desc">
Backup success rates, storage usage, recent runs and upcoming schedules all on one page with live data.
Track 30-day success and failure trends, storage distribution, task counts, data volume and recent runs from one live operations view.
</Translate>
),
},
{
id: 'tasks',
label: <Translate id="showcase.tab.tasks">Backup Tasks</Translate>,
icon: 'database',
image: useBaseUrl('/img/screenshots/backup-tasks.png'),
title: <Translate id="showcase.tasks.title">Visual task editor</Translate>,
imageAlt: translate({
id: 'showcase.tasks.alt',
message: 'BackupX task list showing schedules, storage targets, retention, tags, RPO goals and verification status',
}),
title: <Translate id="showcase.tasks.title">Policies you can scan</Translate>,
description: (
<Translate id="showcase.tasks.desc">
Files, MySQL, PostgreSQL, SQLite and SAP HANA with a three-step wizard. Cron editor, multi-target dispatch, retention, compression and encryption point and click.
Review schedules, multi-target policies, retention, tags, RPO goals and verification status together, then act on any task in one click.
</Translate>
),
},
{
id: 'storage',
label: <Translate id="showcase.tab.storage">Storage Targets</Translate>,
icon: 'storage',
image: useBaseUrl('/img/screenshots/storage-targets.png'),
title: <Translate id="showcase.storage.title">70+ backends, one flow</Translate>,
imageAlt: translate({
id: 'showcase.storage.alt',
message: 'BackupX storage targets showing connection health, live capacity, favourites and redundancy roles',
}),
title: <Translate id="showcase.storage.title">Every target, one view</Translate>,
description: (
<Translate id="showcase.storage.desc">
Alibaba OSS, Tencent COS, S3, Google Drive, WebDAV plus every rclone backend behind a uniform form. Test connection, favourite, and view live usage.
Compare connection health, live capacity, favourites and redundancy roles across local disks and 70+ remote backends.
</Translate>
),
},
{
id: 'nodes',
label: <Translate id="showcase.tab.nodes">Multi-Node</Translate>,
icon: 'network',
image: useBaseUrl('/img/screenshots/nodes.png'),
title: <Translate id="showcase.nodes.title">Master-Agent in minutes</Translate>,
imageAlt: translate({
id: 'showcase.nodes.alt',
message: 'BackupX node list showing health, Agent versions, queue depth, labels and heartbeat times',
}),
title: <Translate id="showcase.nodes.title">Cluster health in one view</Translate>,
description: (
<Translate id="showcase.nodes.desc">
Create a node, copy the token, start the Agent on any remote host. Tasks routed to a node run locally there and upload directly to storage no reverse connectivity required.
Monitor health, Agent versions, queue depth, labels and heartbeat time across the local Master and every remote node.
</Translate>
),
},
@@ -72,45 +95,55 @@ export default function HomepageShowcase(): ReactNode {
<section className={styles.section}>
<div className="container">
<div className={styles.sectionHead}>
<div className={styles.sectionTag}>
<Translate id="showcase.tag">PRODUCT</Translate>
<div>
<div className={styles.sectionTag}>
<Translate id="showcase.tag">Product interface</Translate>
</div>
<Heading as="h2" className={styles.sectionTitle}>
<Translate id="showcase.title">See the workflow before you deploy</Translate>
</Heading>
</div>
<Heading as="h2" className={styles.sectionTitle}>
<Translate id="showcase.title">A polished console, not a DIY script</Translate>
</Heading>
<p className={styles.sectionSubtitle}>
<Translate id="showcase.subtitle">
Every screen designed for day-2 operations visibility first, configuration second.
Screenshots stay connected to the guide that explains the underlying task, configuration, and operating model.
</Translate>
</p>
</div>
<div className={styles.tabs}>
<div className={styles.tabs} role="tablist" aria-label={translate({id: 'showcase.tabs.label', message: 'BackupX product screens'})}>
{tabs.map(tab => (
<button
key={tab.id}
id={`showcase-tab-${tab.id}`}
type="button"
role="tab"
aria-selected={active === tab.id}
aria-controls="showcase-panel"
className={clsx(styles.tabBtn, active === tab.id && styles.tabBtnActive)}
onClick={() => setActive(tab.id)}>
<DocIcon name={tab.icon} size={17} />
{tab.label}
</button>
))}
</div>
<div className={styles.stage}>
<div className={styles.browser}>
<div className={styles.browserBar}>
<span className={clsx(styles.browserDot, styles.browserDotRed)} />
<span className={clsx(styles.browserDot, styles.browserDotYellow)} />
<span className={clsx(styles.browserDot, styles.browserDotGreen)} />
<div className={styles.browserUrl}>backupx.local</div>
<div
id="showcase-panel"
role="tabpanel"
aria-labelledby={`showcase-tab-${current.id}`}
className={styles.stage}>
<div className={styles.preview}>
<div className={styles.previewBar}>
<span><DocIcon name="monitor" size={16} /><Translate id="showcase.preview.label">BackupX console</Translate></span>
<code>backupx.local</code>
</div>
<img src={current.image} alt="" className={styles.screenshot} />
<img src={current.image} alt={current.imageAlt} className={styles.screenshot} />
</div>
<div className={styles.caption}>
<div className={styles.captionKicker}>{current.label}</div>
<Heading as="h3" className={styles.captionTitle}>{current.title}</Heading>
<p className={styles.captionDesc}>{current.description}</p>
<Link to="/docs/getting-started/quick-start" className={styles.captionLink}>
<Translate id="showcase.cta">Explore the docs</Translate>
<span aria-hidden="true"> -&gt;</span>
<DocIcon name="arrowRight" size={17} />
</Link>
</div>
</div>

View File

@@ -1,247 +1,224 @@
.section {
padding: 4.5rem 0 5.5rem;
background:
linear-gradient(180deg, rgba(245, 247, 250, 0) 0%, rgba(245, 247, 250, 0.72) 100%),
var(--ifm-background-color);
}
[data-theme='dark'] .section {
background:
linear-gradient(180deg, rgba(15, 17, 21, 0) 0%, rgba(255, 255, 255, 0.03) 100%),
var(--ifm-background-color);
background: var(--bx-surface-subtle);
border-bottom: 1px solid var(--bx-border);
border-top: 1px solid var(--bx-border);
padding: 5rem 0;
}
.sectionHead {
text-align: center;
max-width: 720px;
margin: 0 auto 2.5rem;
align-items: end;
display: grid;
gap: 3rem;
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.7fr);
margin-bottom: 2.25rem;
}
.sectionTag {
display: inline-flex;
align-items: center;
min-height: 28px;
color: var(--ifm-color-primary);
font-size: 12px;
font-weight: 750;
letter-spacing: 0;
color: #0e7490;
padding: 4px 12px;
background: rgba(20, 201, 201, 0.1);
border: 1px solid rgba(20, 201, 201, 0.2);
border-radius: 8px;
margin-bottom: 1rem;
}
[data-theme='dark'] .sectionTag {
background: rgba(20, 201, 201, 0.16);
color: #67e8f9;
font-weight: 500;
letter-spacing: 0.06em;
margin-bottom: 0.75rem;
text-transform: uppercase;
}
.sectionTitle {
font-size: 2.35rem;
line-height: 1.2;
letter-spacing: 0;
font-weight: 750;
margin: 0 0 1rem;
color: var(--ifm-heading-color);
font-size: clamp(1.9rem, 4vw, 2.55rem);
font-weight: 500;
letter-spacing: -0.025em;
line-height: 1.18;
margin: 0;
max-width: 700px;
}
.sectionSubtitle {
font-size: 1.05rem;
line-height: 1.65;
color: var(--ifm-color-content-secondary);
font-size: 1rem;
line-height: 1.7;
margin: 0;
}
/* Tab bar */
.tabs {
border: 1px solid var(--bx-border);
border-bottom: 0;
border-radius: 4px 4px 0 0;
display: flex;
justify-content: center;
gap: 6px;
margin-bottom: 2rem;
flex-wrap: wrap;
gap: 4px;
overflow-x: auto;
padding: 6px;
background: var(--ifm-color-emphasis-100);
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: 8px;
}
.tabBtn {
min-height: 40px;
padding: 8px 18px;
align-items: center;
background: transparent;
border: 1px solid transparent;
border-radius: 8px;
border-radius: 3px;
color: var(--ifm-color-content-secondary);
font-size: 14px;
font-weight: 650;
cursor: pointer;
transition: color 0.2s ease, border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease;
display: inline-flex;
flex: 1 0 auto;
font-size: 14px;
font-weight: 400;
gap: 7px;
justify-content: center;
min-height: 40px;
padding: 7px 14px;
}
.tabBtn:hover {
color: var(--ifm-color-primary);
background: var(--ifm-background-color);
border-color: var(--bx-border);
color: var(--ifm-color-primary);
}
.tabBtnActive,
.tabBtnActive:hover {
background: var(--ifm-background-color);
color: var(--ifm-color-primary) !important;
border-color: rgba(22, 93, 255, 0.18);
box-shadow: 0 6px 16px rgba(22, 93, 255, 0.12);
background: var(--bx-surface-selected);
border-color: color-mix(in srgb, var(--ifm-color-primary) 30%, var(--bx-border));
color: var(--ifm-color-primary);
}
/* Stage */
.stage {
background: var(--ifm-background-color);
border: 1px solid var(--bx-border);
border-radius: 0 0 4px 4px;
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 3rem;
align-items: center;
}
@media (max-width: 996px) {
.stage {
grid-template-columns: 1fr;
gap: 1.5rem;
}
}
.browser {
background: var(--ifm-background-color);
border-radius: 8px;
grid-template-columns: minmax(0, 1.6fr) minmax(300px, 0.74fr);
overflow: hidden;
box-shadow:
0 24px 58px -22px rgba(22, 93, 255, 0.28),
0 0 0 1px var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .browser {
box-shadow:
0 30px 60px -20px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(255, 255, 255, 0.06);
.preview {
border-right: 1px solid var(--bx-border);
min-width: 0;
}
.browserBar {
display: flex;
.previewBar {
align-items: center;
gap: 6px;
padding: 10px 14px;
background: var(--ifm-color-emphasis-100);
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .browserBar {
background: rgba(255, 255, 255, 0.03);
border-bottom-color: rgba(255, 255, 255, 0.06);
}
.browserDot {
width: 11px;
height: 11px;
border-radius: 50%;
}
.browserDotRed { background: #ff5f56; }
.browserDotYellow { background: #ffbd2e; }
.browserDotGreen { background: #27c93f; }
.browserUrl {
margin: 0 auto;
padding: 3px 14px;
background: var(--ifm-background-color);
border-radius: 8px;
font-size: 12px;
background: var(--bx-surface-subtle);
border-bottom: 1px solid var(--bx-border);
color: var(--ifm-color-content-secondary);
font-family: 'SFMono-Regular', Menlo, monospace;
border: 1px solid var(--ifm-color-emphasis-200);
display: flex;
font-size: 12px;
justify-content: space-between;
min-height: 42px;
padding: 8px 12px;
}
[data-theme='dark'] .browserUrl {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.06);
.previewBar span {
align-items: center;
display: inline-flex;
gap: 7px;
}
.previewBar code {
background: var(--ifm-background-color);
border: 1px solid var(--bx-border);
border-radius: 3px;
color: var(--ifm-color-content-secondary);
font-size: 11px;
padding: 2px 7px;
}
.screenshot {
background: var(--ifm-background-color);
display: block;
width: 100%;
height: auto;
background: var(--ifm-color-emphasis-100);
width: 100%;
}
.caption {
padding: 0 1rem;
align-content: center;
display: grid;
padding: 2rem;
}
@media (max-width: 996px) {
.caption {
padding: 0;
}
.captionKicker {
color: var(--ifm-color-primary);
font-size: 12px;
font-weight: 500;
letter-spacing: 0.05em;
margin-bottom: 0.75rem;
text-transform: uppercase;
}
.captionTitle {
font-size: 1.7rem;
line-height: 1.2;
letter-spacing: 0;
font-weight: 750;
margin: 0 0 1rem;
color: var(--ifm-heading-color);
font-size: 1.65rem;
font-weight: 500;
letter-spacing: -0.02em;
line-height: 1.25;
margin: 0 0 0.9rem;
}
.captionDesc {
font-size: 1.05rem;
line-height: 1.7;
color: var(--ifm-color-content-secondary);
font-size: 0.96rem;
line-height: 1.7;
margin: 0 0 1.25rem;
}
.captionLink {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 40px;
padding: 0 12px;
border: 1px solid rgba(22, 93, 255, 0.18);
border-radius: 8px;
font-weight: 650;
color: var(--ifm-color-primary);
display: inline-flex;
font-size: 14px;
font-weight: 500;
gap: 6px;
justify-self: start;
text-decoration: none !important;
transition: border-color 0.2s ease, background 0.2s ease;
}
.captionLink:hover {
.captionLink:hover,
.captionLink:focus-visible {
color: var(--ifm-color-primary-dark);
background: rgba(22, 93, 255, 0.06);
border-color: var(--ifm-color-primary);
}
@media (max-width: 996px) {
.sectionTitle {
font-size: 2rem;
.section {
padding: 4rem 0;
}
.sectionHead {
align-items: start;
gap: 1rem;
grid-template-columns: 1fr;
}
.stage {
grid-template-columns: 1fr;
}
.preview {
border-bottom: 1px solid var(--bx-border);
border-right: 0;
}
.caption {
padding: 1.5rem;
}
}
@media (max-width: 640px) {
.section {
padding: 3.25rem 0 4rem;
}
.sectionTitle {
font-size: 1.75rem;
padding: 3.25rem 0;
}
.tabs {
justify-content: stretch;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.tabBtn {
flex: 1 1 130px;
min-width: 0;
padding: 7px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.tabBtn,
.captionLink {
transition: none;
.previewBar code {
display: none;
}
.caption {
padding: 1.25rem;
}
}

View File

@@ -1,122 +1,120 @@
/**
* BackupX 官方文档站样式
* 灵感Ant Design / Arco Design
* BackupX documentation UI
* Flat, operational and consistent with the product interface.
*/
:root {
/* Primary palette (Arco blue) */
--ifm-color-primary: #165dff;
--ifm-color-primary-dark: #0e4fe6;
--ifm-color-primary-darker: #0b4bd9;
--ifm-color-primary-darkest: #093eb3;
--ifm-color-primary-light: #2f6cff;
--ifm-color-primary-lighter: #3d75ff;
--ifm-color-primary-lightest: #668eff;
--ifm-color-primary: #175cd3;
--ifm-color-primary-dark: #1452bd;
--ifm-color-primary-darker: #124bad;
--ifm-color-primary-darkest: #0e3c8a;
--ifm-color-primary-light: #2a6bd7;
--ifm-color-primary-lighter: #3978dc;
--ifm-color-primary-lightest: #6d9be5;
/* Surfaces */
--ifm-background-color: #ffffff;
--ifm-background-surface-color: #ffffff;
--ifm-color-emphasis-100: #f5f7fa;
--ifm-color-emphasis-200: #e5e6eb;
--ifm-color-emphasis-300: #c9cdd4;
--ifm-color-emphasis-400: #a9aeb8;
--ifm-color-emphasis-100: #f6f7f9;
--ifm-color-emphasis-200: #e4e7ec;
--ifm-color-emphasis-300: #d0d5dd;
--ifm-color-emphasis-400: #98a2b3;
--ifm-color-emphasis-600: #475467;
--ifm-color-content: #202939;
--ifm-color-content-secondary: #596579;
--ifm-heading-color: #111827;
/* Typography */
--ifm-font-family-base: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
--ifm-font-family-monospace: 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
--ifm-heading-font-weight: 700;
--ifm-font-family-base: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
--ifm-font-family-monospace: ui-monospace, 'SFMono-Regular', Menlo, Monaco, Consolas, monospace;
--ifm-font-weight-bold: 500;
--ifm-heading-font-weight: 500;
--ifm-h1-font-size: 2.15rem;
--ifm-h2-font-size: 1.6rem;
--ifm-h3-font-size: 1.24rem;
--ifm-line-height-base: 1.72;
--ifm-code-font-size: 92%;
--ifm-h1-font-size: 2.25rem;
--ifm-h2-font-size: 1.75rem;
--ifm-h3-font-size: 1.35rem;
--ifm-line-height-base: 1.7;
--ifm-global-radius: 4px;
--ifm-global-shadow-lw: none;
--ifm-global-shadow-md: none;
--ifm-global-shadow-tl: none;
--ifm-container-width: 1160px;
--ifm-container-width-xl: 1280px;
--ifm-color-content: #1d2129;
--ifm-color-content-secondary: #4e5969;
--ifm-heading-color: #1d2129;
--ifm-global-radius: 8px;
--ifm-navbar-height: 58px;
--ifm-navbar-background-color: #ffffff;
--ifm-navbar-link-color: #475467;
--ifm-navbar-link-hover-color: #175cd3;
--ifm-navbar-shadow: none;
/* Navbar */
--ifm-navbar-height: 64px;
--ifm-navbar-background-color: rgba(255, 255, 255, 0.9);
--ifm-navbar-link-color: #4e5969;
--ifm-navbar-link-hover-color: var(--ifm-color-primary);
--ifm-menu-color: #475467;
--ifm-menu-color-background-active: #eef4ff;
--ifm-menu-color-background-hover: #f6f7f9;
--ifm-toc-border-color: transparent;
/* Sidebar */
--ifm-menu-color: #4e5969;
--ifm-menu-color-background-active: rgba(22, 93, 255, 0.08);
--ifm-menu-color-background-hover: var(--ifm-color-emphasis-100);
--ifm-code-background: #f2f4f7;
--docusaurus-highlighted-code-line-bg: #eaf1fb;
/* Code */
--ifm-code-background: rgba(22, 93, 255, 0.06);
--docusaurus-highlighted-code-line-bg: rgba(22, 93, 255, 0.08);
/* Hero background helper (consumed in index.module.css) */
--bx-hero-bg: transparent;
--bx-border: #e4e7ec;
--bx-border-strong: #d0d5dd;
--bx-surface-subtle: #f8f9fb;
--bx-surface-selected: #eef4ff;
--bx-success: #168453;
--bx-warning: #b54708;
}
[data-theme='dark'] {
--ifm-color-primary: #4080ff;
--ifm-color-primary-dark: #3371f2;
--ifm-color-primary-darker: #2c6ae6;
--ifm-color-primary-darkest: #2359c7;
--ifm-color-primary-light: #5a93ff;
--ifm-color-primary-lighter: #74a5ff;
--ifm-color-primary-lightest: #9dbfff;
--ifm-color-primary: #84adff;
--ifm-color-primary-dark: #6f9df5;
--ifm-color-primary-darker: #5f8fe9;
--ifm-color-primary-darkest: #4776c9;
--ifm-color-primary-light: #98bbff;
--ifm-color-primary-lighter: #aac7ff;
--ifm-color-primary-lightest: #c7d8ff;
--ifm-background-color: #0f1115;
--ifm-background-surface-color: #16181d;
--ifm-color-emphasis-100: #1d2129;
--ifm-color-emphasis-200: #272e3b;
--ifm-color-emphasis-300: #384252;
--ifm-color-emphasis-400: #4e5969;
--ifm-background-color: #0f141c;
--ifm-background-surface-color: #151b24;
--ifm-color-emphasis-100: #1a222d;
--ifm-color-emphasis-200: #273240;
--ifm-color-emphasis-300: #344054;
--ifm-color-emphasis-400: #667085;
--ifm-color-emphasis-600: #98a2b3;
--ifm-color-content: #d0d5dd;
--ifm-color-content-secondary: #98a2b3;
--ifm-heading-color: #f2f4f7;
--ifm-color-content: #e6e9ef;
--ifm-color-content-secondary: #9aa3b2;
--ifm-heading-color: #f0f2f5;
--ifm-navbar-background-color: #0f141c;
--ifm-navbar-link-color: #c7ced8;
--ifm-menu-color: #c7ced8;
--ifm-menu-color-background-active: #1c2c48;
--ifm-menu-color-background-hover: #1a222d;
--ifm-navbar-background-color: rgba(15, 17, 21, 0.9);
--ifm-navbar-link-color: #c9d1db;
--ifm-code-background: #1c2531;
--docusaurus-highlighted-code-line-bg: #213352;
--ifm-menu-color: #c9d1db;
--ifm-menu-color-background-active: rgba(64, 128, 255, 0.15);
--ifm-menu-color-background-hover: rgba(255, 255, 255, 0.04);
--ifm-code-background: rgba(64, 128, 255, 0.14);
--docusaurus-highlighted-code-line-bg: rgba(64, 128, 255, 0.18);
--bx-border: #273240;
--bx-border-strong: #344054;
--bx-surface-subtle: #131a23;
--bx-surface-selected: #1c2c48;
--bx-success: #6ce9a6;
--bx-warning: #fec84b;
}
/* Frosted-glass navbar */
.navbar {
backdrop-filter: saturate(180%) blur(10px);
-webkit-backdrop-filter: saturate(180%) blur(10px);
border-bottom: 1px solid var(--ifm-color-emphasis-200);
box-shadow: none;
html {
scroll-padding-top: calc(var(--ifm-navbar-height) + 20px);
}
[data-theme='dark'] .navbar {
border-bottom-color: rgba(255, 255, 255, 0.06);
body {
text-rendering: optimizeLegibility;
}
.navbar__title {
font-weight: 700;
letter-spacing: 0;
}
.navbar__link {
strong,
b {
font-weight: 500;
font-size: 14px;
}
.navbar__link,
.button,
a {
transition: color 0.2s ease, background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
}
a,
button,
.button {
border-radius: 8px;
font-weight: 650;
transition: color 160ms ease, background-color 160ms ease, border-color 160ms ease;
}
:focus-visible {
@@ -124,132 +122,371 @@ a {
outline-offset: 2px;
}
/* Sidebar tweaks */
.menu__link {
.button {
border-radius: 4px;
font-weight: 400;
}
.button--primary {
color: #ffffff;
}
.navbar {
border-bottom: 1px solid var(--bx-border);
}
.navbar__brand {
gap: 8px;
margin-right: 1.5rem;
}
.navbar__logo {
height: 28px;
}
.navbar__title {
font-size: 1rem;
font-weight: 500;
letter-spacing: 0;
}
.navbar__link {
font-size: 14px;
border-radius: 8px;
padding: 6px 10px;
line-height: 1.4;
font-weight: 400;
}
.navbar__item.dropdown .navbar__link::after {
border-width: 0 1px 1px 0;
height: 6px;
margin-left: 7px;
transform: rotate(45deg) translateY(-2px);
width: 6px;
}
.dropdown__menu {
background: var(--ifm-background-surface-color);
border: 1px solid var(--bx-border);
border-radius: 4px;
padding: 4px;
}
.dropdown__link {
border-radius: 2px;
font-size: 14px;
}
.navbar-sidebar {
background: var(--ifm-background-color);
}
.navbar-sidebar__brand {
border-bottom: 1px solid var(--bx-border);
}
.theme-doc-sidebar-container {
border-right: 1px solid var(--bx-border) !important;
}
.theme-doc-sidebar-menu {
padding: 1rem 0.75rem 2rem;
}
.menu__list-item-collapsible {
border-radius: 4px;
}
.menu__link {
border-radius: 3px;
font-size: 14px;
font-weight: 400;
line-height: 1.45;
padding: 7px 10px;
}
.menu__link--active,
.menu__link--active:hover {
font-weight: 600;
color: var(--ifm-color-primary);
font-weight: 500;
}
.theme-doc-sidebar-container {
border-right: 1px solid var(--ifm-color-emphasis-200) !important;
.menu__caret::before,
.menu__link--sublist-caret::after {
background-size: 1.15rem 1.15rem;
}
[data-theme='dark'] .theme-doc-sidebar-container {
border-right-color: rgba(255, 255, 255, 0.06) !important;
.breadcrumbs {
align-items: center;
font-size: 13px;
margin-bottom: 1.5rem;
}
.breadcrumbs__link {
background: transparent;
border-radius: 2px;
color: var(--ifm-color-content-secondary);
padding: 3px 5px;
}
.breadcrumbs__item--active .breadcrumbs__link {
color: var(--ifm-color-content);
}
.theme-doc-markdown {
max-width: 860px;
}
.theme-doc-markdown header + h1,
.theme-doc-markdown > h1:first-child {
font-size: clamp(1.9rem, 4vw, 2.35rem);
line-height: 1.2;
margin-bottom: 1rem;
}
.markdown > p:first-of-type {
color: var(--ifm-color-content-secondary);
font-size: 1.04rem;
}
/* Article: better heading rhythm */
.markdown h2 {
margin-top: 2.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .markdown h2 {
border-top-color: rgba(255, 255, 255, 0.06);
border-top: 1px solid var(--bx-border);
margin-top: 2.75rem;
padding-top: 1.1rem;
}
.markdown h3 {
margin-top: 2rem;
}
/* Tables */
.markdown h2,
.markdown h3,
.markdown h4 {
font-weight: 500;
letter-spacing: 0;
}
.markdown a:not(.hash-link) {
text-decoration-color: color-mix(in srgb, var(--ifm-color-primary) 45%, transparent);
text-underline-offset: 3px;
}
.markdown ul,
.markdown ol {
padding-left: 1.35rem;
}
.markdown li + li {
margin-top: 0.35rem;
}
.markdown blockquote {
background: var(--bx-surface-subtle);
border: 1px solid var(--bx-border);
border-radius: 4px;
color: var(--ifm-color-content-secondary);
margin: 1.5rem 0;
padding: 0.9rem 1rem;
}
.markdown blockquote > :last-child {
margin-bottom: 0;
}
.markdown table {
border-radius: 8px;
overflow: hidden;
box-shadow: 0 0 0 1px var(--ifm-color-emphasis-200);
border: 1px solid var(--bx-border);
border-collapse: separate;
border-radius: 4px;
border-spacing: 0;
display: table;
overflow: hidden;
width: 100%;
}
.markdown table thead tr {
background: var(--ifm-color-emphasis-100);
background: var(--bx-surface-subtle);
}
.markdown table th,
.markdown table td {
border: none;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
padding: 10px 14px;
border: 0;
border-bottom: 1px solid var(--bx-border);
border-right: 1px solid var(--bx-border);
padding: 9px 12px;
text-align: left;
}
.markdown table th {
font-weight: 500;
}
.markdown table th:last-child,
.markdown table td:last-child {
border-right: 0;
}
.markdown table tr:last-child td {
border-bottom: none;
border-bottom: 0;
}
/* Inline code */
code {
background: var(--ifm-code-background);
border: none;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid var(--bx-border);
border-radius: 3px;
font-size: 0.92em;
padding: 1px 5px;
}
pre code {
border: 0;
padding: 0;
}
.theme-code-block {
border: 1px solid var(--bx-border);
border-radius: 4px;
overflow: hidden;
}
.theme-code-block pre {
border-radius: 0;
}
/* Admonitions: softer */
.theme-admonition {
border-radius: 8px;
border-width: 1px;
border-left-width: 4px;
border: 1px solid var(--bx-border-strong) !important;
border-left-width: 1px !important;
border-radius: 4px;
}
.theme-admonition-heading h5 {
font-weight: 500;
}
details {
background: var(--bx-surface-subtle);
border: 1px solid var(--bx-border) !important;
border-radius: 4px;
}
details > summary {
font-weight: 500;
}
.table-of-contents {
border-left: 0;
border-top: 1px solid var(--bx-border);
font-size: 13px;
padding: 0.8rem 0 0;
}
.table-of-contents__link {
color: var(--ifm-color-content-secondary);
}
.table-of-contents__link--active {
color: var(--ifm-color-primary);
font-weight: 500;
}
.pagination-nav {
gap: 12px;
}
.pagination-nav__link {
border: 1px solid var(--bx-border);
border-radius: 4px;
padding: 0.9rem 1rem;
}
.pagination-nav__link:hover {
background: var(--bx-surface-subtle);
border-color: var(--ifm-color-primary);
}
.pagination-nav__sublabel {
color: var(--ifm-color-content-secondary);
font-size: 12px;
font-weight: 400;
}
.pagination-nav__label {
font-size: 14px;
font-weight: 500;
}
.theme-edit-this-page,
.theme-last-updated {
font-size: 13px;
}
/* Footer */
.footer {
--ifm-footer-background-color: #141720;
--ifm-footer-color: #9aa3b2;
--ifm-footer-link-color: #c9d1db;
--ifm-footer-background-color: #101828;
--ifm-footer-color: #98a2b3;
--ifm-footer-link-color: #d0d5dd;
--ifm-footer-link-hover-color: #ffffff;
--ifm-footer-title-color: #f0f2f5;
padding: 3.5rem 0 2.5rem;
--ifm-footer-title-color: #f2f4f7;
border-top: 1px solid #273240;
padding: 3rem 0 2rem;
}
.footer__title {
font-size: 13px;
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 600;
font-weight: 500;
letter-spacing: 0;
}
.footer__link-item {
font-size: 14px;
transition: color 0.15s ease;
}
.footer__bottom {
border-top: 1px solid rgba(255, 255, 255, 0.06);
padding-top: 2rem;
margin-top: 2.5rem;
border-top: 1px solid #273240;
margin-top: 2rem;
padding-top: 1.5rem;
}
.footer__copyright {
color: #98a2b3;
font-size: 13px;
color: #6b7280;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 10px;
height: 10px;
width: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--ifm-color-emphasis-300);
border-radius: 10px;
border: 2px solid var(--ifm-background-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--ifm-color-emphasis-400);
}
[data-theme='dark'] ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
@media (max-width: 996px) {
:root {
--ifm-navbar-height: 54px;
}
.theme-doc-markdown {
max-width: none;
}
.markdown table {
display: block;
overflow-x: auto;
}
}
@media (max-width: 576px) {
.pagination-nav {
display: grid;
grid-template-columns: 1fr;
}
.footer {
padding-top: 2.25rem;
}
}
@media (prefers-reduced-motion: reduce) {

View File

@@ -1,470 +0,0 @@
/* Hero */
.hero {
position: relative;
overflow: hidden;
padding: 7rem 0 5.5rem;
background:
linear-gradient(180deg, rgba(22, 93, 255, 0.08) 0%, rgba(255, 255, 255, 0) 72%),
linear-gradient(90deg, rgba(20, 201, 201, 0.08) 0%, rgba(250, 173, 20, 0.08) 100%),
var(--ifm-background-color);
}
.hero::before {
position: absolute;
inset: 0;
content: "";
pointer-events: none;
background-image:
linear-gradient(rgba(22, 93, 255, 0.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(22, 93, 255, 0.06) 1px, transparent 1px);
background-size: 44px 44px;
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.75), transparent 82%);
}
[data-theme='dark'] .hero {
background:
linear-gradient(180deg, rgba(64, 128, 255, 0.16) 0%, rgba(15, 17, 21, 0) 72%),
linear-gradient(90deg, rgba(20, 201, 201, 0.1) 0%, rgba(250, 173, 20, 0.08) 100%),
var(--ifm-background-color);
}
.heroInner {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(420px, 0.9fr);
gap: 4rem;
align-items: center;
}
.heroContent {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1.25rem;
}
.badge {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 32px;
padding: 5px 12px;
color: var(--ifm-color-primary);
background: rgba(22, 93, 255, 0.09);
border: 1px solid rgba(22, 93, 255, 0.2);
border-radius: 8px;
font-size: 13px;
font-weight: 600;
}
[data-theme='dark'] .badge {
background: rgba(64, 128, 255, 0.16);
border-color: rgba(64, 128, 255, 0.3);
color: var(--ifm-color-primary-lighter);
}
.badgeDot {
width: 7px;
height: 7px;
background: #00b42a;
border-radius: 50%;
box-shadow: 0 0 0 4px rgba(0, 180, 42, 0.12);
}
.heroTitle {
margin: 0;
color: var(--ifm-heading-color);
font-size: 3.45rem;
font-weight: 750;
letter-spacing: 0;
line-height: 1.08;
}
.heroTitleAccent {
display: block;
margin-top: 8px;
color: var(--ifm-color-primary);
}
.heroSubtitle {
max-width: 640px;
margin: 0;
color: var(--ifm-color-content-secondary);
font-size: 1.15rem;
line-height: 1.72;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 8px;
}
.primaryBtn,
.secondaryBtn {
min-height: 46px;
border-radius: 8px;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
.primaryBtn {
display: inline-flex;
align-items: center;
gap: 8px;
color: #fff;
background: #165dff;
border: 1px solid #165dff;
box-shadow: 0 10px 24px rgba(22, 93, 255, 0.24);
font-weight: 650;
}
.primaryBtn:hover,
.primaryBtn:focus-visible {
color: #fff;
background: #0e4fe6;
border-color: #0e4fe6;
box-shadow: 0 14px 30px rgba(22, 93, 255, 0.3);
transform: translateY(-1px);
}
.btnArrow {
transition: transform 0.2s ease;
}
.primaryBtn:hover .btnArrow,
.primaryBtn:focus-visible .btnArrow {
transform: translateX(3px);
}
.secondaryBtn {
display: inline-flex;
align-items: center;
color: var(--ifm-font-color-base);
background: var(--ifm-background-color);
border: 1px solid var(--ifm-color-emphasis-300);
font-weight: 600;
}
.secondaryBtn:hover,
.secondaryBtn:focus-visible {
color: var(--ifm-color-primary);
border-color: var(--ifm-color-primary);
background: var(--ifm-background-color);
transform: translateY(-1px);
}
.metrics {
display: flex;
align-items: center;
gap: 1.5rem;
margin-top: 0.5rem;
padding-top: 1.25rem;
}
.metric {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.metricValue {
color: var(--ifm-heading-color);
font-size: 1.35rem;
font-weight: 750;
letter-spacing: 0;
line-height: 1.1;
white-space: nowrap;
}
.metricLabel {
color: var(--ifm-color-content-secondary);
font-size: 12px;
font-weight: 600;
letter-spacing: 0;
line-height: 1.35;
text-transform: uppercase;
}
.metricDivider {
width: 1px;
height: 30px;
background: var(--ifm-color-emphasis-300);
}
/* Product visual */
.heroVisual {
display: grid;
gap: 1rem;
}
.consolePanel {
overflow: hidden;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(22, 93, 255, 0.16);
border-radius: 8px;
box-shadow: 0 24px 60px rgba(29, 33, 41, 0.12);
}
[data-theme='dark'] .consolePanel {
background: rgba(22, 24, 29, 0.9);
border-color: rgba(255, 255, 255, 0.08);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.34);
}
.consoleHeader {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 1.25rem;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .consoleHeader {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.consoleHeader strong {
display: block;
margin-top: 4px;
color: var(--ifm-heading-color);
font-size: 1.2rem;
}
.consoleEyebrow {
color: var(--ifm-color-content-secondary);
font-size: 12px;
font-weight: 650;
letter-spacing: 0;
text-transform: uppercase;
}
.consoleStatus {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 4px 10px;
color: #00a870;
background: rgba(0, 180, 42, 0.1);
border: 1px solid rgba(0, 180, 42, 0.2);
border-radius: 8px;
font-size: 12px;
font-weight: 700;
}
.consoleGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .consoleGrid {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.consoleGrid > div {
min-width: 0;
padding: 1.1rem 1.25rem;
border-right: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .consoleGrid > div {
border-right-color: rgba(255, 255, 255, 0.08);
}
.consoleGrid > div:last-child {
border-right: 0;
}
.consoleGrid strong {
display: block;
margin-top: 6px;
color: var(--ifm-heading-color);
font-size: 1.45rem;
line-height: 1.1;
}
.consoleLabel {
display: block;
color: var(--ifm-color-content-secondary);
font-size: 12px;
font-weight: 650;
}
.timeline {
display: grid;
}
.timelineRow {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
[data-theme='dark'] .timelineRow {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.timelineRow:last-child {
border-bottom: 0;
}
.timelineRow strong,
.timelineRow span {
display: block;
}
.timelineRow strong {
color: var(--ifm-heading-color);
font-size: 0.95rem;
font-weight: 700;
}
.timelineRow span {
color: var(--ifm-color-content-secondary);
font-size: 0.85rem;
line-height: 1.5;
}
.timelineRow em {
color: var(--ifm-color-content-secondary);
font-size: 0.8rem;
font-style: normal;
font-weight: 650;
white-space: nowrap;
}
.timelineDotOk,
.timelineDotInfo,
.timelineDotWarn {
width: 10px;
height: 10px;
border-radius: 50%;
}
.timelineDotOk {
background: #00b42a;
}
.timelineDotInfo {
background: #165dff;
}
.timelineDotWarn {
background: #ff7d00;
}
.commandCard {
display: grid;
gap: 8px;
padding: 1rem 1.1rem;
background: #111827;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
box-shadow: 0 16px 34px rgba(17, 24, 39, 0.18);
}
.commandTitle {
color: #9ca3af;
font-size: 12px;
font-weight: 650;
letter-spacing: 0;
text-transform: uppercase;
}
.commandCard code {
overflow-x: auto;
color: #e5e7eb;
background: transparent;
border: 0;
padding: 0;
font-size: 13px;
white-space: nowrap;
}
@media (max-width: 996px) {
.hero {
padding: 4.5rem 0 3.5rem;
}
.heroInner {
grid-template-columns: 1fr;
gap: 2.25rem;
}
.heroTitle {
font-size: 2.45rem;
}
}
@media (max-width: 640px) {
.hero {
padding: 3.75rem 0 2.75rem;
}
.heroTitle {
font-size: 2.05rem;
}
.heroSubtitle {
font-size: 1rem;
}
.actions {
width: 100%;
}
.primaryBtn,
.secondaryBtn {
width: 100%;
justify-content: center;
}
.metrics {
width: 100%;
align-items: stretch;
gap: 0.85rem;
flex-direction: column;
}
.metricDivider {
width: 100%;
height: 1px;
}
.consoleHeader,
.timelineRow {
padding: 1rem;
}
.consoleGrid {
grid-template-columns: 1fr;
}
.consoleGrid > div {
border-right: 0;
border-bottom: 1px solid var(--ifm-color-emphasis-200);
}
.consoleGrid > div:last-child {
border-bottom: 0;
}
[data-theme='dark'] .consoleGrid > div {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
}
@media (prefers-reduced-motion: reduce) {
.primaryBtn,
.secondaryBtn,
.btnArrow {
transition: none;
}
}

View File

@@ -1,164 +1,19 @@
import type {ReactNode} from 'react';
import clsx from 'clsx';
import Link from '@docusaurus/Link';
import Translate, {translate} from '@docusaurus/Translate';
import {translate} from '@docusaurus/Translate';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import Layout from '@theme/Layout';
import Heading from '@theme/Heading';
import HomepageHero from '@site/src/components/HomepageHero';
import HomepageFeatures from '@site/src/components/HomepageFeatures';
import HomepageShowcase from '@site/src/components/HomepageShowcase';
import HomepageCommunity from '@site/src/components/HomepageCommunity';
import styles from './index.module.css';
function HomepageHeader() {
return (
<header className={styles.hero}>
<div className={clsx('container', styles.heroInner)}>
<div className={styles.heroContent}>
<div className={styles.badge}>
<span className={styles.badgeDot} />
<Translate id="home.badge">Open-source backup control plane · v2.2.1</Translate>
</div>
<Heading as="h1" className={styles.heroTitle}>
<Translate id="home.title.part1">Backup orchestration</Translate>
<span className={styles.heroTitleAccent}>
<Translate id="home.title.part2">for self-hosted servers.</Translate>
</span>
</Heading>
<p className={styles.heroSubtitle}>
<Translate id="home.tagline">
Run file, database, SAP HANA and remote-node backups from one clean console. Keep the control plane yours, keep the storage flexible.
</Translate>
</p>
<div className={styles.actions}>
<Link className={clsx('button button--primary button--lg', styles.primaryBtn)} to="/docs/getting-started/quick-start">
<Translate id="home.getStarted">Get Started</Translate>
<span className={styles.btnArrow} aria-hidden="true">-&gt;</span>
</Link>
<Link className={clsx('button button--lg', styles.secondaryBtn)} to="https://github.com/Awuqing/BackupX">
<svg width="18" height="18" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style={{marginRight: 6}}>
<path d="M8 0C3.58 0 0 3.58 0 8a8 8 0 005.47 7.59c.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
</svg>
GitHub
</Link>
</div>
<div className={styles.metrics}>
<div className={styles.metric}>
<div className={styles.metricValue}>70+</div>
<div className={styles.metricLabel}>
<Translate id="home.metric.backends">Storage backends</Translate>
</div>
</div>
<div className={styles.metricDivider} />
<div className={styles.metric}>
<div className={styles.metricValue}>Agent</div>
<div className={styles.metricLabel}>
<Translate id="home.metric.backupTypes">Remote execution</Translate>
</div>
</div>
<div className={styles.metricDivider} />
<div className={styles.metric}>
<div className={styles.metricValue}>Apache 2.0</div>
<div className={styles.metricLabel}>
<Translate id="home.metric.license">License</Translate>
</div>
</div>
</div>
</div>
<div className={styles.heroVisual}>
<div className={styles.consolePanel}>
<div className={styles.consoleHeader}>
<div>
<span className={styles.consoleEyebrow}>
<Translate id="home.visual.eyebrow">BackupX Console</Translate>
</span>
<strong>
<Translate id="home.visual.title">Operations overview</Translate>
</strong>
</div>
<span className={styles.consoleStatus}>
<Translate id="home.visual.status">Healthy</Translate>
</span>
</div>
<div className={styles.consoleGrid}>
<div>
<span className={styles.consoleLabel}>
<Translate id="home.visual.success">Success rate</Translate>
</span>
<strong>99.4%</strong>
</div>
<div>
<span className={styles.consoleLabel}>
<Translate id="home.visual.nodes">Active nodes</Translate>
</span>
<strong>12</strong>
</div>
<div>
<span className={styles.consoleLabel}>
<Translate id="home.visual.targets">Storage targets</Translate>
</span>
<strong>8</strong>
</div>
</div>
<div className={styles.timeline}>
<div className={styles.timelineRow}>
<span className={styles.timelineDotOk} />
<div>
<strong>
<Translate id="home.visual.row1.title">PostgreSQL nightly</Translate>
</strong>
<span>
<Translate id="home.visual.row1.desc">Encrypted archive uploaded to S3</Translate>
</span>
</div>
<em>02:10</em>
</div>
<div className={styles.timelineRow}>
<span className={styles.timelineDotInfo} />
<div>
<strong>
<Translate id="home.visual.row2.title">SAP HANA snapshot</Translate>
</strong>
<span>
<Translate id="home.visual.row2.desc">Running on agent-shanghai-02</Translate>
</span>
</div>
<em>68%</em>
</div>
<div className={styles.timelineRow}>
<span className={styles.timelineDotWarn} />
<div>
<strong>
<Translate id="home.visual.row3.title">Retention cleanup</Translate>
</strong>
<span>
<Translate id="home.visual.row3.desc">Next run in 4 hours</Translate>
</span>
</div>
<em>queued</em>
</div>
</div>
</div>
<div className={styles.commandCard}>
<div className={styles.commandTitle}>
<Translate id="home.command.title">Start with Docker</Translate>
</div>
<code>docker run -d -p 8340:8340 awuqing/backupx:v2.2.1</code>
</div>
</div>
</div>
</header>
);
}
export default function Home(): ReactNode {
const {siteConfig} = useDocusaurusContext();
return (
<Layout
title={translate({id: 'home.pageTitle', message: 'Backup orchestration for self-hosted servers'})}
description={siteConfig.tagline}>
<HomepageHeader />
<HomepageHero />
<main>
<HomepageFeatures />
<HomepageShowcase />

View File

@@ -18,15 +18,17 @@ export default function Sponsors(): ReactNode {
<section className={styles.section}>
<div className="container">
<div className={styles.sectionHead}>
<div className={styles.sectionTag}>
<Translate id="sponsors.tag">SPONSORS</Translate>
<div>
<div className={styles.sectionTag}>
<Translate id="sponsors.tag">Sponsorship</Translate>
</div>
<Heading as="h1" className={styles.sectionTitle}>
<Translate id="sponsors.title">Keep critical maintenance moving</Translate>
</Heading>
</div>
<Heading as="h1" className={styles.sectionTitle}>
<Translate id="sponsors.title">Sponsor the BackupX ecosystem</Translate>
</Heading>
<p className={styles.sectionSubtitle}>
<Translate id="sponsors.subtitle">
Sponsorship helps keep BackupX practical for real operators: tested storage providers, reliable releases, restore confidence and better documentation.
Sponsorship funds real provider validation, reliable releases, recovery drills, and better operational documentation.
</Translate>
</p>
</div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -95,4 +95,3 @@ func redirectStderr(path string) (func(), error) {
_ = f.Close()
}, nil
}

View File

@@ -3,141 +3,143 @@ module backupx/server
go 1.25.0
require (
github.com/gin-gonic/gin v1.10.1
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/klauspost/compress v1.18.1
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/klauspost/compress v1.19.2
github.com/natefinch/lumberjack v2.0.0+incompatible
github.com/pquerna/otp v1.5.0
github.com/prometheus/client_golang v1.23.2
github.com/rclone/rclone v1.73.5
github.com/prometheus/client_golang v1.24.1
github.com/rclone/rclone v1.75.0
github.com/robfig/cron/v3 v3.0.1
github.com/spf13/viper v1.20.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.50.0
golang.org/x/oauth2 v0.34.0
google.golang.org/api v0.255.0
github.com/shirou/gopsutil/v4 v4.26.6
github.com/spf13/viper v1.21.0
go.uber.org/zap v1.28.0
golang.org/x/crypto v0.55.0
golang.org/x/oauth2 v0.36.0
google.golang.org/api v0.279.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/gorm v1.25.12
gorm.io/gorm v1.31.2
)
require (
cloud.google.com/go/auth v0.17.0 // indirect
cloud.google.com/go/auth v0.20.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.5.3 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.22.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.14.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.12.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.8.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azfile v1.7.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.7.2 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/FilenCloudDienste/filen-sdk-go v0.0.38 // indirect
github.com/Files-com/files-sdk-go/v3 v3.2.264 // indirect
github.com/IBM/go-sdk-core/v5 v5.18.5 // indirect
github.com/FilenCloudDienste/filen-sdk-go v0.0.39 // indirect
github.com/Files-com/files-sdk-go/v3 v3.3.194 // indirect
github.com/IBM/go-sdk-core/v5 v5.23.1 // indirect
github.com/Max-Sum/base32768 v0.0.0-20230304063302-18e6ce5945fd // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/bcrypt v0.0.0-20211005172633-e235017c1baf // indirect
github.com/ProtonMail/gluon v0.17.1-0.20230724134000-308be39be96e // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect
github.com/ProtonMail/go-crypto v1.4.1 // indirect
github.com/ProtonMail/go-srp v0.0.7 // indirect
github.com/ProtonMail/gopenpgp/v2 v2.9.0 // indirect
github.com/PuerkitoBio/goquery v1.10.3 // indirect
github.com/ProtonMail/gopenpgp/v3 v3.4.1 // indirect
github.com/PuerkitoBio/goquery v1.12.0 // indirect
github.com/a1ex3/zstd-seekable-format-go/pkg v0.10.0 // indirect
github.com/abbot/go-http-auth v0.4.0 // indirect
github.com/anchore/go-lzo v0.1.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/anchore/go-lzo v0.1.1 // indirect
github.com/andybalholm/cascadia v1.3.4 // indirect
github.com/apache/arrow-go/v18 v18.7.0 // indirect
github.com/appscode/go-querystring v0.0.0-20170504095604-0126cfb3f1dc // indirect
github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.8 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.11 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.19 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.12 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.16 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
github.com/aws/smithy-go v1.25.1 // indirect
github.com/aws/aws-sdk-go-v2 v1.42.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.30 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.29 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.22.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.2 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.4.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.32.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.44.1 // indirect
github.com/aws/smithy-go v1.27.4 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/bradenaw/juniper v0.15.3 // indirect
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
github.com/buengese/sgzip v0.1.1 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/buger/jsonparser v1.2.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/calebcase/tmpfile v1.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chilts/sid v0.0.0-20190607042430-660e94789ec9 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudinary/cloudinary-go/v2 v2.13.0 // indirect
github.com/cloudsoda/go-smb2 v0.0.0-20250228001242-d4c70e6251cc // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.4 // indirect
github.com/cloudinary/cloudinary-go/v2 v2.16.0 // indirect
github.com/cloudsoda/go-smb2 v0.0.0-20260701064823-d8c5600d73b8 // indirect
github.com/cloudsoda/sddl v0.0.0-20250224235906-926454e91efc // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/colinmarc/hdfs/v2 v2.4.0 // indirect
github.com/coreos/go-semver v0.3.1 // indirect
github.com/coreos/go-systemd/v22 v22.6.0 // indirect
github.com/creasty/defaults v1.8.0 // indirect
github.com/cronokirby/saferith v0.33.0 // indirect
github.com/cronokirby/saferith v0.33.1-0.20250226174546-1f11f94ce488 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/diskfs/go-diskfs v1.7.0 // indirect
github.com/diskfs/go-diskfs v1.9.4 // indirect
github.com/dromara/dongle v1.0.1 // indirect
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.0.5 // indirect
github.com/dropbox/dropbox-sdk-go-unofficial/v6 v6.4.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-vcard v0.0.0-20241024213814-c9703dde27ff // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/emersion/go-vcard v0.0.0-20260618161152-d854b7e0e2d3 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/flynn/noise v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/geoffgarside/ber v1.2.0 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/go-chi/chi/v5 v5.3.1 // indirect
github.com/go-darwin/apfs v0.0.0-20211011131704-f84b94dbf348 // indirect
github.com/go-git/go-billy/v5 v5.9.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/errors v0.22.4 // indirect
github.com/go-openapi/strfmt v0.25.0 // indirect
github.com/go-openapi/errors v0.22.8 // indirect
github.com/go-openapi/strfmt v0.27.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/go-resty/resty/v2 v2.16.5 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/go-playground/validator/v10 v10.30.3 // indirect
github.com/go-resty/resty/v2 v2.17.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/flatbuffers v25.12.19+incompatible // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.18 // indirect
github.com/googleapis/gax-go/v2 v2.22.0 // indirect
github.com/gorilla/schema v1.4.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/internxt/rclone-adapter v0.0.0-20260220172730-613f4cc8b8fd // indirect
github.com/internxt/rclone-adapter v0.0.0-20260708165336-dd6561bacfa2 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/gofork v1.7.6 // indirect
@@ -146,72 +148,74 @@ require (
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jlaffaye/ftp v0.2.1-0.20240918233326-1b970516f5d3 // indirect
github.com/jlaffaye/ftp v0.2.1-0.20251026020404-6602e981a1bb // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/jtolio/noiseconn v0.0.0-20231127013910-f6d9ecbf1de7 // indirect
github.com/jzelinskie/whirlpool v0.0.0-20201016144138-0675e54bb004 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
github.com/koofr/go-httpclient v0.0.0-20240520111329-e20f8f203988 // indirect
github.com/koofr/go-koofrclient v0.0.0-20221207135200-cbd7fc9ad6a6 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lanrat/extsort v1.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lpar/date v1.0.0 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/lpar/calendar v0.2.0 // indirect
github.com/lufia/plan9stats v0.0.0-20260627054121-477a66015f15 // indirect
github.com/mailru/easyjson v0.9.2 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.23 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/ncw/swift/v2 v2.0.5 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/onsi/ginkgo/v2 v2.19.0 // indirect
github.com/oracle/oci-go-sdk/v65 v65.104.0 // indirect
github.com/panjf2000/ants/v2 v2.11.3 // indirect
github.com/oracle/oci-go-sdk/v65 v65.121.0 // indirect
github.com/panjf2000/ants/v2 v2.12.1 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pengsrc/go-shared v0.2.1-0.20190131101655-1999055a4a14 // indirect
github.com/peterh/liner v1.2.2 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pierrec/lz4/v4 v4.1.27 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pkg/sftp v1.13.10 // indirect
github.com/pkg/sftp v1.13.11 // indirect
github.com/pkg/xattr v0.4.12 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.2 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/prometheus/common v0.70.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/putdotio/go-putio/putio v0.0.0-20200123120452-16d982cac2b8 // indirect
github.com/rclone/Proton-API-Bridge v1.0.1-0.20260127174007-77f974840d11 // indirect
github.com/rclone/go-proton-api v1.0.1-0.20260127173028-eb465cac3b18 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/rclone/Proton-API-Bridge v1.0.4 // indirect
github.com/rclone/go-proton-api v1.0.3 // indirect
github.com/relvacode/iso8601 v1.7.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rfjakob/eme v1.1.2 // indirect
github.com/rfjakob/eme v1.2.0 // indirect
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/shirou/gopsutil/v4 v4.25.10 // indirect
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/samber/lo v1.53.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/sony/gobreaker v1.0.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/sony/gobreaker/v2 v2.4.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spacemonkeygo/monkit/v3 v3.0.25-0.20251022131615-eb24eb109368 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/t3rm1n4l/go-mega v0.0.0-20251031123324-a804aaa87491 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/t3rm1n4l/go-mega v0.0.0-20260717075258-c6acd6a5bd04 // indirect
github.com/tklauser/go-sysconf v0.4.0 // indirect
github.com/tklauser/numcpus v0.12.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/tyler-smith/go-bip39 v1.1.0 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
@@ -220,42 +224,42 @@ require (
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
github.com/zeebo/errs v1.4.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.mongodb.org/mongo-driver v1.17.6 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.etcd.io/bbolt v1.5.0 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/arch v0.14.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/image v0.41.0 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/term v0.42.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.10 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 // indirect
golang.org/x/image v0.44.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260715232425-e75dac1f907d // indirect
google.golang.org/grpc v1.84.0-dev.0.20260723093437-b6eac429d7b6 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/validator.v2 v2.0.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/sqlite v1.23.1 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.53.0 // indirect
moul.io/http2curl/v2 v2.3.0 // indirect
storj.io/common v0.0.0-20251107171817-6221ae45072c // indirect
storj.io/drpc v0.0.35-0.20250513201419-f7819ea69b55 // indirect
storj.io/eventkit v0.0.0-20250410172343-61f26d3de156 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
storj.io/common v0.0.0-20260629224719-ba1bff0a7846 // indirect
storj.io/drpc v1.0.0 // indirect
storj.io/eventkit v0.0.0-20260716074419-6861a92e2aa5 // indirect
storj.io/infectious v0.0.2 // indirect
storj.io/picobuf v0.0.4 // indirect
storj.io/uplink v1.13.1 // indirect
storj.io/uplink v1.14.3 // indirect
)

File diff suppressed because it is too large Load Diff

View File

@@ -357,4 +357,3 @@ func buildStorageRegistry() *storage.Registry {
storageRclone.RegisterAllBackends(registry)
return registry
}

View File

@@ -21,10 +21,10 @@ import (
type Function string
const (
FunctionBackup Function = "backup"
FunctionRestore Function = "restore"
FunctionInquire Function = "inquire"
FunctionDelete Function = "delete"
FunctionBackup Function = "backup"
FunctionRestore Function = "restore"
FunctionInquire Function = "inquire"
FunctionDelete Function = "delete"
)
// BackupRequest 是 BACKUP 操作的单条请求。

View File

@@ -160,7 +160,7 @@ func TestFileRunnerSelectiveRestore(t *testing.T) {
}
diffAssertContent(t, filepath.Join(restoreSrc, "a.txt"), "alpha")
diffAssertContent(t, filepath.Join(restoreSrc, "sub", "c.txt"), "charlie") // 选中目录 → 子项一并恢复
diffAssertAbsent(t, filepath.Join(restoreSrc, "b.txt")) // 未选中 → 不恢复
diffAssertAbsent(t, filepath.Join(restoreSrc, "b.txt")) // 未选中 → 不恢复
}
// TestFileRunnerDifferentialWithoutBaseIsFull 验证无基线时差异请求回退为全量(产出清单、含全部文件)。

View File

@@ -160,4 +160,3 @@ func formatFileSize(size int64) string {
return fmt.Sprintf("%d B", size)
}
}

View File

@@ -32,8 +32,8 @@ func writeTestTar(t *testing.T, entries map[string][]byte) string {
func TestVerifyTarArchive_Valid(t *testing.T) {
path := writeTestTar(t, map[string][]byte{
"readme.md": []byte("hello"),
"data.bin": []byte("world!!!"),
"readme.md": []byte("hello"),
"data.bin": []byte("world!!!"),
})
report, err := VerifyTarArchive(path, "")
if err != nil {

View File

@@ -24,10 +24,11 @@ type MaintenanceWindow struct {
// 简化语法:多个窗口以 ';' 分隔,每个窗口按 "[days=xxx;]time=HH:MM-HH:MM" 格式。
// Days 缺省 = 全周;若不合法,跳过该段而非抛错(让调用方尽力工作)。
// 示例:
// "time=01:00-05:00" 每天 1 点到 5 点
// "days=sat,sun;time=00:00-23:59" 仅周末全天
// "time=22:00-06:00" 每天跨夜
// "days=mon,tue,wed,thu,fri;time=22:00-06:00" 工作日跨夜
//
// "time=01:00-05:00" 每天 1 点到 5 点
// "days=sat,sun;time=00:00-23:59" 仅周末全天
// "time=22:00-06:00" 每天跨夜
// "days=mon,tue,wed,thu,fri;time=22:00-06:00" 工作日跨夜
func ParseMaintenanceWindows(value string) []MaintenanceWindow {
v := strings.TrimSpace(value)
if v == "" {

View File

@@ -6,12 +6,12 @@ import "time"
// 任一 Notification 可订阅多个事件EventTypes 字段存 CSV。
// 空 EventTypes + OnSuccess/OnFailure=true 时沿用旧语义(仅备份成功/失败)。
const (
NotificationEventBackupSuccess = "backup_success"
NotificationEventBackupFailed = "backup_failed"
NotificationEventBackupSuccess = "backup_success"
NotificationEventBackupFailed = "backup_failed"
NotificationEventRestoreSuccess = "restore_success"
NotificationEventRestoreFailed = "restore_failed"
NotificationEventVerifyFailed = "verify_failed"
NotificationEventSLAViolation = "sla_violation"
NotificationEventVerifyFailed = "verify_failed"
NotificationEventSLAViolation = "sla_violation"
// NotificationEventStorageUnhealthy 存储目标连接失败(后台健康扫描触发)。
NotificationEventStorageUnhealthy = "storage_unhealthy"
// NotificationEventReplicationFailed 备份复制失败。
@@ -23,13 +23,13 @@ const (
)
type Notification struct {
ID uint `gorm:"primaryKey" json:"id"`
Type string `gorm:"size:20;index;not null" json:"type"`
Name string `gorm:"size:100;uniqueIndex;not null" json:"name"`
ConfigCiphertext string `gorm:"column:config_ciphertext;type:text;not null" json:"-"`
Enabled bool `gorm:"not null;default:true" json:"enabled"`
OnSuccess bool `gorm:"column:on_success;not null;default:false" json:"onSuccess"`
OnFailure bool `gorm:"column:on_failure;not null;default:true" json:"onFailure"`
ID uint `gorm:"primaryKey" json:"id"`
Type string `gorm:"size:20;index;not null" json:"type"`
Name string `gorm:"size:100;uniqueIndex;not null" json:"name"`
ConfigCiphertext string `gorm:"column:config_ciphertext;type:text;not null" json:"-"`
Enabled bool `gorm:"not null;default:true" json:"enabled"`
OnSuccess bool `gorm:"column:on_success;not null;default:false" json:"onSuccess"`
OnFailure bool `gorm:"column:on_failure;not null;default:true" json:"onFailure"`
// EventTypes 逗号分隔,订阅的事件类型。
// 空 = 仅监听备份成功/失败(兼容旧配置);非空则严格按订阅触发。
EventTypes string `gorm:"column:event_types;size:500" json:"eventTypes"`

View File

@@ -24,19 +24,19 @@ type ReplicationRecord struct {
SourceTargetID uint `gorm:"column:source_target_id;index;not null" json:"sourceTargetId"`
SourceTarget StorageTarget `gorm:"foreignKey:SourceTargetID;references:ID" json:"sourceTarget,omitempty"`
// DestTargetID 目标存储(复制过去)
DestTargetID uint `gorm:"column:dest_target_id;index;not null" json:"destTargetId"`
DestTarget StorageTarget `gorm:"foreignKey:DestTargetID;references:ID" json:"destTarget,omitempty"`
Status string `gorm:"size:20;index;not null" json:"status"`
StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"`
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
Checksum string `gorm:"column:checksum;size:64" json:"checksum"`
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
TriggeredBy string `gorm:"column:triggered_by;size:100" json:"triggeredBy"`
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DestTargetID uint `gorm:"column:dest_target_id;index;not null" json:"destTargetId"`
DestTarget StorageTarget `gorm:"foreignKey:DestTargetID;references:ID" json:"destTarget,omitempty"`
Status string `gorm:"size:20;index;not null" json:"status"`
StoragePath string `gorm:"column:storage_path;size:500" json:"storagePath"`
FileSize int64 `gorm:"column:file_size;not null;default:0" json:"fileSize"`
Checksum string `gorm:"column:checksum;size:64" json:"checksum"`
ErrorMessage string `gorm:"column:error_message;size:2000" json:"errorMessage"`
DurationSeconds int `gorm:"column:duration_seconds;not null;default:0" json:"durationSeconds"`
TriggeredBy string `gorm:"column:triggered_by;size:100" json:"triggeredBy"`
StartedAt time.Time `gorm:"column:started_at;index;not null" json:"startedAt"`
CompletedAt *time.Time `gorm:"column:completed_at;index" json:"completedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
func (ReplicationRecord) TableName() string {

View File

@@ -11,10 +11,10 @@ import "time"
// - name
// - sourcePath / sourcePaths 中的 {{.Host}} / {{.Env}} 等占位符
type TaskTemplate struct {
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:128;uniqueIndex;not null" json:"name"`
Description string `gorm:"size:500" json:"description"`
TaskType string `gorm:"column:task_type;size:20;not null" json:"taskType"`
ID uint `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:128;uniqueIndex;not null" json:"name"`
Description string `gorm:"size:500" json:"description"`
TaskType string `gorm:"column:task_type;size:20;not null" json:"taskType"`
// Payload JSON存完整 BackupTaskUpsertInput 的序列化
Payload string `gorm:"type:text;not null" json:"payload"`
CreatedBy string `gorm:"column:created_by;size:128" json:"createdBy"`

View File

@@ -59,7 +59,7 @@ func NewBackupRecordRepository(db *gorm.DB) *GormBackupRecordRepository {
func (r *GormBackupRecordRepository) List(ctx context.Context, options BackupRecordListOptions) ([]model.BackupRecord, error) {
// Omit("Manifest"):列表不需要可能很大的清单 JSON避免每行拖出该 TEXT 列。
query := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Omit("Manifest").Preload("Task").Preload("Task.StorageTarget").Order("started_at desc")
query := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Omit("Manifest").Preload("Task").Preload("StorageTarget").Preload("Task.StorageTarget").Order("started_at desc")
if options.TaskID != nil {
query = query.Where("task_id = ?", *options.TaskID)
}
@@ -87,7 +87,7 @@ func (r *GormBackupRecordRepository) List(ctx context.Context, options BackupRec
func (r *GormBackupRecordRepository) FindByID(ctx context.Context, id uint) (*model.BackupRecord, error) {
var item model.BackupRecord
if err := r.db.WithContext(ctx).Preload("Task").Preload("Task.StorageTarget").First(&item, id).Error; err != nil {
if err := r.db.WithContext(ctx).Preload("Task").Preload("StorageTarget").Preload("Task.StorageTarget").First(&item, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
@@ -115,7 +115,9 @@ func (r *GormBackupRecordRepository) Create(ctx context.Context, item *model.Bac
}
func (r *GormBackupRecordRepository) Update(ctx context.Context, item *model.BackupRecord) error {
return r.db.WithContext(ctx).Save(item).Error
// Task 与 StorageTarget 是查询时预加载的只读关联。更新记录字段时忽略它们,
// 避免已加载的旧关联把刚修改的外键(例如首个成功上传目标)覆盖回去。
return r.db.WithContext(ctx).Omit("Task", "StorageTarget").Save(item).Error
}
func (r *GormBackupRecordRepository) Delete(ctx context.Context, id uint) error {
@@ -127,7 +129,7 @@ func (r *GormBackupRecordRepository) ListRecent(ctx context.Context, limit int)
limit = 10
}
var items []model.BackupRecord
if err := r.db.WithContext(ctx).Omit("Manifest").Preload("Task").Preload("Task.StorageTarget").Order("started_at desc").Limit(limit).Find(&items).Error; err != nil {
if err := r.db.WithContext(ctx).Omit("Manifest").Preload("Task").Preload("StorageTarget").Preload("Task.StorageTarget").Order("started_at desc").Limit(limit).Find(&items).Error; err != nil {
return nil, err
}
return items, nil

View File

@@ -65,6 +65,9 @@ func TestBackupRecordRepositoryQueries(t *testing.T) {
if stored == nil || stored.FileName != "website.tar.gz" {
t.Fatalf("unexpected stored record: %#v", stored)
}
if stored.StorageTarget.Name != "local" {
t.Fatalf("expected record storage target to be preloaded, got %#v", stored.StorageTarget)
}
listed, err := repo.List(ctx, BackupRecordListOptions{TaskID: &record.TaskID, Status: "success"})
if err != nil {
t.Fatalf("List returned error: %v", err)
@@ -72,6 +75,9 @@ func TestBackupRecordRepositoryQueries(t *testing.T) {
if len(listed) != 1 {
t.Fatalf("expected one listed record, got %d", len(listed))
}
if listed[0].StorageTarget.Name != "local" {
t.Fatalf("expected listed storage target to be preloaded, got %#v", listed[0].StorageTarget)
}
recent, err := repo.ListRecent(ctx, 5)
if err != nil {
t.Fatalf("ListRecent returned error: %v", err)
@@ -79,6 +85,24 @@ func TestBackupRecordRepositoryQueries(t *testing.T) {
if len(recent) != 1 {
t.Fatalf("expected one recent record, got %d", len(recent))
}
if recent[0].StorageTarget.Name != "local" {
t.Fatalf("expected recent storage target to be preloaded, got %#v", recent[0].StorageTarget)
}
secondTarget := &model.StorageTarget{Name: "secondary", Type: "local_disk", Enabled: true, ConfigCiphertext: "{}", ConfigVersion: 1, LastTestStatus: "unknown"}
if err := repo.db.Create(secondTarget).Error; err != nil {
t.Fatalf("seed second storage target error: %v", err)
}
stored.StorageTargetID = secondTarget.ID
if err := repo.Update(ctx, stored); err != nil {
t.Fatalf("Update storage target ID returned error: %v", err)
}
updated, err := repo.FindByID(ctx, record.ID)
if err != nil {
t.Fatalf("FindByID after update returned error: %v", err)
}
if updated == nil || updated.StorageTargetID != secondTarget.ID || updated.StorageTarget.Name != "secondary" {
t.Fatalf("expected updated storage target to remain %d, got %#v", secondTarget.ID, updated)
}
total, err := repo.Count(ctx)
if err != nil {
t.Fatalf("Count returned error: %v", err)

View File

@@ -1026,7 +1026,8 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
return
}
defer os.RemoveAll(result.TempDir)
// 依据运行器产出判定实际类型:产出清单 → 全量(记录清单供后续差异比对);否则为差异。
// 依据运行器产出判定实际类型:文件全量会产出清单;只有明确启用差异模式且
// 未产出清单时才是差异备份。数据库运行器本身不产出文件清单,但仍属于全量。
if result.Manifest != nil {
backupKind = model.BackupKindFull
if data, encErr := backup.EncodeManifest(*result.Manifest); encErr == nil {
@@ -1034,8 +1035,10 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
} else {
logger.Warnf("备份清单序列化失败(不影响本次备份,但将禁用后续差异):%v", encErr)
}
} else {
} else if spec.Differential {
backupKind = model.BackupKindDifferential
} else {
backupKind = model.BackupKindFull
}
finalPath := result.ArtifactPath
if strings.EqualFold(task.Compression, "gzip") && !strings.HasSuffix(strings.ToLower(finalPath), ".gz") {

View File

@@ -108,6 +108,11 @@ func newExecutionTestServices(t *testing.T) (*BackupExecutionService, *BackupRec
if err != nil {
t.Fatalf("database.Open returned error: %v", err)
}
sqlDB, err := db.DB()
if err != nil {
t.Fatalf("db.DB returned error: %v", err)
}
t.Cleanup(func() { _ = sqlDB.Close() })
cipher := codec.NewConfigCipher("execution-secret")
tasks := repository.NewBackupTaskRepository(db)
targets := repository.NewStorageTargetRepository(db)
@@ -156,6 +161,34 @@ func TestBackupExecutionServiceRunTaskByIDSync(t *testing.T) {
}
}
func TestBackupExecutionServiceSQLiteBackupRemainsFull(t *testing.T) {
executionService, _, tasks, _, records, sourceDir, _ := newExecutionTestServices(t)
dbPath := filepath.Join(sourceDir, "finance.db")
if err := os.WriteFile(dbPath, []byte("sqlite-demo"), 0o644); err != nil {
t.Fatalf("WriteFile sqlite fixture returned error: %v", err)
}
task := &model.BackupTask{
Name: "finance-db", Type: model.BackupTaskTypeSQLite, Enabled: true,
DBPath: dbPath, StorageTargetID: 1, RetentionDays: 30,
Compression: "none", MaxBackups: 10, LastStatus: "idle",
}
if err := tasks.Create(context.Background(), task); err != nil {
t.Fatalf("Create sqlite task returned error: %v", err)
}
detail, err := executionService.RunTaskByIDSync(context.Background(), task.ID)
if err != nil {
t.Fatalf("RunTaskByIDSync sqlite returned error: %v", err)
}
stored, err := records.FindByID(context.Background(), detail.ID)
if err != nil {
t.Fatalf("FindByID sqlite record returned error: %v", err)
}
if stored == nil || stored.BackupKind != model.BackupKindFull {
t.Fatalf("expected sqlite backup kind full, got %#v", stored)
}
}
func TestBackupExecutionServiceRepositoryModeRoundTrip(t *testing.T) {
executionService, recordService, tasks, _, records, sourceDir, storageDir := newExecutionTestServices(t)
ctx := context.Background()

View File

@@ -126,14 +126,14 @@ func (s *DashboardService) Timeline(ctx context.Context, days int) ([]repository
// 判定规则:任务设置了 SLAHoursRPO > 0且距最近一次 success 备份的时间 > SLAHoursRPO。
// 从未成功过的任务LastSuccessAt = nil若启用也视为违约from createdAt 起算)。
type SLAViolation struct {
TaskID uint `json:"taskId"`
TaskName string `json:"taskName"`
NodeID uint `json:"nodeId"`
NodeName string `json:"nodeName,omitempty"`
SLAHoursRPO int `json:"slaHoursRpo"`
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`
HoursSinceLastSuccess float64 `json:"hoursSinceLastSuccess"`
NeverSucceeded bool `json:"neverSucceeded"`
TaskID uint `json:"taskId"`
TaskName string `json:"taskName"`
NodeID uint `json:"nodeId"`
NodeName string `json:"nodeName,omitempty"`
SLAHoursRPO int `json:"slaHoursRpo"`
LastSuccessAt *time.Time `json:"lastSuccessAt,omitempty"`
HoursSinceLastSuccess float64 `json:"hoursSinceLastSuccess"`
NeverSucceeded bool `json:"neverSucceeded"`
}
// SLAComplianceReport Dashboard 的 SLA 合规概览。
@@ -204,15 +204,15 @@ func roundHours(value float64) float64 {
// ClusterNodeSummary 集群节点简报Dashboard 用)。
type ClusterNodeSummary struct {
ID uint `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
Status string `json:"status"`
IsLocal bool `json:"isLocal"`
AgentVersion string `json:"agentVersion"`
VersionStatus string `json:"versionStatus"` // current | outdated | unknown
LastSeen time.Time `json:"lastSeen"`
TaskCount int64 `json:"taskCount"`
ID uint `json:"id"`
Name string `json:"name"`
Hostname string `json:"hostname"`
Status string `json:"status"`
IsLocal bool `json:"isLocal"`
AgentVersion string `json:"agentVersion"`
VersionStatus string `json:"versionStatus"` // current | outdated | unknown
LastSeen time.Time `json:"lastSeen"`
TaskCount int64 `json:"taskCount"`
}
// ClusterOverview Dashboard 集群概览卡片。
@@ -312,9 +312,9 @@ func (s *DashboardService) Breakdown(ctx context.Context, days int) (*BreakdownS
}
}
result := &BreakdownStats{
ByType: makeBreakdown(typeCounts, typeLabel),
ByNode: makeBreakdownByUint(nodeCounts, nodeNames, "节点 #"),
ByStatus: []BreakdownItem{},
ByType: makeBreakdown(typeCounts, typeLabel),
ByNode: makeBreakdownByUint(nodeCounts, nodeNames, "节点 #"),
ByStatus: []BreakdownItem{},
ByStorage: []BreakdownItem{},
}
// 按状态(最近 days 天记录)

View File

@@ -8,10 +8,11 @@ import (
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
"backupx/server/internal/config"
"github.com/shirou/gopsutil/v4/disk"
)
type SystemInfo struct {
@@ -74,11 +75,11 @@ func (s *SystemService) CheckUpdate(ctx context.Context) (*UpdateCheckResult, er
}
var release struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
Body string `json:"body"`
Published string `json:"published_at"`
Assets []struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
Body string `json:"body"`
Published string `json:"published_at"`
Assets []struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
} `json:"assets"`
@@ -124,12 +125,10 @@ func (s *SystemService) GetInfo(_ context.Context) *SystemInfo {
if dir == "" {
dir = "."
}
var stat syscall.Statfs_t
if err := syscall.Statfs(dir, &stat); err == nil {
info.DiskTotal = int64(stat.Blocks) * int64(stat.Bsize)
info.DiskFree = int64(stat.Bavail) * int64(stat.Bsize)
info.DiskUsed = info.DiskTotal - info.DiskFree
if stat, err := disk.Usage(dir); err == nil {
info.DiskTotal = int64(stat.Total)
info.DiskFree = int64(stat.Free)
info.DiskUsed = int64(stat.Used)
}
return info
}

View File

@@ -14,9 +14,9 @@ import (
// TaskExportService 管理备份任务的 JSON 导入 / 导出。
// 用途:
// 1. 集群迁移(旧 Master → 新 Master 的任务配置搬迁)
// 2. 灾备恢复任务配置本地文件化Master 宕机后重建)
// 3. 配置审计(版本化 Git 管理 JSON 快照)
// 1. 集群迁移(旧 Master → 新 Master 的任务配置搬迁)
// 2. 灾备恢复任务配置本地文件化Master 宕机后重建)
// 3. 配置审计(版本化 Git 管理 JSON 快照)
//
// 出于安全考虑,导出/导入不包含任何敏感字段:
// - 数据库密码DBPasswordCiphertext跳过导入后需人工填补
@@ -40,35 +40,35 @@ func NewTaskExportService(
// ExportedTask 导出格式:按名称引用存储/节点,不含敏感数据。
type ExportedTask struct {
Name string `json:"name"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
CronExpr string `json:"cronExpr,omitempty"`
SourcePath string `json:"sourcePath,omitempty"`
SourcePaths []string `json:"sourcePaths,omitempty"`
ExcludePatterns []string `json:"excludePatterns,omitempty"`
DBHost string `json:"dbHost,omitempty"`
DBPort int `json:"dbPort,omitempty"`
DBUser string `json:"dbUser,omitempty"`
DBName string `json:"dbName,omitempty"`
DBPath string `json:"dbPath,omitempty"`
ExtraConfig map[string]any `json:"extraConfig,omitempty"`
Name string `json:"name"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
CronExpr string `json:"cronExpr,omitempty"`
SourcePath string `json:"sourcePath,omitempty"`
SourcePaths []string `json:"sourcePaths,omitempty"`
ExcludePatterns []string `json:"excludePatterns,omitempty"`
DBHost string `json:"dbHost,omitempty"`
DBPort int `json:"dbPort,omitempty"`
DBUser string `json:"dbUser,omitempty"`
DBName string `json:"dbName,omitempty"`
DBPath string `json:"dbPath,omitempty"`
ExtraConfig map[string]any `json:"extraConfig,omitempty"`
// 按名称引用:导入时按名称查找对应 ID
StorageTargetNames []string `json:"storageTargetNames"`
ReplicationTargetNames []string `json:"replicationTargetNames,omitempty"`
NodeName string `json:"nodeName,omitempty"`
DependsOnTaskNames []string `json:"dependsOnTaskNames,omitempty"`
Tags string `json:"tags,omitempty"`
Compression string `json:"compression,omitempty"`
Encrypt bool `json:"encrypt,omitempty"`
RetentionDays int `json:"retentionDays,omitempty"`
MaxBackups int `json:"maxBackups,omitempty"`
VerifyEnabled bool `json:"verifyEnabled,omitempty"`
VerifyCronExpr string `json:"verifyCronExpr,omitempty"`
VerifyMode string `json:"verifyMode,omitempty"`
SLAHoursRPO int `json:"slaHoursRpo,omitempty"`
AlertOnConsecutiveFails int `json:"alertOnConsecutiveFails,omitempty"`
MaintenanceWindows string `json:"maintenanceWindows,omitempty"`
StorageTargetNames []string `json:"storageTargetNames"`
ReplicationTargetNames []string `json:"replicationTargetNames,omitempty"`
NodeName string `json:"nodeName,omitempty"`
DependsOnTaskNames []string `json:"dependsOnTaskNames,omitempty"`
Tags string `json:"tags,omitempty"`
Compression string `json:"compression,omitempty"`
Encrypt bool `json:"encrypt,omitempty"`
RetentionDays int `json:"retentionDays,omitempty"`
MaxBackups int `json:"maxBackups,omitempty"`
VerifyEnabled bool `json:"verifyEnabled,omitempty"`
VerifyCronExpr string `json:"verifyCronExpr,omitempty"`
VerifyMode string `json:"verifyMode,omitempty"`
SLAHoursRPO int `json:"slaHoursRpo,omitempty"`
AlertOnConsecutiveFails int `json:"alertOnConsecutiveFails,omitempty"`
MaintenanceWindows string `json:"maintenanceWindows,omitempty"`
}
// ExportPayload 导出整体结构,带元信息。
@@ -233,67 +233,67 @@ func (s *TaskExportService) toExported(item *model.BackupTask, targetNames, node
nodeName = nodeNames[item.NodeID]
}
return ExportedTask{
Name: item.Name,
Type: item.Type,
Enabled: item.Enabled,
CronExpr: item.CronExpr,
SourcePath: item.SourcePath,
SourcePaths: sourcePaths,
ExcludePatterns: excludes,
DBHost: item.DBHost,
DBPort: item.DBPort,
DBUser: item.DBUser,
DBName: item.DBName,
DBPath: item.DBPath,
ExtraConfig: extra,
StorageTargetNames: storageNames,
ReplicationTargetNames: replicationNames,
NodeName: nodeName,
DependsOnTaskNames: dependsOnNames,
Tags: item.Tags,
Compression: item.Compression,
Encrypt: item.Encrypt,
RetentionDays: item.RetentionDays,
MaxBackups: item.MaxBackups,
VerifyEnabled: item.VerifyEnabled,
VerifyCronExpr: item.VerifyCronExpr,
VerifyMode: item.VerifyMode,
SLAHoursRPO: item.SLAHoursRPO,
Name: item.Name,
Type: item.Type,
Enabled: item.Enabled,
CronExpr: item.CronExpr,
SourcePath: item.SourcePath,
SourcePaths: sourcePaths,
ExcludePatterns: excludes,
DBHost: item.DBHost,
DBPort: item.DBPort,
DBUser: item.DBUser,
DBName: item.DBName,
DBPath: item.DBPath,
ExtraConfig: extra,
StorageTargetNames: storageNames,
ReplicationTargetNames: replicationNames,
NodeName: nodeName,
DependsOnTaskNames: dependsOnNames,
Tags: item.Tags,
Compression: item.Compression,
Encrypt: item.Encrypt,
RetentionDays: item.RetentionDays,
MaxBackups: item.MaxBackups,
VerifyEnabled: item.VerifyEnabled,
VerifyCronExpr: item.VerifyCronExpr,
VerifyMode: item.VerifyMode,
SLAHoursRPO: item.SLAHoursRPO,
AlertOnConsecutiveFails: item.AlertOnConsecutiveFails,
MaintenanceWindows: item.MaintenanceWindows,
MaintenanceWindows: item.MaintenanceWindows,
}
}
func (s *TaskExportService) toUpsertInput(t ExportedTask, targetsByName, nodesByName map[string]uint, deps []uint) BackupTaskUpsertInput {
return BackupTaskUpsertInput{
Name: t.Name,
Type: t.Type,
Enabled: t.Enabled,
CronExpr: t.CronExpr,
SourcePath: t.SourcePath,
SourcePaths: t.SourcePaths,
ExcludePatterns: t.ExcludePatterns,
DBHost: t.DBHost,
DBPort: t.DBPort,
DBUser: t.DBUser,
DBName: t.DBName,
DBPath: t.DBPath,
ExtraConfig: t.ExtraConfig,
StorageTargetIDs: idsFromNames(t.StorageTargetNames, targetsByName),
ReplicationTargetIDs: idsFromNames(t.ReplicationTargetNames, targetsByName),
NodeID: nodesByName[t.NodeName],
Tags: t.Tags,
Compression: t.Compression,
Encrypt: t.Encrypt,
RetentionDays: t.RetentionDays,
MaxBackups: t.MaxBackups,
VerifyEnabled: t.VerifyEnabled,
VerifyCronExpr: t.VerifyCronExpr,
VerifyMode: t.VerifyMode,
SLAHoursRPO: t.SLAHoursRPO,
Name: t.Name,
Type: t.Type,
Enabled: t.Enabled,
CronExpr: t.CronExpr,
SourcePath: t.SourcePath,
SourcePaths: t.SourcePaths,
ExcludePatterns: t.ExcludePatterns,
DBHost: t.DBHost,
DBPort: t.DBPort,
DBUser: t.DBUser,
DBName: t.DBName,
DBPath: t.DBPath,
ExtraConfig: t.ExtraConfig,
StorageTargetIDs: idsFromNames(t.StorageTargetNames, targetsByName),
ReplicationTargetIDs: idsFromNames(t.ReplicationTargetNames, targetsByName),
NodeID: nodesByName[t.NodeName],
Tags: t.Tags,
Compression: t.Compression,
Encrypt: t.Encrypt,
RetentionDays: t.RetentionDays,
MaxBackups: t.MaxBackups,
VerifyEnabled: t.VerifyEnabled,
VerifyCronExpr: t.VerifyCronExpr,
VerifyMode: t.VerifyMode,
SLAHoursRPO: t.SLAHoursRPO,
AlertOnConsecutiveFails: t.AlertOnConsecutiveFails,
MaintenanceWindows: t.MaintenanceWindows,
DependsOnTaskIDs: deps,
MaintenanceWindows: t.MaintenanceWindows,
DependsOnTaskIDs: deps,
}
}

View File

@@ -43,7 +43,7 @@ type LocalDiskFactory struct{}
func NewLocalDiskFactory() LocalDiskFactory { return LocalDiskFactory{} }
func (LocalDiskFactory) Type() storage.ProviderType { return storage.ProviderTypeLocalDisk }
func (LocalDiskFactory) SensitiveFields() []string { return nil }
func (LocalDiskFactory) SensitiveFields() []string { return nil }
func (LocalDiskFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
cfg, err := storage.DecodeConfig[storage.LocalDiskConfig](rawConfig)
@@ -66,7 +66,7 @@ type S3Factory struct{}
func NewS3Factory() S3Factory { return S3Factory{} }
func (S3Factory) Type() storage.ProviderType { return storage.ProviderTypeS3 }
func (S3Factory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
func (S3Factory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
func (S3Factory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
cfg, err := storage.DecodeConfig[storage.S3Config](rawConfig)
@@ -116,7 +116,7 @@ type WebDAVFactory struct{}
func NewWebDAVFactory() WebDAVFactory { return WebDAVFactory{} }
func (WebDAVFactory) Type() storage.ProviderType { return storage.ProviderTypeWebDAV }
func (WebDAVFactory) SensitiveFields() []string { return []string{"username", "password"} }
func (WebDAVFactory) SensitiveFields() []string { return []string{"username", "password"} }
func (WebDAVFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
cfg, err := storage.DecodeConfig[storage.WebDAVConfig](rawConfig)
@@ -187,7 +187,7 @@ type FTPFactory struct{}
func NewFTPFactory() FTPFactory { return FTPFactory{} }
func (FTPFactory) Type() storage.ProviderType { return storage.ProviderTypeFTP }
func (FTPFactory) SensitiveFields() []string { return []string{"username", "password"} }
func (FTPFactory) SensitiveFields() []string { return []string{"username", "password"} }
func (FTPFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
cfg, err := storage.DecodeConfig[storage.FTPConfig](rawConfig)
@@ -228,7 +228,7 @@ type AliyunOSSFactory struct{}
func NewAliyunOSSFactory() AliyunOSSFactory { return AliyunOSSFactory{} }
func (AliyunOSSFactory) Type() storage.ProviderType { return storage.ProviderTypeAliyunOSS }
func (AliyunOSSFactory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
func (AliyunOSSFactory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
// AliyunConfig 是阿里云 OSS 的用户配置。
type AliyunConfig struct {
@@ -269,7 +269,9 @@ type TencentCOSFactory struct{}
func NewTencentCOSFactory() TencentCOSFactory { return TencentCOSFactory{} }
func (TencentCOSFactory) Type() storage.ProviderType { return storage.ProviderTypeTencentCOS }
func (TencentCOSFactory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
func (TencentCOSFactory) SensitiveFields() []string {
return []string{"accessKeyId", "secretAccessKey"}
}
// TencentConfig 是腾讯云 COS 的用户配置。
type TencentConfig struct {
@@ -305,7 +307,7 @@ type QiniuKodoFactory struct{}
func NewQiniuKodoFactory() QiniuKodoFactory { return QiniuKodoFactory{} }
func (QiniuKodoFactory) Type() storage.ProviderType { return storage.ProviderTypeQiniuKodo }
func (QiniuKodoFactory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
func (QiniuKodoFactory) SensitiveFields() []string { return []string{"accessKeyId", "secretAccessKey"} }
// QiniuConfig 是七牛云 Kodo 的用户配置。
type QiniuConfig struct {
@@ -355,7 +357,9 @@ type RcloneFactory struct{}
func NewRcloneFactory() RcloneFactory { return RcloneFactory{} }
func (RcloneFactory) Type() storage.ProviderType { return storage.ProviderTypeRclone }
func (RcloneFactory) SensitiveFields() []string { return []string{"pass", "password", "secret_access_key", "client_secret", "token"} }
func (RcloneFactory) SensitiveFields() []string {
return []string{"pass", "password", "secret_access_key", "client_secret", "token"}
}
func (RcloneFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
backend, _ := rawConfig["backend"].(string)
@@ -462,8 +466,10 @@ func NewBackendFactory(backendType string) GenericBackendFactory {
return GenericBackendFactory{backendType: backendType, sensitive: sensitive}
}
func (f GenericBackendFactory) Type() storage.ProviderType { return storage.ProviderType(f.backendType) }
func (f GenericBackendFactory) SensitiveFields() []string { return f.sensitive }
func (f GenericBackendFactory) Type() storage.ProviderType {
return storage.ProviderType(f.backendType)
}
func (f GenericBackendFactory) SensitiveFields() []string { return f.sensitive }
func (f GenericBackendFactory) New(ctx context.Context, rawConfig map[string]any) (storage.StorageProvider, error) {
root, _ := rawConfig["root"].(string)

960
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,14 +3,18 @@
"private": true,
"version": "0.1.0",
"type": "module",
"engines": {
"node": ">=24.0"
},
"scripts": {
"dev": "vite",
"build": "tsc --noEmit -p tsconfig.json && vite build",
"typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json",
"build": "npm run typecheck && vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@arco-design/web-react": "^2.66.0",
"@arco-design/web-react": "^2.66.16",
"axios": "^1.19.0",
"echarts": "^6.1.0",
"echarts-for-react": "^3.0.6",
@@ -18,20 +22,20 @@
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^16.5.6",
"react-router-dom": "^6.30.4",
"zustand": "^5.0.3"
"react-router-dom": "^7.0.0",
"zustand": "^5.0.15"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^22.13.10",
"@testing-library/user-event": "^14.6.4",
"@types/node": "^24.13.3",
"@types/react": "^18.3.20",
"@types/react-dom": "^18.3.6",
"@vitejs/plugin-react": "^4.3.4",
"jsdom": "^26.0.0",
"jsdom": "^30.0.1",
"typescript": "^5.7.3",
"vite": "^6.4.3",
"vitest": "^3.2.7"
"vitest": "^4.1.10"
}
}

View File

@@ -0,0 +1,50 @@
import { Typography } from '@arco-design/web-react'
import type { ReactNode } from 'react'
export interface AdminMetric {
label: string
value: ReactNode
detail: string
}
interface AdminDataSectionProps {
title: string
description: string
actions?: ReactNode
metrics: AdminMetric[]
toolbar: ReactNode
children: ReactNode
}
export function AdminDataSection({ title, description, actions, metrics, toolbar, children }: AdminDataSectionProps) {
return (
<section className="admin-section" aria-labelledby="admin-section-title">
<header className="admin-section__header">
<div>
<Typography.Title id="admin-section-title" heading={5} className="admin-section__title">
{title}
</Typography.Title>
<Typography.Paragraph type="secondary" className="admin-section__description">
{description}
</Typography.Paragraph>
</div>
{actions}
</header>
<div className="admin-summary" aria-label={`${title}概览`}>
{metrics.map((metric) => (
<div key={metric.label} className="admin-summary__item">
<Typography.Text type="secondary">{metric.label}</Typography.Text>
<span className="admin-summary__value">{metric.value}</span>
<span className="admin-summary__detail">{metric.detail}</span>
</div>
))}
</div>
<div className="admin-data-panel">
{toolbar}
{children}
</div>
</section>
)
}

View File

@@ -0,0 +1,34 @@
import { Select } from '@arco-design/web-react'
import type { CSSProperties } from 'react'
import type { UserRole } from '../../services/users'
export const adminRoleOptions = [
{ label: '管理员 (admin)', value: 'admin' },
{ label: '运维 (operator)', value: 'operator' },
{ label: '只读 (viewer)', value: 'viewer' },
]
export const adminRoleDescriptions: Record<UserRole, string> = {
admin: '拥有系统配置、账号与访问凭据的完整管理权限。',
operator: '可执行日常备份、恢复和节点运维操作。',
viewer: '仅可查看仪表盘和允许读取的数据。',
}
interface AdminRoleSelectProps {
value: UserRole
onChange: (role: UserRole) => void
disabled?: boolean
style?: CSSProperties
}
export function AdminRoleSelect({ value, onChange, disabled, style }: AdminRoleSelectProps) {
return (
<Select
value={value}
options={adminRoleOptions}
disabled={disabled}
style={style}
onChange={(role) => onChange(role as UserRole)}
/>
)
}

View File

@@ -9,7 +9,6 @@ import {
IconCopy,
IconBook,
IconUser,
IconCommand,
IconNotification,
IconSettings,
IconMenuFold,
@@ -86,11 +85,8 @@ function resolveSelectedKey(pathname: string) {
if (pathname.startsWith('/task-templates')) {
return '/task-templates'
}
if (pathname.startsWith('/admin/users')) {
return '/admin/users'
}
if (pathname.startsWith('/admin/api-keys')) {
return '/admin/api-keys'
if (pathname.startsWith('/admin')) {
return '/admin'
}
if (pathname.startsWith('/settings') || pathname.startsWith('/system-info')) {
return '/settings'
@@ -117,8 +113,7 @@ const menuItems: MenuItemConfig[] = [
{ key: '/storage-targets', label: '存储目标', icon: <IconStorage /> },
{ key: '/nodes', label: '节点管理', icon: <IconDesktop /> },
{ key: '/settings/notifications', label: '通知配置', icon: <IconNotification /> },
{ key: '/admin/users', label: '用户管理', icon: <IconUser />, adminOnly: true },
{ key: '/admin/api-keys', label: 'API Key', icon: <IconCommand />, adminOnly: true },
{ key: '/admin', label: '访问管理', icon: <IconUser />, adminOnly: true },
{ key: '/audit', label: '审计日志', icon: <IconList /> },
{ key: '/settings', label: '系统设置', icon: <IconSettings /> },
]

View File

@@ -0,0 +1,60 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it } from 'vitest'
import { useAuthStore } from '../../stores/auth'
import { AdminLayout } from './AdminLayout'
describe('AdminLayout', () => {
beforeEach(() => {
useAuthStore.setState({
token: 'test-token',
user: { id: 1, username: 'admin', displayName: 'Admin', role: 'admin' },
status: 'authenticated',
bootstrapped: true,
})
})
it('keeps user and API key management in one navigable admin area', async () => {
const actor = userEvent.setup()
render(
<MemoryRouter initialEntries={['/admin/users']}>
<Routes>
<Route path="/admin" element={<AdminLayout />}>
<Route path="users" element={<div>user management content</div>} />
<Route path="api-keys" element={<div>api key management content</div>} />
</Route>
<Route path="/audit" element={<div>audit content</div>} />
</Routes>
</MemoryRouter>,
)
expect(screen.getByText('user management content')).toBeInTheDocument()
expect(screen.getByRole('navigation', { name: '访问管理分区' })).toBeInTheDocument()
await actor.click(screen.getByRole('button', { name: 'API Key' }))
expect(screen.getByText('api key management content')).toBeInTheDocument()
await actor.click(screen.getByRole('button', { name: '访问审计' }))
expect(screen.getByText('audit content')).toBeInTheDocument()
})
it('blocks non-admin users before rendering management content', () => {
useAuthStore.setState({
user: { id: 2, username: 'viewer', displayName: 'Viewer', role: 'viewer' },
})
render(
<MemoryRouter initialEntries={['/admin/users']}>
<Routes>
<Route path="/admin" element={<AdminLayout />}>
<Route path="users" element={<div>restricted content</div>} />
</Route>
</Routes>
</MemoryRouter>,
)
expect(screen.getByText('当前账号无权进入访问管理(仅管理员)')).toBeInTheDocument()
expect(screen.queryByText('restricted content')).not.toBeInTheDocument()
})
})

View File

@@ -0,0 +1,55 @@
import { Alert, Button, PageHeader } from '@arco-design/web-react'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { IconCommand, IconList, IconUser } from '../../components/icons'
import { useAuthStore } from '../../stores/auth'
import { isAdmin } from '../../utils/permissions'
import './admin.css'
const sections = [
{ path: '/admin/users', label: '用户账号', icon: <IconUser /> },
{ path: '/admin/api-keys', label: 'API Key', icon: <IconCommand /> },
]
export function AdminLayout() {
const user = useAuthStore((state) => state.user)
const location = useLocation()
const navigate = useNavigate()
if (!isAdmin(user)) {
return <Alert type="warning" content="当前账号无权进入访问管理(仅管理员)" />
}
return (
<div className="admin-page">
<PageHeader
className="admin-page__header"
title="访问管理"
subTitle="统一管理系统账号、角色权限、多因素认证与程序化访问凭据。"
extra={(
<Button icon={<IconList />} onClick={() => navigate('/audit')}>
访
</Button>
)}
/>
<nav className="admin-page__nav" aria-label="访问管理分区">
{sections.map((section) => {
const selected = location.pathname.startsWith(section.path)
return (
<Button
key={section.path}
type={selected ? 'secondary' : 'text'}
icon={section.icon}
aria-current={selected ? 'page' : undefined}
onClick={() => navigate(section.path)}
>
{section.label}
</Button>
)
})}
</nav>
<Outlet />
</div>
)
}

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import type { ApiKeySummary } from '../../services/api-keys'
import { resolveApiKeyStatus } from './ApiKeysPage'
const baseKey: ApiKeySummary = {
id: 1,
name: 'automation',
role: 'viewer',
prefix: 'bax_example',
createdBy: 'admin',
disabled: false,
createdAt: '2026-08-01T00:00:00Z',
}
describe('resolveApiKeyStatus', () => {
const now = new Date('2026-08-07T00:00:00Z').getTime()
it('derives active, disabled, and expired states from the credential lifecycle', () => {
expect(resolveApiKeyStatus(baseKey, now)).toBe('active')
expect(resolveApiKeyStatus({ ...baseKey, disabled: true }, now)).toBe('disabled')
expect(resolveApiKeyStatus({ ...baseKey, expiresAt: '2026-08-06T23:59:59Z' }, now)).toBe('expired')
})
it('keeps expiration authoritative when an expired key is also disabled', () => {
expect(resolveApiKeyStatus({
...baseKey,
disabled: true,
expiresAt: '2026-08-01T00:00:00Z',
}, now)).toBe('expired')
})
})

View File

@@ -1,37 +1,70 @@
import { Alert, Button, Card, Empty, Form, Input, InputNumber, Message, Modal, Select, Space, Switch, Table, Tag, Typography } from '@arco-design/web-react'
import { useCallback, useEffect, useState } from 'react'
import { createApiKey, listApiKeys, revokeApiKey, toggleApiKey, type ApiKeyCreateInput, type ApiKeySummary } from '../../services/api-keys'
import {
Alert,
Button,
Empty,
Form,
Grid,
Input,
InputNumber,
Message,
Modal,
Popconfirm,
Select,
Space,
Table,
Tag,
Tooltip,
Typography,
} from '@arco-design/web-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { AdminDataSection } from '../../components/admin/AdminDataSection'
import { AdminRoleSelect, adminRoleDescriptions, adminRoleOptions } from '../../components/admin/AdminRoleSelect'
import { IconCopy, IconDelete, IconList, IconPlus, IconRefresh, IconSearch } from '../../components/icons'
import {
createApiKey,
listApiKeys,
revokeApiKey,
toggleApiKey,
type ApiKeyCreateInput,
type ApiKeySummary,
} from '../../services/api-keys'
import type { UserRole } from '../../services/users'
import { useAuthStore } from '../../stores/auth'
import { resolveErrorMessage } from '../../utils/error'
import { isAdmin, roleLabel } from '../../utils/permissions'
import { formatDateTime } from '../../utils/format'
import { roleLabel } from '../../utils/permissions'
const roleOptions = [
{ label: '管理员 (admin)', value: 'admin' },
{ label: '运维 (operator)', value: 'operator' },
{ label: '只读 (viewer)', value: 'viewer' },
]
type ApiKeyStatus = 'active' | 'disabled' | 'expired'
type ApiKeyStatusFilter = ApiKeyStatus | 'all'
export function resolveApiKeyStatus(item: ApiKeySummary, now: number): ApiKeyStatus {
if (item.expiresAt && new Date(item.expiresAt).getTime() <= now) {
return 'expired'
}
return item.disabled ? 'disabled' : 'active'
}
// ApiKeysPage API Key 管理admin 专属)。
// 新创建的 Key 明文只返回一次,需要用户立即保存。
export function ApiKeysPage() {
const user = useAuthStore((s) => s.user)
const navigate = useNavigate()
const [items, setItems] = useState<ApiKeySummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [query, setQuery] = useState('')
const [roleFilter, setRoleFilter] = useState<UserRole | 'all'>('all')
const [statusFilter, setStatusFilter] = useState<ApiKeyStatusFilter>('all')
const [modalVisible, setModalVisible] = useState(false)
const [draft, setDraft] = useState<ApiKeyCreateInput>({ name: '', role: 'viewer', ttlHours: 0 })
const [submitting, setSubmitting] = useState(false)
const [plainKey, setPlainKey] = useState<string>('')
const [rowAction, setRowAction] = useState('')
const [plainKey, setPlainKey] = useState('')
const load = useCallback(async () => {
setLoading(true)
try {
setItems(await listApiKeys())
setError('')
} catch (e) {
setError(resolveErrorMessage(e, '加载 API Key 失败'))
} catch (loadError) {
setError(resolveErrorMessage(loadError, '加载 API Key 失败'))
} finally {
setLoading(false)
}
@@ -41,47 +74,81 @@ export function ApiKeysPage() {
void load()
}, [load])
const now = useMemo(() => Date.now(), [items])
const filteredItems = useMemo(() => {
const keyword = query.trim().toLowerCase()
return items.filter((item) => {
const matchesQuery = !keyword || [item.name, item.prefix, item.createdBy]
.some((value) => value?.toLowerCase().includes(keyword))
const matchesRole = roleFilter === 'all' || item.role === roleFilter
const matchesStatus = statusFilter === 'all' || resolveApiKeyStatus(item, now) === statusFilter
return matchesQuery && matchesRole && matchesStatus
})
}, [items, now, query, roleFilter, statusFilter])
const activeCount = items.filter((item) => resolveApiKeyStatus(item, now) === 'active').length
const disabledCount = items.filter((item) => resolveApiKeyStatus(item, now) === 'disabled').length
const expiredCount = items.filter((item) => resolveApiKeyStatus(item, now) === 'expired').length
const usedCount = items.filter((item) => Boolean(item.lastUsedAt)).length
const filtersActive = Boolean(query.trim()) || roleFilter !== 'all' || statusFilter !== 'all'
function openCreate() {
setDraft({ name: '', role: 'viewer', ttlHours: 0 })
setPlainKey('')
setModalVisible(true)
}
function closeModal() {
setModalVisible(false)
setPlainKey('')
}
async function handleSubmit() {
if (!draft.name.trim()) {
const payload = { ...draft, name: draft.name.trim(), ttlHours: Number(draft.ttlHours ?? 0) }
if (!payload.name) {
Message.error('名称不能为空')
return
}
if (payload.ttlHours < 0 || payload.ttlHours > 87600) {
Message.error('有效期需要在 0 到 87600 小时之间')
return
}
setSubmitting(true)
try {
const result = await createApiKey(draft)
const result = await createApiKey(payload)
setPlainKey(result.plainKey)
await load()
} catch (e) {
Message.error(resolveErrorMessage(e, '创建失败'))
} catch (submitError) {
Message.error(resolveErrorMessage(submitError, '创建失败'))
} finally {
setSubmitting(false)
}
}
async function handleToggle(item: ApiKeySummary) {
setRowAction(`toggle:${item.id}`)
try {
await toggleApiKey(item.id, !item.disabled)
Message.success(item.disabled ? '已启用' : '已停用')
Message.success(item.disabled ? 'API Key 已启用' : 'API Key 已停用')
await load()
} catch (e) {
Message.error(resolveErrorMessage(e, '操作失败'))
} catch (toggleError) {
Message.error(resolveErrorMessage(toggleError, '操作失败'))
} finally {
setRowAction('')
}
}
async function handleRevoke(item: ApiKeySummary) {
if (!window.confirm(`确定撤销 API Key「${item.name}」?操作不可撤销。`)) return
setRowAction(`revoke:${item.id}`)
try {
await revokeApiKey(item.id)
Message.success('已撤销')
Message.success('API Key 已撤销')
await load()
} catch (e) {
Message.error(resolveErrorMessage(e, '撤销失败'))
} catch (revokeError) {
Message.error(resolveErrorMessage(revokeError, '撤销失败'))
} finally {
setRowAction('')
}
}
@@ -95,83 +162,221 @@ export function ApiKeysPage() {
}
}
if (!isAdmin(user)) {
return <Alert type="warning" content="当前账号无权访问 API Key 管理(仅 admin" />
}
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Typography.Title heading={4}>API Key</Typography.Title>
<Typography.Paragraph type="secondary">
API Key CI/CD访 BackupX <Typography.Text code>Authorization: Bearer bax_xxx</Typography.Text> <Typography.Text code>X-Api-Key: bax_xxx</Typography.Text>
</Typography.Paragraph>
</div>
<Space>
<Button type="primary" onClick={openCreate}> API Key</Button>
</Space>
{error ? <Card><Typography.Text type="error">{error}</Typography.Text></Card> : null}
<Card>
<Table
rowKey="id"
loading={loading}
data={items}
pagination={false}
stripe
noDataElement={<Empty description="暂无 API Key" />}
columns={[
{ title: '名称', dataIndex: 'name' },
{ title: '角色', dataIndex: 'role', render: (v: string) => <Tag color="arcoblue" bordered>{roleLabel(v)}</Tag> },
{ title: 'Key 前缀', dataIndex: 'prefix', render: (v: string) => <Typography.Text code>{v}</Typography.Text> },
{ title: '创建者', dataIndex: 'createdBy', render: (v: string) => v || '-' },
{ title: '最近使用', dataIndex: 'lastUsedAt', render: (v?: string) => v ? formatDateTime(v) : '从未使用' },
{ title: '过期', dataIndex: 'expiresAt', render: (v?: string) => v ? formatDateTime(v) : '永不过期' },
{ title: '状态', dataIndex: 'disabled', render: (disabled: boolean) => disabled ? <Tag color="red" bordered></Tag> : <Tag color="green" bordered></Tag> },
{ title: '操作', width: 180, render: (_: unknown, row: ApiKeySummary) => (
<Space>
<Button size="small" type="text" onClick={() => void handleToggle(row)}>{row.disabled ? '启用' : '停用'}</Button>
<Button size="small" type="text" status="danger" onClick={() => void handleRevoke(row)}></Button>
</Space>
) },
]}
/>
</Card>
<AdminDataSection
title="API Key"
description="为 CI/CD、监控和自动化任务签发独立凭据并集中管理权限、有效期、使用状态与撤销操作。"
metrics={[
{ label: '凭据总数', value: items.length, detail: `${usedCount} 个凭据已有调用记录` },
{ label: '当前可用', value: activeCount, detail: '未停用且未超过有效期' },
{ label: '已停用', value: disabledCount, detail: '保留记录,可再次启用' },
{ label: '已过期', value: expiredCount, detail: '到期后无法继续认证' },
]}
actions={(
<Space>
<Button icon={<IconList />} onClick={() => navigate('/audit?category=api_key')}></Button>
<Button type="primary" icon={<IconPlus />} onClick={openCreate}> API Key</Button>
</Space>
)}
toolbar={(
<div className="admin-toolbar">
<div className="admin-toolbar__filters">
<Input
style={{ width: 260 }}
allowClear
prefix={<IconSearch />}
value={query}
aria-label="搜索 API Key"
placeholder="搜索名称、前缀或创建者"
onChange={setQuery}
/>
<Select
style={{ width: 150 }}
value={roleFilter}
options={[{ label: '全部角色', value: 'all' }, ...adminRoleOptions]}
onChange={(value) => setRoleFilter(value as UserRole | 'all')}
/>
<Select
style={{ width: 140 }}
value={statusFilter}
options={[
{ label: '全部状态', value: 'all' },
{ label: '当前可用', value: 'active' },
{ label: '已停用', value: 'disabled' },
{ label: '已过期', value: 'expired' },
]}
onChange={(value) => setStatusFilter(value as ApiKeyStatusFilter)}
/>
<Button
type="text"
disabled={!filtersActive}
onClick={() => { setQuery(''); setRoleFilter('all'); setStatusFilter('all') }}
>
</Button>
</div>
<div className="admin-toolbar__status">
<Typography.Text type="secondary"> {filteredItems.length} / {items.length}</Typography.Text>
<Button icon={<IconRefresh />} loading={loading} onClick={() => void load()}></Button>
</div>
</div>
)}
>
{error ? (
<div className="admin-data-panel__alert">
<Alert type="error" content={error} />
</div>
) : null}
<Table
rowKey="id"
loading={loading}
data={filteredItems}
stripe
pagination={filteredItems.length > 10 ? { pageSize: 10 } : false}
noDataElement={<Empty description={filtersActive ? '没有符合筛选条件的 API Key' : '暂无 API Key'} />}
columns={[
{
title: '名称',
dataIndex: 'name',
width: 190,
render: (value: string, row: ApiKeySummary) => (
<div className="admin-identity">
<Typography.Text>{value}</Typography.Text>
<span className="admin-identity__secondary" title={`${row.createdBy || '-'} 创建于 ${formatDateTime(row.createdAt)}`}>
{row.createdBy || '-'} · {formatDateTime(row.createdAt)}
</span>
</div>
),
},
{
title: '角色',
dataIndex: 'role',
width: 90,
render: (value: string) => <Tag color="arcoblue" bordered>{roleLabel(value)}</Tag>,
},
{
title: 'Key 前缀',
dataIndex: 'prefix',
width: 135,
render: (value: string) => <span className="admin-key-prefix">{value}</span>,
},
{
title: '最近使用',
dataIndex: 'lastUsedAt',
width: 160,
render: (value?: string) => value ? <span className="admin-date">{formatDateTime(value)}</span> : '从未使用',
},
{
title: '有效期',
dataIndex: 'expiresAt',
width: 160,
render: (value?: string) => value ? <span className="admin-date">{formatDateTime(value)}</span> : '永不过期',
},
{
title: '状态',
dataIndex: 'disabled',
width: 90,
render: (_: boolean, row: ApiKeySummary) => {
const status = resolveApiKeyStatus(row, now)
if (status === 'expired') return <Tag color="orange" bordered></Tag>
if (status === 'disabled') return <Tag color="red" bordered></Tag>
return <Tag color="green" bordered></Tag>
},
},
{
title: '操作',
width: 150,
render: (_: unknown, row: ApiKeySummary) => {
const expired = resolveApiKeyStatus(row, now) === 'expired'
return (
<Space>
{expired ? (
<Tooltip content="已过期凭据不能重新启用,请生成新凭据">
<span><Button size="small" type="text" disabled></Button></span>
</Tooltip>
) : (
<Button
size="small"
type="text"
loading={rowAction === `toggle:${row.id}`}
onClick={() => void handleToggle(row)}
>
{row.disabled ? '启用' : '停用'}
</Button>
)}
<Popconfirm
title={`确定撤销 API Key「${row.name}」?`}
content="撤销后无法恢复,使用该凭据的自动化任务将立即失效。"
onOk={() => handleRevoke(row)}
>
<Button
size="small"
type="text"
status="danger"
icon={<IconDelete />}
loading={rowAction === `revoke:${row.id}`}
>
</Button>
</Popconfirm>
</Space>
)
},
},
]}
/>
<Modal
visible={modalVisible}
title="生成 API Key"
onCancel={() => { setModalVisible(false); setPlainKey('') }}
onOk={plainKey ? () => { setModalVisible(false); setPlainKey('') } : handleSubmit}
okText={plainKey ? '完成' : '生成'}
title={plainKey ? '保存 API Key' : '生成 API Key'}
style={{ width: 640 }}
onCancel={closeModal}
onOk={plainKey ? closeModal : handleSubmit}
okText={plainKey ? '我已保存' : '生成'}
confirmLoading={submitting}
unmountOnExit
>
{plainKey ? (
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
<Alert type="warning" content="明文 Key 只显示一次,请立即妥善保存。" />
<Input.TextArea value={plainKey} autoSize readOnly />
<Button type="outline" onClick={() => void copyPlainKey()}></Button>
</Space>
<div className="admin-key-result">
<Alert type="warning" content="明文 Key 只显示一次。关闭窗口前,请将它保存到安全的密钥管理系统。" />
<Input.TextArea value={plainKey} autoSize={{ minRows: 2, maxRows: 3 }} readOnly />
<Button type="outline" icon={<IconCopy />} onClick={() => void copyPlainKey()}></Button>
</div>
) : (
<Form layout="vertical">
<Form.Item label="名称" required>
<Input value={draft.name} onChange={(v) => setDraft({ ...draft, name: v })} placeholder="例如ci-deploy-script" />
</Form.Item>
<Form.Item label="角色" required>
<Select value={draft.role} options={roleOptions} onChange={(v: UserRole) => setDraft({ ...draft, role: v })} />
</Form.Item>
<Form.Item label="有效期小时0=永不过期)">
<InputNumber style={{ width: '100%' }} min={0} value={draft.ttlHours ?? 0} onChange={(v) => setDraft({ ...draft, ttlHours: Number(v ?? 0) })} />
<Input
value={draft.name}
maxLength={128}
showWordLimit
placeholder="例如ci-deploy-script"
onChange={(value) => setDraft({ ...draft, name: value })}
/>
</Form.Item>
<Grid.Row gutter={16}>
<Grid.Col span={12}>
<Form.Item label="角色" required>
<AdminRoleSelect value={draft.role} onChange={(role) => setDraft({ ...draft, role })} />
<span className="admin-form-note">{adminRoleDescriptions[draft.role]}</span>
</Form.Item>
</Grid.Col>
<Grid.Col span={12}>
<Form.Item label="有效期">
<InputNumber
style={{ width: '100%' }}
min={0}
max={87600}
suffix="小时"
value={draft.ttlHours ?? 0}
onChange={(value) => setDraft({ ...draft, ttlHours: Number(value ?? 0) })}
/>
<span className="admin-form-note">0 </span>
</Form.Item>
</Grid.Col>
</Grid.Row>
</Form>
)}
</Modal>
</Space>
</AdminDataSection>
)
}
// 避免未使用告警
void Switch

View File

@@ -1,40 +1,71 @@
import { Alert, Button, Card, Empty, Form, Input, Message, Modal, Select, Space, Switch, Table, Tag, Typography } from '@arco-design/web-react'
import { useCallback, useEffect, useState } from 'react'
import { createUser, deleteUser, listUsers, resetUserTwoFactor, updateUser, type UserRole, type UserSummary, type UserUpsertPayload } from '../../services/users'
import {
Alert,
Button,
Empty,
Form,
Grid,
Input,
Message,
Modal,
Popconfirm,
Select,
Space,
Switch,
Table,
Tag,
Tooltip,
Typography,
} from '@arco-design/web-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { AdminDataSection } from '../../components/admin/AdminDataSection'
import { AdminRoleSelect, adminRoleDescriptions, adminRoleOptions } from '../../components/admin/AdminRoleSelect'
import { IconDelete, IconEdit, IconList, IconPlus, IconRefresh, IconSafe, IconSearch } from '../../components/icons'
import { clearTrustedDeviceToken } from '../../services/auth'
import {
createUser,
deleteUser,
listUsers,
resetUserTwoFactor,
updateUser,
type UserRole,
type UserSummary,
type UserUpsertPayload,
} from '../../services/users'
import { useAuthStore } from '../../stores/auth'
import { resolveErrorMessage } from '../../utils/error'
import { isAdmin, roleLabel } from '../../utils/permissions'
import { formatDateTime } from '../../utils/format'
import { roleLabel } from '../../utils/permissions'
const roleOptions = [
{ label: '管理员 (admin)', value: 'admin' },
{ label: '运维 (operator)', value: 'operator' },
{ label: '只读 (viewer)', value: 'viewer' },
]
type UserStatusFilter = 'all' | 'enabled' | 'disabled'
function createEmpty(): UserUpsertPayload {
return { username: '', password: '', displayName: '', email: '', phone: '', role: 'operator', disabled: false }
}
// UsersPage admin 用户管理。非 admin 角色进入路由会被路由守卫拦截。
export function UsersPage() {
const user = useAuthStore((s) => s.user)
const setUser = useAuthStore((s) => s.setUser)
const navigate = useNavigate()
const user = useAuthStore((state) => state.user)
const setUser = useAuthStore((state) => state.setUser)
const [items, setItems] = useState<UserSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [query, setQuery] = useState('')
const [roleFilter, setRoleFilter] = useState<UserRole | 'all'>('all')
const [statusFilter, setStatusFilter] = useState<UserStatusFilter>('all')
const [editing, setEditing] = useState<UserSummary | null>(null)
const [modalVisible, setModalVisible] = useState(false)
const [draft, setDraft] = useState<UserUpsertPayload>(createEmpty())
const [submitting, setSubmitting] = useState(false)
const [rowAction, setRowAction] = useState('')
const load = useCallback(async () => {
setLoading(true)
try {
setItems(await listUsers())
setError('')
} catch (e) {
setError(resolveErrorMessage(e, '加载用户失败'))
} catch (loadError) {
setError(resolveErrorMessage(loadError, '加载用户失败'))
} finally {
setLoading(false)
}
@@ -44,6 +75,24 @@ export function UsersPage() {
void load()
}, [load])
const filteredItems = useMemo(() => {
const keyword = query.trim().toLowerCase()
return items.filter((item) => {
const matchesQuery = !keyword || [item.username, item.displayName, item.email, item.phone]
.some((value) => value?.toLowerCase().includes(keyword))
const matchesRole = roleFilter === 'all' || item.role === roleFilter
const matchesStatus = statusFilter === 'all'
|| (statusFilter === 'enabled' && !item.disabled)
|| (statusFilter === 'disabled' && item.disabled)
return matchesQuery && matchesRole && matchesStatus
})
}, [items, query, roleFilter, statusFilter])
const enabledCount = items.filter((item) => !item.disabled).length
const adminCount = items.filter((item) => item.role === 'admin').length
const mfaCount = items.filter((item) => !item.disabled && item.mfaEnabled).length
const filtersActive = Boolean(query.trim()) || roleFilter !== 'all' || statusFilter !== 'all'
function openCreate() {
setEditing(null)
setDraft(createEmpty())
@@ -65,51 +114,65 @@ export function UsersPage() {
}
async function handleSubmit() {
if (!draft.username.trim() || !draft.displayName.trim()) {
Message.error('用户名与显示名称不能为空')
const payload: UserUpsertPayload = {
...draft,
username: draft.username.trim(),
displayName: draft.displayName.trim(),
email: draft.email?.trim(),
phone: draft.phone?.trim(),
}
if (payload.username.length < 3) {
Message.error('用户名至少需要 3 个字符')
return
}
if (!editing && !draft.password?.trim()) {
Message.error('创建用户必须设置初始密码')
if (!payload.displayName) {
Message.error('显示名称不能为空')
return
}
if ((!editing || payload.password?.trim()) && (payload.password?.length ?? 0) < 8) {
Message.error(editing ? '新密码至少需要 8 个字符' : '初始密码至少需要 8 个字符')
return
}
setSubmitting(true)
try {
if (editing) {
const updated = await updateUser(editing.id, draft)
const updated = await updateUser(editing.id, payload)
if (updated.id === user?.id) {
if (draft.password?.trim()) {
if (payload.password?.trim()) {
clearTrustedDeviceToken(updated.username)
}
setUser(updated)
}
Message.success('用户已更新')
} else {
await createUser(draft)
await createUser(payload)
Message.success('用户已创建')
}
setModalVisible(false)
await load()
} catch (e) {
Message.error(resolveErrorMessage(e, '保存失败'))
} catch (submitError) {
Message.error(resolveErrorMessage(submitError, '保存失败'))
} finally {
setSubmitting(false)
}
}
async function handleDelete(item: UserSummary) {
if (!window.confirm(`确定删除用户「${item.username}」吗?`)) return
setRowAction(`delete:${item.id}`)
try {
await deleteUser(item.id)
Message.success('已删除')
Message.success('用户已删除')
await load()
} catch (e) {
Message.error(resolveErrorMessage(e, '删除失败'))
} catch (deleteError) {
Message.error(resolveErrorMessage(deleteError, '删除失败'))
} finally {
setRowAction('')
}
}
async function handleResetTwoFactor(item: UserSummary) {
if (!window.confirm(`确定重置用户「${item.username}」的全部 MFA 配置吗?该用户之后可仅凭密码登录。`)) return
setRowAction(`mfa:${item.id}`)
try {
const updated = await resetUserTwoFactor(item.id)
if (updated.id === user?.id) {
@@ -118,104 +181,274 @@ export function UsersPage() {
}
Message.success('MFA 已重置')
await load()
} catch (e) {
Message.error(resolveErrorMessage(e, '重置 MFA 失败'))
} catch (resetError) {
Message.error(resolveErrorMessage(resetError, '重置 MFA 失败'))
} finally {
setRowAction('')
}
}
if (!isAdmin(user)) {
return <Alert type="warning" content="当前账号无权访问用户管理(仅 admin" />
}
const editingSelf = editing?.id === user?.id
return (
<Space direction="vertical" size="large" style={{ width: '100%' }}>
<div>
<Typography.Title heading={4}></Typography.Title>
<Typography.Paragraph type="secondary"></Typography.Paragraph>
</div>
<Space>
<Button type="primary" onClick={openCreate}></Button>
</Space>
{error ? <Card><Typography.Text type="error">{error}</Typography.Text></Card> : null}
<Card>
<Table
rowKey="id"
loading={loading}
data={items}
pagination={false}
stripe
noDataElement={<Empty description="暂无用户" />}
columns={[
{ title: '用户名', dataIndex: 'username', render: (value: string, row: UserSummary) => (
<Space direction="vertical" size={2}>
<Typography.Text bold>{value}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{row.displayName}</Typography.Text>
</Space>
) },
{ title: '角色', dataIndex: 'role', render: (value: string) => <Tag color="arcoblue" bordered>{roleLabel(value)}</Tag> },
{ title: '邮箱 / 手机', dataIndex: 'email', render: (_: string, row: UserSummary) => (
<Space direction="vertical" size={2}>
<Typography.Text>{row.email || '-'}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{row.phone || '-'}</Typography.Text>
</Space>
) },
{ title: '状态', dataIndex: 'disabled', render: (disabled: boolean) => disabled ? <Tag color="red" bordered></Tag> : <Tag color="green" bordered></Tag> },
{ title: 'MFA', dataIndex: 'mfaEnabled', render: (_: boolean, row: UserSummary) => row.mfaEnabled ? (
<AdminDataSection
title="用户账号"
description="维护登录账号、角色、联系方式和多因素认证状态。当前账号与最后一个管理员受到界面级保护。"
metrics={[
{ label: '账号总数', value: items.length, detail: '系统中的全部登录账号' },
{ label: '启用账号', value: enabledCount, detail: `${items.length - enabledCount} 个账号已停用` },
{ label: '管理员', value: adminCount, detail: '拥有访问管理权限' },
{ label: 'MFA 覆盖', value: `${mfaCount}/${enabledCount}`, detail: '已启用账号中的 MFA 使用情况' },
]}
actions={(
<Space>
<Button icon={<IconList />} onClick={() => navigate('/audit?category=user')}></Button>
<Button type="primary" icon={<IconPlus />} onClick={openCreate}></Button>
</Space>
)}
toolbar={(
<div className="admin-toolbar">
<div className="admin-toolbar__filters">
<Input
style={{ width: 260 }}
allowClear
prefix={<IconSearch />}
value={query}
aria-label="搜索用户"
placeholder="搜索用户名、名称或联系方式"
onChange={setQuery}
/>
<Select
style={{ width: 150 }}
value={roleFilter}
options={[{ label: '全部角色', value: 'all' }, ...adminRoleOptions]}
onChange={(value) => setRoleFilter(value as UserRole | 'all')}
/>
<Select
style={{ width: 140 }}
value={statusFilter}
options={[
{ label: '全部状态', value: 'all' },
{ label: '已启用', value: 'enabled' },
{ label: '已停用', value: 'disabled' },
]}
onChange={(value) => setStatusFilter(value as UserStatusFilter)}
/>
<Button
type="text"
disabled={!filtersActive}
onClick={() => { setQuery(''); setRoleFilter('all'); setStatusFilter('all') }}
>
</Button>
</div>
<div className="admin-toolbar__status">
<Typography.Text type="secondary"> {filteredItems.length} / {items.length}</Typography.Text>
<Button icon={<IconRefresh />} loading={loading} onClick={() => void load()}></Button>
</div>
</div>
)}
>
{error ? (
<div className="admin-data-panel__alert">
<Alert type="error" content={error} />
</div>
) : null}
<Table
rowKey="id"
loading={loading}
data={filteredItems}
stripe
pagination={filteredItems.length > 10 ? { pageSize: 10 } : false}
noDataElement={<Empty description={filtersActive ? '没有符合筛选条件的用户' : '暂无用户'} />}
columns={[
{
title: '用户',
dataIndex: 'username',
width: 170,
render: (value: string, row: UserSummary) => (
<div className="admin-identity">
<Space size={6}>
<Typography.Text>{value}</Typography.Text>
{row.id === user?.id ? <Tag bordered></Tag> : null}
</Space>
<span className="admin-identity__secondary">{row.displayName}</span>
<span className="admin-identity__secondary" title={formatDateTime(row.createdAt)}>
{formatDateTime(row.createdAt)}
</span>
</div>
),
},
{
title: '角色',
dataIndex: 'role',
width: 80,
render: (value: string) => <Tag color="arcoblue" bordered>{roleLabel(value)}</Tag>,
},
{
title: '联系方式',
dataIndex: 'email',
width: 190,
render: (_: string, row: UserSummary) => (
<div className="admin-contact">
<Typography.Text>{row.email || '未配置邮箱'}</Typography.Text>
<span className="admin-contact__secondary">{row.phone || '未配置手机号'}</span>
</div>
),
},
{
title: '状态',
dataIndex: 'disabled',
width: 80,
render: (disabled: boolean) => disabled
? <Tag color="red" bordered></Tag>
: <Tag color="green" bordered></Tag>,
},
{
title: '多因素认证',
dataIndex: 'mfaEnabled',
width: 210,
render: (_: boolean, row: UserSummary) => row.mfaEnabled ? (
<Space wrap size={4}>
{row.twoFactorEnabled ? <Tag color="green" bordered>TOTP</Tag> : null}
{row.webAuthnEnabled ? <Tag color="arcoblue" bordered>Passkey {row.webAuthnCredentialCount}</Tag> : null}
{row.emailOtpEnabled ? <Tag color="purple" bordered></Tag> : null}
{row.smsOtpEnabled ? <Tag color="orange" bordered></Tag> : null}
{row.twoFactorEnabled ? <Typography.Text type="secondary" style={{ fontSize: 12 }}> {row.twoFactorRecoveryCodesRemaining}</Typography.Text> : null}
{row.trustedDeviceCount > 0 ? <Tag bordered> {row.trustedDeviceCount}</Tag> : null}
{row.twoFactorEnabled ? <Typography.Text type="secondary"> {row.twoFactorRecoveryCodesRemaining}</Typography.Text> : null}
</Space>
) : <Tag bordered></Tag> },
{ title: '创建时间', dataIndex: 'createdAt' },
{ title: '操作', width: 260, render: (_: unknown, row: UserSummary) => (
<Space>
<Button size="small" type="text" onClick={() => openEdit(row)}></Button>
{row.mfaEnabled && <Button size="small" type="text" onClick={() => void handleResetTwoFactor(row)}> MFA</Button>}
<Button size="small" type="text" status="danger" onClick={() => void handleDelete(row)} disabled={row.id === user?.id}></Button>
</Space>
) },
]}
/>
</Card>
) : <Tag bordered></Tag>,
},
{
title: '操作',
width: 270,
render: (_: unknown, row: UserSummary) => {
const deleteDisabled = row.id === user?.id || (row.role === 'admin' && adminCount <= 1)
const deleteReason = row.id === user?.id ? '不能删除当前登录账号' : '不能删除系统最后一个管理员'
return (
<Space wrap>
<Button size="small" type="text" icon={<IconEdit />} onClick={() => openEdit(row)}></Button>
{row.mfaEnabled ? (
<Popconfirm
title={`确定重置用户「${row.username}」的全部 MFA 配置?`}
content="重置后,该用户可仅凭密码登录。"
onOk={() => handleResetTwoFactor(row)}
>
<Button
size="small"
type="text"
icon={<IconSafe />}
loading={rowAction === `mfa:${row.id}`}
>
MFA
</Button>
</Popconfirm>
) : null}
{deleteDisabled ? (
<Tooltip content={deleteReason}>
<span>
<Button size="small" type="text" status="danger" icon={<IconDelete />} disabled></Button>
</span>
</Tooltip>
) : (
<Popconfirm
title={`确定删除用户「${row.username}」?`}
content="删除后无法恢复。"
onOk={() => handleDelete(row)}
>
<Button
size="small"
type="text"
status="danger"
icon={<IconDelete />}
loading={rowAction === `delete:${row.id}`}
>
</Button>
</Popconfirm>
)}
</Space>
)
},
},
]}
/>
<Modal
visible={modalVisible}
title={editing ? '编辑用户' : '新建用户'}
style={{ width: 680 }}
onCancel={() => setModalVisible(false)}
onOk={handleSubmit}
confirmLoading={submitting}
unmountOnExit
>
<Form layout="vertical">
<Form.Item label="用户名" required>
<Input value={draft.username} onChange={(v) => setDraft({ ...draft, username: v })} disabled={!!editing} />
</Form.Item>
<Form.Item label="显示名称" required>
<Input value={draft.displayName} onChange={(v) => setDraft({ ...draft, displayName: v })} />
</Form.Item>
<Form.Item label="邮箱">
<Input value={draft.email} onChange={(v) => setDraft({ ...draft, email: v })} />
</Form.Item>
<Form.Item label="手机号">
<Input value={draft.phone} onChange={(v) => setDraft({ ...draft, phone: v })} />
</Form.Item>
<Grid.Row gutter={16}>
<Grid.Col span={12}>
<Form.Item label="用户名" required>
<Input
value={draft.username}
placeholder="至少 3 个字符"
disabled={Boolean(editing)}
onChange={(value) => setDraft({ ...draft, username: value })}
/>
</Form.Item>
</Grid.Col>
<Grid.Col span={12}>
<Form.Item label="显示名称" required>
<Input value={draft.displayName} onChange={(value) => setDraft({ ...draft, displayName: value })} />
</Form.Item>
</Grid.Col>
</Grid.Row>
<Grid.Row gutter={16}>
<Grid.Col span={12}>
<Form.Item label="邮箱">
<Input value={draft.email ?? ''} onChange={(value) => setDraft({ ...draft, email: value })} />
</Form.Item>
</Grid.Col>
<Grid.Col span={12}>
<Form.Item label="手机号">
<Input value={draft.phone ?? ''} onChange={(value) => setDraft({ ...draft, phone: value })} />
</Form.Item>
</Grid.Col>
</Grid.Row>
<Form.Item label={editing ? '新密码(留空不修改)' : '初始密码'} required={!editing}>
<Input.Password value={draft.password} onChange={(v) => setDraft({ ...draft, password: v })} />
</Form.Item>
<Form.Item label="角色" required>
<Select value={draft.role} options={roleOptions} onChange={(v: UserRole) => setDraft({ ...draft, role: v })} />
</Form.Item>
<Form.Item label="停用账号">
<Switch checked={draft.disabled} onChange={(v) => setDraft({ ...draft, disabled: v })} />
<Input.Password
value={draft.password}
placeholder="至少 8 个字符"
onChange={(value) => setDraft({ ...draft, password: value })}
/>
</Form.Item>
<Grid.Row gutter={16}>
<Grid.Col span={12}>
<Form.Item label="角色" required>
<AdminRoleSelect
value={draft.role}
disabled={editingSelf}
onChange={(role) => setDraft({ ...draft, role })}
/>
<span className="admin-form-note">
{editingSelf ? '当前登录账号不能在此修改自身角色。' : adminRoleDescriptions[draft.role]}
</span>
</Form.Item>
</Grid.Col>
<Grid.Col span={12}>
<Form.Item label="账号状态">
<div className="admin-switch-field">
<Switch
checked={!draft.disabled}
disabled={editingSelf}
onChange={(enabled) => setDraft({ ...draft, disabled: !enabled })}
/>
<Typography.Text>{draft.disabled ? '已停用' : '已启用'}</Typography.Text>
</div>
{editingSelf ? <span className="admin-form-note"></span> : null}
</Form.Item>
</Grid.Col>
</Grid.Row>
</Form>
</Modal>
</Space>
</AdminDataSection>
)
}

View File

@@ -0,0 +1,178 @@
.admin-page {
display: flex;
flex-direction: column;
gap: 20px;
width: 100%;
}
.admin-page__header {
padding: 0;
}
.admin-page__nav {
display: flex;
gap: 8px;
padding: 8px;
border: 1px solid var(--color-border-2);
border-radius: 4px;
background: var(--color-bg-2);
}
.admin-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.admin-section__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
}
.admin-section__title {
margin: 0 0 4px;
}
.admin-section__description {
max-width: 760px;
margin: 0;
}
.admin-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 24px;
padding: 16px 20px;
border: 1px solid var(--color-border-2);
border-radius: 4px;
background: var(--color-bg-2);
}
.admin-summary__item {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.admin-summary__value {
color: var(--color-text-1);
font-size: 24px;
line-height: 32px;
}
.admin-summary__detail {
overflow: hidden;
color: var(--color-text-3);
font-size: 12px;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-data-panel {
overflow: hidden;
border: 1px solid var(--color-border-2);
border-radius: 4px;
background: var(--color-bg-2);
}
.admin-data-panel__alert {
padding: 12px 16px 0;
}
.admin-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 16px;
border-bottom: 1px solid var(--color-border-2);
}
.admin-toolbar__filters,
.admin-toolbar__status {
display: flex;
align-items: center;
gap: 8px;
}
.admin-identity {
display: flex;
flex-direction: column;
gap: 2px;
}
.admin-identity__secondary,
.admin-contact__secondary {
overflow: hidden;
color: var(--color-text-3);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-identity > .arco-typography {
overflow: hidden;
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-contact {
display: flex;
flex-direction: column;
gap: 2px;
}
.admin-contact > .arco-typography {
overflow: hidden;
max-width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-date,
.admin-key-prefix {
font-size: 12px;
white-space: nowrap;
}
.admin-form-note {
display: block;
margin-top: 6px;
color: var(--color-text-3);
font-size: 12px;
line-height: 20px;
}
.admin-switch-field {
display: flex;
align-items: center;
min-height: 32px;
gap: 8px;
}
.admin-key-result {
display: flex;
flex-direction: column;
gap: 12px;
}
@media (max-width: 1440px) {
.admin-summary {
gap: 16px;
}
.admin-toolbar {
align-items: flex-start;
flex-direction: column;
}
.admin-toolbar__status {
align-self: stretch;
justify-content: flex-end;
}
}

View File

@@ -1,6 +1,7 @@
import { Button, DatePicker, Input, InputNumber, Message, PageHeader, Select, Space, Table, Tag, Typography } from '@arco-design/web-react'
import type { ColumnProps } from '@arco-design/web-react/es/Table'
import { useCallback, useEffect, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { exportAuditLogs, listAuditLogs } from '../../services/audit'
import { fetchSettings, updateSettings } from '../../services/system'
import { useAuthStore } from '../../stores/auth'
@@ -16,6 +17,8 @@ const categoryOptions = [
{ label: '备份任务', value: 'backup_task' },
{ label: '备份记录', value: 'backup_record' },
{ label: '系统设置', value: 'settings' },
{ label: '用户账号', value: 'user' },
{ label: 'API Key', value: 'api_key' },
]
const categoryLabels: Record<string, string> = {
@@ -24,6 +27,8 @@ const categoryLabels: Record<string, string> = {
backup_task: '备份任务',
backup_record: '备份记录',
settings: '系统设置',
user: '用户账号',
api_key: 'API Key',
}
const actionLabels: Record<string, string> = {
@@ -53,6 +58,7 @@ const actionLabels: Record<string, string> = {
delete: '删除',
enable: '启用',
disable: '停用',
revoke: '撤销',
run: '执行',
restore: '恢复',
}
@@ -105,11 +111,15 @@ const columns: ColumnProps<AuditLog>[] = [
]
export function AuditLogsPage() {
const [searchParams, setSearchParams] = useSearchParams()
const requestedCategory = searchParams.get('category') ?? ''
const [logs, setLogs] = useState<AuditLog[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [category, setCategory] = useState('')
const [category, setCategory] = useState(
categoryOptions.some((option) => option.value === requestedCategory) ? requestedCategory : '',
)
const [username, setUsername] = useState('')
const [keyword, setKeyword] = useState('')
const [dateRange, setDateRange] = useState<string[] | null>(null)
@@ -190,6 +200,7 @@ export function AuditLogsPage() {
function handleReset() {
setCategory('')
setSearchParams({}, { replace: true })
setUsername('')
setKeyword('')
setDateRange(null)
@@ -230,7 +241,11 @@ export function AuditLogsPage() {
style={{ width: 160 }}
value={category}
options={categoryOptions}
onChange={(v) => { setCategory(v); setPage(1) }}
onChange={(v) => {
setCategory(v)
setSearchParams(v ? { category: v } : {}, { replace: true })
setPage(1)
}}
placeholder="分类"
/>
<Input

View File

@@ -11,6 +11,7 @@ import { ReplicationRecordsPage } from '../pages/replication-records/Replication
import { TaskTemplatesPage } from '../pages/task-templates/TaskTemplatesPage'
import { UsersPage } from '../pages/admin/UsersPage'
import { ApiKeysPage } from '../pages/admin/ApiKeysPage'
import { AdminLayout } from '../pages/admin/AdminLayout'
import { GoogleDriveCallbackPage } from '../pages/storage-targets/GoogleDriveCallbackPage'
import { StorageTargetsPage } from '../pages/storage-targets/StorageTargetsPage'
import { SettingsPage } from '../pages/settings/SettingsPage'
@@ -40,8 +41,11 @@ export function RouterView() {
<Route path="verify/records" element={<VerificationRecordsPage />} />
<Route path="replication/records" element={<ReplicationRecordsPage />} />
<Route path="task-templates" element={<TaskTemplatesPage />} />
<Route path="admin/users" element={<UsersPage />} />
<Route path="admin/api-keys" element={<ApiKeysPage />} />
<Route path="admin" element={<AdminLayout />}>
<Route index element={<Navigate to="users" replace />} />
<Route path="users" element={<UsersPage />} />
<Route path="api-keys" element={<ApiKeysPage />} />
</Route>
<Route path="storage-targets" element={<StorageTargetsPage />} />
<Route path="storage-targets/google-drive/callback" element={<GoogleDriveCallbackPage />} />
<Route path="settings" element={<SettingsPage />} />

View File

@@ -29,6 +29,5 @@
"src/pages/login/page.tsx",
"src/pages/system-info/page.tsx",
"src/stores/auth.test.ts"
],
"references": [{ "path": "./tsconfig.node.json" }]
]
}

View File

@@ -1,11 +1,11 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2020",
"lib": ["ES2020", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"noEmit": true,
"types": ["node"]
},
"include": ["vite.config.ts"]

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
{"root":["./src/rootapp.tsx","./src/main.tsx","./src/components/authbootstrap.tsx","./src/components/fullpageloading.tsx","./src/layouts/applayout.tsx","./src/pages/dashboard/dashboardpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/system-info/systeminfopage.tsx","./src/router/protectedroute.tsx","./src/router/index.tsx","./src/services/auth.ts","./src/services/http.ts","./src/services/system.ts","./src/stores/auth.ts","./src/test/setup.ts","./src/types/api.ts","./src/types/auth.ts","./src/types/system.ts"],"version":"5.9.3"}

View File

@@ -1,2 +0,0 @@
declare const _default: import("vite").UserConfig;
export default _default;

View File

@@ -1,32 +0,0 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:8340',
changeOrigin: true,
},
},
},
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-arco': ['@arco-design/web-react'],
'vendor-echarts': ['echarts', 'echarts-for-react'],
},
},
},
},
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
globals: true,
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
exclude: ['src/components/auth-guard.test.tsx', 'src/stores/auth.test.ts'],
},
});