diff --git a/ACCEPTANCE.md b/ACCEPTANCE.md index f502ea2..850c20b 100644 --- a/ACCEPTANCE.md +++ b/ACCEPTANCE.md @@ -187,6 +187,18 @@ ## 早期进度记录(以下由上方最新结论覆盖) +## UI 改版与功能回归(2026-10-15) + +本节为本轮证据,后文保留历史记录;不代表所有历史待验收项均已关闭。 + +- 前端改为 Tailwind v4、Radix 原语的 shadcn 风格组件及 lucide 图标;桌面侧栏、移动导航、响应式卡片、确认弹窗和备份操作统一。 +- 最终前端 20 个测试文件、131 个用例通过,TypeScript/Vite 生产构建通过;Go 全量测试、race 测试、vet、build 均通过;check-trial 脚本的 10 个测试通过,git diff --check 通过。 +- 浏览器验证桌面及 320/375/667px 移动视口:无页面横向溢出、主要控件触控高度至少 44px、弹窗关闭焦点恢复、准入编辑器只挂载一份且跨断点保留草稿。配额读回、重设密码后登录、自助修改密码后退出、删除取消/确认、重命名冲突在弹窗内提示并可修正重试、错误备份密码在当前弹窗提示均通过。截图位于本地 .playwright-cli/。 +- 隔离真实 Docker 面板执行 scripts/test-panel.mjs,TEST_UPGRADE=1,最终退出码 0:登录/刷新、镜像选择、创建/代理、文件夹/上传/下载/ZIP/重命名/删除、Profile 保存、加密备份/下载/导入/恢复、启停、同镜像替换升级、实例删除通过。补充验证未认证 401、缺少 CSRF/外部 Origin 403、普通用户不能访问用户管理、配额更新与负值拒绝。 +- 真实回归发现并修复两个后端缺陷:空 Profile 规范化为合法空数组 YAML;回滚停止容器接受 Docker 304,但必须继续核验确实停止。新增回归用例,修复后完整集成流程重新通过。 +- 本次升级仅覆盖 0.2.0-rc.2 到相同版本的容器替换;跨版本升级、插件重建、离线失败回滚、长时会话续期,以及真实配额耗尽/跨用户实例访问未在本轮完整实测,不计为通过。未调用付费模型。 +- 一次性测试实例及其容器/网络均已清理;原有 DSH 实例未重启。测试日志及隔离备份保留于 /tmp/dsh-isolated-integration-BFUCkK/(本地临时证据,不随提交分发)。 + ## 当前实现与待验收 | 设计章节 | 代码已接入 | 尚需真实证据 | diff --git a/README.md b/README.md index ab12475..13322ed 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ 登录后默认进入内嵌 DSH 工作台。在“设置”中选择当前账号的实例;选择仅保存在当前浏览器并按账号区分,不保存登录凭据。没有实例时先到“实例”创建,停止或异常的实例需先启动或处理。切换实例会重新加载内嵌页面,但不会停止原容器。 -移动端使用底部导航:DSH、实例、文件、设置;桌面保留侧栏。镜像管理和管理员功能从设置进入。切换面板栏目会保留内嵌 DSH 页面,提供重新加载及独立窗口入口。此为同源 iframe,不是安全隔离边界,仍仅适合本机或受信任团队。 +移动端使用底部导航:DSH、实例、文件、设置;桌面保留侧栏,管理员可直接从侧栏进入镜像与用户管理,移动端这两个入口仍在“设置”中。切换面板栏目会保留内嵌 DSH 页面,重新加载与独立窗口入口收在工具栏的溢出菜单中。此为同源 iframe,不是安全隔离边界,仍仅适合本机或受信任团队。 ## 当前实现 @@ -203,6 +203,6 @@ bash scripts/smoke-image.sh "$RUNTIME_IMAGE_ID" # 已由管理员构建的本 构建历史接口现默认分页(20条,最大100条),列表不再携带日志正文;日志通过 `/api/builds/{id}` 按需读取。升级面板后请刷新已打开的旧页面;外部脚本需按 API.md 更新,不能再依赖 `/api/builds` 返回全部历史或日志。 -合理替代方案为:stdlib HTTP Docker client而非官方SDK;每实例独立bridge而非共享bridge;服务端保管DSH cookie而非下发浏览器;普通React组件而非shadcn/ui(保留Monaco)。这些是选型差异,不降低功能、安全或验收标准,详见验收表。 +合理替代方案为:stdlib HTTP Docker client而非官方SDK;每实例独立bridge而非共享bridge;服务端保管DSH cookie而非下发浏览器。前端已改用 Tailwind CSS + Radix 原语的 shadcn 风格组件(保留本地Monaco),桌面与移动端共用一套响应式界面。这些是选型差异,不降低功能、安全或验收标准,详见验收表。 同源路径代理仅适用于可信用户/插件,尚无独立origin及宿主/局域网出站防护。默认软配额不能阻止agent直接写满磁盘;硬quota是依赖宿主的可选增强,不是默认路线必做项。升级/恢复快照需额外容量,home快照上限1 GiB,模板7的 `/workspace` 就是实例home,会随home快照回滚;旧共享目录不随回滚恢复;中断及回滚失败仍可能需要管理员恢复。 diff --git a/internal/api/config.go b/internal/api/config.go index 7927183..2a01b85 100644 --- a/internal/api/config.go +++ b/internal/api/config.go @@ -205,6 +205,9 @@ func (a *API) config(w http.ResponseWriter, r *http.Request, i store.Instance, a return } var document any + if strings.TrimSpace(in.Content) == "" { + in.Content = "[]\n" + } if yaml.Unmarshal([]byte(in.Content), &document) != nil { fail(w, 400, "invalid YAML") return diff --git a/internal/api/config_helpers.go b/internal/api/config_helpers.go index 35eda1c..572ce3c 100644 --- a/internal/api/config_helpers.go +++ b/internal/api/config_helpers.go @@ -3,7 +3,9 @@ package api import ( "context" "errors" + "git.misaka.ren/m1saka/dsh-web/internal/docker" "git.misaka.ren/m1saka/dsh-web/internal/store" + "net/http" "net/url" "os" "path/filepath" @@ -57,7 +59,10 @@ func (a *API) configStop(ctx context.Context, id string) error { ctx, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() if err := a.docker.Do(ctx, "POST", "/containers/"+url.PathEscape(id)+"/stop?t=10", nil, nil); err != nil { - return err + var apiErr *docker.APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusNotModified { + return err + } } c, err := a.docker.Inspect(ctx, id) if err != nil { diff --git a/internal/api/config_test.go b/internal/api/config_test.go index 840f59f..d52196d 100644 --- a/internal/api/config_test.go +++ b/internal/api/config_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "fmt" + "git.misaka.ren/m1saka/dsh-web/internal/docker" "git.misaka.ren/m1saka/dsh-web/internal/store" "golang.org/x/crypto/bcrypt" "mime/multipart" @@ -20,6 +21,47 @@ import ( "time" ) +func TestConfigStopAlreadyStopped(t *testing.T) { + for _, state := range []string{`{}`, `{"Running":true}`, `{"Paused":true}`, `{"Restarting":true}`} { + t.Run(state, func(t *testing.T) { + inspected := false + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + w.WriteHeader(http.StatusNotModified) + return + } + inspected = true + fmt.Fprintf(w, `{"State":%s}`, state) + })) + defer srv.Close() + a := &API{docker: docker.New(srv.URL)} + err := a.configStop(context.Background(), "test") + if !inspected || (err == nil) != (state == `{}`) { + t.Fatalf("inspected=%v error=%v", inspected, err) + } + }) + } +} + +func TestConfigEmptyProfileRoundTrip(t *testing.T) { + if os.Geteuid() != 0 { + t.Skip("requires chown") + } + a, i, home := configTestAPI(t, false) + w := configCall(a, i, "GET", "profile", "") + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + w = configCall(a, i, "PUT", "profile", w.Body.String()) + if w.Code != 200 { + t.Fatal(w.Code, w.Body.String()) + } + content, err := os.ReadFile(filepath.Join(home, "profiles/web/cordis.patch.yml")) + if err != nil || string(content) != "[]\n" { + t.Fatalf("content=%q error=%v", content, err) + } +} + func TestConfigPasswordBackupImport(t *testing.T) { if os.Geteuid() != 0 { t.Skip("requires chown") diff --git a/scripts/test-panel.mjs b/scripts/test-panel.mjs index d92bee8..b3dce19 100644 --- a/scripts/test-panel.mjs +++ b/scripts/test-panel.mjs @@ -29,7 +29,8 @@ try { const target=images.find(i=>!process.env.TEST_TO_VERSION || i.version===process.env.TEST_TO_VERSION); assert.ok(source,'source image required');assert.ok(target,'target image required'); console.log('PASS validated and admitted local image selection'); - instance=(await request('/api/instances','POST',{name:'Integration check',image_id:source.image_id || source.id,version:source.version})).data; + instance=(await request('/api/instances','POST',{name:'Integration check',image_id:source.image_id || source.id})).data; + console.log('Disposable instance:', instance.id); await request('/api/ws/mkdir','POST',{path:testDirectory}); const form=new FormData();form.set('path',testDirectory);form.set('file',new Blob(['hello workspace']), 'hello.txt');await request('/api/ws/upload','POST',form); assert.equal((await request('/api/ws/download?path='+testDirectory+'/hello.txt')).data,'hello workspace'); diff --git a/web/package-lock.json b/web/package-lock.json index 4e2eefc..018d96d 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,9 +8,20 @@ "name": "dsh-web-console", "version": "0.1.0", "dependencies": { + "@radix-ui/react-dialog": "^1.3.1", + "@radix-ui/react-dropdown-menu": "^2.2.1", + "@radix-ui/react-label": "^2.1.17", + "@radix-ui/react-slot": "^1.5.0", + "@radix-ui/react-tabs": "^1.2.1", + "@tailwindcss/vite": "^4.3.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.57.0", "monaco-editor": "^0.52.2", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "tailwind-merge": "^3.7.0", + "tailwindcss": "^4.3.3" }, "devDependencies": { "@types/node": "^26.6.5", @@ -311,7 +322,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -328,7 +338,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -345,7 +354,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -362,7 +370,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -379,7 +386,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -396,7 +402,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -413,7 +418,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -430,7 +434,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -447,7 +450,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -464,7 +466,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -481,7 +482,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -498,7 +498,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -515,7 +514,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -532,7 +530,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -549,7 +546,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -566,7 +562,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -583,7 +578,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -600,7 +594,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -617,7 +610,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -634,7 +626,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -651,7 +642,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -668,7 +658,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -685,7 +674,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -702,7 +690,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -719,7 +706,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -736,7 +722,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -746,11 +731,48 @@ "node": ">=18" } }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.9", + "resolved": "https://registry.npmmirror.com/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.8.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -761,7 +783,6 @@ "version": "2.3.5", "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -772,7 +793,6 @@ "version": "3.1.2", "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -782,14 +802,12 @@ "version": "1.6.0", "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -803,7 +821,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "glibc" ], @@ -816,6 +833,632 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@radix-ui/primitive": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/@radix-ui/primitive/-/primitive-1.2.0.tgz", + "integrity": "sha512-KABY+VMIRPJL+Vyzv/335FEhXXD/xDv8BMb1kl48/aXJZMH0LtHCTx5ga5RnxaEP3nZHtmTLf1P9vanmFX46bw==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.17", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-arrow/-/react-arrow-1.1.17.tgz", + "integrity": "sha512-67jjcVvxjXCCStnTW3Z+M7jmaFMuMR7M2JgpDzH4w7JSRrikmzhjfYgP/09ECb8n7whnsgh1ifJ+Ie96qEj+pQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-collection/-/react-collection-1.2.0.tgz", + "integrity": "sha512-+yiQzqcc7Sy7/5s0jrMCjpPRFB3538VNrXPWdTLZhE/SCfgEws4aI6lHWN/vvcnyhHndROWBzqmqB4QBrITZ9Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-slot": "1.5.0", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", + "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-context/-/react-context-1.3.0.tgz", + "integrity": "sha512-v5AYEocMISkbJ+1fTXzZpiFv7NRr3D2WiYbLIKf+FkmJ8VkP6d503eV2n34MFussHXY1pcoq3yfJiQH77dc4Mg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-dialog/-/react-dialog-1.3.1.tgz", + "integrity": "sha512-+0E4qvVTwjAWra9sEhJ+9BTsvHE00zkUuNw1VGh7S+Gc13dfYk+S1PVQdchWDj+XYHhAai7Z7IpzXVDCGsDplg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-dismissable-layer": "1.2.1", + "@radix-ui/react-focus-guards": "1.2.0", + "@radix-ui/react-focus-scope": "1.3.1", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-portal": "1.2.0", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-slot": "1.5.0", + "@radix-ui/react-use-controllable-state": "1.3.0", + "@radix-ui/react-use-layout-effect": "1.1.4", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-direction/-/react-direction-1.2.0.tgz", + "integrity": "sha512-FtRA52a/r+LKStFT+neLUm+8vSe7wbzkbQGGeU8ixFiwC8NU14Evoq9ATECqL3eGbcUzJR4fyfA4KkK/q6dfqg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.2.1.tgz", + "integrity": "sha512-KIfDcAsx+oVylFZ4xryVKusJUrLLhTQmfnJW4kwk4Km5myvhHCrj04hnWWUC166+xD4DHdH8tQGylwYX1h6+Pg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5", + "@radix-ui/react-use-effect-event": "0.0.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.2.1.tgz", + "integrity": "sha512-XsHcmYG2AcqnvTqrOXXWcMrhquEIlqucizuDH1MV/qMRW+DndpBCMvNeTRXOxqmG1H5j89M/+I0/34Zdd+zDcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-menu": "2.2.1", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-controllable-state": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-guards/-/react-focus-guards-1.2.0.tgz", + "integrity": "sha512-MPm5R0iRGUDrDnPNxmLBkv+X7pN4XKzt+MbWJEuJQsH3NFH4omrbpXpucyYCCKTSt+na/ZxGhEWQrWTZ0285cA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-focus-scope/-/react-focus-scope-1.3.1.tgz", + "integrity": "sha512-ugeMhbXt4y5S2T1GnIqbWDKEN962PDwnX1fN0eKOOgf5f63Dh2mn8F1FUySlgW0iF6GiVsRF32DzuDIWTUyQJg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-id/-/react-id-1.1.4.tgz", + "integrity": "sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.17", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-label/-/react-label-2.1.17.tgz", + "integrity": "sha512-xmj7MN/drx1oUAXkfiFIvyDmGNUIN/omGobvA5oAoNqu9zO2MkISYGZOkBcqSzs/UZXXwV+/3ACRE7q7mGcmkA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.2.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-menu/-/react-menu-2.2.1.tgz", + "integrity": "sha512-G34BsU90SBj5foYpz8pQqq/Q0Dlfyyl6KitWEfRwXC0QvfwhWNAwS49DWP2+rRJ0iwv20Z2Qx7Sk+T03bGo5JA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-collection": "1.2.0", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-direction": "1.2.0", + "@radix-ui/react-dismissable-layer": "1.2.1", + "@radix-ui/react-focus-guards": "1.2.0", + "@radix-ui/react-focus-scope": "1.3.1", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-popper": "1.4.0", + "@radix-ui/react-portal": "1.2.0", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-roving-focus": "1.2.1", + "@radix-ui/react-slot": "1.5.0", + "@radix-ui/react-use-callback-ref": "1.1.5", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.4.0", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-popper/-/react-popper-1.4.0.tgz", + "integrity": "sha512-yCWRvLeGv9Pz3+efwHqGPp70mZ56r3kfIgQVF56Oj2aAa4uP/Tb+IE59wNcc+758rctqbCKx4LbK8II/r7Bn0Q==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.17", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5", + "@radix-ui/react-use-layout-effect": "1.1.4", + "@radix-ui/react-use-rect": "1.1.4", + "@radix-ui/react-use-size": "1.1.5", + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-portal/-/react-portal-1.2.0.tgz", + "integrity": "sha512-StX7xXnaRU41VvFtMm9zlhVGz8XF6GmWWTXftkQaY+tqVkbs/R9UmAp5mLzHHapk5hM9RMZQvO/3usyIP/g42g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.11", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-presence/-/react-presence-1.1.11.tgz", + "integrity": "sha512-Z7/atlkab36Z8hm+V8zYc79r0W+tiOB1o0tun1By94JyadkekLQmtEAnBSP9XKXBNQsStoStS4+gX10JpS5akA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-primitive/-/react-primitive-2.2.0.tgz", + "integrity": "sha512-HCTSPMNqv55Qk0IX4geJbpyXazmqulcimwuyjxTkKi29v+QZbG2Zho9BnbBq4YS2PF+KlNb2CXoLz66uUmblkg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-roving-focus/-/react-roving-focus-1.2.1.tgz", + "integrity": "sha512-UwraORzVUeEPMclhO1p0vz0qI+TbdDLO9i+FiOqhJyHKwdpCRCck9yVEscUnAMIzCiuNoQa8D4A9bIpOPXGt6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-collection": "1.2.0", + "@radix-ui/react-compose-refs": "1.1.5", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-direction": "1.2.0", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-use-callback-ref": "1.1.5", + "@radix-ui/react-use-controllable-state": "1.3.0", + "@radix-ui/react-use-is-hydrated": "0.1.3", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-slot/-/react-slot-1.5.0.tgz", + "integrity": "sha512-tyYd9ddPXAysAbdbD9eYxgwpCSv6f3PncqVSNKQFSR5lcJVLz3cb/pANDoYrLiHBpXv7skortyUgGUWsYuPPFQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.5" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-tabs/-/react-tabs-1.2.1.tgz", + "integrity": "sha512-hRoJlode+hg5ZNlR+3tkfkUliIybdTpD5lkAvHQ9yaXseGf7XVO9Ib2GPEIOuaJJbo7YWYbNpDDRfo8Dhf6/xA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-context": "1.3.0", + "@radix-ui/react-direction": "1.2.0", + "@radix-ui/react-id": "1.1.4", + "@radix-ui/react-presence": "1.1.11", + "@radix-ui/react-primitive": "2.2.0", + "@radix-ui/react-roving-focus": "1.2.1", + "@radix-ui/react-use-controllable-state": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.5.tgz", + "integrity": "sha512-Y5GBf4NKeADoXI1geiMlMuMoURT3k9MizzzKhGb/m55H94aGigZ01PIpkvsO6lML13BA6XYAXSJ6gza7vTn5nw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.3.0.tgz", + "integrity": "sha512-TAcBI7bw2ljYCbv4+zuTSd6zICu8pRt6SDN2vJ/Z06K2MXh6I9WYFe/A9ovlFDtR5HVHGm5iDxDoABm/++dbKg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.2.0", + "@radix-ui/react-use-effect-event": "0.0.5", + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.5", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.5.tgz", + "integrity": "sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.3.tgz", + "integrity": "sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.4.tgz", + "integrity": "sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-rect/-/react-use-rect-1.1.4.tgz", + "integrity": "sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.5", + "resolved": "https://registry.npmmirror.com/@radix-ui/react-use-size/-/react-use-size-1.1.5.tgz", + "integrity": "sha512-V1McDQkMGzgivwqdu7u96APs7oi0Jvj9LTzgq3WWrDZqxWdAqU9yZnZQqk5OizGFR8badUQwZsrlao7MOlWYOA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/@radix-ui/rect/-/rect-1.1.3.tgz", + "integrity": "sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==", + "license": "MIT" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -830,7 +1473,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -844,7 +1486,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -858,7 +1499,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -872,7 +1512,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -886,7 +1525,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -900,7 +1538,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -914,7 +1551,6 @@ "cpu": [ "arm" ], - "dev": true, "libc": [ "glibc" ], @@ -931,7 +1567,6 @@ "cpu": [ "arm" ], - "dev": true, "libc": [ "musl" ], @@ -948,7 +1583,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "glibc" ], @@ -965,7 +1599,6 @@ "cpu": [ "arm64" ], - "dev": true, "libc": [ "musl" ], @@ -982,7 +1615,6 @@ "cpu": [ "loong64" ], - "dev": true, "libc": [ "glibc" ], @@ -999,7 +1631,6 @@ "cpu": [ "loong64" ], - "dev": true, "libc": [ "musl" ], @@ -1016,7 +1647,6 @@ "cpu": [ "ppc64" ], - "dev": true, "libc": [ "glibc" ], @@ -1033,7 +1663,6 @@ "cpu": [ "ppc64" ], - "dev": true, "libc": [ "musl" ], @@ -1050,7 +1679,6 @@ "cpu": [ "riscv64" ], - "dev": true, "libc": [ "glibc" ], @@ -1067,7 +1695,6 @@ "cpu": [ "riscv64" ], - "dev": true, "libc": [ "musl" ], @@ -1084,7 +1711,6 @@ "cpu": [ "s390x" ], - "dev": true, "libc": [ "glibc" ], @@ -1101,7 +1727,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "glibc" ], @@ -1118,7 +1743,6 @@ "cpu": [ "x64" ], - "dev": true, "libc": [ "musl" ], @@ -1135,7 +1759,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1149,7 +1772,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1163,7 +1785,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1177,7 +1798,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1191,7 +1811,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1205,13 +1824,290 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ] }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/node/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmmirror.com/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1279,14 +2175,13 @@ "version": "1.0.9", "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, "license": "MIT" }, "node_modules/@types/node": { "version": "26.6.5", "resolved": "https://registry.npmmirror.com/@types/node/-/node-26.6.5.tgz", "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~8.9.0" @@ -1296,7 +2191,7 @@ "version": "19.3.0", "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.3.0.tgz", "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1306,7 +2201,7 @@ "version": "19.3.0", "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.3.0.tgz", "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.3.0" @@ -1343,6 +2238,18 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmmirror.com/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1431,6 +2338,27 @@ "node": ">=18" } }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1442,7 +2370,7 @@ "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -1463,6 +2391,21 @@ } } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.452", "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.452.tgz", @@ -1470,6 +2413,19 @@ "dev": true, "license": "ISC" }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/es-module-lexer": { "version": "2.3.2", "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-2.3.2.tgz", @@ -1481,7 +2437,6 @@ "version": "0.25.12", "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -1553,7 +2508,6 @@ "version": "6.5.0", "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -1571,7 +2525,6 @@ "version": "2.3.3", "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -1592,6 +2545,30 @@ "node": ">=6.9.0" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1625,6 +2602,267 @@ "node": ">=6" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1635,6 +2873,21 @@ "yallist": "^3.0.2" } }, + "node_modules/lucide-react": { + "version": "1.57.0", + "resolved": "https://registry.npmmirror.com/lucide-react/-/lucide-react-1.57.0.tgz", + "integrity": "sha512-bXFZEucYhAzLD62Jse76AbTR6LiSLTn3Tqir2f1n8rZrWAdLodYy7jnRZSPIT89dRLk/IseMOUWG82wJtTyTTw==", + "license": "ISC", + "peerDependencies": { + "@types/react": "*", + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/magic-string": { "version": "1.4.3", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-1.4.3.tgz", @@ -1662,7 +2915,6 @@ "version": "3.3.20", "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.20.tgz", "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", - "dev": true, "funding": [ { "type": "github", @@ -1705,14 +2957,12 @@ "version": "1.1.1", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.7", "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -1725,7 +2975,6 @@ "version": "8.5.29", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.29.tgz", "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -1781,11 +3030,79 @@ "node": ">=0.10.0" } }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmmirror.com/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmmirror.com/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/rollup": { "version": "4.64.3", "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.64.3.tgz", "integrity": "sha512-iIMWbptLYsZdGTJMH8ngApXdsCatE6bCMv2eqGIfGE1++kwh7TCWBdWKK0FKC0yx++196vGK5VdagzRXSLMa8A==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.9" @@ -1847,7 +3164,6 @@ "version": "1.2.2", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.2.tgz", "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -1860,6 +3176,35 @@ "dev": true, "license": "MIT" }, + "node_modules/tailwind-merge": { + "version": "3.7.0", + "resolved": "https://registry.npmmirror.com/tailwind-merge/-/tailwind-merge-3.7.0.tgz", + "integrity": "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/tinybench": { "version": "6.2.2", "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-6.2.2.tgz", @@ -1884,7 +3229,6 @@ "version": "0.2.17", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -1897,6 +3241,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/typescript": { "version": "5.7.3", "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.7.3.tgz", @@ -1915,7 +3265,7 @@ "version": "8.9.0", "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-8.9.0.tgz", "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -1949,11 +3299,53 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/vite": { "version": "6.4.4", "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.4.tgz", "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", diff --git a/web/package.json b/web/package.json index 6b38fe5..324fb9a 100644 --- a/web/package.json +++ b/web/package.json @@ -10,9 +10,20 @@ "test": "vitest run" }, "dependencies": { + "@radix-ui/react-dialog": "^1.3.1", + "@radix-ui/react-dropdown-menu": "^2.2.1", + "@radix-ui/react-label": "^2.1.17", + "@radix-ui/react-slot": "^1.5.0", + "@radix-ui/react-tabs": "^1.2.1", + "@tailwindcss/vite": "^4.3.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.57.0", "monaco-editor": "^0.52.2", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "tailwind-merge": "^3.7.0", + "tailwindcss": "^4.3.3" }, "devDependencies": { "@types/node": "^26.6.5", diff --git a/web/src/Admin.test.tsx b/web/src/Admin.test.tsx new file mode 100644 index 0000000..5a17220 --- /dev/null +++ b/web/src/Admin.test.tsx @@ -0,0 +1,26 @@ +import { expect, it, vi } from 'vitest'; +vi.mock('react', async original => ({ + ...await original(), + useState: (value: unknown) => [value, vi.fn()], + useRef: (value: unknown) => ({current: value}), +})); +vi.mock('./ui', async original => ({ + ...await original(), + useResource: () => ({data: [{id: 'one', username: 'alice'}, {id: 'two', username: 'bob'}], loading: false, refresh: vi.fn()}), +})); +import { Admin } from './Admin'; +import { ResetPassword } from './ResetPassword'; +function nodes(root: any, predicate: (node: any) => boolean): any[] { + if (Array.isArray(root)) return root.flatMap(child => nodes(child, predicate)); + if (!root || typeof root !== 'object') return []; + return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)]; +} +it('mounts one reset-password owner per user in a shared responsive list', () => { + const accounts = (Admin().props.children as any[])[0]; + const tree = accounts.type(accounts.props); + const editors = nodes(tree, n => n.type === ResetPassword); + expect(editors.map(n => n.props.user.id)).toEqual(['one', 'two']); + for (const branch of nodes(tree, n => typeof n.props?.className === 'string' && /md:hidden|md:block/.test(n.props.className))) { + expect(nodes(branch, n => n.type === ResetPassword)).toHaveLength(0); + } +}); diff --git a/web/src/Admin.tsx b/web/src/Admin.tsx index f2ee224..d1165db 100644 --- a/web/src/Admin.tsx +++ b/web/src/Admin.tsx @@ -5,13 +5,104 @@ import { passwordResetError } from './passwordReset'; import { Orphans } from './Orphans'; import { ResetPassword } from './ResetPassword'; import { api, type User, type Audit } from './api'; -import { Panel, ResourceState, Empty, bytes, date, useAction, useResource } from './ui'; +import { Panel, ResourceState, Empty, bytes, date, useAction, useResource, RefreshButton, tableClass, tableHeadRowClass, thClass, tdClass, trClass } from './ui'; +import { AlertBox } from './components/ui/alert'; +import { buttonVariants } from './components/ui/button'; +import { inputClass } from './components/ui/input'; +import { cn } from './lib/utils'; + export function Admin() { - return <>; + return <>; } + function AdminAccounts() { - const users = useResource(() => api.request('/users')), audit = useResource(() => api.request('/audit')); const action = useAction(); + const users = useResource(() => api.request('/users')), audit = useResource(() => api.request('/audit')); + const action = useAction(); const [quotaUser, setQuotaUser] = useState(null), [notice, setNotice] = useState(''); - function create(e: FormEvent) {e.preventDefault(); const element = e.currentTarget, form = new FormData(element); void action.run(async () => {const password = String(form.get('password') || ''); const error = passwordResetError(password, password); if (error) throw new Error(error); const quota = quotaBytes(String(form.get('quota'))); if (quota === null) throw new Error('请输入有效配额'); await api.request('/users','POST',{username:form.get('username'),password:form.get('password'),admin:form.get('admin') === 'on',quota_bytes:quota}); element.reset(); await Promise.all([users.refresh(),audit.refresh()]);}, '账号已创建');} - return <>{action.feedback}{notice &&
{notice}
}{quotaUser && setQuotaUser(null)} onSaved={() => {setNotice(quotaUser.username + ' 的配额已更新'); void users.refresh(); void audit.refresh();}}/>}
刷新}>{users.data?.length === 0 && 暂无用户}{!!users.data?.length &&
{users.data.map(u => )}
用户角色配额创建时间账号安全
{u.username}{u.id}{u.admin ? '管理员' : '成员'}
{bytes(u.quota_bytes)}
{date(u.created_at)}
}
刷新}>{audit.data?.length === 0 && 暂无审计记录}{!!audit.data?.length &&
{audit.data.map(a => )}
时间用户操作目标详情
{date(a.created_at)}{a.user_id}{a.action}{a.target}{a.detail}
}
; + function create(e: FormEvent) { + e.preventDefault(); + const element = e.currentTarget, form = new FormData(element); + void action.run(async () => { + const password = String(form.get('password') || ''); + const error = passwordResetError(password, password); + if (error) throw new Error(error); + const quota = quotaBytes(String(form.get('quota'))); + if (quota === null) throw new Error('请输入有效配额'); + await api.request('/users', 'POST', {username: form.get('username'), password: form.get('password'), admin: form.get('admin') === 'on', quota_bytes: quota}); + element.reset(); + await Promise.all([users.refresh(), audit.refresh()]); + }, '账号已创建'); + } + return <> + {action.feedback} + {notice && {notice}} + {quotaUser && setQuotaUser(null)} onSaved={() => { setNotice(quotaUser.username + ' 的配额已更新'); void users.refresh(); void audit.refresh(); }} />} + +
+ + + + + +
+
+ }> + + {users.data?.length === 0 && 暂无用户} + {!!users.data?.length && <> +
+ {users.data.map(u =>
+
+ {u.username} + {u.admin ? '管理员' : '成员'} +
+

{u.id}

+

创建于 {date(u.created_at)}

+
+ 配额 {bytes(u.quota_bytes)} + + +
+
)} +
+ } +
+ }> + + {audit.data?.length === 0 && 暂无审计记录} + {!!audit.data?.length && <> +
+ + + + + + + + + {audit.data.map(a => + + + + + + )} +
时间用户操作目标详情
{date(a.created_at)}{a.user_id}{a.action}{a.target}{a.detail}
+
+
+ {audit.data.map(a =>
+
+ {date(a.created_at)}{a.user_id} +
+

{a.action} · {a.target}

+ {a.detail &&

{a.detail}

} +
)} +
+ } +
+ ; } diff --git a/web/src/App.tsx b/web/src/App.tsx index c74432e..a286d08 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, type FormEvent } from 'react'; +import { Boxes, Container, FolderOpen, LogOut, Package, Settings, SquareTerminal, Users } from 'lucide-react'; import { api, ApiError, type User } from './api'; import { Alert, message, Panel, useAction } from './ui'; import { Instances } from './Instances'; @@ -8,52 +9,192 @@ import { Workspace } from './Workspace'; import { Admin } from './Admin'; import { ChangePassword } from './ChangePassword'; import { RuntimeWorkspace, useRuntimeInstances } from './RuntimeWorkspace'; -const pages = [{id:'dsh',label:'DSH',icon:'◈',description:'你的 DeepSeek Harness 工作台'}, {id:'overview',label:'实例',icon:'◫',description:'管理你的 DeepSeek Harness 运行环境'}, {id:'workspace',label:'文件',icon:'▤',description:'管理当前实例的文件与目录'}, {id:'settings',label:'设置',icon:'⚙',description:'选择默认实例与管理工作台'}]; -const secondaryPages = [{id:'images',label:'镜像与构建',description:'本地版本管理 · 手动构建 · 任务日志'}, {id:'admin',label:'用户与审计',description:'账号管理与操作追踪'}]; +import { Button, buttonVariants } from './components/ui/button'; +import { Input } from './components/ui/input'; +import { cn } from './lib/utils'; + +const pages = [ + {id: 'dsh', label: 'DSH', icon: SquareTerminal, description: '你的 DeepSeek Harness 工作台'}, + {id: 'overview', label: '实例', icon: Container, description: '管理你的 DeepSeek Harness 运行环境'}, + {id: 'workspace', label: '文件', icon: FolderOpen, description: '管理当前实例的文件与目录'}, + {id: 'settings', label: '设置', icon: Settings, description: '选择默认实例与管理工作台'}, +]; +const secondaryPages = [ + {id: 'images', label: '镜像与构建', icon: Package, description: '本地版本管理 · 手动构建 · 任务日志'}, + {id: 'admin', label: '用户与审计', icon: Users, description: '账号管理与操作追踪'}, +]; + export default function App() { const [user, setUser] = useState(null), [loading, setLoading] = useState(true), [error, setError] = useState(''); - useEffect(() => { let active = true; api.me().then(s => {if(active) setUser(s.user);}).catch(e => {if(active && (!(e instanceof ApiError) || e.status !== 401)) setError(message(e));}).finally(() => {if(active) setLoading(false);}); const expired = (event: Event) => {setUser(null); setError(event instanceof CustomEvent && typeof event.detail === 'string' ? event.detail : '会话已过期,请重新登录。');}; window.addEventListener('session-expired', expired); return () => {active = false; window.removeEventListener('session-expired', expired);}; }, []); - if (loading) return
正在连接工作台…
; - if (!user) return {setUser(u); setError('');}} />; - return setUser(null)} onPasswordChanged={() => {setUser(null); setError('密码已修改,请使用新密码重新登录。');}}/>; + useEffect(() => { + let active = true; + api.me().then(s => { if (active) setUser(s.user); }).catch(e => { if (active && (!(e instanceof ApiError) || e.status !== 401)) setError(message(e)); }).finally(() => { if (active) setLoading(false); }); + const expired = (event: Event) => { setUser(null); setError(event instanceof CustomEvent && typeof event.detail === 'string' ? event.detail : '会话已过期,请重新登录。'); }; + window.addEventListener('session-expired', expired); + return () => { active = false; window.removeEventListener('session-expired', expired); }; + }, []); + if (loading) return
正在连接工作台…
; + if (!user) return { setUser(u); setError(''); }} />; + return setUser(null)} onPasswordChanged={() => { setUser(null); setError('密码已修改,请使用新密码重新登录。'); }} />; } + function Console({user, onLogout, onPasswordChanged}: {user: User; onLogout: () => void; onPasswordChanged: () => void}) { const [page, setPage] = useState('dsh'); const logout = useAction(); const runtime = useRuntimeInstances(user.id, page); const current = [...pages, ...secondaryPages].find(p => p.id === page)!; - const primaryPage = secondaryPages.some(p => p.id === page) ? 'settings' : page; - const exit = () => void logout.run(async () => {await api.logout(); onLogout();}); - return
- -
控制台 / {current.label}本地镜像 · 独立实例
- {logout.feedback} - setPage('overview')} settings={() => setPage('settings')}/> - {page !== 'dsh' &&

DEEPSEEK HARNESS

{current.label}

{current.description}

- {page === 'overview' && } - {page === 'images' && <>} - {page === 'workspace' && } - {page === 'admin' && user.admin && <>} - {page === 'settings' && <> - void runtime.refresh()}>刷新}> - {runtime.error && {runtime.error}} - {runtime.loading &&

正在刷新实例…

} - -

