diff --git a/ops/pnpm-lock.yaml b/ops/pnpm-lock.yaml new file mode 100644 index 0000000..1db4bbb --- /dev/null +++ b/ops/pnpm-lock.yaml @@ -0,0 +1,1179 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + react: + specifier: ^19.2.4 + version: 19.2.7 + react-dom: + specifier: ^19.2.4 + version: 19.2.7(react@19.2.7) + react-router-dom: + specifier: ^7.13.1 + version: 7.17.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + devDependencies: + '@types/react': + specifier: ^19.2.14 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.3) + typescript: + specifier: ^5.8.2 + version: 5.9.3 + vite: + specifier: ^6.2.0 + version: 6.4.3 + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.62.0': + resolution: {integrity: sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.0': + resolution: {integrity: sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.0': + resolution: {integrity: sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.0': + resolution: {integrity: sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.0': + resolution: {integrity: sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.0': + resolution: {integrity: sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.0': + resolution: {integrity: sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.0': + resolution: {integrity: sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.0': + resolution: {integrity: sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.0': + resolution: {integrity: sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.0': + resolution: {integrity: sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.0': + resolution: {integrity: sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.0': + resolution: {integrity: sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.0': + resolution: {integrity: sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.0': + resolution: {integrity: sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.0': + resolution: {integrity: sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.0': + resolution: {integrity: sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.0': + resolution: {integrity: sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.0': + resolution: {integrity: sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.0': + resolution: {integrity: sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.0': + resolution: {integrity: sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.0': + resolution: {integrity: sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.0': + resolution: {integrity: sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.0': + resolution: {integrity: sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.0': + resolution: {integrity: sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA==} + cpu: [x64] + os: [win32] + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + baseline-browser-mapping@2.10.37: + resolution: {integrity: sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001799: + resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + electron-to-chromium@1.5.372: + resolution: {integrity: sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.47: + resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} + engines: {node: '>=18'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-router-dom@7.17.0: + resolution: {integrity: sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.17.0: + resolution: {integrity: sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + rollup@4.62.0: + resolution: {integrity: sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.62.0': + optional: true + + '@rollup/rollup-android-arm64@4.62.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.0': + optional: true + + '@rollup/rollup-darwin-x64@4.62.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.0': + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/estree@1.0.9': {} + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@4.7.0(vite@6.4.3)': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.3 + transitivePeerDependencies: + - supports-color + + baseline-browser-mapping@2.10.37: {} + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.37 + caniuse-lite: 1.0.30001799 + electron-to-chromium: 1.5.372 + node-releases: 2.0.47 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + caniuse-lite@1.0.30001799: {} + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + electron-to-chromium@1.5.372: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + escalade@3.2.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + node-releases@2.0.47: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-refresh@0.17.0: {} + + react-router-dom@7.17.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-router: 7.17.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + + react-router@7.17.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + cookie: 1.1.1 + react: 19.2.7 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.7(react@19.2.7) + + react@19.2.7: {} + + rollup@4.62.0: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.0 + '@rollup/rollup-android-arm64': 4.62.0 + '@rollup/rollup-darwin-arm64': 4.62.0 + '@rollup/rollup-darwin-x64': 4.62.0 + '@rollup/rollup-freebsd-arm64': 4.62.0 + '@rollup/rollup-freebsd-x64': 4.62.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.0 + '@rollup/rollup-linux-arm-musleabihf': 4.62.0 + '@rollup/rollup-linux-arm64-gnu': 4.62.0 + '@rollup/rollup-linux-arm64-musl': 4.62.0 + '@rollup/rollup-linux-loong64-gnu': 4.62.0 + '@rollup/rollup-linux-loong64-musl': 4.62.0 + '@rollup/rollup-linux-ppc64-gnu': 4.62.0 + '@rollup/rollup-linux-ppc64-musl': 4.62.0 + '@rollup/rollup-linux-riscv64-gnu': 4.62.0 + '@rollup/rollup-linux-riscv64-musl': 4.62.0 + '@rollup/rollup-linux-s390x-gnu': 4.62.0 + '@rollup/rollup-linux-x64-gnu': 4.62.0 + '@rollup/rollup-linux-x64-musl': 4.62.0 + '@rollup/rollup-openbsd-x64': 4.62.0 + '@rollup/rollup-openharmony-arm64': 4.62.0 + '@rollup/rollup-win32-arm64-msvc': 4.62.0 + '@rollup/rollup-win32-ia32-msvc': 4.62.0 + '@rollup/rollup-win32-x64-gnu': 4.62.0 + '@rollup/rollup-win32-x64-msvc': 4.62.0 + fsevents: 2.3.3 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + set-cookie-parser@2.7.2: {} + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + typescript@5.9.3: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + vite@6.4.3: + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.62.0 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + yallist@3.1.1: {} diff --git a/plaza-api.mjs b/plaza-api.mjs new file mode 100644 index 0000000..da7f105 --- /dev/null +++ b/plaza-api.mjs @@ -0,0 +1,496 @@ +import express from 'express'; +import { createPlazaPostService, formatPostRow, mapPlazaError } from './plaza-posts.mjs'; +import { createPlazaInteractionService } from './plaza-interactions.mjs'; +import { + ensureAlgorithmConfig, + loadAlgorithmConfig, + recalculateHotScores, +} from './plaza-algorithm.mjs'; +import { createPlazaRedis, createNoopPlazaRedis } from './plaza-redis.mjs'; +import { startPlazaTasks, writebackPublications } from './plaza-tasks.mjs'; +import { createPlazaSeoService } from './plaza-seo.mjs'; +import { createPlazaOpsService, hasOpsRole } from './plaza-ops.mjs'; + +export async function bootstrapPlaza(pool) { + await ensureAlgorithmConfig(pool); + const algorithmConfig = await loadAlgorithmConfig(pool); + const redis = await createPlazaRedis(process.env.PLAZA_REDIS_URL, pool); + if (redis.enabled) { + console.log('Plaza Redis enabled'); + } + const seo = createPlazaSeoService(pool); + const interactions = createPlazaInteractionService(pool, { + formatPostRow, + plazaRedis: redis, + }); + + let ops = null; + const posts = createPlazaPostService(pool, { + loadViewerReactions: (viewerId, postIds) => + interactions.loadViewerReactions(viewerId, postIds), + plazaRedis: redis, + algorithmConfig, + onPostPublished: (postId) => seo?.notifyPostPublished(postId), + loadFeaturedPosts: async (viewerId) => { + if (!ops) return { homepage_banner: [], trending: [], category_top: {} }; + return ops.loadActiveFeaturedPosts(viewerId); + }, + }); + ops = createPlazaOpsService(pool, { + formatPostRow, + reviewPost: (...args) => posts.reviewPost(...args), + invalidateFeedCaches: () => redis?.invalidateFeedCaches?.(), + }); + startPlazaTasks({ + pool, + plazaRedis: redis, + recalculateHotScores, + writebackPublications, + }); + return { posts, interactions, seo, ops, redis }; +} + +export function createNoopPlaza() { + return { posts: null, interactions: null, seo: null, ops: null, redis: createNoopPlazaRedis() }; +} + +export function isPlazaPublicRead(path, method) { + if (method !== 'GET' || !path.startsWith('/plaza/v1/')) return false; + return ( + path === '/plaza/v1/feed' || + path === '/plaza/v1/categories' || + path === '/plaza/v1/seo/sitemap' || + /^\/plaza\/v1\/posts\/[^/]+$/.test(path) || + /^\/plaza\/v1\/posts\/[^/]+\/comments$/.test(path) || + /^\/plaza\/v1\/users\/[^/]+$/.test(path) || + /^\/plaza\/v1\/users\/[^/]+\/posts$/.test(path) + ); +} + +export function plazaClientIp(req) { + const forwarded = req.headers['x-forwarded-for']; + if (typeof forwarded === 'string' && forwarded.length > 0) { + return forwarded.split(',')[0].trim(); + } + return req.ip; +} + +function plazaRouteError(res, req, error, { sendError }) { + const status = mapPlazaError(error); + const code = error?.code ?? 'internal_error'; + const message = error instanceof Error ? error.message : 'Plaza 请求失败'; + return sendError(res, req, status, code, message, error?.details); +} + +export function attachPlazaApiRoutes(api, plaza, { sendData, sendError }) { + const { posts, interactions, seo, ops, redis } = plaza; + + function ensurePosts(res, req) { + if (!posts) { sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); return false; } + return true; + } + function ensureInteractions(res, req) { + if (!interactions) { sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); return false; } + return true; + } + function routeError(res, req, error) { + return plazaRouteError(res, req, error, { sendError }); + } + + function makeRequireOps(minRole = 'reviewer') { + return async (req, res, next) => { + if (!ops) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); + if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); + const role = await ops.loadOperatorRole(req.currentUser.id); + if (!hasOpsRole(role, minRole)) { + return sendError(res, req, 403, 'OPS_PERMISSION_DENIED', '无运营权限'); + } + req.opsRole = role; + return next(); + }; + } + + // ---- Plaza feed & posts ---- + + api.get('/plaza/v1/categories', async (req, res) => { + if (!ensurePosts(res, req)) return; + try { + return sendData(res, req, { categories: await posts.listCategories() }); + } catch (error) { + return routeError(res, req, error); + } + }); + + api.get('/plaza/v1/seo/sitemap', async (req, res) => { + if (!ensurePosts(res, req)) return; + if (!seo) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza SEO 未启用'); + try { + const data = await seo.listSitemapData({ + postLimit: req.query.post_limit, + userLimit: req.query.user_limit, + }); + return sendData(res, req, data); + } catch (error) { + return routeError(res, req, error); + } + }); + + api.post('/plaza/v1/attribution/events', async (req, res) => { + if (!seo) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); + try { + const result = await seo.recordAttribution(req.body ?? {}, plazaClientIp(req)); + return sendData(res, req, result, 201); + } catch (error) { + return routeError(res, req, error); + } + }); + + api.post('/plaza/v1/posts/:id/reports', async (req, res) => { + if (!ops) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); + if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); + try { + const report = await ops.createReport(req.currentUser.id, { + target_type: 'post', + target_id: req.params.id, + reason: req.body?.reason, + detail: req.body?.detail, + }); + return sendData(res, req, { report }, 201); + } catch (error) { + return routeError(res, req, error); + } + }); + + api.post('/plaza/v1/comments/:id/reports', async (req, res) => { + if (!ops) return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 未启用'); + if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); + try { + const report = await ops.createReport(req.currentUser.id, { + target_type: 'comment', + target_id: req.params.id, + reason: req.body?.reason, + detail: req.body?.detail, + }); + return sendData(res, req, { report }, 201); + } catch (error) { + return routeError(res, req, error); + } + }); + + api.get('/plaza/v1/feed', async (req, res) => { + if (!ensurePosts(res, req)) return; + try { + const feed = await posts.listFeed({ + sort: req.query.sort, + categorySlug: req.query.category ?? null, + cursor: req.query.cursor ?? null, + limit: req.query.limit, + viewerId: req.currentUser?.id ?? null, + }); + return sendData(res, req, feed); + } catch (error) { + return routeError(res, req, error); + } + }); + + api.post('/plaza/v1/posts/:id/reactions', async (req, res) => { + if (!ensureInteractions(res, req)) return; + if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); + try { + const result = await interactions.addReaction(req.currentUser.id, req.params.id, req.body?.type); + return sendData(res, req, result); + } catch (error) { + return routeError(res, req, error); + } + }); + + api.delete('/plaza/v1/posts/:id/reactions/:type', async (req, res) => { + if (!ensureInteractions(res, req)) return; + if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); + try { + const result = await interactions.removeReaction(req.currentUser.id, req.params.id, req.params.type); + return sendData(res, req, result); + } catch (error) { + return routeError(res, req, error); + } + }); + + api.get('/plaza/v1/posts/:id/comments', async (req, res) => { + if (!ensureInteractions(res, req)) return; + try { + const comments = await interactions.listComments(req.params.id, { + cursor: req.query.cursor ?? null, + limit: req.query.limit, + parentId: req.query.parent_id ?? null, + viewerId: req.currentUser?.id ?? null, + }); + return sendData(res, req, comments); + } catch (error) { + return routeError(res, req, error); + } + }); + + api.post('/plaza/v1/posts/:id/comments', async (req, res) => { + if (!ensureInteractions(res, req)) return; + if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); + try { + const comment = await interactions.createComment(req.currentUser.id, req.params.id, req.body ?? {}); + return sendData(res, req, { comment }, 201); + } catch (error) { + return routeError(res, req, error); + } + }); + + api.delete('/plaza/v1/comments/:id', async (req, res) => { + if (!ensureInteractions(res, req)) return; + if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); + try { + const comment = await interactions.deleteComment(req.currentUser.id, req.params.id); + return sendData(res, req, { comment }); + } catch (error) { + return routeError(res, req, error); + } + }); + + api.post('/plaza/v1/comments/:id/reactions', async (req, res) => { + if (!ensureInteractions(res, req)) return; + if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); + try { + const liked = req.body?.liked !== false; + const result = await interactions.toggleCommentLike(req.currentUser.id, req.params.id, liked); + return sendData(res, req, result); + } catch (error) { + return routeError(res, req, error); + } + }); + + api.get('/plaza/v1/users/:slug', async (req, res) => { + if (!ensureInteractions(res, req)) return; + try { + const profile = await interactions.getUserProfile(req.params.slug, req.currentUser?.id ?? null); + return sendData(res, req, profile); + } catch (error) { + return routeError(res, req, error); + } + }); + + api.get('/plaza/v1/users/:slug/posts', async (req, res) => { + if (!ensureInteractions(res, req)) return; + try { + const feed = await interactions.listUserPosts(req.params.slug, { + cursor: req.query.cursor ?? null, + limit: req.query.limit, + viewerId: req.currentUser?.id ?? null, + }); + return sendData(res, req, feed); + } catch (error) { + return routeError(res, req, error); + } + }); + + api.post('/plaza/v1/users/:slug/follow', async (req, res) => { + if (!ensureInteractions(res, req)) return; + if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); + try { + const result = await interactions.followUser(req.currentUser.id, req.params.slug); + return sendData(res, req, result); + } catch (error) { + return routeError(res, req, error); + } + }); + + api.delete('/plaza/v1/users/:slug/follow', async (req, res) => { + if (!ensureInteractions(res, req)) return; + if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); + try { + const result = await interactions.unfollowUser(req.currentUser.id, req.params.slug); + return sendData(res, req, result); + } catch (error) { + return routeError(res, req, error); + } + }); + + api.get('/plaza/v1/posts/:id', async (req, res) => { + if (!ensurePosts(res, req)) return; + try { + const post = await posts.getPostById(req.params.id, { + viewerId: req.currentUser?.id ?? null, + }); + void redis.recordView(req.params.id, plazaClientIp(req)).catch(() => {}); + return sendData(res, req, { post }); + } catch (error) { + return routeError(res, req, error); + } + }); + + api.post('/plaza/v1/posts', async (req, res) => { + if (!ensurePosts(res, req)) return; + if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); + try { + const post = await posts.createPost(req.currentUser.id, req.body ?? {}); + return sendData(res, req, { post }, 201); + } catch (error) { + return routeError(res, req, error); + } + }); + + api.patch('/plaza/v1/posts/:id', async (req, res) => { + if (!ensurePosts(res, req)) return; + if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); + try { + const post = await posts.updatePost(req.currentUser.id, req.params.id, req.body ?? {}); + return sendData(res, req, { post }); + } catch (error) { + return routeError(res, req, error); + } + }); + + api.delete('/plaza/v1/posts/:id', async (req, res) => { + if (!ensurePosts(res, req)) return; + if (!req.currentUser) return sendError(res, req, 401, 'unauthorized', '未授权,请重新登录'); + try { + const post = await posts.hidePost(req.currentUser.id, req.params.id); + return sendData(res, req, { post }); + } catch (error) { + return routeError(res, req, error); + } + }); + + // ---- Ops API ---- + + const opsApi = express.Router(); + opsApi.use(express.json({ limit: '1mb' })); + opsApi.use(makeRequireOps('reviewer')); + + opsApi.get('/review/queue', async (req, res) => { + try { + const queue = await ops.listReviewQueue({ + status: req.query.status ?? 'pending_review', + cursor: req.query.cursor ?? null, + limit: req.query.limit, + keyword: req.query.keyword ?? null, + }); + return sendData(res, req, queue); + } catch (error) { + return routeError(res, req, error); + } + }); + + opsApi.post('/review/posts/:id', async (req, res) => { + try { + const result = await ops.reviewPostAsOps(req.currentUser.id, req.params.id, req.body?.action, { + reason: req.body?.reason ?? null, + }); + return sendData(res, req, { post: result }); + } catch (error) { + return routeError(res, req, error); + } + }); + + opsApi.post('/review/batch', makeRequireOps('editor'), async (req, res) => { + try { + const result = await ops.batchReviewPosts(req.currentUser.id, req.body ?? {}); + return sendData(res, req, result); + } catch (error) { + return routeError(res, req, error); + } + }); + + opsApi.get('/reports', async (req, res) => { + try { + return sendData(res, req, await ops.listReports({ status: req.query.status ?? 'pending' })); + } catch (error) { + return routeError(res, req, error); + } + }); + + opsApi.post('/reports/:id/process', async (req, res) => { + try { + const result = await ops.processReport(req.currentUser.id, req.params.id, req.body ?? {}); + return sendData(res, req, { report: result }); + } catch (error) { + return routeError(res, req, error); + } + }); + + opsApi.get('/featured', makeRequireOps('editor'), async (req, res) => { + try { + return sendData(res, req, await ops.listFeatured()); + } catch (error) { + return routeError(res, req, error); + } + }); + + opsApi.post('/featured', makeRequireOps('editor'), async (req, res) => { + try { + const result = await ops.setFeatured(req.currentUser.id, req.body ?? {}); + return sendData(res, req, { featured: result }, 201); + } catch (error) { + return routeError(res, req, error); + } + }); + + opsApi.delete('/featured/:id', makeRequireOps('editor'), async (req, res) => { + try { + const result = await ops.removeFeatured(req.currentUser.id, req.params.id); + return sendData(res, req, { featured: result }); + } catch (error) { + return routeError(res, req, error); + } + }); + + opsApi.get('/analytics/overview', makeRequireOps('ops_admin'), async (req, res) => { + try { + return sendData(res, req, await ops.getAnalyticsOverview()); + } catch (error) { + return routeError(res, req, error); + } + }); + + opsApi.get('/creators', async (req, res) => { + try { + return sendData(res, req, await ops.listCreators({ keyword: req.query.keyword ?? null })); + } catch (error) { + return routeError(res, req, error); + } + }); + + opsApi.patch('/creators/:userId', async (req, res) => { + try { + const result = await ops.updateCreator(req.currentUser.id, req.params.userId, req.body ?? {}); + return sendData(res, req, { creator: result }); + } catch (error) { + return routeError(res, req, error); + } + }); + + api.use('/ops/v1', opsApi); +} + +export function attachPlazaAdminRoutes(adminApi, plaza, { sendError }) { + const { posts, ops } = plaza; + + adminApi.get('/plaza/pending', async (_req, res) => { + if (!posts) return res.status(503).json({ message: 'Plaza 未启用' }); + const plazaPosts = await posts.listPendingPosts(); + res.json({ data: { posts: plazaPosts } }); + }); + + adminApi.post('/plaza/posts/:id/review', async (req, res) => { + if (!posts) return res.status(503).json({ message: 'Plaza 未启用' }); + try { + const action = String(req.body?.action ?? 'approve'); + const reason = req.body?.reason ?? null; + const result = + ops && req.currentUser?.id + ? await ops.reviewPostAsOps(req.currentUser.id, req.params.id, action, { reason }) + : await posts.reviewPost(req.params.id, action, { reason }); + res.json({ data: result }); + } catch (error) { + const status = mapPlazaError(error); + res.status(status).json({ + error: { code: error?.code ?? 'review_failed', message: error.message }, + }); + } + }); +} diff --git a/src/App.tsx b/src/App.tsx index 49e0236..5fd3443 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,22 +1,12 @@ import { useEffect, useState } from 'react'; import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'; import { checkAuth, logout, setUnauthorizedHandler } from './api/client'; -import { AdminLayout } from './admin/AdminLayout'; -import { BillingPage } from './admin/pages/BillingPage'; -import { CapabilitiesPage } from './admin/pages/CapabilitiesPage'; -import { DashboardPage } from './admin/pages/DashboardPage'; -import { PoliciesPage } from './admin/pages/PoliciesPage'; -import { ProvidersPage } from './admin/pages/ProvidersPage'; -import { SkillsPage } from './admin/pages/SkillsPage'; -import { UserDetailPage } from './admin/pages/UserDetailPage'; -import { UsersPage } from './admin/pages/UsersPage'; import { AuthView } from './components/AuthView'; import { ChatView } from './components/ChatView'; import { MindSpaceView } from './components/MindSpaceView'; import { ChatProvider } from './context/ChatProvider'; import { PREVIEW_USER } from './dev/mindspacePreviewData'; import { MindSpaceRoute } from './routes/MindSpaceRoute'; -import { RequireAdmin } from './routes/RequireAdmin'; import type { CapabilityMap, PortalUser } from './types'; function isMindSpacePreview() { @@ -88,7 +78,11 @@ function AuthenticatedApp({ : undefined } onOpenPage={user ? (pageId) => navigate(`/space/page/${pageId}`) : undefined} - onOpenAdmin={user?.role === 'admin' ? () => navigate('/admin') : undefined} + onOpenAdmin={ + user?.role === 'admin' + ? () => { window.location.href = import.meta.env.VITE_ADMIN_APP_URL ?? '/admin-app'; } + : undefined + } onLogout={handleLogout} /> ); @@ -96,24 +90,6 @@ function AuthenticatedApp({ return ( - - {user ? : null} - - } - > - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } @@ -180,11 +156,7 @@ export function App() { legacyMode={legacyMode} onAuth={(nextUser, nextCapabilities, nextSkills) => { const params = new URLSearchParams(window.location.search); - const returnTo = - params.get('return_to') ?? - (window.location.pathname.startsWith('/admin') - ? `${window.location.pathname}${window.location.search}` - : null); + const returnTo = params.get('return_to') ?? null; if (returnTo) { try { const url = new URL(returnTo, window.location.origin); diff --git a/src/admin/AdminLayout.tsx b/src/admin/AdminLayout.tsx deleted file mode 100644 index 141d71f..0000000 --- a/src/admin/AdminLayout.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Link, Outlet } from 'react-router-dom'; -import type { PortalUser } from '../types'; -import { AdminNav } from './AdminNav'; - -export function AdminLayout({ - user, - onLogout, -}: { - user: PortalUser; - onLogout: () => void; -}) { - return ( -
-
-
- - ← 返回对话 - -

管理后台

-
-
- {user.displayName ?? user.username} - -
-
-
- -
- -
-
-
- ); -} diff --git a/src/admin/AdminNav.tsx b/src/admin/AdminNav.tsx deleted file mode 100644 index 81f9c5a..0000000 --- a/src/admin/AdminNav.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { NavLink } from 'react-router-dom'; - -type NavItem = { - to: string; - label: string; - end?: boolean; -}; - -type NavSection = { - label?: string; - items: NavItem[]; -}; - -const NAV_SECTIONS: NavSection[] = [ - { - items: [{ to: '/admin', label: '概览', end: true }], - }, - { - label: '用户与账户', - items: [{ to: '/admin/users', label: '用户管理' }], - }, - { - label: '计费', - items: [{ to: '/admin/billing', label: '计费中心', end: false }], - }, - { - label: '平台配置', - items: [ - { to: '/admin/capabilities', label: '能力' }, - { to: '/admin/skills', label: '技能' }, - { to: '/admin/policies', label: '策略' }, - { to: '/admin/providers', label: 'LLM Provider' }, - ], - }, -]; - -export function AdminNav() { - return ( - - ); -} diff --git a/src/admin/hooks/useAdminUsers.ts b/src/admin/hooks/useAdminUsers.ts deleted file mode 100644 index 6f595d6..0000000 --- a/src/admin/hooks/useAdminUsers.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { listAdminUsers } from '../../api/client'; -import type { AdminUserRow } from '../../types'; - -export function useAdminUsers() { - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const reload = useCallback(async () => { - setLoading(true); - setError(null); - try { - setUsers(await listAdminUsers()); - } catch (err) { - setError(err instanceof Error ? err.message : '加载用户失败'); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void reload(); - }, [reload]); - - return { users, loading, error, reload, setError }; -} diff --git a/src/admin/pages/BillingPage.tsx b/src/admin/pages/BillingPage.tsx deleted file mode 100644 index 030f7ec..0000000 --- a/src/admin/pages/BillingPage.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { NavLink, Navigate, Route, Routes } from 'react-router-dom'; -import { listAdminLedger, listAdminUsage, rechargeUser } from '../../api/client'; -import type { LedgerEntry, UsageRecord } from '../../types'; -import { useAdminUsers } from '../hooks/useAdminUsers'; -import { formatTime, formatYuan } from '../utils/format'; - -const TABS = [ - { to: 'recharge', label: '充值' }, - { to: 'usage', label: '用量记录' }, - { to: 'ledger', label: '资金流水' }, -] as const; - -function RechargeTab() { - const { users, reload, error, setError } = useAdminUsers(); - const [message, setMessage] = useState(null); - const [recharge, setRecharge] = useState({ userId: '', amountYuan: '10', note: '' }); - - const handleRecharge = async (e: React.FormEvent) => { - e.preventDefault(); - if (!recharge.userId) return; - setMessage(null); - setError(null); - try { - const amountCents = Math.round(Number(recharge.amountYuan) * 100); - await rechargeUser(recharge.userId, amountCents, recharge.note || undefined); - setMessage('充值成功'); - setRecharge({ userId: '', amountYuan: '10', note: '' }); - await reload(); - } catch (err) { - setError(err instanceof Error ? err.message : '充值失败'); - } - }; - - return ( -
-

充值

- {error &&

{error}

} - {message &&

{message}

} -
- - setRecharge((s) => ({ ...s, amountYuan: e.target.value }))} - /> - setRecharge((s) => ({ ...s, note: e.target.value }))} - /> - -
-
- ); -} - -function UsageTab() { - const [usage, setUsage] = useState([]); - const [loading, setLoading] = useState(true); - - const load = useCallback(async () => { - setLoading(true); - try { - setUsage(await listAdminUsage()); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void load(); - }, [load]); - - return ( -
-
-

用量记录

- -
- {loading ? ( -

加载中…

- ) : ( -
- - - - - - - - - - - - {usage.map((row) => ( - - - - - - - - ))} - -
时间用户Token扣费余额后
{formatTime(row.createdAt)}@{row.username} - in {row.inputTokens} / out {row.outputTokens} - ¥{formatYuan(row.costCents)}¥{formatYuan(row.balanceAfterCents)}
-
- )} -
- ); -} - -function LedgerTab() { - const [ledger, setLedger] = useState([]); - const [loading, setLoading] = useState(true); - - const load = useCallback(async () => { - setLoading(true); - try { - setLedger(await listAdminLedger()); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void load(); - }, [load]); - - return ( -
-
-

资金流水

- -
- {loading ? ( -

加载中…

- ) : ( -
- - - - - - - - - - - - {ledger.map((row) => ( - - - - - - - - ))} - -
时间用户类型金额备注
{formatTime(row.createdAt)}@{row.username}{row.type} - {row.amountCents >= 0 ? '+' : ''}¥{formatYuan(Math.abs(row.amountCents))} - {row.note ?? '—'}
-
- )} -
- ); -} - -export function BillingPage() { - return ( -
-
-

计费中心

-

充值、用量与资金流水

-
- - - - - } /> - } /> - } /> - } /> - } /> - -
- ); -} diff --git a/src/admin/pages/CapabilitiesPage.tsx b/src/admin/pages/CapabilitiesPage.tsx deleted file mode 100644 index c29f8e0..0000000 --- a/src/admin/pages/CapabilitiesPage.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { CapabilitySettings } from '../../components/CapabilitySettings'; -import { useAdminUsers } from '../hooks/useAdminUsers'; - -export function CapabilitiesPage() { - const { users, loading, error } = useAdminUsers(); - - return ( -
-
-

能力权限

-

控制各扩展与工具是否可用

-
- {error &&

{error}

} - {loading ?

加载用户列表…

: } -
- ); -} diff --git a/src/admin/pages/DashboardPage.tsx b/src/admin/pages/DashboardPage.tsx deleted file mode 100644 index 5bd46c7..0000000 --- a/src/admin/pages/DashboardPage.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { Link } from 'react-router-dom'; -import { useCallback, useEffect, useState } from 'react'; -import { getAdminDashboardSummary } from '../../api/client'; -import type { AdminDashboardSummary } from '../../types'; -import { formatTime, formatYuan } from '../utils/format'; - -const QUICK_LINKS = [ - { to: '/admin/users', label: '用户管理', desc: '创建账号、启用禁用' }, - { to: '/admin/billing/recharge', label: '充值', desc: '为用户账户充值' }, - { to: '/admin/providers', label: 'LLM Provider', desc: '模型 Key 与全局配置' }, - { to: '/admin/capabilities', label: '能力权限', desc: '扩展与工具开关' }, -] as const; - -export function DashboardPage() { - const [summary, setSummary] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - const load = useCallback(async () => { - setLoading(true); - setError(null); - try { - setSummary(await getAdminDashboardSummary()); - } catch (err) { - setError(err instanceof Error ? err.message : '加载概览失败'); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void load(); - }, [load]); - - return ( -
-
-

概览

-

平台运行摘要与快捷入口

-
- - {error &&

{error}

} - -
-
-
用户总数
-
{loading ? '—' : summary?.users.total}
-
活跃 {loading ? '—' : summary?.users.active}
-
-
-
余额不足
-
{loading ? '—' : summary?.users.lowBalance}
-
普通用户余额 ≤ ¥0
-
-
-
账户总余额
-
- {loading ? '—' : `¥${formatYuan(summary?.users.totalBalanceCents ?? 0)}`} -
-
-
-
24h 用量
-
{loading ? '—' : summary?.usage24h.count}
-
- 扣费 ¥{loading ? '—' : formatYuan(summary?.usage24h.costCents ?? 0)} -
-
-
-
LLM Provider
-
{loading ? '—' : (summary?.llm?.keyCount ?? '—')}
-
- {loading - ? '—' - : summary?.llm?.selectedKeyName - ? `当前 ${summary.llm.selectedKeyName}` - : '未配置'} -
-
-
- - {!loading && summary && summary.lowBalanceUsers.length > 0 && ( -
-
-

余额告警

- - 去充值 - -
-
- - - - - - - - - - {summary.lowBalanceUsers.map((user) => ( - - - - - - ))} - -
用户余额操作
-
{user.displayName}
-
@{user.username}
-
¥{formatYuan(user.balanceCents)} - - 用户详情 - -
-
-
- )} - -
-

快捷入口

-
- {QUICK_LINKS.map((item) => ( - - {item.label} - {item.desc} - - ))} -
-
- - {!loading && summary && ( - <> -
-
-

最近用量

- - 查看全部 - -
-
- - - - - - - - - - - {summary.recentUsage.length === 0 ? ( - - - - ) : ( - summary.recentUsage.map((row) => ( - - - - - - - )) - )} - -
时间用户Token扣费
- 暂无记录 -
{formatTime(row.createdAt)}@{row.username} - in {row.inputTokens} / out {row.outputTokens} - ¥{formatYuan(row.costCents)}
-
-
- -
-
-

最近流水

- - 查看全部 - -
-
- - - - - - - - - - - {summary.recentLedger.length === 0 ? ( - - - - ) : ( - summary.recentLedger.map((row) => ( - - - - - - - )) - )} - -
时间用户类型金额
- 暂无记录 -
{formatTime(row.createdAt)}@{row.username}{row.type} - {row.amountCents >= 0 ? '+' : ''}¥ - {formatYuan(Math.abs(row.amountCents))} -
-
-
- - )} -
- ); -} diff --git a/src/admin/pages/PoliciesPage.tsx b/src/admin/pages/PoliciesPage.tsx deleted file mode 100644 index 43ade87..0000000 --- a/src/admin/pages/PoliciesPage.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { PolicySettings } from '../../components/PolicySettings'; -import { useAdminUsers } from '../hooks/useAdminUsers'; - -export function PoliciesPage() { - const { users, loading, error } = useAdminUsers(); - - return ( -
-
-

安全策略

-

TKMind 模式、工作区只读与 API 代理锁定

-
- {error &&

{error}

} - {loading ?

加载用户列表…

: } -
- ); -} diff --git a/src/admin/pages/ProvidersPage.tsx b/src/admin/pages/ProvidersPage.tsx deleted file mode 100644 index 1050e70..0000000 --- a/src/admin/pages/ProvidersPage.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { ProviderKeySettings } from '../../components/ProviderKeySettings'; - -export function ProvidersPage() { - return ( -
-
-

LLM Provider

-

模型 Key、全局默认模型与联通测试

-
- -
- ); -} diff --git a/src/admin/pages/SkillsPage.tsx b/src/admin/pages/SkillsPage.tsx deleted file mode 100644 index 4e7fc8f..0000000 --- a/src/admin/pages/SkillsPage.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { SkillSettings } from '../../components/SkillSettings'; -import { useAdminUsers } from '../hooks/useAdminUsers'; - -export function SkillsPage() { - const { users, loading, error } = useAdminUsers(); - - return ( -
-
-

技能配置

-

管理平台技能与用户 MindSpace 安装

-
- {error &&

{error}

} - {loading ?

加载用户列表…

: } -
- ); -} diff --git a/src/admin/pages/UserDetailPage.tsx b/src/admin/pages/UserDetailPage.tsx deleted file mode 100644 index 467745d..0000000 --- a/src/admin/pages/UserDetailPage.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useState } from 'react'; -import { Link, Navigate, useParams } from 'react-router-dom'; -import { rechargeUser, updateAdminUser } from '../../api/client'; -import { CapabilitySettings } from '../../components/CapabilitySettings'; -import { PolicySettings } from '../../components/PolicySettings'; -import { SkillSettings } from '../../components/SkillSettings'; -import { useAdminUsers } from '../hooks/useAdminUsers'; -import { formatYuan } from '../utils/format'; - -export function UserDetailPage() { - const { userId = '' } = useParams(); - const { users, loading, error, reload, setError } = useAdminUsers(); - const [message, setMessage] = useState(null); - const [recharge, setRecharge] = useState({ amountYuan: '10', note: '' }); - - const user = users.find((row) => row.id === userId); - - const handleRecharge = async (e: React.FormEvent) => { - e.preventDefault(); - if (!user) return; - setMessage(null); - setError(null); - try { - const amountCents = Math.round(Number(recharge.amountYuan) * 100); - await rechargeUser(user.id, amountCents, recharge.note || undefined); - setMessage('充值成功'); - setRecharge({ amountYuan: '10', note: '' }); - await reload(); - } catch (err) { - setError(err instanceof Error ? err.message : '充值失败'); - } - }; - - if (!loading && !user) { - return ; - } - - return ( -
-
- - ← 返回用户列表 - -

{user?.displayName ?? '用户详情'}

- {user &&

@{user.username} · {user.role} · {user.status}

} -
- - {error &&

{error}

} - {message &&

{message}

} - - {loading || !user ? ( -

加载中…

- ) : ( - <> -
-
-

账户信息

- {user.role === 'user' && ( - - )} -
-
-
-
余额
-
¥{formatYuan(user.balanceCents)}
-
-
-
工作目录
-
{user.workspaceRoot}
-
-
-
- - {user.role === 'user' && ( - <> -
-

充值

-
- setRecharge((s) => ({ ...s, amountYuan: e.target.value }))} - /> - setRecharge((s) => ({ ...s, note: e.target.value }))} - /> - -
-
- - - - - - )} - - {user.role === 'admin' && ( -
-

- 管理员账号拥有 goose 全权限:不受能力、技能、策略、计费与路径限制,会话以 auto 模式运行。 -

-
- )} - - )} -
- ); -} diff --git a/src/admin/pages/UsersPage.tsx b/src/admin/pages/UsersPage.tsx deleted file mode 100644 index a295946..0000000 --- a/src/admin/pages/UsersPage.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { useState } from 'react'; -import { Link } from 'react-router-dom'; -import { createAdminUser, updateAdminUser } from '../../api/client'; -import { useAdminUsers } from '../hooks/useAdminUsers'; -import { formatYuan } from '../utils/format'; - -export function UsersPage() { - const { users, loading, error, reload, setError } = useAdminUsers(); - const [message, setMessage] = useState(null); - const [newUser, setNewUser] = useState({ - username: '', - password: '', - displayName: '', - workspaceRoot: '', - balanceCents: 500, - }); - - const handleCreate = async (e: React.FormEvent) => { - e.preventDefault(); - setMessage(null); - setError(null); - try { - await createAdminUser({ - username: newUser.username, - password: newUser.password, - displayName: newUser.displayName || undefined, - workspaceRoot: newUser.workspaceRoot || undefined, - balanceCents: Number(newUser.balanceCents), - }); - setNewUser({ - username: '', - password: '', - displayName: '', - workspaceRoot: '', - balanceCents: 500, - }); - setMessage('用户已创建'); - await reload(); - } catch (err) { - setError(err instanceof Error ? err.message : '创建失败'); - } - }; - - return ( -
-
-

用户管理

-

创建账号、查看列表、进入用户详情配置权限

-
- - {error &&

{error}

} - {message &&

{message}

} - -
-

创建用户

-
- setNewUser((s) => ({ ...s, username: e.target.value }))} - /> - setNewUser((s) => ({ ...s, password: e.target.value }))} - /> - setNewUser((s) => ({ ...s, displayName: e.target.value }))} - /> - setNewUser((s) => ({ ...s, workspaceRoot: e.target.value }))} - /> - - setNewUser((s) => ({ ...s, balanceCents: Number(e.target.value) })) - } - /> - -
-
- -
-
-

