feat(sync): 添加 Vue TypeScript 管理控制台

This commit is contained in:
2026-09-09 20:59:43 +08:00
parent 28f7d9f4e6
commit 88799ddee1
21 changed files with 1760 additions and 0 deletions
+6
View File
@@ -38,6 +38,12 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-python@v5 - uses: actions/setup-python@v5
with: { python-version: "3.12" } with: { python-version: "3.12" }
- uses: actions/setup-node@v4
with: { node-version: "22", cache: pnpm, cache-dependency-path: "server sync/console/pnpm-lock.yaml" }
- run: corepack enable && corepack prepare pnpm@10.28.0 --activate
- run: pnpm install --frozen-lockfile && pnpm build
working-directory: server sync/console
- run: git diff --exit-code -- "server sync/sync_server/static"
- run: pip install uv==0.9.24 - run: pip install uv==0.9.24
- run: uv sync --frozen - run: uv sync --frozen
working-directory: backend working-directory: backend
+1
View File
@@ -42,6 +42,7 @@ Thumbs.db
**/.pytest_cache/ **/.pytest_cache/
server sync/.venv/ server sync/.venv/
server sync/.env server sync/.env
server sync/console/node_modules/
community-server/.venv/ community-server/.venv/
community-server/.env community-server/.env
frontend/src-tauri/target/ frontend/src-tauri/target/
+1
View File
@@ -43,6 +43,7 @@ fn main() {
"credentials_lock", "credentials_lock",
"credentials_change_password", "credentials_change_password",
"credentials_import", "credentials_import",
"credentials_cleanup",
"credentials_backup", "credentials_backup",
"credentials_restore", "credentials_restore",
"core_request", "core_request",
@@ -0,0 +1,11 @@
# Automatically generated - DO NOT EDIT!
[[permission]]
identifier = "allow-credentials-cleanup"
description = "Enables the credentials_cleanup command without any pre-configured scope."
commands.allow = ["credentials_cleanup"]
[[permission]]
identifier = "deny-credentials-cleanup"
description = "Denies the credentials_cleanup command without any pre-configured scope."
commands.deny = ["credentials_cleanup"]
+9
View File
@@ -1,9 +1,18 @@
FROM node:22-alpine AS console
WORKDIR /console
RUN corepack enable
COPY console/package.json console/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY console ./
RUN pnpm build
FROM python:3.12-slim FROM python:3.12-slim
COPY --from=ghcr.io/astral-sh/uv:0.9.24 /uv /bin/uv COPY --from=ghcr.io/astral-sh/uv:0.9.24 /uv /bin/uv
WORKDIR /service WORKDIR /service
COPY pyproject.toml uv.lock ./ COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev RUN uv sync --frozen --no-dev
COPY sync_server ./sync_server COPY sync_server ./sync_server
COPY --from=console /sync_server/static ./sync_server/static
RUN useradd --uid 10001 --create-home opennexus && mkdir /staging && chown opennexus /staging RUN useradd --uid 10001 --create-home opennexus && mkdir /staging && chown opennexus /staging
USER 10001 USER 10001
ENV SYNC_STAGING_DIR=/staging ENV SYNC_STAGING_DIR=/staging
+8
View File
@@ -2,6 +2,14 @@
协议及限制见 [Sync v1](../docs/contracts/Sync-v1契约.md)。服务独立于 AI Core,生产入口仅支持 PostgreSQL 和 S3。当前尚未达到 M3 运维退出条件。 协议及限制见 [Sync v1](../docs/contracts/Sync-v1契约.md)。服务独立于 AI Core,生产入口仅支持 PostgreSQL 和 S3。当前尚未达到 M3 运维退出条件。
服务根路径 `/``/console/` 提供同源的 Vue 3 + TypeScript Sync Console,可查看服务健康与依赖就绪状态,并使用普通 Sync 账户管理自己的 Vault 和设备。页面只调用公开的 Sync v1 API;密码在请求发出前从输入框清除,访问和刷新令牌只保留在页面内存,刷新或关闭页面即丢弃。控制台源码位于 `console/`,生产静态文件由 Docker 多阶段构建生成。
```powershell
cd console
pnpm install --frozen-lockfile
pnpm build
```
## 隔离测试 ## 隔离测试
```powershell ```powershell
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#07120f">
<title>OpenNexus Sync Console</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
{
"name": "opennexus-sync-console",
"private": true,
"version": "0.3.0-alpha.1",
"packageManager": "pnpm@10.28.0",
"type": "module",
"scripts": {
"build": "vue-tsc --noEmit && vite build",
"dev": "vite",
"type-check": "vue-tsc --noEmit"
},
"dependencies": {
"vue": "3.5.21"
},
"devDependencies": {
"@vitejs/plugin-vue": "5.2.4",
"typescript": "5.9.2",
"vite": "6.1.0",
"vue-tsc": "2.2.12"
}
}
+958
View File
@@ -0,0 +1,958 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
vue:
specifier: 3.5.21
version: 3.5.21(typescript@5.9.2)
devDependencies:
'@vitejs/plugin-vue':
specifier: 5.2.4
version: 5.2.4(vite@6.1.0)(vue@3.5.21(typescript@5.9.2))
typescript:
specifier: 5.9.2
version: 5.9.2
vite:
specifier: 6.1.0
version: 6.1.0
vue-tsc:
specifier: 2.2.12
version: 2.2.12(typescript@5.9.2)
packages:
'@babel/helper-string-parser@7.29.7':
resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
engines: {node: '>=6.9.0'}
'@babel/helper-validator-identifier@7.29.7':
resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
engines: {node: '>=6.9.0'}
'@babel/parser@7.29.8':
resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==}
engines: {node: '>=6.0.0'}
hasBin: true
'@babel/types@7.29.8':
resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
engines: {node: '>=6.9.0'}
'@esbuild/aix-ppc64@0.24.2':
resolution: {integrity: sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.24.2':
resolution: {integrity: sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.24.2':
resolution: {integrity: sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.24.2':
resolution: {integrity: sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.24.2':
resolution: {integrity: sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.24.2':
resolution: {integrity: sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.24.2':
resolution: {integrity: sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.24.2':
resolution: {integrity: sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.24.2':
resolution: {integrity: sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.24.2':
resolution: {integrity: sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.24.2':
resolution: {integrity: sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.24.2':
resolution: {integrity: sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.24.2':
resolution: {integrity: sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.24.2':
resolution: {integrity: sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.24.2':
resolution: {integrity: sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.24.2':
resolution: {integrity: sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.24.2':
resolution: {integrity: sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.24.2':
resolution: {integrity: sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.24.2':
resolution: {integrity: sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.24.2':
resolution: {integrity: sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.24.2':
resolution: {integrity: sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/sunos-x64@0.24.2':
resolution: {integrity: sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.24.2':
resolution: {integrity: sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.24.2':
resolution: {integrity: sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.24.2':
resolution: {integrity: sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@jridgewell/sourcemap-codec@1.6.0':
resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
engines: {node: ^22.20 || ^24.12 || >=25}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-android-arm-eabi@4.63.1':
resolution: {integrity: sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==}
cpu: [arm]
os: [android]
'@rollup/rollup-android-arm64@4.63.1':
resolution: {integrity: sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==}
cpu: [arm64]
os: [android]
'@rollup/rollup-darwin-arm64@4.63.1':
resolution: {integrity: sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==}
cpu: [arm64]
os: [darwin]
'@rollup/rollup-darwin-x64@4.63.1':
resolution: {integrity: sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==}
cpu: [x64]
os: [darwin]
'@rollup/rollup-freebsd-arm64@4.63.1':
resolution: {integrity: sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==}
cpu: [arm64]
os: [freebsd]
'@rollup/rollup-freebsd-x64@4.63.1':
resolution: {integrity: sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==}
cpu: [x64]
os: [freebsd]
'@rollup/rollup-linux-arm-gnueabihf@4.63.1':
resolution: {integrity: sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.63.1':
resolution: {integrity: sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.63.1':
resolution: {integrity: sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.63.1':
resolution: {integrity: sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.63.1':
resolution: {integrity: sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.63.1':
resolution: {integrity: sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.63.1':
resolution: {integrity: sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.63.1':
resolution: {integrity: sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.63.1':
resolution: {integrity: sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.63.1':
resolution: {integrity: sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.63.1':
resolution: {integrity: sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.63.1':
resolution: {integrity: sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.63.1':
resolution: {integrity: sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.63.1':
resolution: {integrity: sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==}
cpu: [x64]
os: [openbsd]
'@rollup/rollup-openharmony-arm64@4.63.1':
resolution: {integrity: sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==}
cpu: [arm64]
os: [openharmony]
'@rollup/rollup-win32-arm64-msvc@4.63.1':
resolution: {integrity: sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==}
cpu: [arm64]
os: [win32]
'@rollup/rollup-win32-ia32-msvc@4.63.1':
resolution: {integrity: sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==}
cpu: [ia32]
os: [win32]
'@rollup/rollup-win32-x64-gnu@4.63.1':
resolution: {integrity: sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==}
cpu: [x64]
os: [win32]
'@rollup/rollup-win32-x64-msvc@4.63.1':
resolution: {integrity: sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==}
cpu: [x64]
os: [win32]
'@types/estree@1.0.9':
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
'@vitejs/plugin-vue@5.2.4':
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
engines: {node: ^18.0.0 || >=20.0.0}
peerDependencies:
vite: ^5.0.0 || ^6.0.0
vue: ^3.2.25
'@volar/language-core@2.4.15':
resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==}
'@volar/source-map@2.4.15':
resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==}
'@volar/typescript@2.4.15':
resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==}
'@vue/compiler-core@3.5.21':
resolution: {integrity: sha512-8i+LZ0vf6ZgII5Z9XmUvrCyEzocvWT+TeR2VBUVlzIH6Tyv57E20mPZ1bCS+tbejgUgmjrEh7q/0F0bibskAmw==}
'@vue/compiler-core@3.5.42':
resolution: {integrity: sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==}
'@vue/compiler-dom@3.5.21':
resolution: {integrity: sha512-jNtbu/u97wiyEBJlJ9kmdw7tAr5Vy0Aj5CgQmo+6pxWNQhXZDPsRr1UWPN4v3Zf82s2H3kF51IbzZ4jMWAgPlQ==}
'@vue/compiler-dom@3.5.42':
resolution: {integrity: sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==}
'@vue/compiler-sfc@3.5.21':
resolution: {integrity: sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ==}
'@vue/compiler-ssr@3.5.21':
resolution: {integrity: sha512-vKQ5olH5edFZdf5ZrlEgSO1j1DMA4u23TVK5XR1uMhvwnYvVdDF0nHXJUblL/GvzlShQbjhZZ2uvYmDlAbgo9w==}
'@vue/compiler-vue2@2.7.16':
resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==}
'@vue/language-core@2.2.12':
resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
'@vue/reactivity@3.5.21':
resolution: {integrity: sha512-3ah7sa+Cwr9iiYEERt9JfZKPw4A2UlbY8RbbnH2mGCE8NwHkhmlZt2VsH0oDA3P08X3jJd29ohBDtX+TbD9AsA==}
'@vue/runtime-core@3.5.21':
resolution: {integrity: sha512-+DplQlRS4MXfIf9gfD1BOJpk5RSyGgGXD/R+cumhe8jdjUcq/qlxDawQlSI8hCKupBlvM+3eS1se5xW+SuNAwA==}
'@vue/runtime-dom@3.5.21':
resolution: {integrity: sha512-3M2DZsOFwM5qI15wrMmNF5RJe1+ARijt2HM3TbzBbPSuBHOQpoidE+Pa+XEaVN+czbHf81ETRoG1ltztP2em8w==}
'@vue/server-renderer@3.5.21':
resolution: {integrity: sha512-qr8AqgD3DJPJcGvLcJKQo2tAc8OnXRcfxhOJCPF+fcfn5bBGz7VCcO7t+qETOPxpWK1mgysXvVT/j+xWaHeMWA==}
peerDependencies:
vue: 3.5.21
'@vue/shared@3.5.21':
resolution: {integrity: sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==}
'@vue/shared@3.5.42':
resolution: {integrity: sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==}
alien-signals@1.0.13:
resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
brace-expansion@2.1.4:
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
de-indent@1.0.2:
resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==}
entities@4.5.0:
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
engines: {node: '>=0.12'}
entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'}
esbuild@0.24.2:
resolution: {integrity: sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==}
engines: {node: '>=18'}
hasBin: true
estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
he@1.2.0:
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
hasBin: true
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
minimatch@9.0.9:
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
engines: {node: '>=16 || 14 >=14.17'}
muggle-string@0.4.1:
resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==}
nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
postcss@8.5.28:
resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==}
engines: {node: ^10 || ^12 || >=14}
rollup@4.63.1:
resolution: {integrity: sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
typescript@5.9.2:
resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==}
engines: {node: '>=14.17'}
hasBin: true
vite@6.1.0:
resolution: {integrity: sha512-RjjMipCKVoR4hVfPY6GQTgveinjNuyLw+qruksLDvA5ktI1150VmcMBKmQaEWJhg/j6Uaf6dNCNA0AfdzUb/hQ==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
hasBin: true
peerDependencies:
'@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
jiti: '>=1.21.0'
less: '*'
lightningcss: ^1.21.0
sass: '*'
sass-embedded: '*'
stylus: '*'
sugarss: '*'
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
jiti:
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
vscode-uri@3.2.0:
resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==}
vue-tsc@2.2.12:
resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==}
hasBin: true
peerDependencies:
typescript: '>=5.0.0'
vue@3.5.21:
resolution: {integrity: sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==}
peerDependencies:
typescript: '*'
peerDependenciesMeta:
typescript:
optional: true
snapshots:
'@babel/helper-string-parser@7.29.7': {}
'@babel/helper-validator-identifier@7.29.7': {}
'@babel/parser@7.29.8':
dependencies:
'@babel/types': 7.29.8
'@babel/types@7.29.8':
dependencies:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
'@esbuild/aix-ppc64@0.24.2':
optional: true
'@esbuild/android-arm64@0.24.2':
optional: true
'@esbuild/android-arm@0.24.2':
optional: true
'@esbuild/android-x64@0.24.2':
optional: true
'@esbuild/darwin-arm64@0.24.2':
optional: true
'@esbuild/darwin-x64@0.24.2':
optional: true
'@esbuild/freebsd-arm64@0.24.2':
optional: true
'@esbuild/freebsd-x64@0.24.2':
optional: true
'@esbuild/linux-arm64@0.24.2':
optional: true
'@esbuild/linux-arm@0.24.2':
optional: true
'@esbuild/linux-ia32@0.24.2':
optional: true
'@esbuild/linux-loong64@0.24.2':
optional: true
'@esbuild/linux-mips64el@0.24.2':
optional: true
'@esbuild/linux-ppc64@0.24.2':
optional: true
'@esbuild/linux-riscv64@0.24.2':
optional: true
'@esbuild/linux-s390x@0.24.2':
optional: true
'@esbuild/linux-x64@0.24.2':
optional: true
'@esbuild/netbsd-arm64@0.24.2':
optional: true
'@esbuild/netbsd-x64@0.24.2':
optional: true
'@esbuild/openbsd-arm64@0.24.2':
optional: true
'@esbuild/openbsd-x64@0.24.2':
optional: true
'@esbuild/sunos-x64@0.24.2':
optional: true
'@esbuild/win32-arm64@0.24.2':
optional: true
'@esbuild/win32-ia32@0.24.2':
optional: true
'@esbuild/win32-x64@0.24.2':
optional: true
'@jridgewell/sourcemap-codec@1.6.0': {}
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
optional: true
'@rollup/rollup-android-arm-eabi@4.63.1':
optional: true
'@rollup/rollup-android-arm64@4.63.1':
optional: true
'@rollup/rollup-darwin-arm64@4.63.1':
optional: true
'@rollup/rollup-darwin-x64@4.63.1':
optional: true
'@rollup/rollup-freebsd-arm64@4.63.1':
optional: true
'@rollup/rollup-freebsd-x64@4.63.1':
optional: true
'@rollup/rollup-linux-arm-gnueabihf@4.63.1':
optional: true
'@rollup/rollup-linux-arm-musleabihf@4.63.1':
optional: true
'@rollup/rollup-linux-arm64-gnu@4.63.1':
optional: true
'@rollup/rollup-linux-arm64-musl@4.63.1':
optional: true
'@rollup/rollup-linux-loong64-gnu@4.63.1':
optional: true
'@rollup/rollup-linux-loong64-musl@4.63.1':
optional: true
'@rollup/rollup-linux-ppc64-gnu@4.63.1':
optional: true
'@rollup/rollup-linux-ppc64-musl@4.63.1':
optional: true
'@rollup/rollup-linux-riscv64-gnu@4.63.1':
optional: true
'@rollup/rollup-linux-riscv64-musl@4.63.1':
optional: true
'@rollup/rollup-linux-s390x-gnu@4.63.1':
optional: true
'@rollup/rollup-linux-x64-gnu@4.63.1':
optional: true
'@rollup/rollup-linux-x64-musl@4.63.1':
optional: true
'@rollup/rollup-openbsd-x64@4.63.1':
optional: true
'@rollup/rollup-openharmony-arm64@4.63.1':
optional: true
'@rollup/rollup-win32-arm64-msvc@4.63.1':
optional: true
'@rollup/rollup-win32-ia32-msvc@4.63.1':
optional: true
'@rollup/rollup-win32-x64-gnu@4.63.1':
optional: true
'@rollup/rollup-win32-x64-msvc@4.63.1':
optional: true
'@types/estree@1.0.9': {}
'@vitejs/plugin-vue@5.2.4(vite@6.1.0)(vue@3.5.21(typescript@5.9.2))':
dependencies:
vite: 6.1.0
vue: 3.5.21(typescript@5.9.2)
'@volar/language-core@2.4.15':
dependencies:
'@volar/source-map': 2.4.15
'@volar/source-map@2.4.15': {}
'@volar/typescript@2.4.15':
dependencies:
'@volar/language-core': 2.4.15
path-browserify: 1.0.1
vscode-uri: 3.2.0
'@vue/compiler-core@3.5.21':
dependencies:
'@babel/parser': 7.29.8
'@vue/shared': 3.5.21
entities: 4.5.0
estree-walker: 2.0.2
source-map-js: 1.2.1
'@vue/compiler-core@3.5.42':
dependencies:
'@babel/parser': 7.29.8
'@vue/shared': 3.5.42
entities: 7.0.1
estree-walker: 2.0.2
source-map-js: 1.2.1
'@vue/compiler-dom@3.5.21':
dependencies:
'@vue/compiler-core': 3.5.21
'@vue/shared': 3.5.21
'@vue/compiler-dom@3.5.42':
dependencies:
'@vue/compiler-core': 3.5.42
'@vue/shared': 3.5.42
'@vue/compiler-sfc@3.5.21':
dependencies:
'@babel/parser': 7.29.8
'@vue/compiler-core': 3.5.21
'@vue/compiler-dom': 3.5.21
'@vue/compiler-ssr': 3.5.21
'@vue/shared': 3.5.21
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.28
source-map-js: 1.2.1
'@vue/compiler-ssr@3.5.21':
dependencies:
'@vue/compiler-dom': 3.5.21
'@vue/shared': 3.5.21
'@vue/compiler-vue2@2.7.16':
dependencies:
de-indent: 1.0.2
he: 1.2.0
'@vue/language-core@2.2.12(typescript@5.9.2)':
dependencies:
'@volar/language-core': 2.4.15
'@vue/compiler-dom': 3.5.42
'@vue/compiler-vue2': 2.7.16
'@vue/shared': 3.5.42
alien-signals: 1.0.13
minimatch: 9.0.9
muggle-string: 0.4.1
path-browserify: 1.0.1
optionalDependencies:
typescript: 5.9.2
'@vue/reactivity@3.5.21':
dependencies:
'@vue/shared': 3.5.21
'@vue/runtime-core@3.5.21':
dependencies:
'@vue/reactivity': 3.5.21
'@vue/shared': 3.5.21
'@vue/runtime-dom@3.5.21':
dependencies:
'@vue/reactivity': 3.5.21
'@vue/runtime-core': 3.5.21
'@vue/shared': 3.5.21
csstype: 3.2.3
'@vue/server-renderer@3.5.21(vue@3.5.21(typescript@5.9.2))':
dependencies:
'@vue/compiler-ssr': 3.5.21
'@vue/shared': 3.5.21
vue: 3.5.21(typescript@5.9.2)
'@vue/shared@3.5.21': {}
'@vue/shared@3.5.42': {}
alien-signals@1.0.13: {}
balanced-match@1.0.2: {}
brace-expansion@2.1.4:
dependencies:
balanced-match: 1.0.2
csstype@3.2.3: {}
de-indent@1.0.2: {}
entities@4.5.0: {}
entities@7.0.1: {}
esbuild@0.24.2:
optionalDependencies:
'@esbuild/aix-ppc64': 0.24.2
'@esbuild/android-arm': 0.24.2
'@esbuild/android-arm64': 0.24.2
'@esbuild/android-x64': 0.24.2
'@esbuild/darwin-arm64': 0.24.2
'@esbuild/darwin-x64': 0.24.2
'@esbuild/freebsd-arm64': 0.24.2
'@esbuild/freebsd-x64': 0.24.2
'@esbuild/linux-arm': 0.24.2
'@esbuild/linux-arm64': 0.24.2
'@esbuild/linux-ia32': 0.24.2
'@esbuild/linux-loong64': 0.24.2
'@esbuild/linux-mips64el': 0.24.2
'@esbuild/linux-ppc64': 0.24.2
'@esbuild/linux-riscv64': 0.24.2
'@esbuild/linux-s390x': 0.24.2
'@esbuild/linux-x64': 0.24.2
'@esbuild/netbsd-arm64': 0.24.2
'@esbuild/netbsd-x64': 0.24.2
'@esbuild/openbsd-arm64': 0.24.2
'@esbuild/openbsd-x64': 0.24.2
'@esbuild/sunos-x64': 0.24.2
'@esbuild/win32-arm64': 0.24.2
'@esbuild/win32-ia32': 0.24.2
'@esbuild/win32-x64': 0.24.2
estree-walker@2.0.2: {}
fsevents@2.3.3:
optional: true
he@1.2.0: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.6.0
minimatch@9.0.9:
dependencies:
brace-expansion: 2.1.4
muggle-string@0.4.1: {}
nanoid@3.3.18: {}
path-browserify@1.0.1: {}
picocolors@1.1.1: {}
postcss@8.5.28:
dependencies:
nanoid: 3.3.18
picocolors: 1.1.1
source-map-js: 1.2.1
rollup@4.63.1:
dependencies:
'@types/estree': 1.0.9
optionalDependencies:
'@napi-rs/lzma-linux-x64-gnu': 1.5.1
'@rollup/rollup-android-arm-eabi': 4.63.1
'@rollup/rollup-android-arm64': 4.63.1
'@rollup/rollup-darwin-arm64': 4.63.1
'@rollup/rollup-darwin-x64': 4.63.1
'@rollup/rollup-freebsd-arm64': 4.63.1
'@rollup/rollup-freebsd-x64': 4.63.1
'@rollup/rollup-linux-arm-gnueabihf': 4.63.1
'@rollup/rollup-linux-arm-musleabihf': 4.63.1
'@rollup/rollup-linux-arm64-gnu': 4.63.1
'@rollup/rollup-linux-arm64-musl': 4.63.1
'@rollup/rollup-linux-loong64-gnu': 4.63.1
'@rollup/rollup-linux-loong64-musl': 4.63.1
'@rollup/rollup-linux-ppc64-gnu': 4.63.1
'@rollup/rollup-linux-ppc64-musl': 4.63.1
'@rollup/rollup-linux-riscv64-gnu': 4.63.1
'@rollup/rollup-linux-riscv64-musl': 4.63.1
'@rollup/rollup-linux-s390x-gnu': 4.63.1
'@rollup/rollup-linux-x64-gnu': 4.63.1
'@rollup/rollup-linux-x64-musl': 4.63.1
'@rollup/rollup-openbsd-x64': 4.63.1
'@rollup/rollup-openharmony-arm64': 4.63.1
'@rollup/rollup-win32-arm64-msvc': 4.63.1
'@rollup/rollup-win32-ia32-msvc': 4.63.1
'@rollup/rollup-win32-x64-gnu': 4.63.1
'@rollup/rollup-win32-x64-msvc': 4.63.1
fsevents: 2.3.3
source-map-js@1.2.1: {}
typescript@5.9.2: {}
vite@6.1.0:
dependencies:
esbuild: 0.24.2
postcss: 8.5.28
rollup: 4.63.1
optionalDependencies:
fsevents: 2.3.3
vscode-uri@3.2.0: {}
vue-tsc@2.2.12(typescript@5.9.2):
dependencies:
'@volar/typescript': 2.4.15
'@vue/language-core': 2.2.12(typescript@5.9.2)
typescript: 5.9.2
vue@3.5.21(typescript@5.9.2):
dependencies:
'@vue/compiler-dom': 3.5.21
'@vue/compiler-sfc': 3.5.21
'@vue/runtime-dom': 3.5.21
'@vue/server-renderer': 3.5.21(vue@3.5.21(typescript@5.9.2))
'@vue/shared': 3.5.21
optionalDependencies:
typescript: 5.9.2
+250
View File
@@ -0,0 +1,250 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { SyncApi, type Device, type ServiceStatus, type Vault } from './api'
const api = new SyncApi()
const service = reactive<ServiceStatus>({ health: false, ready: false, protocol: 1, maxObjectSize: 100 * 1024 * 1024 })
const checking = ref(true)
const signedIn = ref(false)
const busy = ref(false)
const username = ref('')
const password = ref('')
const deviceName = ref('OpenNexus Web Console')
const sessionLabel = ref('')
const newVaultName = ref('')
const vaults = ref<Vault[]>([])
const devices = ref<Device[]>([])
const toast = ref('')
const toastError = ref(false)
let toastTimer: number | undefined
let statusTimer: number | undefined
const used = computed(() => vaults.value.reduce((total, vault) => total + Number(vault.used || 0), 0))
const quota = computed(() => vaults.value.reduce((total, vault) => total + Number(vault.quota || 0), 0))
const activeDevices = computed(() => devices.value.filter(device => !device.revoked).length)
function formatBytes(value: number) {
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB']
let size = Number(value) || 0
let unit = 0
while (size >= 1024 && unit < units.length - 1) { size /= 1024; unit += 1 }
return `${size >= 10 || unit === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unit]}`
}
function shortId(value: string) { return `${value.slice(0, 12)}` }
function usagePercent(vault: Vault) { return Math.min(100, vault.quota ? vault.used / vault.quota * 100 : 0) }
function firstCharacter(value: string, fallback: string) { return value.trim().slice(0, 1).toUpperCase() || fallback }
function notify(text: string, error = false) {
toast.value = text
toastError.value = error
window.clearTimeout(toastTimer)
toastTimer = window.setTimeout(() => { toast.value = '' }, 4800)
}
async function refreshStatus() {
checking.value = true
try { Object.assign(service, await api.status()) }
finally { checking.value = false }
}
async function loadAccount() {
const [vaultPayload, devicePayload] = await Promise.all([api.vaults(), api.devices()])
vaults.value = vaultPayload.items
devices.value = devicePayload.items
}
async function refreshAccount() {
if (busy.value) return
busy.value = true
try {
await loadAccount()
notify('账户数据已刷新')
} catch (error) {
if (!api.signedIn) leaveConsole()
notify(error instanceof Error ? error.message : 'REFRESH_FAILED', true)
} finally { busy.value = false }
}
function leaveConsole() {
api.clear()
signedIn.value = false
password.value = ''
vaults.value = []
devices.value = []
}
async function signIn() {
if (busy.value) return
busy.value = true
const secret = password.value
const account = username.value.trim()
const device = deviceName.value.trim()
password.value = ''
try {
await api.login(account, secret, device)
sessionLabel.value = `${account} · ${device}`
signedIn.value = true
await loadAccount()
notify('设备会话已建立')
} catch (error) {
leaveConsole()
notify(error instanceof Error ? error.message : 'LOGIN_FAILED', true)
} finally { busy.value = false }
}
async function createVault() {
const name = newVaultName.value.trim()
if (!name || busy.value) return
busy.value = true
try {
await api.createVault(name)
newVaultName.value = ''
await loadAccount()
notify(`已创建 Vault${name}`)
} catch (error) {
notify(error instanceof Error ? error.message : 'CREATE_VAULT_FAILED', true)
} finally { busy.value = false }
}
async function revokeDevice(device: Device) {
if (!window.confirm(`撤销设备“${device.name}”?该设备的下一次请求会被拒绝。`)) return
busy.value = true
try {
await api.revokeDevice(device.id)
if (device.id === api.deviceId) {
leaveConsole()
notify('当前设备已撤销')
} else {
await loadAccount()
notify(`已撤销设备:${device.name}`)
}
} catch (error) {
notify(error instanceof Error ? error.message : 'REVOKE_FAILED', true)
} finally { busy.value = false }
}
async function logout() {
if (busy.value) return
busy.value = true
try { await api.logout() }
finally {
leaveConsole()
busy.value = false
notify('会话已退出')
}
}
onMounted(() => {
void refreshStatus()
statusTimer = window.setInterval(() => { void refreshStatus() }, 15_000)
})
onBeforeUnmount(() => {
window.clearInterval(statusTimer)
window.clearTimeout(toastTimer)
leaveConsole()
})
</script>
<template>
<div class="ambient ambient-one" aria-hidden="true" />
<div class="ambient ambient-two" aria-hidden="true" />
<header class="topbar">
<a class="brand" href="/console" aria-label="OpenNexus Sync Console">
<span class="brand-mark" aria-hidden="true"><i /><i /><i /></span>
<span><strong>OpenNexus</strong><small>Sync Console</small></span>
</a>
<div class="service-strip" aria-label="服务状态">
<span class="status-chip" :class="{ ok: service.health, bad: !checking && !service.health }"><i /><b>服务</b><em>{{ checking ? '检测中' : service.health ? '在线' : '离线' }}</em></span>
<span class="status-chip" :class="{ ok: service.ready, bad: !checking && !service.ready }"><i /><b>依赖</b><em>{{ checking ? '检测中' : service.ready ? '就绪' : '不可用' }}</em></span>
<button class="icon-button" type="button" :disabled="checking" aria-label="刷新服务状态" title="刷新服务状态" @click="refreshStatus">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7" /></svg>
</button>
</div>
</header>
<main>
<section v-if="!signedIn" class="hero">
<div class="hero-copy">
<div class="eyebrow"><span /> Self-hosted workspace sync</div>
<h1>让知识在设备之间<br><em>安静地抵达</em></h1>
<p>OpenNexus Sync 为每个 Vault 保留完整修订历史附件完整性校验和设备级撤销此控制台只连接当前服务器不在浏览器持久保存密码或令牌</p>
<div class="protocol-grid" aria-label="协议能力">
<article><strong>v{{ service.protocol }}</strong><span>同步协议</span></article>
<article><strong>{{ formatBytes(service.maxObjectSize) }}</strong><span>单对象上限</span></article>
<article><strong>SHA-256</strong><span>内容完整性</span></article>
</div>
</div>
<section class="login-card" aria-labelledby="login-title">
<div class="card-glow" aria-hidden="true" />
<div class="card-heading">
<span class="lock-mark" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="5" y="10" width="14" height="11" rx="3" /><path d="M8 10V7a4 4 0 0 1 8 0v3" /></svg></span>
<div><p>设备会话</p><h2 id="login-title">登录 Sync Server</h2></div>
</div>
<form @submit.prevent="signIn">
<label>账户<input v-model="username" autocomplete="username" maxlength="80" required placeholder="输入 Sync 账户"></label>
<label>密码<input v-model="password" type="password" autocomplete="current-password" minlength="12" maxlength="256" required placeholder="至少 12 个字符"></label>
<label>设备名称<input v-model="deviceName" autocomplete="off" maxlength="120" required></label>
<button class="primary-button" type="submit" :disabled="busy || !password"><span>{{ busy ? '正在建立会话…' : '建立安全会话' }}</span><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg></button>
</form>
<p class="privacy-note"><span></span>密码在发出请求前即从输入框清除会话令牌只保存在当前页面内存</p>
</section>
</section>
<section v-else class="console-view">
<div class="console-heading">
<div><div class="eyebrow"><span /> Connected workspace</div><h1>同步空间</h1><p>{{ sessionLabel }}</p></div>
<button class="secondary-button" type="button" :disabled="busy" @click="logout">退出登录</button>
</div>
<div class="metric-grid">
<article><span>远端 Vault</span><strong>{{ vaults.length }}</strong><small>当前账户可访问</small></article>
<article><span>已使用空间</span><strong>{{ formatBytes(used) }}</strong><small>总配额 {{ formatBytes(quota) }}</small></article>
<article><span>设备</span><strong>{{ devices.length }}</strong><small>{{ activeDevices }} 台可访问</small></article>
<article><span>协议</span><strong>v{{ service.protocol }}</strong><small>历史长期保留</small></article>
</div>
<div class="content-grid">
<section class="surface">
<div class="surface-heading">
<div><p>Vaults</p><h2>远端知识库</h2></div>
<button class="icon-button" type="button" :disabled="busy" aria-label="刷新账户数据" title="刷新账户数据" @click="refreshAccount">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 5.7M20 4v7h-7" /></svg>
</button>
</div>
<form class="create-form" @submit.prevent="createVault">
<label>创建新 Vault</label>
<div><input v-model="newVaultName" maxlength="120" required placeholder="例如:产品知识库"><button type="submit" :disabled="busy || !newVaultName.trim()">创建</button></div>
</form>
<div class="vault-list">
<div v-if="!vaults.length" class="empty-state">还没有远端 Vault</div>
<article v-for="vault in vaults" :key="vault.id" class="vault-item">
<div class="vault-symbol">N</div>
<div class="item-copy">
<strong>{{ vault.name }}</strong><small>{{ shortId(vault.id) }} · revision {{ vault.sequence }}</small>
<progress :value="usagePercent(vault)" max="100" />
</div>
<div class="item-side"><strong>{{ formatBytes(vault.used) }}</strong><small>of {{ formatBytes(vault.quota) }}</small></div>
</article>
</div>
</section>
<section class="surface">
<div class="surface-heading"><div><p>Devices</p><h2>设备会话</h2></div><span class="secure-badge">逐次鉴权</span></div>
<p class="surface-intro">撤销会让该设备的下一次请求立即失败Vault 内容和本地文件不会因此删除</p>
<div class="device-list">
<article v-for="device in devices" :key="device.id" class="device-item" :class="{ revoked: device.revoked }">
<div class="device-symbol">{{ firstCharacter(device.name, 'D') }}</div>
<div class="item-copy"><strong>{{ device.name }}</strong><small>{{ device.revoked ? '已撤销' : '可访问' }}<template v-if="device.id === api.deviceId"> · 当前设备</template></small></div>
<button v-if="!device.revoked" class="danger-button" type="button" :disabled="busy" @click="revokeDevice(device)">撤销</button>
</article>
</div>
</section>
</div>
</section>
</main>
<footer><span>OpenNexus Sync v1</span><span>Transport encryption · Device revocation · Immutable history</span></footer>
<div v-if="toast" class="toast" :class="{ error: toastError }" role="status" aria-live="polite">{{ toast }}</div>
</template>
+140
View File
@@ -0,0 +1,140 @@
export interface ServiceStatus {
health: boolean
ready: boolean
protocol: number
maxObjectSize: number
}
export interface Session {
access_token: string
refresh_token: string
expires_in: number
device_id: string
}
export interface Vault {
id: string
name: string
sequence: number
used: number
quota: number
}
export interface Device {
id: string
name: string
revoked: number | boolean
}
interface ErrorPayload {
error?: { code?: string }
}
async function safeJson<T>(response: Response): Promise<T | null> {
try { return await response.json() as T }
catch { return null }
}
export class SyncApi {
private access = ''
private refresh = ''
deviceId = ''
get signedIn() { return Boolean(this.access) }
private async raw(path: string, init: RequestInit = {}, authenticate = true): Promise<Response> {
const headers = new Headers(init.headers)
if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json')
if (authenticate && this.access) headers.set('Authorization', `Bearer ${this.access}`)
return fetch(path, { ...init, headers, cache: 'no-store', credentials: 'same-origin' })
}
private async rotate(): Promise<boolean> {
if (!this.refresh) return false
const response = await this.raw('/sync/v1/auth/refresh', {
method: 'POST',
body: JSON.stringify({ refresh_token: this.refresh }),
}, false)
if (!response.ok) return false
const session = await safeJson<Session>(response)
if (!session) return false
this.accept(session)
return true
}
private async request<T>(path: string, init: RequestInit = {}, allowRotate = true): Promise<T> {
let response = await this.raw(path, init)
if (response.status === 401 && allowRotate && await this.rotate()) response = await this.raw(path, init)
if (!response.ok) {
const payload = await safeJson<ErrorPayload>(response)
if (response.status === 401) this.clear()
throw new Error(payload?.error?.code ?? `HTTP_${response.status}`)
}
if (response.status === 204) return undefined as T
const payload = await safeJson<T>(response)
if (payload === null) throw new Error('INVALID_RESPONSE')
return payload
}
private accept(session: Session) {
this.access = session.access_token
this.refresh = session.refresh_token
this.deviceId = session.device_id
}
async status(): Promise<ServiceStatus> {
const [health, ready, handshake] = await Promise.allSettled([
this.raw('/health', {}, false),
this.raw('/ready', {}, false),
this.raw('/sync/v1/handshake?protocol=1', {}, false),
])
let protocol = 1
let maxObjectSize = 100 * 1024 * 1024
if (handshake.status === 'fulfilled' && handshake.value.ok) {
const payload = await safeJson<{ protocol: number; max_object_size: number }>(handshake.value)
protocol = payload?.protocol ?? protocol
maxObjectSize = payload?.max_object_size ?? maxObjectSize
}
return {
health: health.status === 'fulfilled' && health.value.ok,
ready: ready.status === 'fulfilled' && ready.value.ok,
protocol,
maxObjectSize,
}
}
async login(username: string, password: string, deviceName: string): Promise<void> {
const response = await this.raw('/sync/v1/auth/sessions', {
method: 'POST',
body: JSON.stringify({ username, password, device_name: deviceName }),
}, false)
if (!response.ok) {
const payload = await safeJson<ErrorPayload>(response)
throw new Error(payload?.error?.code ?? `HTTP_${response.status}`)
}
const session = await safeJson<Session>(response)
if (!session) throw new Error('INVALID_RESPONSE')
this.accept(session)
}
vaults() { return this.request<{ items: Vault[] }>('/sync/v1/vaults') }
devices() { return this.request<{ items: Device[] }>('/sync/v1/devices') }
createVault(name: string) {
return this.request<{ vault_id: string; name: string }>('/sync/v1/vaults', {
method: 'POST', body: JSON.stringify({ name }),
})
}
revokeDevice(id: string) {
return this.request<void>(`/sync/v1/devices/${encodeURIComponent(id)}`, { method: 'DELETE' })
}
async logout(): Promise<void> {
try {
if (this.access) await this.request<void>('/sync/v1/auth/sessions', { method: 'DELETE' }, false)
} finally { this.clear() }
}
clear() {
this.access = ''
this.refresh = ''
this.deviceId = ''
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+5
View File
@@ -0,0 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
createApp(App).mount('#app')
+175
View File
@@ -0,0 +1,175 @@
:root {
color-scheme: dark;
--bg: #06100d;
--surface: rgba(13, 29, 24, .84);
--surface-strong: #10241d;
--line: rgba(169, 222, 196, .15);
--line-strong: rgba(169, 222, 196, .28);
--text: #edf7f2;
--muted: #8fa99c;
--mint: #72e0ad;
--mint-bright: #a6f4cf;
--purple: #a991ff;
--danger: #ff8e8e;
--warning: #f5ca72;
--shadow: 0 26px 90px rgba(0, 0, 0, .34);
font-family: Inter, "Segoe UI", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
}
* { box-sizing: border-box; }
html { min-width: 320px; min-height: 100%; background: var(--bg); }
body {
margin: 0;
min-height: 100vh;
color: var(--text);
background:
linear-gradient(rgba(255,255,255,.018) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.018) 1px, transparent 1px),
radial-gradient(circle at 48% 0%, #15372c 0, var(--bg) 46%);
background-size: 52px 52px, 52px 52px, auto;
overflow-x: hidden;
}
button, input { font: inherit; }
button { color: inherit; }
button:focus-visible, input:focus-visible { outline: 2px solid var(--mint); outline-offset: 3px; }
button:disabled { cursor: default; opacity: .5; }
.ambient { position: fixed; border-radius: 50%; filter: blur(80px); pointer-events: none; opacity: .16; }
.ambient-one { width: 420px; height: 420px; background: var(--mint); top: -220px; right: 8%; }
.ambient-two { width: 360px; height: 360px; background: var(--purple); bottom: -220px; left: -100px; opacity: .1; }
.topbar {
width: min(1280px, calc(100% - 48px));
height: 88px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--line);
position: relative;
z-index: 2;
}
.brand { color: inherit; text-decoration: none; display: inline-flex; align-items: center; gap: 13px; }
.brand > span:last-child { display: grid; gap: 1px; }
.brand strong { font-size: 17px; letter-spacing: -.02em; }
.brand small { color: var(--muted); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; }
.brand-mark { width: 34px; height: 34px; display: grid; place-items: center; position: relative; transform: rotate(30deg); }
.brand-mark i { position: absolute; display: block; border: 1.5px solid var(--mint); border-radius: 4px; }
.brand-mark i:nth-child(1) { width: 25px; height: 25px; opacity: .45; }
.brand-mark i:nth-child(2) { width: 17px; height: 17px; opacity: .72; }
.brand-mark i:nth-child(3) { width: 8px; height: 8px; background: var(--mint); box-shadow: 0 0 20px rgba(114,224,173,.8); }
.service-strip { display: flex; align-items: center; gap: 9px; }
.status-chip { min-height: 34px; padding: 0 12px; border: 1px solid var(--line); border-radius: 999px; display: flex; align-items: center; gap: 7px; background: rgba(7,18,15,.7); }
.status-chip i { width: 7px; height: 7px; border-radius: 50%; background: var(--warning); box-shadow: 0 0 12px currentColor; }
.status-chip b { color: var(--muted); font-size: 11px; font-weight: 500; }
.status-chip em { font-size: 11px; font-style: normal; }
.status-chip.ok i { background: var(--mint); }
.status-chip.bad i { background: var(--danger); }
.status-chip.bad em { color: var(--danger); }
.icon-button { width: 36px; height: 36px; border: 1px solid var(--line); border-radius: 10px; display: grid; place-items: center; background: rgba(14,31,25,.7); cursor: pointer; transition: .18s ease; }
.icon-button:hover { border-color: var(--line-strong); background: var(--surface-strong); transform: translateY(-1px); }
.icon-button svg { width: 17px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
main { width: min(1180px, calc(100% - 48px)); margin: 0 auto; position: relative; z-index: 1; }
.hero { min-height: calc(100vh - 150px); display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(360px, .75fr); gap: clamp(48px, 8vw, 116px); align-items: center; padding: 64px 0 82px; }
.hero-copy { max-width: 680px; }
.eyebrow { color: var(--mint); font-size: 11px; font-weight: 700; letter-spacing: .18em; text-transform: uppercase; display: flex; align-items: center; gap: 10px; }
.eyebrow span { width: 24px; height: 1px; background: var(--mint); box-shadow: 0 0 10px var(--mint); }
.hero h1, .console-heading h1 { margin: 20px 0; letter-spacing: -.06em; line-height: .99; font-size: clamp(50px, 6.4vw, 86px); font-weight: 620; }
.hero h1 em { font-style: normal; color: transparent; background: linear-gradient(95deg, var(--mint-bright), #82d8c1 47%, var(--purple)); background-clip: text; -webkit-background-clip: text; }
.hero-copy > p { max-width: 620px; color: var(--muted); font-size: 16px; line-height: 1.85; }
.protocol-grid { display: grid; grid-template-columns: repeat(3, 1fr); margin-top: 42px; border: 1px solid var(--line); border-radius: 16px; overflow: hidden; background: rgba(8,22,17,.45); backdrop-filter: blur(15px); }
.protocol-grid article { padding: 19px 22px; display: grid; gap: 5px; border-right: 1px solid var(--line); }
.protocol-grid article:last-child { border-right: 0; }
.protocol-grid strong { font-size: 18px; font-weight: 600; }
.protocol-grid span { color: var(--muted); font-size: 11px; }
.login-card { padding: 31px; border: 1px solid var(--line-strong); border-radius: 24px; background: linear-gradient(145deg, rgba(18,40,32,.94), rgba(8,21,17,.9)); box-shadow: var(--shadow); position: relative; overflow: hidden; backdrop-filter: blur(25px); }
.card-glow { position: absolute; width: 210px; height: 210px; right: -100px; top: -120px; border-radius: 50%; background: var(--mint); filter: blur(60px); opacity: .16; }
.card-heading { display: flex; gap: 14px; align-items: center; margin-bottom: 28px; position: relative; }
.card-heading p, .surface-heading p { margin: 0 0 3px; color: var(--mint); font-size: 10px; font-weight: 700; letter-spacing: .13em; text-transform: uppercase; }
.card-heading h2, .surface-heading h2 { margin: 0; font-size: 21px; letter-spacing: -.025em; }
.lock-mark { width: 43px; height: 43px; border-radius: 13px; display: grid; place-items: center; background: rgba(114,224,173,.1); color: var(--mint); border: 1px solid rgba(114,224,173,.18); }
.lock-mark svg { width: 21px; fill: none; stroke: currentColor; stroke-width: 1.7; }
form { position: relative; }
.login-card form { display: grid; gap: 17px; }
label { display: grid; gap: 7px; color: var(--muted); font-size: 11px; font-weight: 600; letter-spacing: .04em; }
input { width: 100%; color: var(--text); background: rgba(3,12,9,.58); border: 1px solid var(--line); border-radius: 11px; padding: 12px 13px; transition: border-color .18s, background .18s; }
input::placeholder { color: #5f786c; }
input:hover, input:focus { border-color: rgba(114,224,173,.46); background: rgba(3,12,9,.8); }
.primary-button { width: 100%; margin-top: 5px; padding: 13px 16px; border: 0; border-radius: 11px; display: flex; justify-content: center; align-items: center; gap: 8px; color: #062015; background: linear-gradient(100deg, var(--mint-bright), var(--mint)); font-weight: 750; cursor: pointer; box-shadow: 0 10px 35px rgba(114,224,173,.16); transition: .18s ease; }
.primary-button:hover { transform: translateY(-1px); filter: brightness(1.05); }
.primary-button svg { width: 18px; fill: none; stroke: currentColor; stroke-width: 2; }
.privacy-note { margin: 18px 0 0; color: #718b7e; font-size: 10px; line-height: 1.5; display: flex; gap: 8px; }
.privacy-note span { color: var(--mint); font-size: 7px; margin-top: 3px; }
.console-view { padding: 64px 0 90px; min-height: calc(100vh - 150px); }
.console-heading { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 38px; }
.console-heading h1 { font-size: clamp(42px, 5vw, 64px); margin: 13px 0 7px; }
.console-heading p { margin: 0; color: var(--muted); }
.secondary-button { padding: 10px 15px; border-radius: 10px; border: 1px solid var(--line-strong); background: transparent; cursor: pointer; }
.secondary-button:hover { border-color: var(--mint); color: var(--mint-bright); }
.metric-grid { display: grid; grid-template-columns: repeat(4, 1fr); border: 1px solid var(--line); background: rgba(8,22,17,.52); border-radius: 18px; overflow: hidden; margin-bottom: 22px; }
.metric-grid article { min-height: 134px; padding: 23px; display: flex; flex-direction: column; border-right: 1px solid var(--line); }
.metric-grid article:last-child { border-right: 0; }
.metric-grid span { color: var(--muted); font-size: 11px; }
.metric-grid strong { margin: 12px 0 8px; font-size: 27px; font-weight: 620; letter-spacing: -.04em; }
.metric-grid small { color: #668075; font-size: 10px; }
.content-grid { display: grid; grid-template-columns: 1.25fr .75fr; gap: 22px; }
.surface { border: 1px solid var(--line); border-radius: 18px; background: var(--surface); padding: 25px; backdrop-filter: blur(18px); }
.surface-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 22px; }
.secure-badge { color: var(--mint); background: rgba(114,224,173,.08); border: 1px solid rgba(114,224,173,.18); padding: 6px 9px; border-radius: 999px; font-size: 9px; letter-spacing: .08em; }
.surface-intro { margin: -9px 0 19px; color: var(--muted); font-size: 12px; line-height: 1.65; }
.create-form { margin-bottom: 20px; padding: 16px; border-radius: 13px; background: rgba(4,15,11,.42); border: 1px solid var(--line); }
.create-form > div { display: flex; gap: 8px; margin-top: 8px; }
.create-form input { padding: 10px 12px; }
.create-form button { border: 0; border-radius: 9px; padding: 0 17px; color: #082016; background: var(--mint); font-weight: 700; cursor: pointer; }
.vault-list, .device-list { display: grid; gap: 9px; }
.vault-item, .device-item { min-width: 0; border: 1px solid var(--line); background: rgba(6,19,14,.48); border-radius: 13px; padding: 15px 16px; display: flex; align-items: center; gap: 13px; }
.vault-symbol, .device-symbol { width: 38px; height: 38px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 11px; background: rgba(114,224,173,.08); color: var(--mint); font-weight: 700; }
.device-symbol { color: var(--purple); background: rgba(169,145,255,.08); }
.item-copy { min-width: 0; flex: 1; }
.item-copy strong { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
.item-copy small { color: var(--muted); font-size: 10px; }
progress { display: block; width: 100%; height: 4px; margin-top: 9px; border: 0; border-radius: 99px; overflow: hidden; accent-color: var(--mint); }
progress::-webkit-progress-bar { background: rgba(255,255,255,.06); }
progress::-webkit-progress-value { background: linear-gradient(90deg, var(--mint), var(--purple)); }
.item-side { text-align: right; flex: 0 0 auto; }
.item-side strong { display: block; font-size: 12px; }
.item-side small { color: var(--muted); font-size: 9px; }
.danger-button { border: 1px solid rgba(255,142,142,.25); color: var(--danger); background: rgba(255,142,142,.05); border-radius: 8px; padding: 7px 9px; cursor: pointer; font-size: 10px; }
.danger-button:hover { background: rgba(255,142,142,.12); }
.revoked { opacity: .48; }
.empty-state { min-height: 100px; display: grid; place-items: center; color: var(--muted); border: 1px dashed var(--line); border-radius: 12px; font-size: 12px; }
footer { width: min(1280px, calc(100% - 48px)); min-height: 62px; margin: 0 auto; border-top: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center; color: #597166; font-size: 9px; letter-spacing: .04em; position: relative; z-index: 1; }
.toast { position: fixed; z-index: 10; right: 24px; bottom: 24px; max-width: min(420px, calc(100% - 48px)); padding: 13px 16px; border: 1px solid var(--line-strong); border-radius: 11px; background: #142b23; box-shadow: var(--shadow); font-size: 12px; }
.toast.error { color: #ffd2d2; border-color: rgba(255,142,142,.35); background: #321a1a; }
@media (max-width: 920px) {
.hero { grid-template-columns: 1fr; padding-top: 76px; }
.hero-copy { max-width: none; }
.login-card { max-width: 560px; width: 100%; }
.content-grid { grid-template-columns: 1fr; }
.metric-grid { grid-template-columns: repeat(2, 1fr); }
.metric-grid article:nth-child(2) { border-right: 0; }
.metric-grid article:nth-child(-n+2) { border-bottom: 1px solid var(--line); }
}
@media (max-width: 620px) {
.topbar, main, footer { width: min(100% - 28px, 1180px); }
.topbar { height: 74px; }
.brand small, .status-chip b, footer span:last-child { display: none; }
.status-chip { padding: 0 9px; }
.hero { padding: 52px 0 64px; gap: 45px; }
.hero h1 { font-size: 43px; }
.hero-copy > p { font-size: 14px; }
.protocol-grid article { padding: 15px 11px; }
.protocol-grid strong { font-size: 14px; }
.login-card, .surface { padding: 21px; }
.console-heading { align-items: flex-start; gap: 18px; }
.metric-grid { grid-template-columns: 1fr; }
.metric-grid article { min-height: 116px; border-right: 0; border-bottom: 1px solid var(--line); }
.metric-grid article:last-child { border-bottom: 0; }
.create-form > div { flex-direction: column; }
.create-form button { padding: 10px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto !important; transition: none !important; }
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"skipLibCheck": true,
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
base: '/console/',
plugins: [vue()],
build: {
outDir: '../sync_server/static',
emptyOutDir: true,
sourcemap: false,
assetsInlineLimit: 0,
},
server: {
port: 5174,
strictPort: true,
proxy: {
'/health': 'http://127.0.0.1:18081',
'/ready': 'http://127.0.0.1:18081',
'/sync': 'http://127.0.0.1:18081',
},
},
})
+23
View File
@@ -16,6 +16,7 @@ from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse, FileResponse from fastapi.responses import JSONResponse, FileResponse
from starlette.background import BackgroundTask from starlette.background import BackgroundTask
from starlette.concurrency import run_in_threadpool from starlette.concurrency import run_in_threadpool
from starlette.staticfiles import StaticFiles
from .database import Database, password_hash, row, rows, run from .database import Database, password_hash, row, rows, run
from .models import Commit, Login, Refresh, Upload, VaultCreate from .models import Commit, Login, Refresh, Upload, VaultCreate
@@ -69,6 +70,28 @@ def create_app(db: Database, objects, staging: Path, *, quota=1024**3, clock=tim
app = FastAPI(title="OpenNexus Sync", version="1.0.0", lifespan=lifespan) app = FastAPI(title="OpenNexus Sync", version="1.0.0", lifespan=lifespan)
app.state.database = db app.state.database = db
worker_id = secrets.token_hex(8) worker_id = secrets.token_hex(8)
console_root = Path(__file__).with_name("static")
@app.middleware("http")
async def console_security(request: Request, call_next):
response = await call_next(request)
if request.url.path == "/" or request.url.path.startswith("/console"):
response.headers["Cache-Control"] = "no-store"
response.headers["Content-Security-Policy"] = (
"default-src 'none'; script-src 'self'; style-src 'self'; "
"img-src 'self' data:; connect-src 'self'; font-src 'self'; "
"base-uri 'none'; form-action 'self'; frame-ancestors 'none'"
)
response.headers["Referrer-Policy"] = "no-referrer"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "DENY"
return response
@app.get("/", include_in_schema=False)
def console():
return FileResponse(console_root / "index.html", media_type="text/html; charset=utf-8")
app.mount("/console", StaticFiles(directory=console_root, html=True), name="sync-console")
@app.exception_handler(SyncError) @app.exception_handler(SyncError)
async def error(_request, exc): async def error(_request, exc):
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#07120f">
<title>OpenNexus Sync Console</title>
<script type="module" crossorigin src="/console/assets/index-CV7tuz7b.js"></script>
<link rel="stylesheet" crossorigin href="/console/assets/index-BS5pfOkS.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+63
View File
@@ -0,0 +1,63 @@
"""Same-origin Vue console delivery and its public account workflow."""
import re
from fastapi.testclient import TestClient
from sync_server.app import create_app
from sync_server.database import Database
from sync_server.storage import DiskObjects
def test_vue_console_is_hardened_and_uses_the_public_sync_api(tmp_path):
database = Database("sqlite:///" + str(tmp_path / "sync.db"))
app = create_app(
database,
DiskObjects(tmp_path / "objects"),
tmp_path / "staging",
)
database.add_user("demo", "controlled-demo-password")
with TestClient(app) as client:
root = client.get("/")
console = client.get("/console/")
assert root.status_code == console.status_code == 200
assert "OpenNexus Sync Console" in console.text
assert "<script type=\"module\"" in console.text
assert "controlled-demo-password" not in console.text
for response in (root, console):
assert response.headers["cache-control"] == "no-store"
assert response.headers["x-content-type-options"] == "nosniff"
assert response.headers["x-frame-options"] == "DENY"
assert "script-src 'self'" in response.headers["content-security-policy"]
assert "frame-ancestors 'none'" in response.headers["content-security-policy"]
assets = re.findall(r'(?:src|href)="(/console/assets/[^"]+)"', console.text)
assert len(assets) == 2
bodies = []
for asset in assets:
response = client.get(asset)
assert response.status_code == 200
assert response.headers["cache-control"] == "no-store"
assert response.headers["x-content-type-options"] == "nosniff"
bodies.append(response.content)
bundle = b"".join(bodies)
assert b"localStorage" not in bundle
assert b"sessionStorage" not in bundle
assert b"controlled-demo-password" not in bundle
session = client.post(
"/sync/v1/auth/sessions",
json={
"username": "demo",
"password": "controlled-demo-password",
"device_name": "Console test",
},
)
assert session.status_code == 200
token = session.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
created = client.post("/sync/v1/vaults", headers=headers, json={"name": "Demo Vault"})
assert created.status_code == 200
assert client.get("/sync/v1/vaults", headers=headers).json()["items"][0]["name"] == "Demo Vault"
assert client.get("/sync/v1/devices", headers=headers).json()["items"][0]["name"] == "Console test"
database.engine.dispose()