仅在此浏览器按账号记住实例 ID,不保存密钥。已选择的停止实例不会自动切换。

-
-
-
{user.admin && }
-

{user.username} · {user.admin ? '管理员' : '成员'}

修改密码会使所有设备退出登录。

仅退出当前浏览器会话,不影响运行中的实例。

+ // Secondary pages live under 设置 on the mobile bottom nav. + const navPage = secondaryPages.some(p => p.id === page) ? 'settings' : page; + const exit = () => void logout.run(async () => { await api.logout(); onLogout(); }); + + const navButton = (p: typeof pages[number], mobile: boolean) => { + const Icon = p.icon; + // Desktop sidebar lists secondary pages directly, so highlight by the real + // page; the mobile bottom bar only has the four primary entries. + const active = (mobile ? navPage : page) === p.id; + return ; + }; + + return
+ {/* Desktop sidebar */} +
DSH 工作台 数据与操作以服务器实际结果为准
} + +
+
{user.username.slice(0, 1).toUpperCase()}
+
+ {user.username} + {user.admin ? '管理员' : '成员'} +
+ +
+ + + {/* Mobile bottom navigation */} + + +
+
+ 控制台 / {current.label} + 本地镜像 · 独立实例 +
+ {logout.feedback} + setPage('overview')} settings={() => setPage('settings')} /> + {page !== 'dsh' &&
+
+
+

DEEPSEEK HARNESS

+

{current.label}

+

{current.description}

+
+ {page === 'overview' && } + {(page === 'images' || page === 'admin') && } + {page === 'images' && } + {page === 'workspace' && } + {page === 'admin' && user.admin && } + {page === 'settings' && <> + void runtime.refresh()}>刷新}> + {runtime.error && {runtime.error}} + {runtime.loading &&

正在刷新实例…

} + +