用户列表

- -
- {loading ? ( -

加载中…

- ) : ( -
- - - - - - - - - - - - - {users.map((user) => ( - - - - - - - - - ))} - -
用户角色状态余额工作目录操作
-
{user.displayName}
-
@{user.username}
-
{user.role}{user.status}¥{formatYuan(user.balanceCents)}{user.workspaceRoot} - - 详情 - - {user.role === 'user' && ( - - )} -
-
- )} -
-
- ); -} diff --git a/src/admin/utils/format.ts b/src/admin/utils/format.ts deleted file mode 100644 index 00b5771..0000000 --- a/src/admin/utils/format.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function formatYuan(cents: number) { - return (cents / 100).toFixed(2); -} - -export function formatTime(ts: number) { - return new Date(ts).toLocaleString('zh-CN'); -} diff --git a/src/components/CapabilitySettings.tsx b/src/components/CapabilitySettings.tsx deleted file mode 100644 index f591675..0000000 --- a/src/components/CapabilitySettings.tsx +++ /dev/null @@ -1,315 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { - clearUserCapabilityOverrides, - getRoleCapabilities, - getUserCapabilities, - listCapabilityCatalog, - updateRoleCapabilities, - updateUserCapabilities, -} from '../api/client'; -import type { AdminUserRow, CapabilityDefinition, CapabilityMap } from '../types'; - -const RISK_LABEL: Record = { - low: '低', - medium: '中', - high: '高', -}; - -type FilterType = 'all' | 'high' | 'enabled'; - -function CapabilityGrid({ - catalog, - values, - disabled, - onChange, -}: { - catalog: CapabilityDefinition[]; - values: CapabilityMap; - disabled?: boolean; - onChange: (key: string, allowed: boolean) => void; -}) { - const [filter, setFilter] = useState('all'); - - const grouped = catalog.reduce>((acc, item) => { - acc[item.category] ??= []; - acc[item.category].push(item); - return acc; - }, {}); - - const filterItem = (item: CapabilityDefinition) => { - if (filter === 'high') return item.risk === 'high'; - if (filter === 'enabled') return Boolean(values[item.key]); - return true; - }; - - const FILTER_OPTIONS: { key: FilterType; label: string }[] = [ - { key: 'all', label: '全部' }, - { key: 'high', label: '高风险' }, - { key: 'enabled', label: '已启用' }, - ]; - - return ( -
-
- {FILTER_OPTIONS.map((opt) => ( - - ))} -
- {Object.entries(grouped).map(([category, items]) => { - const visible = items.filter(filterItem); - if (visible.length === 0) return null; - const enabledCount = items.filter((i) => Boolean(values[i.key])).length; - return ( -
-
-

{category}

- - {enabledCount}/{items.length} 已启用 - -
-
    - {visible.map((item) => { - const checked = Boolean(values[item.key]); - return ( -
  • -
    - - {item.label} - - 风险 {RISK_LABEL[item.risk] ?? item.risk} - - - {item.description} -
    - -
  • - ); - })} -
-
- ); - })} -
- ); -} - -export function CapabilitySettings({ - users, - userId, - userOnly = false, -}: { - users: AdminUserRow[]; - userId?: string; - userOnly?: boolean; -}) { - const [catalog, setCatalog] = useState([]); - const [roleCapabilities, setRoleCapabilities] = useState({}); - const [selectedUserId, setSelectedUserId] = useState(userId ?? ''); - const [userCapabilities, setUserCapabilities] = useState({}); - const [userOverrides, setUserOverrides] = useState({}); - const [userUnrestricted, setUserUnrestricted] = useState(false); - const [loading, setLoading] = useState(true); - const [message, setMessage] = useState(null); - const [error, setError] = useState(null); - - const load = useCallback(async () => { - setLoading(true); - setError(null); - try { - const [catalogItems, roleState] = await Promise.all([ - listCapabilityCatalog(), - getRoleCapabilities('user'), - ]); - setCatalog(catalogItems); - setRoleCapabilities(roleState.capabilities); - } catch (err) { - setError(err instanceof Error ? err.message : '加载权限配置失败'); - } finally { - setLoading(false); - } - }, []); - - const loadUserCapabilities = useCallback(async (userId: string) => { - if (!userId) { - setUserCapabilities({}); - setUserOverrides({}); - setUserUnrestricted(false); - return; - } - try { - const state = await getUserCapabilities(userId); - setUserCapabilities(state.capabilities); - setUserOverrides(state.overrides); - setUserUnrestricted(state.unrestricted); - } catch (err) { - setError(err instanceof Error ? err.message : '加载用户权限失败'); - } - }, []); - - useEffect(() => { - void load(); - }, [load]); - - useEffect(() => { - void loadUserCapabilities(selectedUserId); - }, [loadUserCapabilities, selectedUserId]); - - useEffect(() => { - if (userId) { - setSelectedUserId(userId); - } - }, [userId]); - - const saveRoleDefaults = async () => { - setMessage(null); - setError(null); - try { - const result = await updateRoleCapabilities('user', roleCapabilities); - setRoleCapabilities(result.capabilities); - setMessage('普通用户默认权限已保存(对新用户及无单独配置的用户生效)'); - } catch (err) { - setError(err instanceof Error ? err.message : '保存失败'); - } - }; - - const saveUserOverrides = async () => { - if (!selectedUserId) return; - setMessage(null); - setError(null); - try { - const result = await updateUserCapabilities(selectedUserId, userCapabilities); - setUserCapabilities(result.capabilities); - await loadUserCapabilities(selectedUserId); - setMessage('用户单独权限已保存'); - } catch (err) { - setError(err instanceof Error ? err.message : '保存失败'); - } - }; - - const resetUserOverrides = async () => { - if (!selectedUserId) return; - setMessage(null); - setError(null); - try { - await clearUserCapabilityOverrides(selectedUserId); - await loadUserCapabilities(selectedUserId); - setMessage('已恢复为角色默认权限'); - } catch (err) { - setError(err instanceof Error ? err.message : '重置失败'); - } - }; - - const regularUsers = users.filter((user) => user.role === 'user'); - - return ( -
-
-

能力权限

- -
-

- 控制各扩展/工具是否可用。用户沙箱(MindSpace/用户名)请在「技能配置」勾选 static-page-publish。 - 「安全策略」可进一步收紧模式与 API。管理员不受限制。 -

- {error &&

{error}

} - {message &&

{message}

} - {loading ? ( -

加载中…

- ) : ( - <> - {!userOnly && ( - <> -

普通用户默认权限

- - setRoleCapabilities((prev) => ({ ...prev, [key]: allowed })) - } - /> -
- -
- - )} - - {!userOnly &&

单用户覆盖

} - {!userOnly && ( -
- -
- )} - {selectedUserId && ( - <> - {userUnrestricted ? ( -

该用户为管理员,不受能力限制。

- ) : ( - <> - {Object.keys(userOverrides).length > 0 && ( -

- 已设置 {Object.keys(userOverrides).length} 项单独覆盖,保存后将覆盖角色默认。 -

- )} - - setUserCapabilities((prev) => ({ ...prev, [key]: allowed })) - } - /> -
- - -
- - )} - - )} - - )} -
- ); -} diff --git a/src/components/PolicySettings.tsx b/src/components/PolicySettings.tsx deleted file mode 100644 index f1d4f58..0000000 --- a/src/components/PolicySettings.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { - clearUserPolicyOverrides, - getRolePolicies, - getUserPolicies, - listPolicyCatalog, - updateRolePolicies, - updateUserPolicies, -} from '../api/client'; -import type { AdminUserRow, PolicyDefinition, PolicyMap } from '../types'; - -const RISK_LABEL: Record = { - low: '低', - medium: '中', - high: '高', -}; - -function PolicyGrid({ - catalog, - values, - disabled, - onChange, -}: { - catalog: PolicyDefinition[]; - values: PolicyMap; - disabled?: boolean; - onChange: (key: string, value: PolicyMap[string]) => void; -}) { - const grouped = catalog.reduce>((acc, item) => { - acc[item.category] ??= []; - acc[item.category].push(item); - return acc; - }, {}); - - return ( -
- {Object.entries(grouped).map(([category, items]) => ( -
-

{category}

-
    - {items.map((item) => ( -
  • - -
  • - ))} -
-
- ))} -
- ); -} - -export function PolicySettings({ - users, - userId, - userOnly = false, -}: { - users: AdminUserRow[]; - userId?: string; - userOnly?: boolean; -}) { - const [catalog, setCatalog] = useState([]); - const [rolePolicies, setRolePolicies] = useState({}); - const [selectedUserId, setSelectedUserId] = useState(userId ?? ''); - const [userPolicies, setUserPolicies] = useState({}); - const [userOverrides, setUserOverrides] = useState({}); - const [userUnrestricted, setUserUnrestricted] = useState(false); - const [loading, setLoading] = useState(true); - const [message, setMessage] = useState(null); - const [error, setError] = useState(null); - - const load = useCallback(async () => { - setLoading(true); - setError(null); - try { - const [catalogItems, roleState] = await Promise.all([ - listPolicyCatalog(), - getRolePolicies('user'), - ]); - setCatalog(catalogItems); - setRolePolicies(roleState.policies); - } catch (err) { - setError(err instanceof Error ? err.message : '加载安全策略失败'); - } finally { - setLoading(false); - } - }, []); - - const loadUserPolicies = useCallback(async (userId: string) => { - if (!userId) { - setUserPolicies({}); - setUserOverrides({}); - setUserUnrestricted(false); - return; - } - try { - const state = await getUserPolicies(userId); - setUserPolicies(state.policies); - setUserOverrides(state.overrides); - setUserUnrestricted(state.unrestricted); - } catch (err) { - setError(err instanceof Error ? err.message : '加载用户策略失败'); - } - }, []); - - useEffect(() => { - void load(); - }, [load]); - - useEffect(() => { - void loadUserPolicies(selectedUserId); - }, [loadUserPolicies, selectedUserId]); - - useEffect(() => { - if (userId) { - setSelectedUserId(userId); - } - }, [userId]); - - const saveRoleDefaults = async () => { - setMessage(null); - setError(null); - try { - const result = await updateRolePolicies('user', rolePolicies); - setRolePolicies(result.policies); - setMessage('普通用户默认安全策略已保存'); - } catch (err) { - setError(err instanceof Error ? err.message : '保存失败'); - } - }; - - const saveUserOverrides = async () => { - if (!selectedUserId) return; - setMessage(null); - setError(null); - try { - const result = await updateUserPolicies(selectedUserId, userPolicies); - setUserPolicies(result.policies); - await loadUserPolicies(selectedUserId); - setMessage('用户单独策略已保存'); - } catch (err) { - setError(err instanceof Error ? err.message : '保存失败'); - } - }; - - const resetUserOverrides = async () => { - if (!selectedUserId) return; - setMessage(null); - setError(null); - try { - await clearUserPolicyOverrides(selectedUserId); - await loadUserPolicies(selectedUserId); - setMessage('已恢复为角色默认策略'); - } catch (err) { - setError(err instanceof Error ? err.message : '重置失败'); - } - }; - - const regularUsers = users.filter((user) => user.role === 'user'); - - return ( -
-
-

安全策略

- -
-

- 控制 TKMind 模式、工作区只读、网络出站与 API 代理锁定。与上方「能力权限」叠加生效(策略会进一步收紧高风险能力)。 -

- {error &&

{error}

} - {message &&

{message}

} - {loading ? ( -

加载中…

- ) : ( - <> - {!userOnly && ( - <> -

普通用户默认策略

- setRolePolicies((prev) => ({ ...prev, [key]: value }))} - /> -
- -
- - )} - - {!userOnly &&

单用户策略覆盖

} - {!userOnly && ( -
- -
- )} - {selectedUserId && ( - <> - {userUnrestricted ? ( -

该用户为管理员,不受策略限制。

- ) : ( - <> - {Object.keys(userOverrides).length > 0 && ( -

- 已设置 {Object.keys(userOverrides).length} 项单独覆盖。 -

- )} - - setUserPolicies((prev) => ({ ...prev, [key]: value })) - } - /> -
- - -
- - )} - - )} - - )} -
- ); -} diff --git a/src/components/ProviderKeySettings.tsx b/src/components/ProviderKeySettings.tsx deleted file mode 100644 index c9fa531..0000000 --- a/src/components/ProviderKeySettings.tsx +++ /dev/null @@ -1,535 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { - createLlmProviderKey, - deleteLlmProviderKey, - getLlmGlobalSettings, - listLlmProviderCatalog, - listLlmProviderKeys, - selectLlmProviderKey, - setLlmGlobalModel, - syncLlmProviderToGoosed, - testLlmProviderDraft, - testLlmProviderKey, - updateLlmProviderKey, -} from '../api/client'; -import type { - LlmConnectionTestResult, - LlmGlobalSettings, - LlmProviderDefinition, - LlmProviderKeyRow, -} from '../types'; - -const CUSTOM_PROVIDER_ID = '__custom__'; - -const emptyForm = { - providerId: CUSTOM_PROVIDER_ID, - name: 'Relay Buyer Ollama', - apiKey: 'UqyHPKSSEZq0-oPnl8sru-7hZcJ2anPUL1yAVk866Vo', - apiUrl: 'http://127.0.0.1:18300/relay/buyer/v1/chat/completions', - basePath: '', - modelsText: 'qwen2.5:3b', - defaultModel: 'qwen2.5:3b', - relayProvider: 'ollama', - engine: 'openai', -}; - -function parseModelsText(text: string) { - return [...new Set(text.split(/[\n,]/).map((item) => item.trim()).filter(Boolean))]; -} - -function TestResultBanner({ result }: { result: LlmConnectionTestResult | null }) { - if (!result) return null; - if (!result.ok) { - return

联通失败:{result.message ?? '未知错误'}

; - } - return ( -

- 联通成功({result.latencyMs}ms,模型 {result.model}):{result.reply} -

- ); -} - -export function ProviderKeySettings() { - const [catalog, setCatalog] = useState([]); - const [keys, setKeys] = useState([]); - const [globalSettings, setGlobalSettings] = useState(null); - const [globalModelDraft, setGlobalModelDraft] = useState(''); - const [form, setForm] = useState(emptyForm); - const [loading, setLoading] = useState(true); - const [message, setMessage] = useState(null); - const [error, setError] = useState(null); - const [testingForm, setTestingForm] = useState(false); - const [testingGlobal, setTestingGlobal] = useState(false); - const [testingKeyId, setTestingKeyId] = useState(null); - const [formTestResult, setFormTestResult] = useState(null); - const [globalTestResult, setGlobalTestResult] = useState(null); - - const isCustom = form.providerId === CUSTOM_PROVIDER_ID; - const selectedCatalog = useMemo( - () => catalog.find((item) => item.id === form.providerId) ?? null, - [catalog, form.providerId], - ); - const customModels = useMemo(() => parseModelsText(form.modelsText), [form.modelsText]); - - const load = useCallback(async () => { - setLoading(true); - setError(null); - try { - const [catalogItems, keyRows, globalState] = await Promise.all([ - listLlmProviderCatalog(), - listLlmProviderKeys(), - getLlmGlobalSettings(), - ]); - setCatalog(catalogItems); - setKeys(keyRows); - setGlobalSettings(globalState); - setGlobalModelDraft(globalState.globalModel ?? ''); - } catch (err) { - setError(err instanceof Error ? err.message : '加载 LLM 配置失败'); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void load(); - }, [load]); - - useEffect(() => { - if (isCustom) { - setForm((current) => ({ - ...current, - defaultModel: current.defaultModel || customModels[0] || '', - })); - return; - } - if (!selectedCatalog) return; - setForm((current) => ({ - ...current, - defaultModel: current.defaultModel || selectedCatalog.defaultModel, - })); - }, [customModels, isCustom, selectedCatalog]); - - const handleCreate = async (e: React.FormEvent) => { - e.preventDefault(); - setMessage(null); - setError(null); - setFormTestResult(null); - try { - if (isCustom) { - await createLlmProviderKey({ - providerId: CUSTOM_PROVIDER_ID, - name: form.name, - apiKey: form.apiKey, - apiUrl: form.apiUrl, - basePath: form.basePath || undefined, - models: customModels, - defaultModel: form.defaultModel || customModels[0], - engine: form.engine, - relayProvider: form.relayProvider || undefined, - }); - } else { - await createLlmProviderKey({ - providerId: form.providerId, - name: form.name, - apiKey: form.apiKey, - defaultModel: form.defaultModel || undefined, - }); - } - setForm({ ...emptyForm, providerId: form.providerId }); - setMessage('LLM 配置已保存到数据库'); - await load(); - } catch (err) { - setError(err instanceof Error ? err.message : '保存失败'); - } - }; - - const handleTestForm = async () => { - setTestingForm(true); - setFormTestResult(null); - setError(null); - try { - const result = await testLlmProviderDraft({ - providerId: CUSTOM_PROVIDER_ID, - name: form.name || 'test', - apiKey: form.apiKey, - apiUrl: form.apiUrl, - basePath: form.basePath || undefined, - models: customModels, - defaultModel: form.defaultModel || customModels[0], - testModel: form.defaultModel || customModels[0], - relayProvider: form.relayProvider || undefined, - }); - setFormTestResult(result); - } catch (err) { - setFormTestResult({ - ok: false, - message: err instanceof Error ? err.message : '联通测试失败', - }); - } finally { - setTestingForm(false); - } - }; - - const handleApplyGlobalModel = async () => { - setMessage(null); - setError(null); - setGlobalTestResult(null); - try { - const result = await setLlmGlobalModel(globalModelDraft); - setGlobalSettings(result.global); - setMessage(`全局模型已设为 ${result.global.globalModel},并已同步到 TKMind Agent`); - await load(); - } catch (err) { - setError(err instanceof Error ? err.message : '保存全局模型失败'); - } - }; - - const handleTestGlobal = async () => { - if (!globalSettings?.keyId) { - setError('请先启用一个 LLM 配置'); - return; - } - setTestingGlobal(true); - setGlobalTestResult(null); - setError(null); - try { - const result = await testLlmProviderKey( - globalSettings.keyId, - globalModelDraft || globalSettings.globalModel || undefined, - ); - setGlobalTestResult(result); - } catch (err) { - setGlobalTestResult({ - ok: false, - message: err instanceof Error ? err.message : '联通测试失败', - }); - } finally { - setTestingGlobal(false); - } - }; - - const handleSelect = async (keyId: string) => { - setMessage(null); - setError(null); - try { - await selectLlmProviderKey(keyId); - setMessage('已切换当前 LLM 并同步到 TKMind Agent'); - await load(); - } catch (err) { - setError(err instanceof Error ? err.message : '切换失败'); - } - }; - - const handleTestKey = async (row: LlmProviderKeyRow) => { - setTestingKeyId(row.id); - setError(null); - try { - const result = await testLlmProviderKey(row.id, row.defaultModel); - if (result.ok) { - setMessage(`「${row.name}」联通成功(${result.latencyMs}ms):${result.reply}`); - } else { - setError(`「${row.name}」联通失败:${result.message}`); - } - } catch (err) { - setError(err instanceof Error ? err.message : '联通测试失败'); - } finally { - setTestingKeyId(null); - } - }; - - const handleToggleStatus = async (row: LlmProviderKeyRow) => { - setMessage(null); - setError(null); - try { - await updateLlmProviderKey(row.id, { - status: row.status === 'active' ? 'disabled' : 'active', - }); - setMessage('状态已更新'); - await load(); - } catch (err) { - setError(err instanceof Error ? err.message : '更新失败'); - } - }; - - const handleDelete = async (row: LlmProviderKeyRow) => { - if (!window.confirm(`确定删除配置「${row.name}」?`)) return; - setMessage(null); - setError(null); - try { - await deleteLlmProviderKey(row.id); - setMessage('配置已删除'); - await load(); - } catch (err) { - setError(err instanceof Error ? err.message : '删除失败'); - } - }; - - const handleSync = async () => { - setMessage(null); - setError(null); - try { - const result = await syncLlmProviderToGoosed(); - setMessage(result.synced ? '已重新同步到 TKMind Agent' : '当前没有启用的 LLM 配置'); - } catch (err) { - setError(err instanceof Error ? err.message : '同步失败'); - } - }; - - return ( -
-
-

LLM Key 管理

- -
-

- 服务启动时会自动导入 Relay Buyer Ollama(若尚未存在)。全局模型会写入 - GOOSE_MODEL / TKMIND_MODEL,全站对话均使用该模型。 -

- -
-
-

全局模型

-
- {loading ? ( -

加载中…

- ) : !globalSettings?.keyId ? ( -

尚未启用 LLM 配置。保存并启用 Relay 后即可选择全局模型。

- ) : ( - <> -

- 当前 Provider:{globalSettings.providerLabel}( - {globalSettings.keyName}) -

-
- - - -
- - - )} -
- -
- - - setForm((state) => ({ ...state, name: e.target.value }))} - /> - - {isCustom ? ( - <> - setForm((state) => ({ ...state, apiUrl: e.target.value }))} - /> -