仅在此浏览器按账号记住实例 ID,不保存密钥。已选择的停止实例不会自动切换。

+
+ + +
+
+ +
+ + {user.admin && } +
+
+ +

{user.username} · {user.admin ? '管理员' : '成员'}

+

修改密码会使所有设备退出登录。

+ +
+

仅退出当前浏览器会话,不影响运行中的实例。

+ +
+
+ } +
+ DSH 工作台数据与操作以服务器实际结果为准 +
+
+
}
; } + function Login({error, onLogin}: {error: string; onLogin: (user: User) => void}) { const action = useAction(); - function submit(e: FormEvent) {e.preventDefault(); const form = new FormData(e.currentTarget); void action.run(async () => {const session = await api.login(String(form.get('username')), String(form.get('password'))); onLogin(session.user);});} - return
DSH●HARNESS CONSOLE

你的 AI 开发空间

专注创造,
让环境管理更简单。

独立实例、本地镜像、实例数据根。
在一个工作台中,掌控你的 DeepSeek Harness。

安全会话 · 资源隔离 · 操作审计

WELCOME BACK

登录工作台

使用管理员分配的账号继续

{error && {error}}{action.feedback}

没有账号?请联系平台管理员。
登录凭据不会存储在浏览器本地存储中。

; + function submit(e: FormEvent) { + e.preventDefault(); + const form = new FormData(e.currentTarget); + void action.run(async () => { + const session = await api.login(String(form.get('username')), String(form.get('password'))); + onLogin(session.user); + }); + } + return
+
+
+ DSH● + HARNESS CONSOLE +
+
+

你的 AI 开发空间

+

专注创造,
让环境管理更简单。

+

独立实例、本地镜像、实例数据根。
在一个工作台中,掌控你的 DeepSeek Harness。

+
+ 安全会话 · 资源隔离 · 操作审计 +
+
+
+

WELCOME BACK

+

登录工作台

+

使用管理员分配的账号继续

+
+ {error && {error}} + {action.feedback} +
+
+ + +
+ +

没有账号?请联系平台管理员。
登录凭据不会存储在浏览器本地存储中。

+
+
+
; } diff --git a/web/src/BackupPanel.test.tsx b/web/src/BackupPanel.test.tsx new file mode 100644 index 0000000..f09e09a --- /dev/null +++ b/web/src/BackupPanel.test.tsx @@ -0,0 +1,41 @@ +import { beforeEach, expect, it, vi } from 'vitest'; +const state = vi.hoisted(() => ({slots: [] as any[], cursor: 0})); +vi.mock('react', async original => ({ + ...await original(), + useState(value: unknown) {const i = state.cursor++; if (!(i in state.slots)) state.slots[i] = value; return [state.slots[i], (next: unknown) => {state.slots[i] = next;}];}, + useRef(value: unknown) {const i = state.cursor++; return state.slots[i] ??= {current: value};}, + useEffect() {}, +})); +vi.mock('./ui', async original => ({ + ...await original(), + useResource: () => ({data: [{id: 'backup', created_at: '', size: 1}], loading: false, refresh: vi.fn(async () => {})}), +})); +import { BackupPanel } from './BackupPanel'; +import { api } from './api'; +import { ConfirmDialog } from './components/ConfirmDialog'; +import { DialogContent } from './components/ui/dialog'; +function nodes(root: any, predicate: (node: any) => boolean): any[] { + if (Array.isArray(root)) return root.flatMap(child => nodes(child, predicate)); + if (!root || typeof root !== 'object') return []; + return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)]; +} +const refresh = vi.fn(async () => {}); +function render() {state.cursor = 0; return BackupPanel({instance: {id: 'one'} as any, refresh});} +function editor() { + const child = nodes(render(), n => typeof n.type === 'function' && n.props.backup)[0]; + return child.type(child.props); +} +beforeEach(() => {state.slots = []; state.cursor = 0; vi.restoreAllMocks(); refresh.mockClear();}); +it('retains restore password and shows failure inside the owning modal after confirmation', async () => { + const request = vi.spyOn(api, 'request').mockRejectedValue(new Error('restore denied')); + nodes(render(), n => n.type === 'button' && Array.isArray(n.props.children) && n.props.children.includes('恢复'))[0].props.onClick(); + nodes(editor(), n => n.type === 'input')[0].props.onChange({target: {value: 'original-password'}}); + nodes(editor(), n => n.type === 'form')[0].props.onSubmit({preventDefault: vi.fn()}); + nodes(render(), n => n.type === ConfirmDialog)[0].props.onClose(true); + await new Promise(resolve => setTimeout(resolve, 0)); + const modal = nodes(editor(), n => n.type === DialogContent)[0]; + expect(JSON.stringify(modal)).toContain('restore denied'); + expect(nodes(modal, n => n.type === 'input')[0].props.value).toBe('original-password'); + expect(request).toHaveBeenCalledExactlyOnceWith('/instances/one/restore', 'POST', {backup_id: 'backup', password: 'original-password'}); + expect(refresh).not.toHaveBeenCalled(); +}); diff --git a/web/src/BackupPanel.tsx b/web/src/BackupPanel.tsx index 044328a..ffa2ac1 100644 --- a/web/src/BackupPanel.tsx +++ b/web/src/BackupPanel.tsx @@ -1,26 +1,112 @@ -import { useState } from 'react'; +import { useState, type FormEvent, type ReactNode } from 'react'; +import { Download, HardDriveDownload, RotateCcw, Upload } from 'lucide-react'; import { api, instancePath, type Instance, type Backup } from './api'; import { ResourceState, Empty, bytes, date, useAction, useResource } from './ui'; +import { useConfirm } from './components/ConfirmDialog'; +import { AlertBox } from './components/ui/alert'; +import { DialogContent, DialogFooter, DialogRoot } from './components/ui/dialog'; +import { buttonVariants } from './components/ui/button'; +import { inputClass } from './components/ui/input'; +import { cn } from './lib/utils'; -export function BackupPanel({ instance, refresh }: { instance: Instance; refresh: () => Promise }) { +export function BackupPanel({instance, refresh}: {instance: Instance; refresh: () => Promise}) { const base = instancePath(instance.id); const backups = useResource(() => api.request(base + '/backups')); const action = useAction(); - const [password, setPassword] = useState(''), [originalPassword, setOriginalPassword] = useState(''), [file, setFile] = useState(); - return
-

操作会按需停止实例并恢复原运行状态。备份使用创建时的登录密码加密;修改登录密码不改变旧包密码。恢复包含插件重建、重启验证和失败回滚。

- {action.feedback} -
{ e.preventDefault(); void action.run(async () => { try { await api.request(base + '/backups', 'POST', { password }); await backups.refresh(); } finally { setPassword(''); } }, '加密备份已创建'); }}> - - -
- -
{ e.preventDefault(); if (!file) return; void action.run(async () => { try { if (file.size > 44743364) throw new Error('备份包超过大小限制'); const form = new FormData(); form.set('file', file); form.set('password', originalPassword); await api.request(base + '/backups', 'POST', form); await backups.refresh(); } finally { setOriginalPassword(''); } }, '备份已验证并导入;尚未覆盖当前配置'); }}> - - -
+ const confirm = useConfirm(); + const [password, setPassword] = useState(''); + const [importPassword, setImportPassword] = useState(''), [file, setFile] = useState(); + const [restoreTarget, setRestoreTarget] = useState(); + + function createBackup(e: FormEvent) { + e.preventDefault(); + void action.run(async () => { + try { await api.request(base + '/backups', 'POST', {password}); await backups.refresh(); } + finally { setPassword(''); } + }, '加密备份已创建'); + } + function importBackup(e: FormEvent) { + e.preventDefault(); + if (!file) return; + void action.run(async () => { + try { + if (file.size > 44743364) throw new Error('备份包超过大小限制'); + const form = new FormData(); + form.set('file', file); + form.set('password', importPassword); + await api.request(base + '/backups', 'POST', form); + await backups.refresh(); + } finally { setImportPassword(''); } + }, '备份已验证并导入;尚未覆盖当前配置'); + } + async function restore(backup: Backup, originalPassword: string) { + if (!await confirm.ask({ + title: '恢复配置备份', danger: true, confirmLabel: '确认恢复', + body: '确认覆盖当前配置?服务器将先保留当前 home 快照,随后按需停止实例并恢复原运行状态。', + })) return false; + return action.run(async () => { await api.request(base + '/restore', 'POST', {backup_id: backup.id, password: originalPassword}); await refresh(); }, '配置恢复及插件验证完成,已恢复原运行状态。'); + } + + return
+ {confirm.element} + 操作会按需停止实例并恢复原运行状态。备份使用创建时的登录密码加密;修改登录密码不改变旧包密码。恢复包含插件重建、重启验证和失败回滚。 + +
+
+

创建加密备份

+ +
+
+ +
+

导入外部备份

+ + +
+
+
+ + {!restoreTarget && action.feedback} {backups.data?.length === 0 && 暂无配置备份} - {backups.data?.map(b =>
{b.id}{date(b.created_at)}{bytes(b.size)}恢复
)} + + + {restoreTarget && setRestoreTarget(undefined)} onSubmit={async password => { + const ok = await restore(restoreTarget, password); + if (ok) setRestoreTarget(undefined); + }} />}
; } + +function RestoreDialog({backup, busy, feedback, onClose, onSubmit}: {backup: Backup; busy: boolean; feedback: ReactNode; onClose: () => void; onSubmit: (password: string) => Promise}) { + const [password, setPassword] = useState(''); + return { if (!open && !busy) onClose(); }}> + +
{ e.preventDefault(); void onSubmit(password); }}> + {feedback} + 创建于 {date(backup.created_at)} · {bytes(backup.size)}。输入创建该备份时使用的登录密码;旧平台密钥包可留空。 + + + + + +
+
+
; +} diff --git a/web/src/ChangePassword.tsx b/web/src/ChangePassword.tsx index f6d182e..817dc02 100644 --- a/web/src/ChangePassword.tsx +++ b/web/src/ChangePassword.tsx @@ -1,8 +1,17 @@ import { useState } from 'react'; +import { KeyRound } from 'lucide-react'; import { api } from './api'; import { PasswordDialog } from './PasswordDialog'; +import { buttonVariants } from './components/ui/button'; +import { cn } from './lib/utils'; export function ChangePassword({onChanged}: {onChanged: () => void}) { const [open, setOpen] = useState(false); - return <>{open && setOpen(false)} onSubmit={async (password, current) => {await api.request('/me/password', 'POST', {current_password:current, password}); onChanged();}}/>}; + return <> + + {open && setOpen(false)} onSubmit={async (password, current) => { + await api.request('/me/password', 'POST', {current_password: current, password}); + onChanged(); + }} />} + ; } diff --git a/web/src/ConfirmDialog.test.tsx b/web/src/ConfirmDialog.test.tsx new file mode 100644 index 0000000..8440c01 --- /dev/null +++ b/web/src/ConfirmDialog.test.tsx @@ -0,0 +1,26 @@ +import { beforeEach, expect, it, vi } from 'vitest'; +const hooks = vi.hoisted(() => ({slots: [] as any[], cursor: 0, cleanup: undefined as undefined | (() => void)})); +vi.mock('react', async original => ({ + ...await original(), + useState(value: unknown) { const i = hooks.cursor++; return [hooks.slots[i] ?? value, (next: unknown) => { hooks.slots[i] = next; }]; }, + useRef(value: unknown) { const i = hooks.cursor++; return hooks.slots[i] ??= {current: value}; }, + useEffect(effect: () => () => void) { hooks.cleanup = effect(); }, +})); +import { useConfirm } from './components/ConfirmDialog'; +beforeEach(() => { hooks.slots = []; hooks.cursor = 0; hooks.cleanup = undefined; }); +it('cancels a superseded confirmation and only accepts the current request', async () => { + const confirm = useConfirm(); + const first = confirm.ask({title: 'first'}); + const second = confirm.ask({title: 'second'}); + expect(await first).toBe(false); + confirm.element.props.onClose(true); + expect(await second).toBe(true); +}); +it('unmount cancels a pending action instead of leaving it suspended', async () => { + const confirm = useConfirm(); + const pending = confirm.ask({title: 'delete'}); + hooks.cleanup?.(); + expect(await pending).toBe(false); + confirm.element.props.onClose(true); + expect(await pending).toBe(false); +}); diff --git a/web/src/CreateInstance.tsx b/web/src/CreateInstance.tsx index 33d94dd..740d214 100644 --- a/web/src/CreateInstance.tsx +++ b/web/src/CreateInstance.tsx @@ -1,12 +1,16 @@ import { useEffect, useRef, useState, type FormEvent } from 'react'; import { api, selectableImage, type Instance } from './api'; import { ResourceState, useAction, useResource } from './ui'; +import { AlertBox } from './components/ui/alert'; +import { buttonVariants } from './components/ui/button'; +import { inputClass, selectClass, selectStyle } from './components/ui/input'; +import { cn } from './lib/utils'; export function CreateInstance({close, refresh}: {close: () => void; refresh: () => Promise}) { const images = useResource(api.images), action = useAction(); const [progress, setProgress] = useState(''); const active = useRef(true); - useEffect(() => {active.current = true; return () => {active.current = false;};}, []); + useEffect(() => { active.current = true; return () => { active.current = false; }; }, []); const available = images.data?.filter(selectableImage) || []; async function submit(e: FormEvent) { e.preventDefault(); @@ -16,18 +20,31 @@ export function CreateInstance({close, refresh}: {close: () => void; refresh: () if (!active.current) return; if (!image) throw new Error('请选择可用的本地镜像。'); setProgress('正在创建实例,请勿重复提交…'); - const created = await api.request('/instances', 'POST', {name, image_id:image.image_id || image.id}); + const created = await api.request('/instances', 'POST', {name, image_id: image.image_id || image.id}); if (!created?.id) throw new Error('未收到实例确认,请刷新实例列表核实,勿重复提交。'); - if (active.current) {await refresh(); close();} + if (active.current) { await refresh(); close(); } }); if (active.current) setProgress(''); } - return

创建独立实例

{action.feedback} -

请选择已验证并准入的本地镜像。创建使用所选镜像的精确 ID,不会自动构建或切换版本。

-
- - {!images.loading && !available.length &&

暂无已准入的可用镜像。请联系管理员构建并验证发布镜像。

} - -
{progress &&

{progress}

} + return +

创建独立实例

+ {action.feedback} + 请选择已验证并准入的本地镜像。创建使用所选镜像的精确 ID,不会自动构建或切换版本。 +
+ + + + {!images.loading && !available.length &&

暂无已准入的可用镜像。请联系管理员构建并验证发布镜像。

} +
+ + +
+
+ {progress &&

{progress}

}
; } diff --git a/web/src/Dialog.tsx b/web/src/Dialog.tsx index 72f1d95..327cb4a 100644 --- a/web/src/Dialog.tsx +++ b/web/src/Dialog.tsx @@ -1,14 +1,15 @@ -import { useEffect, useId, useRef, type ReactNode } from 'react'; +import type { ReactNode } from 'react'; +import { DialogContent, DialogRoot } from './components/ui/dialog'; export function Dialog({title, busy, onClose, children}: {title: string; busy: boolean; onClose: () => void; children: ReactNode}) { - const ref = useRef(null), titleId = useId(); - useEffect(() => { - const trigger = document.activeElement as HTMLElement | null, dialog = ref.current!; - dialog.showModal(); - dialog.querySelector('input')?.focus(); - return () => {dialog.close(); if (trigger?.isConnected) trigger.focus();}; - }, []); - return {event.preventDefault(); if (!busy) onClose();}}> -

{title}

{children} -
; + return { if (!open && !busy) onClose(); }}> + { + // Focus the first input when the dialog opens, mirroring the old native dialog. + e.preventDefault(); + const content = e.currentTarget as HTMLElement; + content.querySelector('input')?.focus(); + }}> + {children} + + ; } diff --git a/web/src/Images.test.tsx b/web/src/Images.test.tsx index baaab3f..edfa324 100644 --- a/web/src/Images.test.tsx +++ b/web/src/Images.test.tsx @@ -16,13 +16,18 @@ import { ReleasePanel } from './ReleasePanel'; import { CreateInstance } from './CreateInstance'; vi.mock('./ProfileEditor', () => ({ProfileEditor: () => null})); import { Instances } from './Instances'; +import { ConfirmDialog } from './components/ConfirmDialog'; function nodes(root: any, predicate: (node: any) => boolean): any[] { if (Array.isArray(root)) return root.flatMap(child => nodes(child, predicate)); if (!root || typeof root !== 'object') return []; return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)]; } function render(admin = false) {state.cursor = 0; return Images({admin});} -beforeEach(() => {state.slots = []; state.cursor = 0; state.emptyImages = false; vi.stubGlobal('window', {confirm:vi.fn(() => false)});}); +// In-app confirm dialog replaced window.confirm: resolve it via onClose. +const dialog = (root: any) => nodes(root, n => n.type === ConfirmDialog)[0]; +const approve = (root: any) => dialog(root).props.onClose(true); +const flush = () => new Promise(resolve => setTimeout(resolve, 0)); +beforeEach(() => {state.slots = []; state.cursor = 0; state.emptyImages = false; vi.stubGlobal('window', {});}); afterEach(() => {vi.restoreAllMocks(); vi.unstubAllGlobals();}); it('hides experimental and admission controls from ordinary users', () => { const tree = render(); @@ -30,14 +35,23 @@ it('hides experimental and admission controls from ordinary users', () => { expect(nodes(render(true), n => n.type === ReleasePanel)).toHaveLength(1); expect(nodes(tree, n => n.type === 'input' && n.props.type === 'checkbox')).toHaveLength(0); expect(nodes(tree, n => n.type === 'button' && n.props.children === '准入管理')).toHaveLength(0); - expect(nodes(render(true), n => n.type === 'button' && n.props.children === '准入管理')).toHaveLength(2); + // Each image row renders twice: once in the desktop table, once as a mobile card. + expect(nodes(render(true), n => n.type === 'button' && n.props.children === '准入管理')).toHaveLength(4); }); it.each([false, true])('has no legacy build controls (admin=%s)', admin => { const tree = render(admin); expect(nodes(tree, n => n.type === 'form' || n.type === 'input')).toHaveLength(0); expect(JSON.stringify(tree)).not.toMatch(/查询 latest|精确 DSH 版本|提交实验构建/); }); -it('requires evidence and confirmation; cancel sends no admission mutation', () => { +it('mounts only one selected admission editor outside responsive branches', () => { + nodes(render(true), n => n.type === 'button' && n.props.children === '准入管理')[0].props.onClick(); + const tree = render(true); + expect(nodes(tree, n => typeof n.type === 'function' && n.props.imageId)).toHaveLength(1); + for (const branch of nodes(tree, n => typeof n.props?.className === 'string' && /md:hidden|md:block/.test(n.props.className))) { + expect(nodes(branch, n => typeof n.type === 'function' && n.props.imageId)).toHaveLength(0); + } +}); +it('requires evidence and confirmation; cancel sends no admission mutation', async () => { const request = vi.spyOn(api, 'admitImage'); nodes(render(true), n => n.type === 'button' && n.props.children === '准入管理')[0].props.onClick(); const child = nodes(render(true), n => typeof n.type === 'function' && n.props.imageId)[0]; @@ -48,7 +62,13 @@ it('requires evidence and confirmation; cancel sends no admission mutation', () nodes(form, n => n.type === 'input')[0].props.onChange({target:{checked:true}}); state.cursor = 0; form = child.type(child.props); form.props.onSubmit({preventDefault:vi.fn()}); - expect(window.confirm).toHaveBeenCalledOnce(); expect(request).not.toHaveBeenCalled(); + state.cursor = 0; form = child.type(child.props); + // The in-app confirm dialog opens; cancelling it sends no mutation. + expect(dialog(form).props.request.options.title).toBe('更新准入策略'); + expect(request).not.toHaveBeenCalled(); + dialog(form).props.onClose(false); + await flush(); + expect(request).not.toHaveBeenCalled(); }); it('creates with only name and exact image ID, leaving version resolution to the server', async () => { const request = vi.spyOn(api, 'request').mockResolvedValue({id:'created'}); @@ -88,9 +108,9 @@ it('admission rejects blank/unconfirmed evidence, resets consent on edits, and s nodes(renderAdmission(), n => n.type === 'select')[0].props.onChange({target:{value:'recommended'}}); expect(nodes(renderAdmission(), n => n.type === 'input')[0].props.checked).toBe(false); nodes(renderAdmission(), n => n.type === 'input')[0].props.onChange({target:{checked:true}}); - vi.mocked(window.confirm).mockReturnValue(true); submit(); submit(); - await Promise.resolve(); + approve(renderAdmission()); + await flush(); expect(request).toHaveBeenCalledExactlyOnceWith('ok', 'recommended', '验收记录 #42'); }); it.each([ @@ -105,7 +125,6 @@ it.each([ [' ' + 'é'.repeat(2048) + '\n', 4096, true], ] as const)('checks UTF-8 evidence bytes (case %#)', async (evidence, bytes, valid) => { const request = vi.spyOn(api, 'admitImage').mockResolvedValue({}); - vi.mocked(window.confirm).mockReturnValue(true); nodes(render(true), n => n.type === 'button' && n.props.children === '准入管理')[0].props.onClick(); const child = nodes(render(true), n => typeof n.type === 'function' && n.props.imageId)[0]; state.slots = []; @@ -118,9 +137,16 @@ it.each([ expect(nodes(form, n => n.type === 'p' && Array.isArray(n.props.children) && n.props.children.includes(bytes))).toHaveLength(1); expect(nodes(form, n => n.type === 'button')[0].props.disabled).toBe(!valid); form.props.onSubmit({preventDefault:vi.fn()}); - await Promise.resolve(); - if (valid) expect(request).toHaveBeenCalledExactlyOnceWith('ok', 'compatible', evidence.trim()); - else {expect(request).not.toHaveBeenCalled(); expect(window.confirm).not.toHaveBeenCalled();} + if (valid) { + // Valid evidence opens the in-app confirm dialog; approving submits once. + approve(renderAdmission()); + await flush(); + expect(request).toHaveBeenCalledExactlyOnceWith('ok', 'compatible', evidence.trim()); + } else { + // Invalid evidence never reaches the confirmation step. + expect(dialog(renderAdmission()).props.request).toBeUndefined(); + expect(request).not.toHaveBeenCalled(); + } }); it('upgrade selector excludes experimental and unadmitted images', () => { state.cursor = 0; Instances(); diff --git a/web/src/Images.tsx b/web/src/Images.tsx index 6ce9411..2a0eaba 100644 --- a/web/src/Images.tsx +++ b/web/src/Images.tsx @@ -1,107 +1,203 @@ import { useState } from 'react'; -import { api, type AdmissionState, type Build } from './api'; -import { Panel, ResourceState, Empty, Status, date, useAction, useResource } from './ui'; +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { api, type AdmissionState, type Build, type Image } from './api'; +import { Panel, ResourceState, Empty, Status, date, useAction, useResource, RefreshButton, tableClass, tableHeadRowClass, thClass, tdClass, trClass } from './ui'; import { BuildProvenance, ReleasePanel } from './ReleasePanel'; +import { useConfirm } from './components/ConfirmDialog'; +import { AlertBox } from './components/ui/alert'; +import { buttonVariants } from './components/ui/button'; +import { selectClass, selectStyle, textareaClass } from './components/ui/input'; +import { cn } from './lib/utils'; + export const activeBuild = (build: Build) => ['queued', 'running', 'validating'].includes(build.status); + +const admissionLabels: Record = {recommended: '推荐', compatible: '兼容', existing_only: '仅存量', blocked: '禁止使用'}; + function BuildLogs({id, close}: {id: string; close: () => void}) { const logs = useResource(() => api.build(id), id, 5000, activeBuild); - return
-

构建日志

- - {logs.data && <> -

{logs.data.experimental && 实验构建 · 不自动准入}{logs.data.version} · -

{logs.data.error &&

{logs.data.error}

}
{logs.data.logs || '任务尚未输出日志'}
- }
; + return
+
+

构建日志

+ +
+ + {logs.data && <> +

+ {logs.data.experimental && 实验构建 · 不自动准入} + {logs.data.version} · +

+ + {logs.data.error && {logs.data.error}} +
{logs.data.logs || '任务尚未输出日志'}
+ } +
; } -function Admission({imageId, refresh}: {imageId:string; refresh:() => Promise}) { + +function Admission({imageId, refresh}: {imageId: string; refresh: () => Promise}) { const [state, setState] = useState('compatible'), [evidence, setEvidence] = useState(''), [confirmed, setConfirmed] = useState(false); const action = useAction(); + const confirm = useConfirm(); const submittedEvidence = evidence.trim(), evidenceBytes = new TextEncoder().encode(submittedEvidence).length; const evidenceInvalid = evidenceBytes === 0 || evidenceBytes > 4096; - return
{e.preventDefault(); if (!confirmed || evidenceInvalid) return; void action.run(async () => {await api.admitImage(imageId, state, submittedEvidence); setEvidence(''); setConfirmed(false); await refresh();}, '镜像准入策略已更新', '确认依据实际验收结果修改此镜像准入策略?');}}>{action.feedback} -

构建成功不等于通过验收。请记录启动、登录、终端、文件和配置等实际兼容性验收结果及可追溯依据,不可仅填写“构建成功”。创建或升级须选择已准入的本地镜像 ID。

-