first commit
3
apps/web/.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
VITE_GATEWAY_URL=ws://localhost:8787
|
||||
VITE_GATEWAY_TOKEN=remoteconn-dev-token
|
||||
VITE_ENABLE_PLUGIN_RUNTIME=true
|
||||
16
apps/web/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="theme-color" content="#192b4d" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<title>RemoteConn</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
26
apps/web/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "@remoteconn/web",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build && node ../../scripts/check-web-bundle-size.mjs",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"lint": "eslint src --ext .ts,.vue",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@remoteconn/plugin-runtime": "1.0.0",
|
||||
"@remoteconn/shared": "1.0.0",
|
||||
"dexie": "^4.2.0",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.21",
|
||||
"vue-router": "^4.5.1",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0",
|
||||
"xterm-addon-search": "^0.13.0",
|
||||
"xterm-addon-unicode11": "^0.6.0",
|
||||
"xterm-addon-webgl": "^0.16.0"
|
||||
}
|
||||
}
|
||||
4
apps/web/public/assets/icons/ai.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
|
||||
<path fill="#FFC16E" d="M10.962 0a10.909 10.909 0 0 0-6.083 1.845A10.988 10.988 0 0 0 .84 6.775a11.05 11.05 0 0 0-.633 6.353 11.017 11.017 0 0 0 2.985 5.636 10.93 10.93 0 0 0 5.599 3.02c2.122.429 4.323.216 6.324-.613a10.958 10.958 0 0 0 4.92-4.04 11.038 11.038 0 0 0 1.858-6.107v-.015A11.033 11.033 0 0 0 21.07 6.8a10.988 10.988 0 0 0-2.364-3.57A10.928 10.928 0 0 0 15.16.842 10.885 10.885 0 0 0 10.975 0h-.013Zm.614 14.925-.772-1.833H7.8l-.772 1.833H5.053l3.276-7.96h1.935l3.278 7.96h-1.966Zm4.511 0H14.4v-7.96h1.687v7.96Z"/>
|
||||
<path fill="#FFC16E" d="M8.18 11.668h2.255l-1.127-2.78-1.128 2.78Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 704 B |
3
apps/web/public/assets/icons/cancel.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
|
||||
<path fill="#FFC16E" d="M10.937 0C4.897 0 0 4.896 0 10.937c0 6.04 4.896 10.936 10.937 10.936 6.04 0 10.936-4.896 10.936-10.936S16.977 0 10.937 0Zm5.694 14.507a1.364 1.364 0 0 1 0 1.923l-.481.48a1.364 1.364 0 0 1-1.923 0l-3.43-3.43-3.43 3.43a1.364 1.364 0 0 1-1.924 0l-.48-.48a1.364 1.364 0 0 1 0-1.923l3.43-3.43-3.71-3.71a1.364 1.364 0 0 1 0-1.924l.48-.48a1.364 1.364 0 0 1 1.924 0l3.71 3.71 3.71-3.71a1.364 1.364 0 0 1 1.923 0l.48.48a1.364 1.364 0 0 1 0 1.923l-3.71 3.71 3.43 3.43Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 591 B |
3
apps/web/public/assets/icons/clear-input.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="24" fill="none" viewBox="0 0 22 24">
|
||||
<path fill="#FFC16E" d="M14.071.652a3.268 3.268 0 0 0-1.694-.614c-.444-.03-.89-.042-1.335-.036h-.27c-.43-.008-.862.004-1.292.036a3.266 3.266 0 0 0-1.726.64c-.346.276-.633.62-.841 1.011-.2.35-.398.786-.622 1.28l-.39.85h-4.81a1.091 1.091 0 1 0 0 2.183h.818V19.91A4.09 4.09 0 0 0 6 24h9.818a4.09 4.09 0 0 0 4.09-4.09V6.002h.818a1.092 1.092 0 0 0 0-2.18h-4.71l-.465-.961c-.195-.42-.408-.833-.638-1.235a3.254 3.254 0 0 0-.841-.974Zm-.48 3.17H8.3c.154-.358.323-.708.507-1.051a1.003 1.003 0 0 1 .87-.56c.291-.026.67-.026 1.27-.026.586 0 .955 0 1.237.026a1.004 1.004 0 0 1 .859.539c.144.237.303.56.55 1.071Zm-5.41 14.41a.818.818 0 0 1-.818-.817V10.87a.818.818 0 0 1 1.636 0v6.545a.818.818 0 0 1-.818.818Zm6.273-7.362v6.545a.818.818 0 0 1-1.636 0V10.87a.818.818 0 0 1 1.636 0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 876 B |
3
apps/web/public/assets/icons/move.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="21" fill="none" viewBox="0 0 12 21">
|
||||
<path fill="#FFC16E" d="M2.098 4.195a2.099 2.099 0 1 1 .001-4.197 2.099 2.099 0 0 1-.001 4.197Zm0-1.399a.699.699 0 1 0 0-1.397.699.699 0 0 0 0 1.397Zm6.993 1.4a2.099 2.099 0 1 1 .002-4.198 2.099 2.099 0 0 1-.002 4.197Zm0-1.4a.699.699 0 1 0 0-1.397.699.699 0 0 0 0 1.397Zm-6.993 9.79a2.099 2.099 0 1 1 .001-4.197 2.099 2.099 0 0 1-.001 4.197Zm0-1.4a.699.699 0 1 0 0-1.397.699.699 0 0 0 0 1.398Zm6.993 1.4a2.099 2.099 0 1 1 .002-4.197 2.099 2.099 0 0 1-.002 4.197Zm0-1.4a.699.699 0 1 0 0-1.397.699.699 0 0 0 0 1.398Zm-6.993 9.79a2.099 2.099 0 0 1 0-4.195 2.099 2.099 0 0 1 0 4.196Zm0-1.399a.699.699 0 1 0 0-1.397.699.699 0 0 0 0 1.397Zm6.993 1.4a2.099 2.099 0 0 1 0-4.196 2.099 2.099 0 0 1 0 4.196Zm0-1.4a.699.699 0 1 0 0-1.397.699.699 0 0 0 0 1.397Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 857 B |
3
apps/web/public/assets/icons/record.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="3.2 4.8 17.6 17" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#FFC16E" d="M4 20h5.2l10-10a1.8 1.8 0 0 0 0-2.55l-2.65-2.64a1.8 1.8 0 0 0-2.55 0L4 14.8V20zm1.8-4.47l8.92-8.92 1.67 1.67-8.92 8.92H5.8v-1.67zm10.2-9.8l.9-.9.95.95-.9.9-.95-.95zM3.2 21.8h17.6v-1.8H3.2v1.8z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 319 B |
3
apps/web/public/assets/icons/recordmanage.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#000" d="M3 5.2h11.2V7H3V5.2zm0 5h8.6V12H3v-1.8zm0 5h7V17H3v-1.8zm9.9 5.3h2.8l5.34-5.34a1.45 1.45 0 0 0 0-2.05l-1.72-1.72a1.45 1.45 0 0 0-2.05 0l-5.35 5.34v2.77zm1.2-2.26l4.58-4.58 1.2 1.2-4.58 4.58h-1.2v-1.2z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 318 B |
3
apps/web/public/assets/icons/recordmanager.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
|
||||
<path fill="#FFC16E" d="M20.977 9.325a.963.963 0 0 0-.963.965v8.815a.966.966 0 0 1-.963.965H4.538a.966.966 0 0 1-.963-.965v-4.897h.685a.963.963 0 0 0 .963-.965.963.963 0 0 0-.963-.965H.963a.963.963 0 0 0-.963.965c0 .534.43.965.963.965h.685v4.897A2.897 2.897 0 0 0 4.538 22H19.05c1.592 0 2.89-1.3 2.89-2.895V10.29a.963.963 0 0 0-.964-.965ZM8.164 9.61l-1.278 3.419c-.218.583-.066 1.233.396 1.696.323.324.736.496 1.154.496.182 0 .364-.033.54-.1l3.411-1.28c.33-.124.621-.31.867-.557l8.153-8.17c.405-.405.613-.982.568-1.578a2.358 2.358 0 0 0-.694-1.486L19.935.7A2.35 2.35 0 0 0 18.45.007a2.006 2.006 0 0 0-1.575.568L8.72 8.741a2.427 2.427 0 0 0-.556.869Zm1.804.678a.487.487 0 0 1 .114-.18l8.155-8.172a.118.118 0 0 1 .052-.008h.017a.447.447 0 0 1 .265.135l1.347 1.349c.079.08.128.178.134.266.003.043-.006.066-.008.068L11.89 11.92a.505.505 0 0 1-.18.114L8.924 13.08l1.044-2.792ZM.963 6.9H4.26a.963.963 0 0 0 .963-.965.963.963 0 0 0-.963-.965h-.685V2.934c0-.532.432-.966.963-.966h7.256a.963.963 0 0 0 .963-.965.963.963 0 0 0-.963-.965H4.538c-1.593 0-2.89 1.3-2.89 2.896V4.97H.963A.963.963 0 0 0 0 5.936c0 .534.43.965.963.965Zm0 3.653H4.26a.963.963 0 0 0 .963-.965.963.963 0 0 0-.963-.966H.963A.963.963 0 0 0 0 9.59c0 .534.43.965.963.965Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
3
apps/web/public/assets/icons/sent.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="#FFC16E" d="m0 24 2.496-11.112 13.8-.864-13.8-.936L0 0l24 12L0 24Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 187 B |
6
apps/web/public/assets/icons/voice.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="27" height="36" fill="none" viewBox="0 0 27 36">
|
||||
<g opacity="1">
|
||||
<path fill="#FFC16E" d="M13.326 26.661a8.254 8.254 0 0 0 8.246-8.246V8.245a8.246 8.246 0 1 0-16.491 0v10.17a8.254 8.254 0 0 0 8.245 8.246Z"/>
|
||||
<path fill="#FFC16E" d="M22.759 26.932a13.487 13.487 0 0 0 3.894-9.47V11.64a1.47 1.47 0 1 0-2.941 0v5.822a10.453 10.453 0 0 1-10.386 10.513A10.458 10.458 0 0 1 2.941 17.462V11.64a1.47 1.47 0 0 0-2.941 0v5.822a13.483 13.483 0 0 0 3.898 9.47 13.242 13.242 0 0 0 7.966 3.882v2.245h-6.16a1.47 1.47 0 0 0 0 2.941h15.254a1.47 1.47 0 1 0 0-2.94h-6.161v-2.246a13.263 13.263 0 0 0 7.962-3.882Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 666 B |
3
apps/web/public/brand/ai矩连.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="52" height="24" fill="none" viewBox="0 0 52 24">
|
||||
<path fill="#FFC16E" d="M7.902 0c.033 0 .55 7.989 1.554 23.967L9.423 24H6.708c-.055 0-.29-3.621-.704-10.863H3.452c-.436 7.198-.665 10.814-.687 10.846L2.732 24h-2.7L0 23.967C.992 7.99 1.505 0 1.538 0h6.364Zm-3.78 2.29-.49 8.573h2.192l-.49-8.573H4.122ZM15.642 0c.022 0 .038.016.05.05v23.9c0 .012-.017.028-.05.05h-2.716c-.01 0-.027-.016-.049-.05V.05c0-.023.017-.039.05-.05h2.715Zm3.021 0h2.204v1.102h3.954v1.802h-2.178v5.628h2.178v1.815h-2.204c-.086.64-.13.977-.13 1.011h.623c.026 0 .06.169.104.506.008.008.73 3.963 2.165 11.864.026.086.043.177.052.272h-1.984c-.017 0-.056-.195-.117-.584l-1.452-7.87h-.013L20.672 24h-2.01c.01-.043.022-.13.04-.26.008-.068.017-.12.025-.155.009-.06.013-.117.013-.169.044-.293.087-.574.13-.842.104-.787.216-1.595.337-2.425l.117-.843a4.64 4.64 0 0 1 .039-.246l.013-.13.025-.142c.035-.225.061-.415.078-.57a.335.335 0 0 0 .013-.105l.013-.064.013-.065a2.54 2.54 0 0 1 .04-.285l.038-.26.039-.26c.017-.172.043-.37.078-.595l.026-.13.013-.13.039-.233.363-2.606c.051-.337.108-.726.168-1.167.017-.139.043-.325.078-.558l.039-.272.039-.26.116-.881h-1.931V8.532h1.957V2.904h-1.957V0Zm7.26 0h7.34v1.815h-5.317V8.22a96.79 96.79 0 0 1 1.919-.557l.233-.078.22-.065c.182-.052.33-.09.442-.116.328-.104.613-.186.855-.247.165-.052.342-.103.532-.155.07-.026.147-.052.233-.078l.117-.026.104-.039c.104-.026.199-.052.285-.078l.104-.026a.501.501 0 0 1 .065-.026v.013c0 .649.004.994.013 1.038a172.39 172.39 0 0 0-.013 2.333v1.064c0 1.288.004 2.442.013 3.462a7.516 7.516 0 0 0-.013.583L27.946 16.7v5.485h5.316V24h-7.339V0Zm2.023 14.807 3.099-.895V9.206l-3.099.895v4.706ZM39.37 0c.008 0 .016.009.025.026v3.267c0 .018-.009.03-.026.04h-2.152c-.009 0-.017-.014-.026-.04V.026c0-.009.008-.017.026-.026h2.152Zm5.47 0c.018 0 .027.009.027.026l-.208 1.141c.009.009.013.022.013.039h6.704c.008 0 .021.009.038.026v1.75c0 .009-.013.018-.038.026h-7.04c-.736 4.218-1.107 6.384-1.116 6.496h2.256V4.707c0-.018.009-.03.026-.04h2.152c.018 0 .03.014.04.04v4.797h3.474c.017 0 .03.013.039.039v1.737c0 .018-.013.03-.039.04h-3.475v2.813l3.423-.7.013.013v2.035c0 .026-.168.065-.505.117-.087.026-1.064.233-2.93.622v4.5c0 .008-.014.017-.04.026h-2.152c-.009 0-.017-.01-.026-.026v-4.046h-.013c-.017 0-1.5.307-4.447.92 0-.017-.005-.025-.013-.025v-2.023c0-.026.276-.09.83-.194.008-.009 1.223-.26 3.643-.752v-3.28h-4.434c-.009 0-.022-.014-.04-.04V9.57c.018-.121.394-2.308 1.129-6.56h-1.297c-.008 0-.017-.01-.026-.027v-1.75c0-.009.009-.017.026-.026h1.608c.009 0 .065-.285.169-.856.017-.233.047-.35.09-.35h2.14Zm-5.108 5.29c.017 0 .03.013.04.04v15.22c0 .043.09.138.271.285.96.9 1.457 1.349 1.491 1.349h9.816c.017 0 .03.013.038.039v1.737c0 .017-.012.03-.038.039H40.277c-.035 0-.22-.164-.558-.493a27.372 27.372 0 0 1-.428-.376h-.026c-.147.484-.237.77-.272.856a.115.115 0 0 1-.052.013h-.233c-.58 0-1.202-.004-1.867-.013l-.013-.026c.458-1.53.7-2.368.726-2.515V7.106h-.674c-.044 0-.065-.022-.065-.065V5.329c0-.017.013-.03.039-.039h2.878Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.9 KiB |
7
apps/web/public/brand/logo.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="56" height="56" fill="none" viewBox="0 0 56 56">
|
||||
<circle cx="28" cy="28" r="28" fill="#192B4D" fill-opacity=".7"/>
|
||||
<path fill="#0F5" d="m18.706 30.834.799.943 3.2-3.777-3.2-3.777-.8.943L21.109 28l-2.402 2.834Z"/>
|
||||
<path fill="#0F5" d="M42.785 44h-28.63c-1.934 0-3.508-1.463-3.508-3.261V19.167c0-1.799 1.574-3.262 3.509-3.262h28.63c1.934 0 3.508 1.463 3.508 3.261V40.74c0 1.798-1.574 3.261-3.508 3.261Zm-28.63-25.684c-.537 0-.975.381-.975.85V40.74c0 .47.438.85.976.85h28.63c.538 0 .975-.38.975-.85V19.167c0-.47-.438-.851-.976-.851h-28.63ZM28.47 8c1.345 0 2.435 1.038 2.435 2.317 0 1.28-1.09 2.317-2.434 2.317-1.345 0-2.435-1.037-2.435-2.317 0-1.28 1.09-2.317 2.435-2.317Z"/>
|
||||
<path fill="#0F5" d="M28.47 17.736c-.7 0-1.266-.54-1.266-1.206v-4.017c0-.665.567-1.205 1.267-1.205s1.266.54 1.266 1.205v4.017c0 .666-.567 1.206-1.266 1.206ZM7.268 36.88c-.7 0-1.267-.54-1.267-1.205V24.963c0-.666.567-1.206 1.267-1.206s1.266.54 1.266 1.206v10.712c0 .665-.567 1.205-1.266 1.205Zm42.407 0c-.7 0-1.266-.54-1.266-1.205V24.963c0-.666.567-1.206 1.267-1.206s1.266.54 1.266 1.206v10.712c0 .665-.567 1.205-1.267 1.205Zm-25.087-2.762c.76 1.26 2.161 1.423 3.765 1.423s3.005-.163 3.764-1.423v2c-.759 1.26-2.16 1.294-3.764 1.294-1.604 0-3.006-.035-3.765-1.294v-2Z"/>
|
||||
<circle cx="35.647" cy="28" r="1.882" fill="#0F5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
3
apps/web/public/brand/remoteconn.svg
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
3
apps/web/public/icons/about.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
|
||||
<path fill="#FFC16E" d="M11 22c6.075 0 11-4.925 11-11S17.075 0 11 0 0 4.925 0 11s4.925 11 11 11ZM9.281 5.5a1.719 1.719 0 0 1 3.438 0v.687a1.719 1.719 0 0 1-3.438 0V5.5Zm0 5.5a1.719 1.719 0 0 1 3.438 0v5.5a1.719 1.719 0 0 1-3.438 0V11Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 343 B |
4
apps/web/public/icons/back.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="#fff" d="M17.053 10.8h-7.24l3.043-3.045a1.216 1.216 0 0 0 .024-1.718 1.216 1.216 0 0 0-1.72.02l-5.064 5.066c-.011.01-.026.013-.037.024a1.185 1.185 0 0 0-.342.866c0 .305.112.611.344.841.01.01.022.013.032.021l5.067 5.067c.482.48 1.251.493 1.72.024a1.216 1.216 0 0 0-.024-1.718L9.811 13.2h7.242c.68 0 1.232-.538 1.232-1.2 0-.662-.552-1.2-1.232-1.2Z"/>
|
||||
<path fill="#fff" d="M12 0A11.998 11.998 0 0 0 0 12c0 6.629 5.371 12 12 12s12-5.371 12-12S18.629 0 12 0Zm0 21.6a9.599 9.599 0 1 1 0-19.198A9.599 9.599 0 0 1 12 21.6Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 637 B |
3
apps/web/public/icons/cancel.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
|
||||
<path fill="#FFC16E" d="M10.937 0C4.897 0 0 4.896 0 10.937c0 6.04 4.896 10.936 10.937 10.936 6.04 0 10.936-4.896 10.936-10.936S16.977 0 10.937 0Zm5.694 14.507a1.364 1.364 0 0 1 0 1.923l-.481.48a1.364 1.364 0 0 1-1.923 0l-3.43-3.43-3.43 3.43a1.364 1.364 0 0 1-1.924 0l-.48-.48a1.364 1.364 0 0 1 0-1.923l3.43-3.43-3.71-3.71a1.364 1.364 0 0 1 0-1.924l.48-.48a1.364 1.364 0 0 1 1.924 0l3.71 3.71 3.71-3.71a1.364 1.364 0 0 1 1.923 0l.48.48a1.364 1.364 0 0 1 0 1.923l-3.71 3.71 3.43 3.43Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 591 B |
3
apps/web/public/icons/clear-input.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="24" fill="none" viewBox="0 0 22 24">
|
||||
<path fill="#FFC16E" d="M14.071.652a3.268 3.268 0 0 0-1.694-.614c-.444-.03-.89-.042-1.335-.036h-.27c-.43-.008-.862.004-1.292.036a3.266 3.266 0 0 0-1.726.64c-.346.276-.633.62-.841 1.011-.2.35-.398.786-.622 1.28l-.39.85h-4.81a1.091 1.091 0 1 0 0 2.183h.818V19.91A4.09 4.09 0 0 0 6 24h9.818a4.09 4.09 0 0 0 4.09-4.09V6.002h.818a1.092 1.092 0 0 0 0-2.18h-4.71l-.465-.961c-.195-.42-.408-.833-.638-1.235a3.254 3.254 0 0 0-.841-.974Zm-.48 3.17H8.3c.154-.358.323-.708.507-1.051a1.003 1.003 0 0 1 .87-.56c.291-.026.67-.026 1.27-.026.586 0 .955 0 1.237.026a1.004 1.004 0 0 1 .859.539c.144.237.303.56.55 1.071Zm-5.41 14.41a.818.818 0 0 1-.818-.817V10.87a.818.818 0 0 1 1.636 0v6.545a.818.818 0 0 1-.818.818Zm6.273-7.362v6.545a.818.818 0 0 1-1.636 0V10.87a.818.818 0 0 1 1.636 0Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 876 B |
3
apps/web/public/icons/clear.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="23" fill="none" viewBox="0 0 24 23">
|
||||
<path fill="#E6F0FF" d="M22.313 6.835c0-1.14-.91-2.05-2.05-2.05h-4.098V1.94c0-1.14-.89-1.94-2.03-1.94-1.14 0-2.07.8-2.07 1.94v2.846H8.199c-1.14 0-2.05.91-2.05 2.05v2.049h16.274v-2.05h-.11Zm0 4.1H6.039S4.899 23 0 23h5.809c2.28 0 3.298-6.597 3.298-6.597s1.019 5.918.11 6.597h3.528c1.589-.23 1.819-7.506 1.819-7.506s1.589 7.397 1.37 7.397h-3.189 5.009c1.479-.34 1.588-5.688 1.588-5.688s.679 5.688.46 5.688h-2.158 2.729c4.098.109 4.329-5.578 1.94-11.957Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 559 B |
4
apps/web/public/icons/codex.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="22" fill="none" viewBox="0 0 24 22">
|
||||
<path fill="#E6F0FF" d="M9.7 8.708c-.15-.458-.25-.916-.4-1.375-.3 1.146-.65 2.384-1 3.484l-.35 1.1h2.65l-.35-1.1a32.72 32.72 0 0 0-.55-2.109Z"/>
|
||||
<path fill="#E6F0FF" d="M24 6.417v-5.5C24 .412 23.55 0 23 0h-6v1.833H7V0H1C.45 0 0 .412 0 .917v5.5h2v9.166H0v5.5C0 21.588.45 22 1 22h6v-1.833h10V22h6c.55 0 1-.413 1-.917v-5.5h-2V6.417h2Zm-9.3 10.129-.05.046H12.1c-.05 0-.05 0-.05-.046l-.85-2.796H7.45l-.85 2.796c0 .046-.05.046-.05.046H4.1s-.05 0-.05-.046V16.5L7.9 5.454c0-.046.05-.046.05-.046h2.85c.05 0 .05 0 .05.046L14.7 16.5v.046Zm3.85-.046c0 .046-.05.046-.1.046h-2.4c-.05 0-.1-.046-.1-.046V5.454c0-.046.05-.046.1-.046h2.4c.05 0 .1.046.1.046V16.5Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 755 B |
3
apps/web/public/icons/config.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="#E6F0FF" d="M22.286 9.429h-1.213a9.45 9.45 0 0 0-.857-2.023l.857-.857a1.715 1.715 0 0 0 0-2.422L19.877 2.91a1.714 1.714 0 0 0-2.421 0l-.857.857a9.226 9.226 0 0 0-2.028-.836V1.714A1.713 1.713 0 0 0 12.857 0h-1.714a1.714 1.714 0 0 0-1.714 1.714v1.217a9.227 9.227 0 0 0-2.023.836l-.857-.857a1.714 1.714 0 0 0-2.422 0L2.91 4.123a1.714 1.714 0 0 0 0 2.421l.857.857a9.45 9.45 0 0 0-.857 2.023H1.714A1.714 1.714 0 0 0 0 11.14v1.714a1.714 1.714 0 0 0 1.714 1.714h1.213a9.45 9.45 0 0 0 .857 2.023l-.857.857a1.714 1.714 0 0 0 0 2.422l1.213 1.21a1.714 1.714 0 0 0 2.421 0l.858-.857c.634.36 1.309.644 2.01.845v1.217A1.714 1.714 0 0 0 11.143 24h1.714a1.713 1.713 0 0 0 1.714-1.714v-1.217a9.233 9.233 0 0 0 2.023-.836l.857.857a1.714 1.714 0 0 0 2.422 0l1.213-1.213a1.714 1.714 0 0 0 0-2.421l-.857-.857a9.45 9.45 0 0 0 .857-2.023h1.2A1.714 1.714 0 0 0 24 12.86v-1.718a1.714 1.714 0 0 0-1.714-1.714Zm-7.822 4.954a3.429 3.429 0 1 1-4.93-4.767 3.429 3.429 0 0 1 4.93 4.767Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
7
apps/web/public/icons/connect.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
|
||||
<path fill="#E6F0FF" d="M11.023 0c6.08.04 10.993 4.985 10.964 11.031C21.957 17.103 17 22.035 10.96 22 4.86 21.966-.057 16.988 0 10.908.06 4.868 5.02-.04 11.024 0ZM4.145 14.27c.096 1.317.73 2.444 2.067 3.066 1.308.61 2.626.497 3.711-.45 1.142-.995 2.204-2.094 3.21-3.229.831-.938.926-2.095.55-3.277-.247-.77-.71-1.066-1.318-.872-.575.185-.77.655-.556 1.417.18.643.135 1.24-.344 1.732-.864.89-1.727 1.783-2.63 2.63-.696.65-1.661.612-2.268-.013-.625-.642-.628-1.629.01-2.33.308-.34.655-.646.968-.982.4-.428.398-1.026.018-1.397-.362-.352-.942-.363-1.355.019a17.04 17.04 0 0 0-1.22 1.24c-.574.658-.835 1.444-.843 2.446Zm4.118-3.929c.014.135.027.396.072.65.03.176.093.347.159.514.254.642.763.924 1.302.73.497-.178.725-.72.525-1.343-.244-.767-.076-1.41.488-1.973.793-.79 1.575-1.594 2.374-2.38.687-.676 1.679-.714 2.318-.108.665.629.682 1.635.025 2.37-.293.328-.625.62-.925.942-.426.458-.437.999-.047 1.39.406.407.947.434 1.387.014.462-.442.92-.895 1.312-1.396 1.093-1.394.914-3.464-.372-4.695-1.305-1.25-3.353-1.363-4.696-.181-1.034.91-2.005 1.896-2.966 2.885-.65.671-.95 1.516-.956 2.581Z"/>
|
||||
<path fill="#E6F0FF" d="M20.166 5.268c0-.11-.002-.219-.004-.328A11.05 11.05 0 0 0 11.022 0C5.019-.04.059 4.87 0 10.908c-.037 3.828 1.898 7.217 4.86 9.212.148.004.297.006.445.006 8.207.002 14.86-6.65 14.86-14.858Zm-7.033 8.39c-1.007 1.133-2.068 2.233-3.21 3.23-1.085.945-2.404 1.057-3.711.449-1.338-.622-1.97-1.75-2.067-3.067.007-1.002.269-1.788.845-2.444a17.04 17.04 0 0 1 1.219-1.24c.413-.382.993-.373 1.355-.02.38.373.382.97-.018 1.399-.313.336-.66.643-.969.983-.637.7-.634 1.687-.009 2.33.607.624 1.574.663 2.267.011.905-.847 1.766-1.74 2.63-2.629.48-.492.526-1.09.345-1.732-.215-.762-.019-1.232.556-1.417.607-.194 1.072.102 1.317.872.376 1.18.281 2.337-.55 3.275Zm1.421-2.524c-.391-.392-.38-.933.047-1.39.3-.323.632-.615.925-.943.657-.735.64-1.74-.025-2.37-.638-.604-1.631-.566-2.318.108-.8.786-1.58 1.59-2.374 2.38-.564.561-.732 1.206-.488 1.973.199.624-.028 1.164-.525 1.343-.54.194-1.048-.09-1.302-.73a2.59 2.59 0 0 1-.16-.513c-.044-.255-.057-.516-.07-.65.006-1.065.306-1.909.957-2.58.961-.99 1.933-1.974 2.967-2.885 1.343-1.184 3.39-1.07 4.696.18 1.286 1.231 1.465 3.302.372 4.696-.393.5-.852.954-1.312 1.396-.443.418-.984.39-1.39-.015Z"/>
|
||||
<path fill="#E6F0FF" d="M11.73 10.517c-.045-.524.17-.86.635-1.008.109-.036.223-.056.337-.058a14.81 14.81 0 0 0 2.111-3.4c-.545-.138-1.16.025-1.629.488-.799.786-1.58 1.59-2.374 2.38-.564.561-.732 1.206-.487 1.973.105.33.09.634-.02.876.503-.387.98-.804 1.427-1.251Z"/>
|
||||
<path fill="#E6F0FF" d="M.002 10.908c-.014 1.342.22 2.674.69 3.93 1.169.044 2.339-.05 3.486-.279a4.1 4.1 0 0 1-.031-.291c.006-1.003.268-1.789.845-2.445a17.04 17.04 0 0 1 1.218-1.24c.413-.382.994-.373 1.355-.019.381.373.382.97-.017 1.398-.313.336-.66.643-.97.983-.293.324-.45.708-.473 1.09a14.72 14.72 0 0 0 3.485-1.75c-.46.063-.875-.223-1.095-.781a2.593 2.593 0 0 1-.159-.513c-.044-.255-.058-.516-.071-.651.006-1.065.306-1.908.958-2.58.96-.988 1.932-1.974 2.966-2.885.925-.815 2.183-1.015 3.303-.652.283-.955.474-1.95.559-2.976A11.018 11.018 0 0 0 11.026.002C5.02-.04.06 4.869.002 10.908Z"/>
|
||||
<path fill="#E6F0FF" d="M.127 9.362c5.187-.924 9.441-4.54 11.27-9.35A15.24 15.24 0 0 0 11.023 0C5.543-.036.933 4.053.127 9.362Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
10
apps/web/public/icons/copy.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
|
||||
<path
|
||||
fill="#67D1FF"
|
||||
d="M8.25 1.833c0-1.012.821-1.833 1.833-1.833h8.25c1.012 0 1.833.821 1.833 1.833v10.084a1.833 1.833 0 0 1-1.833 1.833H17.05V1.833H8.25Z"
|
||||
/>
|
||||
<path
|
||||
fill="#67D1FF"
|
||||
d="M3.667 4.583c0-1.012.821-1.833 1.833-1.833h8.25c1.012 0 1.833.821 1.833 1.833v13.75A1.833 1.833 0 0 1 13.75 20.167H5.5a1.833 1.833 0 0 1-1.833-1.834V4.583Zm2.75 1.834v1.833H12.833V6.417H6.417Zm0 4.583v1.833H12.833V11H6.417Zm0 4.583v1.834h4.583v-1.834H6.417Z"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 573 B |
3
apps/web/public/icons/create.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
|
||||
<path fill="#E6F0FF" d="M11.005 0C4.93 0 0 4.924 0 11c0 6.074 4.93 11 11.005 11 6.071 0 11-4.926 11-11 0-6.076-4.929-11-11-11Zm5.5 12.373h-4.129V16.5H9.63v-4.127H5.506v-2.75h4.123V5.5h2.747v4.124h4.13v2.75Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 315 B |
3
apps/web/public/icons/delete.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
|
||||
<path fill="#E6F0FF" d="M11.005 0C4.93 0 0 4.924 0 11c0 6.074 4.93 11 11.005 11 6.071 0 11-4.926 11-11 0-6.076-4.929-11-11-11Zm5.5 12.373H5.506v-2.75h11v2.75Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 267 B |
3
apps/web/public/icons/enter.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="21" height="17" fill="none" viewBox="0 0 21 17">
|
||||
<path fill="#FFC16E" stroke="#FFC16E" stroke-width=".5" d="m14.422.25.312.008a6.495 6.495 0 0 1 4.156 1.75c1.187 1.126 1.858 2.655 1.86 4.254l-.008.3a5.89 5.89 0 0 1-1.852 3.955 6.509 6.509 0 0 1-4.468 1.757H3.134l3.248 3.079H6.38a.783.783 0 0 1 .016 1.166.846.846 0 0 1-.603.231.867.867 0 0 1-.588-.247L.5 12.044a.795.795 0 0 1-.25-.576c0-.219.092-.425.25-.575L5.206 6.43l.007-.006a.857.857 0 0 1 1.154.02.794.794 0 0 1 .25.56.792.792 0 0 1-.23.57l-.005.007H6.38l-3.246 3.077h11.287a4.793 4.793 0 0 0 3.295-1.292 4.278 4.278 0 0 0 1.357-3.104 4.278 4.278 0 0 0-1.357-3.105 4.794 4.794 0 0 0-3.295-1.293H5.793a.855.855 0 0 1-.588-.231.794.794 0 0 1-.25-.576c0-.219.092-.426.25-.577a.855.855 0 0 1 .588-.23h8.629Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 821 B |
20
apps/web/public/icons/keyboard-arrows.svg
Normal file
@@ -0,0 +1,20 @@
|
||||
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 76 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Frame 2249">
|
||||
<g id="Vector">
|
||||
<path d="M38 20C32.477 20 28 15.523 28 10C28 4.477 32.477 0 38 0C43.523 0 48 4.477 48 10C48 15.523 43.523 20 38 20ZM38 18C42.4185 18 46 14.4185 46 10C46 5.5815 42.4185 2 38 2C33.5815 2 30 5.5815 30 10C30 14.4185 33.5815 18 38 18Z" fill="#FFC16E"/>
|
||||
<path d="M37.2855 7.3395C37.3758 7.23686 37.4862 7.15382 37.6098 7.09552C37.7335 7.03722 37.8678 7.00489 38.0044 7.00053C38.1411 6.99617 38.2772 7.01988 38.4043 7.07018C38.5314 7.12049 38.6468 7.19632 38.7435 7.293L42.279 10.828C42.3719 10.9208 42.4456 11.0311 42.4959 11.1524C42.5462 11.2737 42.5722 11.4038 42.5722 11.5351C42.5723 11.6665 42.5464 11.7966 42.4962 11.9179C42.446 12.0393 42.3723 12.1496 42.2795 12.2425C42.1867 12.3354 42.0764 12.4091 41.9551 12.4594C41.8338 12.5097 41.7037 12.5357 41.5724 12.5357C41.441 12.5358 41.3109 12.5099 41.1896 12.4597C41.0682 12.4095 40.9579 12.3358 40.865 12.243L38.038 9.4155L35.207 12.2465C35.0194 12.434 34.765 12.5393 34.4998 12.5393C34.2346 12.5392 33.9803 12.4338 33.7928 12.2463C33.6052 12.0587 33.4999 11.8043 33.5 11.5391C33.5 11.2739 33.6054 11.0195 33.793 10.832L37.2855 7.3395Z" fill="#FFC16E"/>
|
||||
</g>
|
||||
<g id="Vector_2">
|
||||
<path d="M56 38C56 32.477 60.477 28 66 28C71.523 28 76 32.477 76 38C76 43.523 71.523 48 66 48C60.477 48 56 43.523 56 38ZM58 38C58 42.4185 61.5815 46 66 46C70.4185 46 74 42.4185 74 38C74 33.5815 70.4185 30 66 30C61.5815 30 58 33.5815 58 38Z" fill="#FFC16E"/>
|
||||
<path d="M68.6605 37.2855C68.7631 37.3758 68.8462 37.4862 68.9045 37.6098C68.9628 37.7335 68.9951 37.8678 68.9995 38.0044C69.0038 38.1411 68.9801 38.2772 68.9298 38.4043C68.8795 38.5314 68.8037 38.6468 68.707 38.7435L65.172 42.279C65.0792 42.3719 64.9689 42.4456 64.8476 42.4959C64.7263 42.5462 64.5962 42.5722 64.4649 42.5722C64.3335 42.5723 64.2034 42.5464 64.0821 42.4962C63.9607 42.446 63.8504 42.3723 63.7575 42.2795C63.6646 42.1867 63.5909 42.0764 63.5406 41.9551C63.4903 41.8338 63.4643 41.7037 63.4643 41.5724C63.4642 41.441 63.4901 41.3109 63.5403 41.1896C63.5905 41.0682 63.6642 40.9579 63.757 40.865L66.5845 38.038L63.7535 35.207C63.566 35.0194 63.4607 34.765 63.4607 34.4998C63.4608 34.2346 63.5662 33.9803 63.7537 33.7928C63.9413 33.6052 64.1957 33.4999 64.4609 33.5C64.7261 33.5 64.9805 33.6054 65.168 33.793L68.6605 37.2855Z" fill="#FFC16E"/>
|
||||
</g>
|
||||
<g id="Vector_3">
|
||||
<path d="M20 38C20 43.523 15.523 48 10 48C4.477 48 0 43.523 0 38C0 32.477 4.477 28 10 28C15.523 28 20 32.477 20 38ZM18 38C18 33.5815 14.4185 30 10 30C5.5815 30 2 33.5815 2 38C2 42.4185 5.5815 46 10 46C14.4185 46 18 42.4185 18 38Z" fill="#FFC16E"/>
|
||||
<path d="M7.3395 38.7145C7.23686 38.6242 7.15382 38.5138 7.09552 38.3902C7.03722 38.2665 7.00489 38.1322 7.00053 37.9956C6.99617 37.8589 7.01988 37.7228 7.07018 37.5957C7.12049 37.4686 7.19632 37.3532 7.293 37.2565L10.828 33.721C10.9208 33.6281 11.0311 33.5544 11.1524 33.5041C11.2737 33.4538 11.4038 33.4278 11.5351 33.4278C11.6665 33.4277 11.7966 33.4536 11.9179 33.5038C12.0393 33.554 12.1496 33.6277 12.2425 33.7205C12.3354 33.8133 12.4091 33.9236 12.4594 34.0449C12.5097 34.1662 12.5357 34.2963 12.5357 34.4276C12.5358 34.559 12.5099 34.6891 12.4597 34.8104C12.4095 34.9318 12.3358 35.0421 12.243 35.135L9.4155 37.962L12.2465 40.793C12.434 40.9806 12.5393 41.235 12.5393 41.5002C12.5392 41.7654 12.4338 42.0197 12.2463 42.2072C12.0587 42.3948 11.8043 42.5001 11.5391 42.5C11.2739 42.5 11.0195 42.3946 10.832 42.207L7.3395 38.7145Z" fill="#FFC16E"/>
|
||||
</g>
|
||||
<g id="Vector_4">
|
||||
<path d="M38 28C43.523 28 48 32.477 48 38C48 43.523 43.523 48 38 48C32.477 48 28 43.523 28 38C28 32.477 32.477 28 38 28ZM38 30C33.5815 30 30 33.5815 30 38C30 42.4185 33.5815 46 38 46C42.4185 46 46 42.4185 46 38C46 33.5815 42.4185 30 38 30Z" fill="#FFC16E"/>
|
||||
<path d="M38.7145 40.6605C38.6242 40.7631 38.5138 40.8462 38.3902 40.9045C38.2665 40.9628 38.1322 40.9951 37.9956 40.9995C37.8589 41.0038 37.7228 40.9801 37.5957 40.9298C37.4686 40.8795 37.3532 40.8037 37.2565 40.707L33.721 37.172C33.6281 37.0792 33.5544 36.9689 33.5041 36.8476C33.4538 36.7263 33.4278 36.5962 33.4278 36.4649C33.4277 36.3335 33.4536 36.2034 33.5038 36.0821C33.554 35.9607 33.6277 35.8504 33.7205 35.7575C33.8133 35.6646 33.9236 35.5909 34.0449 35.5406C34.1662 35.4903 34.2963 35.4643 34.4276 35.4643C34.559 35.4642 34.6891 35.4901 34.8104 35.5403C34.9318 35.5905 35.0421 35.6642 35.135 35.757L37.962 38.5845L40.793 35.7535C40.9806 35.566 41.235 35.4607 41.5002 35.4607C41.7654 35.4608 42.0197 35.5662 42.2072 35.7537C42.3948 35.9413 42.5001 36.1957 42.5 36.4609C42.5 36.7261 42.3946 36.9805 42.207 37.168L38.7145 40.6605Z" fill="#FFC16E"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
3
apps/web/public/icons/keyboard.svg
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
6
apps/web/public/icons/log.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
|
||||
<path fill="#FFC16E" fill-rule="evenodd" d="M11.299 14.804c1.045 0 1.901.316 2.545.961.646.647.956 1.537.956 2.646 0 1.031-.271 1.873-.833 2.506l-.116.124c-.64.645-1.491.959-2.535.959-.987 0-1.803-.275-2.429-.838l-.122-.116c-.64-.642-.95-1.518-.95-2.603 0-.69.105-1.282.327-1.769.164-.356.39-.676.672-.959.196-.2.422-.37.669-.505l.264-.128.016-.005c.45-.184.964-.273 1.536-.273Zm2.181 6.32a2.888 2.888 0 0 1 .119-.101l-.119.101Zm-4.865-.544a2.745 2.745 0 0 0-.017-.024l.017.024Zm2.697-4.265c-.517 0-.92.168-1.227.502-.303.329-.471.844-.471 1.58 0 .723.171 1.237.485 1.576.318.346.718.517 1.213.517.496 0 .893-.17 1.206-.511.308-.335.479-.857.479-1.599 0-.642-.128-1.114-.36-1.439l-.105-.13c-.301-.329-.702-.496-1.22-.496Zm-3.29 1.702c-.003.034-.004.069-.006.104l.01-.162-.004.058Zm5.896-1.886a2.836 2.836 0 0 1-.102-.121l.102.121Zm4.908-1.327c.868 0 1.575.176 2.094.551.515.373.845.888.99 1.535l.042.187-.194.035-1.365.247-.174.032-.046-.166a1.225 1.225 0 0 0-.466-.665l-.09-.058a1.542 1.542 0 0 0-.79-.19c-.557 0-.98.17-1.293.495v-.001c-.31.323-.48.818-.48 1.518 0 .665.134 1.158.38 1.5l.11.138.002.001c.318.349.735.525 1.267.525.263 0 .527-.049.795-.151h.003a2.79 2.79 0 0 0 .62-.323v-.555h-1.318l-.258.188v-1.67H22v2.894l-.058.055c-.313.293-.755.543-1.315.754v-.001A4.786 4.786 0 0 1 18.9 22h-.001c-.74-.001-1.394-.15-1.957-.458a3.006 3.006 0 0 1-1.264-1.308l-.004-.009-.003-.007-.097-.214a4.038 4.038 0 0 1-.316-1.355l-.005-.233v-.038c0-.714.155-1.355.468-1.92a3.177 3.177 0 0 1 1.37-1.299l.007-.003.018-.008c.47-.233 1.043-.344 1.71-.344Zm-3.19 2.321-.01.034a2.55 2.55 0 0 1 .07-.197l-.06.163Z" clip-rule="evenodd"/>
|
||||
<path fill="#FFC16E" d="M4.079 14.97v5.435h3.417v1.483H2.051l.272-.263V14.97h1.756Z"/>
|
||||
<path fill="#FFC16E" fill-rule="evenodd" d="M16.812.004c1.383.048 2.591 1.306 2.591 2.734v11.467h-1.957V2.501a.6.6 0 0 0-.227-.444.777.777 0 0 0-.489-.194H2.562c-.315 0-.596.272-.596.638v19.186l-.002.183H0l.008-.193c.012-.254.013-.867.01-1.422a170.26 170.26 0 0 0-.006-.727c0-.095-.002-.173-.003-.227V2.736C.008 1.294 1.132 0 2.56 0h14.248l.003.004Z" clip-rule="evenodd"/>
|
||||
<path fill="#FFC16E" d="M16.24 11.13H3.177V9.22H16.24v1.91Zm0-4.61H3.177V4.61H16.24v1.91Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
3
apps/web/public/icons/paste.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="19" height="20" fill="none" viewBox="0 0 19 20">
|
||||
<path fill="#FFC16E" d="M17 9h-7a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2Zm1-2V4a2 2 0 0 0-2-2h-2a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2H2a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4V9a2 2 0 0 1 2-2h10ZM6 4V2h6v2H6Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 326 B |
6
apps/web/public/icons/plugins.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22.0016 22">
|
||||
<path
|
||||
fill="#E6F0FF"
|
||||
d="M9.00219 0C9.48228 3.80706e-05 9.95536 0.11529 10.3817 0.336074C10.808 0.556857 11.1751 0.876725 11.4522 1.26881C11.7292 1.66089 11.9081 2.11374 11.9739 2.58931C12.0396 3.06488 11.9903 3.54929 11.83 4.00184H16.0011C17.1068 4.00184 18.0013 4.89631 18.0013 6.00042V10.1715C18.4538 10.0115 18.9381 9.96244 19.4135 10.0284C19.889 10.0943 20.3416 10.2734 20.7335 10.5505C21.1254 10.8276 21.4451 11.1947 21.6658 11.6209C21.8864 12.0472 22.0016 12.5202 22.0016 13.0001C22.0016 13.4801 21.8864 13.9531 21.6658 14.3793C21.4451 14.8056 21.1254 15.1727 20.7335 15.4498C20.3416 15.7269 19.889 15.906 19.4135 15.9719C18.9381 16.0378 18.4538 15.9888 18.0013 15.8288V19.9999C18.0013 21.104 17.1068 22 16.0011 22H11.8316C11.9912 21.5475 12.0399 21.0634 11.9737 20.5882C11.9075 20.113 11.7283 19.6606 11.4512 19.269C11.174 18.8774 10.807 18.5579 10.3809 18.3374C9.95475 18.1169 9.48197 18.0019 9.00219 18.0019C8.52241 18.0019 8.04962 18.1169 7.6235 18.3374C7.19738 18.5579 6.83034 18.8774 6.55319 19.269C6.27604 19.6606 6.09685 20.113 6.03066 20.5882C5.96447 21.0634 6.01322 21.5475 6.1728 22H2.00014C1.46967 22 0.960927 21.7893 0.585828 21.4142C0.210729 21.0391 0 20.5303 0 19.9999V15.8288C0.452526 15.9888 0.936835 16.0378 1.41226 15.9719C1.88769 15.906 2.34036 15.7269 2.73226 15.4498C3.12416 15.1727 3.44386 14.8056 3.66451 14.3793C3.88515 13.9531 4.00031 13.4801 4.00031 13.0001C4.00031 12.5202 3.88515 12.0472 3.66451 11.6209C3.44386 11.1947 3.12416 10.8276 2.73226 10.5505C2.34036 10.2734 1.88769 10.0943 1.41226 10.0284C0.936835 9.96244 0.452526 10.0115 0 10.1715V6.00042C0 4.89631 0.896026 4.00028 2.00014 4.00028H6.17124C6.01145 3.54754 5.96265 3.06305 6.02894 2.58753C6.09523 2.11201 6.27467 1.65935 6.55219 1.26756C6.82971 0.875777 7.1972 0.556317 7.6238 0.33602C8.05039 0.115722 8.52362 0.0010211 9.00374 0.00155293L9.00219 0Z"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
3
apps/web/public/icons/save.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 20 20">
|
||||
<path fill="#E6F0FF" d="M1.111 0h14.445l4.118 4.119c.209.208.326.49.326.785V18.89A1.111 1.111 0 0 1 18.889 20H1.11A1.111 1.111 0 0 1 0 18.889V1.11A1.111 1.111 0 0 1 1.111 0ZM10 16.667A3.333 3.333 0 1 0 10 10a3.333 3.333 0 0 0 0 6.667ZM2.222 2.222v4.445h11.111V2.222H2.223Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 381 B |
6
apps/web/public/icons/search.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none" viewBox="0 0 15 15">
|
||||
<path
|
||||
fill="#E6F0FF"
|
||||
d="M9.322 8.255h-.564l-.2-.194a4.648 4.648 0 1 0-.5.5l.194.2v.564l3.57 3.563 1.064-1.064-3.563-3.57Zm-4.288 0a3.222 3.222 0 1 1 0-6.444 3.222 3.222 0 0 1 0 6.444Z"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 303 B |
3
apps/web/public/icons/selectall.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
|
||||
<path fill="#E6F0FF" fill-rule="evenodd" d="M20.428 16.62c.866 0 1.572.602 1.572 1.346v2.688c0 .742-.703 1.346-1.572 1.346H1.572C.706 22 0 21.398 0 20.654v-2.691c0-.742.703-1.344 1.572-1.344h18.856Zm-4.086 2.61-1.292-1.098-1.05.891L16.326 21 20 17.879 18.966 17l-2.624 2.23ZM5.235 17.963c-.866 0-1.572.601-1.572 1.346 0 .744.703 1.345 1.572 1.345.867 0 1.574-.601 1.574-1.345 0-.745-.704-1.346-1.574-1.346Zm15.193-9.651c.866 0 1.572.601 1.572 1.345v2.689c0 .741-.703 1.345-1.572 1.345H1.572C.706 13.691 0 13.09 0 12.346V9.654c0-.741.703-1.342 1.572-1.342h18.856Zm-4.086 2.918-1.292-1.098-1.05.891L16.326 13 20 9.879 18.966 9l-2.624 2.23ZM5.235 9.653c-.866 0-1.572.602-1.572 1.346 0 .744.703 1.346 1.572 1.346.867 0 1.574-.602 1.574-1.346 0-.744-.704-1.346-1.574-1.346ZM20.428 0C21.294 0 22 .602 22 1.346v2.688c0 .742-.703 1.347-1.572 1.347H1.572C.706 5.38 0 4.778 0 4.034V1.343C0 .6.703 0 1.572 0h18.856Zm-4.086 3.23L15.05 2.131 14 3.023 16.326 5 20 1.879 18.966 1l-2.624 2.23ZM5.235 1.342c-.866 0-1.572.602-1.572 1.345 0 .745.703 1.346 1.572 1.346.867 0 1.574-.601 1.574-1.346 0-.744-.704-1.345-1.574-1.345Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
3
apps/web/public/icons/sent.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path fill="#FFC16E" d="m0 24 2.496-11.112 13.8-.864-13.8-.936L0 0l24 12L0 24Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 187 B |
3
apps/web/public/icons/serverlist.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="20" fill="none" viewBox="0 0 22 20">
|
||||
<path fill="#fff" d="M2.2 4.4a2.2 2.2 0 1 0 0-4.4 2.2 2.2 0 0 0 0 4.4ZM8.25.55a1.65 1.65 0 0 0 0 3.3h12.1a1.65 1.65 0 1 0 0-3.3H8.25ZM6.6 9.9a1.65 1.65 0 0 1 1.65-1.65h12.1a1.65 1.65 0 1 1 0 3.3H8.25A1.65 1.65 0 0 1 6.6 9.9Zm0 7.7a1.65 1.65 0 0 1 1.65-1.65h12.1a1.65 1.65 0 1 1 0 3.3H8.25A1.65 1.65 0 0 1 6.6 17.6ZM4.4 9.9a2.2 2.2 0 1 1-4.4 0 2.2 2.2 0 0 1 4.4 0Zm-2.2 9.9a2.2 2.2 0 1 0 0-4.4 2.2 2.2 0 0 0 0 4.4Z" opacity=".99"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 536 B |
5
apps/web/public/icons/share.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none" viewBox="0 0 18 18">
|
||||
<path stroke="#FFC16E" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M10.5 2.25h5.25V7.5"/>
|
||||
<path stroke="#FFC16E" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 3 8.625 9.375"/>
|
||||
<path stroke="#FFC16E" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.75 9.75V12A5.25 5.25 0 0 1 10.5 17.25H6A5.25 5.25 0 0 1 .75 12V7.5A5.25 5.25 0 0 1 6 2.25h2.25"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 531 B |
6
apps/web/public/icons/voice.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="27" height="36" fill="none" viewBox="0 0 27 36">
|
||||
<g opacity="1">
|
||||
<path fill="#FFC16E" d="M13.326 26.661a8.254 8.254 0 0 0 8.246-8.246V8.245a8.246 8.246 0 1 0-16.491 0v10.17a8.254 8.254 0 0 0 8.245 8.246Z"/>
|
||||
<path fill="#FFC16E" d="M22.759 26.932a13.487 13.487 0 0 0 3.894-9.47V11.64a1.47 1.47 0 1 0-2.941 0v5.822a10.453 10.453 0 0 1-10.386 10.513A10.458 10.458 0 0 1 2.941 17.462V11.64a1.47 1.47 0 0 0-2.941 0v5.822a13.483 13.483 0 0 0 3.898 9.47 13.242 13.242 0 0 0 7.966 3.882v2.245h-6.16a1.47 1.47 0 0 0 0 2.941h15.254a1.47 1.47 0 1 0 0-2.94h-6.161v-2.246a13.263 13.263 0 0 0 7.962-3.882Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 666 B |
616
apps/web/src/App.vue
Normal file
@@ -0,0 +1,616 @@
|
||||
<template>
|
||||
<div class="app-shell" :class="{ 'keyboard-open': keyboardOpen }">
|
||||
<section
|
||||
class="app-canvas"
|
||||
:class="{ 'without-bottom-bar': hideBottomFrame, 'bottom-frame-hidden': hideBottomFrame }"
|
||||
:style="{ gridTemplateRows: hideBottomFrame ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) 52px' }"
|
||||
>
|
||||
<main class="screen-content">
|
||||
<RouterView />
|
||||
</main>
|
||||
|
||||
<footer v-if="!hideBottomFrame" class="bottom-bar" :class="{ 'terminal-bottom-bar': isTerminalRoute }">
|
||||
<button
|
||||
class="icon-btn bottom-nav-btn"
|
||||
type="button"
|
||||
title="返回上一页"
|
||||
aria-label="返回上一页"
|
||||
:disabled="!canGoBack"
|
||||
@click="goBack"
|
||||
>
|
||||
<span class="icon-mask" style="--icon: url("/icons/back.svg")" aria-hidden="true"></span>
|
||||
</button>
|
||||
<div class="bottom-right-actions">
|
||||
<button
|
||||
v-for="item in rightNavItems"
|
||||
:key="item.path"
|
||||
class="icon-btn bottom-nav-btn"
|
||||
type="button"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
:title="item.label"
|
||||
:aria-label="item.label"
|
||||
@click="goTo(item.path)"
|
||||
>
|
||||
<span class="icon-mask" :style="`--icon: url(${item.icon})`" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<section class="toast-stack" :class="{ 'terminal-toast': isTerminalRoute }" aria-live="polite">
|
||||
<article
|
||||
v-for="toast in appStore.toasts"
|
||||
:key="toast.id"
|
||||
class="toast-item"
|
||||
:class="`toast-${toast.level}`"
|
||||
>
|
||||
<header class="toast-title">{{ toToastTitle(toast.level) }}</header>
|
||||
<p class="toast-message">{{ toast.message }}</p>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { RouterView, useRoute, useRouter } from "vue-router";
|
||||
import { useSettingsStore } from "@/stores/settingsStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { toToastTitle } from "@/utils/feedback";
|
||||
import {
|
||||
applyThemeChromeColor,
|
||||
resolveThemeChromeColor,
|
||||
type ThemeChromeDocument
|
||||
} from "@/utils/themeChrome";
|
||||
|
||||
const settingsStore = useSettingsStore();
|
||||
const appStore = useAppStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const keyboardOpen = ref(false);
|
||||
const formFieldFocused = ref(false);
|
||||
const touchLikeDevice = ref(false);
|
||||
const canGoBack = ref(false);
|
||||
const inputFocusSessionActive = ref(false);
|
||||
type FocusViewportSession = {
|
||||
target: HTMLElement;
|
||||
scrollContainer: HTMLElement | null;
|
||||
};
|
||||
const focusViewportSession = ref<FocusViewportSession | null>(null);
|
||||
let ensureVisibleRafId: number | null = null;
|
||||
let ensureVisibleRetryTimer: number | null = null;
|
||||
let baselineViewportHeight = 0;
|
||||
const hideGlobalBottomBar = computed(() => /^\/server\/.+\/settings$/.test(route.path));
|
||||
/**
|
||||
* 临时隐藏底栏策略(面向移动端):
|
||||
* - 触屏设备上,只要输入控件聚焦就隐藏底栏(不强依赖 keyboardOpen 判定);
|
||||
* - 兜底保留 keyboardOpen 条件,兼容部分设备触摸特征识别异常。
|
||||
*/
|
||||
const hideBottomBarForKeyboard = computed(
|
||||
() =>
|
||||
inputFocusSessionActive.value && (touchLikeDevice.value || keyboardOpen.value || formFieldFocused.value)
|
||||
);
|
||||
/**
|
||||
* 底部框架层总开关:
|
||||
* - 页面语义要求隐藏(如 server settings);
|
||||
* - 输入态临时隐藏(软键盘场景)。
|
||||
* 命中任一条件时,直接移除整块底部框架与占位行。
|
||||
*/
|
||||
const hideBottomFrame = computed(() => hideGlobalBottomBar.value || hideBottomBarForKeyboard.value);
|
||||
const pluginRuntimeEnabled = import.meta.env.VITE_ENABLE_PLUGIN_RUNTIME !== "false";
|
||||
const isTerminalRoute = computed(() => route.path === "/terminal");
|
||||
type BottomNavItem = { path: string; label: string; icon: string };
|
||||
const recordsNavItem: BottomNavItem = {
|
||||
path: "/records",
|
||||
label: "闪念清单",
|
||||
icon: "/assets/icons/recordmanager.svg?v=2026022701"
|
||||
};
|
||||
const aboutNavItem: BottomNavItem = {
|
||||
path: "/about",
|
||||
label: "关于",
|
||||
icon: "/icons/about.svg"
|
||||
};
|
||||
|
||||
function appendRecordsNavItem(items: BottomNavItem[]): BottomNavItem[] {
|
||||
if (route.path === "/records") {
|
||||
return items;
|
||||
}
|
||||
if (items.some((item) => item.path === recordsNavItem.path)) {
|
||||
return items;
|
||||
}
|
||||
return [...items, recordsNavItem];
|
||||
}
|
||||
|
||||
function appendAboutNavItem(items: BottomNavItem[]): BottomNavItem[] {
|
||||
if (route.path.startsWith("/about")) {
|
||||
return items;
|
||||
}
|
||||
if (items.some((item) => item.path === aboutNavItem.path)) {
|
||||
return items;
|
||||
}
|
||||
return [...items, aboutNavItem];
|
||||
}
|
||||
|
||||
/**
|
||||
* 底部工具条统一约束:关于入口(about.svg)始终排在最后。
|
||||
* 仅调整顺序,不改变各页面实际可见按钮集合。
|
||||
*/
|
||||
function ensureAboutNavItemLast(items: BottomNavItem[]): BottomNavItem[] {
|
||||
const index = items.findIndex((item) => item.path === aboutNavItem.path);
|
||||
if (index < 0 || index === items.length - 1) {
|
||||
return items;
|
||||
}
|
||||
const next = items.slice();
|
||||
const [aboutItem] = next.splice(index, 1);
|
||||
if (!aboutItem) {
|
||||
return items;
|
||||
}
|
||||
next.push(aboutItem);
|
||||
return next;
|
||||
}
|
||||
|
||||
const rightNavItems = computed<BottomNavItem[]>(() => {
|
||||
let items: BottomNavItem[];
|
||||
|
||||
if (route.path === "/connect") {
|
||||
items = appendAboutNavItem(
|
||||
appendRecordsNavItem([
|
||||
{ path: "/settings", label: "全局配置", icon: "/icons/config.svg" },
|
||||
{ path: "/logs", label: "日志", icon: "/icons/log.svg?v=20260227" }
|
||||
])
|
||||
);
|
||||
return ensureAboutNavItemLast(items);
|
||||
}
|
||||
|
||||
if (route.path === "/settings") {
|
||||
const items = [
|
||||
{ path: "/connect", label: "服务器管理", icon: "/icons/serverlist.svg" },
|
||||
{ path: "/logs", label: "日志", icon: "/icons/log.svg?v=20260227" }
|
||||
];
|
||||
|
||||
if (pluginRuntimeEnabled) {
|
||||
items.push({ path: "/plugins", label: "插件管理", icon: "/icons/plugins.svg" });
|
||||
}
|
||||
|
||||
return ensureAboutNavItemLast(appendAboutNavItem(appendRecordsNavItem(items)));
|
||||
}
|
||||
|
||||
if (route.path === "/plugins") {
|
||||
items = appendAboutNavItem(
|
||||
appendRecordsNavItem([
|
||||
{ path: "/connect", label: "服务器管理", icon: "/icons/serverlist.svg" },
|
||||
{ path: "/settings", label: "全局配置", icon: "/icons/config.svg" }
|
||||
])
|
||||
);
|
||||
return ensureAboutNavItemLast(items);
|
||||
}
|
||||
|
||||
if (route.path === "/terminal") {
|
||||
items = appendAboutNavItem(
|
||||
appendRecordsNavItem([
|
||||
{ path: "/connect", label: "服务器管理", icon: "/icons/serverlist.svg" },
|
||||
{ path: "/settings", label: "全局配置", icon: "/icons/config.svg" },
|
||||
{ path: "/logs", label: "日志", icon: "/icons/log.svg?v=20260227" }
|
||||
])
|
||||
);
|
||||
return ensureAboutNavItemLast(items);
|
||||
}
|
||||
|
||||
if (route.path === "/records") {
|
||||
items = appendAboutNavItem([
|
||||
{ path: "/connect", label: "服务器管理", icon: "/icons/serverlist.svg" },
|
||||
{ path: "/settings", label: "全局配置", icon: "/icons/config.svg" },
|
||||
{ path: "/logs", label: "日志", icon: "/icons/log.svg?v=20260227" }
|
||||
]);
|
||||
return ensureAboutNavItemLast(items);
|
||||
}
|
||||
|
||||
if (route.path.startsWith("/about")) {
|
||||
items = appendRecordsNavItem([
|
||||
{ path: "/connect", label: "服务器管理", icon: "/icons/serverlist.svg" },
|
||||
{ path: "/settings", label: "全局配置", icon: "/icons/config.svg" },
|
||||
{ path: "/logs", label: "日志", icon: "/icons/log.svg?v=20260227" }
|
||||
]);
|
||||
return ensureAboutNavItemLast(items);
|
||||
}
|
||||
|
||||
items = appendAboutNavItem(
|
||||
appendRecordsNavItem([
|
||||
{ path: "/connect", label: "服务器管理", icon: "/icons/serverlist.svg" },
|
||||
{ path: "/settings", label: "全局配置", icon: "/icons/config.svg" }
|
||||
])
|
||||
);
|
||||
return ensureAboutNavItemLast(items);
|
||||
});
|
||||
|
||||
function updateViewportLayout(): void {
|
||||
/**
|
||||
* 移动端软键盘弹出时,visualViewport.height 会变小。
|
||||
* 将根布局高度绑定到可视区域高度,确保 shell 向上收缩且光标行可见。
|
||||
*/
|
||||
const vv = window.visualViewport;
|
||||
const viewportHeight = Math.round(vv?.height ?? window.innerHeight);
|
||||
/**
|
||||
* iOS 键盘弹出时,Safari 可能会把 visual viewport 向下平移(offsetTop > 0)。
|
||||
* 若仅更新高度不处理 offset,页面可视区域会“切到容器底部”,表现为底栏被顶到顶部覆盖内容。
|
||||
* 因此这里同步写入偏移量,令根容器跟随 visual viewport 对齐。
|
||||
*/
|
||||
const viewportOffsetTop = Math.max(0, Math.round(vv?.offsetTop ?? 0));
|
||||
const viewportOffsetLeft = Math.max(0, Math.round(vv?.offsetLeft ?? 0));
|
||||
const viewportWidth = Math.round(vv?.width ?? window.innerWidth);
|
||||
document.documentElement.style.setProperty("--app-viewport-height", `${viewportHeight}px`);
|
||||
document.documentElement.style.setProperty("--app-viewport-width", `${viewportWidth}px`);
|
||||
document.documentElement.style.setProperty("--app-viewport-offset-top", `${viewportOffsetTop}px`);
|
||||
document.documentElement.style.setProperty("--app-viewport-offset-left", `${viewportOffsetLeft}px`);
|
||||
document.documentElement.style.setProperty(
|
||||
"--focus-scroll-margin-top",
|
||||
`${Math.round(viewportHeight * 0.25)}px`
|
||||
);
|
||||
|
||||
/**
|
||||
* 键盘态判定:
|
||||
* - 首选“历史最大可视高度 - 当前可视高度”的收缩量;
|
||||
* - 同时保留 visualViewport 与 innerHeight 差值判定作兜底;
|
||||
* - 阈值取 120px,过滤地址栏收展等小幅抖动。
|
||||
*
|
||||
* 说明:部分内核在弹键盘时会同步改写 innerHeight,若只看 innerHeight-vv.height
|
||||
* 可能长期为 0,导致 keyboardOpen 假阴性(页面不触发输入态滚动)。
|
||||
*/
|
||||
const candidateBaseHeight = Math.max(window.innerHeight, viewportHeight);
|
||||
if (baselineViewportHeight <= 0) {
|
||||
baselineViewportHeight = candidateBaseHeight;
|
||||
}
|
||||
if (!formFieldFocused.value) {
|
||||
baselineViewportHeight = Math.max(baselineViewportHeight, candidateBaseHeight);
|
||||
}
|
||||
const viewportShrink = Math.max(0, baselineViewportHeight - viewportHeight);
|
||||
const innerHeightDelta = vv ? Math.max(0, window.innerHeight - vv.height) : 0;
|
||||
keyboardOpen.value = viewportShrink > 120 || innerHeightDelta > 120;
|
||||
if (formFieldFocused.value) {
|
||||
scheduleEnsureFocusedFieldVisible();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前聚焦元素是否为会触发软键盘/编辑态的输入控件。
|
||||
* 约束:
|
||||
* - 仅把 input/textarea/select/contenteditable 视为“需要让位”的目标;
|
||||
* - terminal 页的 xterm helper textarea 由 TerminalPanel 内部状态机接管,
|
||||
* 这里必须排除,避免“全局滚动补偿”与“终端焦点控制”互相干扰;
|
||||
* - 普通按钮、链接等不触发底栏隐藏。
|
||||
*/
|
||||
function isEditableField(target: EventTarget | null): target is HTMLElement {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
if (target.classList.contains("xterm-helper-textarea") || target.closest(".terminal-wrapper, .xterm")) {
|
||||
return false;
|
||||
}
|
||||
if (target.isContentEditable) return true;
|
||||
return target.matches("input, textarea, select");
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步“是否有输入控件处于聚焦态”:
|
||||
* - focusin/focusout 在切换两个 input 时会连续触发;
|
||||
* - 使用微任务读取最终 activeElement,避免中间态闪烁。
|
||||
*/
|
||||
function syncFormFieldFocusState(): void {
|
||||
const active = document.activeElement;
|
||||
formFieldFocused.value = isEditableField(active);
|
||||
}
|
||||
|
||||
/**
|
||||
* 向上查找“可纵向滚动容器”:
|
||||
* - 优先使用最近的 overflowY=auto/scroll 容器;
|
||||
* - 若找不到,后续回退到 window.scrollBy。
|
||||
*/
|
||||
function findScrollableAncestor(node: HTMLElement | null): HTMLElement | null {
|
||||
let current = node?.parentElement ?? null;
|
||||
while (current) {
|
||||
const style = window.getComputedStyle(current);
|
||||
const overflowY = style.overflowY;
|
||||
const canScroll = /(auto|scroll|overlay)/.test(overflowY);
|
||||
/**
|
||||
* 这里不再要求“当前就可滚动”:
|
||||
* 键盘弹出前很多容器尚未 overflow,
|
||||
* 若此时返回 null,后续会错误回退到 window.scroll(页面可能不可滚)。
|
||||
*/
|
||||
if (canScroll) {
|
||||
return current;
|
||||
}
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入控件进入聚焦态时创建会话:
|
||||
* - 标记当前目标与可滚动容器;
|
||||
* - 供后续键盘动画阶段反复做可见性校正;
|
||||
* - 会话只跟随焦点,不再做“滚动位置回滚”。
|
||||
*/
|
||||
function beginFocusViewportSession(target: HTMLElement): void {
|
||||
focusViewportSession.value?.target.classList.remove("viewport-focus-target");
|
||||
const scrollContainer = findScrollableAncestor(target);
|
||||
inputFocusSessionActive.value = true;
|
||||
target.classList.add("viewport-focus-target");
|
||||
focusViewportSession.value = {
|
||||
target,
|
||||
scrollContainer
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 若当前 activeElement 是输入控件但会话缺失/已切换目标,则重建会话。
|
||||
* 目的:兜底浏览器事件顺序差异,保证后续滚动计算有基线。
|
||||
*/
|
||||
function ensureFocusViewportSession(): void {
|
||||
const active = document.activeElement;
|
||||
if (!isEditableField(active)) {
|
||||
return;
|
||||
}
|
||||
if (focusViewportSession.value?.target === active) {
|
||||
const latestContainer = findScrollableAncestor(active);
|
||||
focusViewportSession.value.scrollContainer = latestContainer;
|
||||
return;
|
||||
}
|
||||
beginFocusViewportSession(active);
|
||||
}
|
||||
|
||||
function resolveEffectiveViewportHeight(): number {
|
||||
return Math.round(window.visualViewport?.height ?? window.innerHeight);
|
||||
}
|
||||
|
||||
/**
|
||||
* 输入会话结束:
|
||||
* - 清理会话状态与样式标记;
|
||||
* - 不回滚滚动位置,避免多输入框切换或路由切换时出现反向跳动。
|
||||
*/
|
||||
function endFocusViewportSession(): void {
|
||||
const session = focusViewportSession.value;
|
||||
focusViewportSession.value = null;
|
||||
inputFocusSessionActive.value = false;
|
||||
session?.target.classList.remove("viewport-focus-target");
|
||||
}
|
||||
|
||||
/**
|
||||
* 将当前聚焦输入框滚入可视区:
|
||||
* - 基于滚动容器与 visualViewport 高度计算可见窗口;
|
||||
* - 键盘弹出场景将输入框顶部对齐到可见区“上到下 1/4”位置。
|
||||
*/
|
||||
function ensureFocusedFieldVisible(): void {
|
||||
ensureFocusViewportSession();
|
||||
const session = focusViewportSession.value;
|
||||
if (!session) {
|
||||
return;
|
||||
}
|
||||
if (!document.contains(session.target)) {
|
||||
endFocusViewportSession();
|
||||
return;
|
||||
}
|
||||
let container = session.scrollContainer;
|
||||
if (!container || !document.contains(container)) {
|
||||
container = findScrollableAncestor(session.target);
|
||||
session.scrollContainer = container;
|
||||
}
|
||||
/**
|
||||
* 键盘态兜底:
|
||||
* - 某些设备 keyboardOpen 判定会短暂失真,但“触屏 + 输入聚焦”基本可视为软键盘过程;
|
||||
* - 这里放宽为 likelyOpen,避免因为判定误差导致完全不滚动。
|
||||
*/
|
||||
const likelyKeyboardOpen = keyboardOpen.value || (touchLikeDevice.value && formFieldFocused.value);
|
||||
/**
|
||||
* 先尝试一次原生滚动(center 比 start 更稳,能在多数浏览器直接避开键盘遮挡),
|
||||
* 然后再做几何微调,确保目标落在“上到下约 1/4”的稳定阅读区。
|
||||
*/
|
||||
if (likelyKeyboardOpen) {
|
||||
session.target.scrollIntoView({ block: "center", inline: "nearest", behavior: "auto" });
|
||||
}
|
||||
const rect = session.target.getBoundingClientRect();
|
||||
const viewportHeight = resolveEffectiveViewportHeight();
|
||||
const containerRect = container?.getBoundingClientRect() ?? null;
|
||||
const visibleTop = (containerRect?.top ?? 0) + 12;
|
||||
const visibleBottom = Math.min(containerRect?.bottom ?? viewportHeight, viewportHeight) - 12;
|
||||
if (visibleBottom - visibleTop < 8) {
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* 交互策略:
|
||||
* - likelyKeyboardOpen 时:输入框“顶部”对齐到可见区上到下 1/4;
|
||||
* - 非键盘态:仅在超出可见区时才滚动。
|
||||
*/
|
||||
const desiredTop = visibleTop + (visibleBottom - visibleTop) * 0.25;
|
||||
const delta = rect.top - desiredTop;
|
||||
const outOfVisibleRange = rect.top < visibleTop || rect.bottom > visibleBottom;
|
||||
const needAdjust = likelyKeyboardOpen ? Math.abs(delta) > 3 : outOfVisibleRange;
|
||||
if (!needAdjust || Math.abs(delta) < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (container && document.contains(container)) {
|
||||
const before = container.scrollTop;
|
||||
container.scrollTop += delta;
|
||||
if (Math.abs(container.scrollTop - before) > 0.5) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const beforeWindowY = window.scrollY;
|
||||
window.scrollBy({ top: delta, behavior: "auto" });
|
||||
if (Math.abs(window.scrollY - beforeWindowY) > 0.5) {
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* 最终兜底:若容器/window 都未实际移动,再次触发原生 nearest 对齐。
|
||||
* 这一步主要覆盖个别 WebView 对 scrollTop 写入不生效的情况。
|
||||
*/
|
||||
if (likelyKeyboardOpen) {
|
||||
session.target.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "auto" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 键盘动画期会触发多次 viewport 变化,使用 RAF 合并滚动请求,
|
||||
* 避免同一帧重复改 scrollTop 引发抖动。
|
||||
*/
|
||||
function scheduleEnsureFocusedFieldVisible(): void {
|
||||
if (!formFieldFocused.value) {
|
||||
return;
|
||||
}
|
||||
if (ensureVisibleRafId !== null) {
|
||||
return;
|
||||
}
|
||||
ensureVisibleRafId = window.requestAnimationFrame(() => {
|
||||
ensureVisibleRafId = null;
|
||||
ensureFocusedFieldVisible();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 某些机型键盘展开较慢,额外做一次延迟校正,确保输入框最终可见。
|
||||
*/
|
||||
function scheduleEnsureFocusedFieldVisibleRetry(): void {
|
||||
if (ensureVisibleRetryTimer !== null) {
|
||||
window.clearTimeout(ensureVisibleRetryTimer);
|
||||
}
|
||||
ensureVisibleRetryTimer = window.setTimeout(() => {
|
||||
ensureVisibleRetryTimer = null;
|
||||
scheduleEnsureFocusedFieldVisible();
|
||||
}, 180);
|
||||
}
|
||||
|
||||
/**
|
||||
* focusin 进入输入态:
|
||||
* - 同步焦点状态;
|
||||
* - 建立输入会话;
|
||||
* - 立即 + 延迟一次可见性校正。
|
||||
*/
|
||||
function onFocusIn(event: FocusEvent): void {
|
||||
syncFormFieldFocusState();
|
||||
if (!isEditableField(event.target)) {
|
||||
return;
|
||||
}
|
||||
beginFocusViewportSession(event.target);
|
||||
scheduleEnsureFocusedFieldVisible();
|
||||
scheduleEnsureFocusedFieldVisibleRetry();
|
||||
}
|
||||
|
||||
/**
|
||||
* focusout 发生时,activeElement 可能还没切换完成;
|
||||
* 放到微任务里读取最终焦点,保证状态稳定。
|
||||
*/
|
||||
function onFocusOut(): void {
|
||||
queueMicrotask(() => {
|
||||
syncFormFieldFocusState();
|
||||
if (!formFieldFocused.value) {
|
||||
endFocusViewportSession();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一“返回”语义:
|
||||
* - 仅表示“返回历史上一页”;
|
||||
* - 当历史栈不足(length<=1)时禁用返回按钮并置灰。
|
||||
*/
|
||||
function syncCanGoBack(): void {
|
||||
if (typeof window === "undefined") {
|
||||
canGoBack.value = false;
|
||||
return;
|
||||
}
|
||||
canGoBack.value = window.history.length > 1;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
touchLikeDevice.value = window.matchMedia?.("(pointer: coarse)")?.matches ?? "ontouchstart" in window;
|
||||
updateViewportLayout();
|
||||
syncFormFieldFocusState();
|
||||
syncCanGoBack();
|
||||
window.addEventListener("resize", updateViewportLayout, { passive: true });
|
||||
window.visualViewport?.addEventListener("resize", updateViewportLayout, { passive: true });
|
||||
window.visualViewport?.addEventListener("scroll", updateViewportLayout, { passive: true });
|
||||
document.addEventListener("focusin", onFocusIn);
|
||||
document.addEventListener("focusout", onFocusOut);
|
||||
window.addEventListener("popstate", syncCanGoBack);
|
||||
await settingsStore.ensureBootstrapped();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", updateViewportLayout);
|
||||
window.visualViewport?.removeEventListener("resize", updateViewportLayout);
|
||||
window.visualViewport?.removeEventListener("scroll", updateViewportLayout);
|
||||
document.removeEventListener("focusin", onFocusIn);
|
||||
document.removeEventListener("focusout", onFocusOut);
|
||||
window.removeEventListener("popstate", syncCanGoBack);
|
||||
if (ensureVisibleRafId !== null) {
|
||||
window.cancelAnimationFrame(ensureVisibleRafId);
|
||||
ensureVisibleRafId = null;
|
||||
}
|
||||
if (ensureVisibleRetryTimer !== null) {
|
||||
window.clearTimeout(ensureVisibleRetryTimer);
|
||||
ensureVisibleRetryTimer = null;
|
||||
}
|
||||
endFocusViewportSession();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => settingsStore.themeVars,
|
||||
(vars) => {
|
||||
const root = document.documentElement;
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
root.style.setProperty(key, value);
|
||||
}
|
||||
/**
|
||||
* 同步浏览器/宿主顶栏颜色,避免顶部工具栏上方区域停留在旧主题色。
|
||||
*/
|
||||
applyThemeChromeColor(
|
||||
resolveThemeChromeColor(vars),
|
||||
document as unknown as ThemeChromeDocument<HTMLMetaElement>
|
||||
);
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
syncCanGoBack();
|
||||
endFocusViewportSession();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(keyboardOpen, (open, prev) => {
|
||||
if (open) {
|
||||
if (formFieldFocused.value) {
|
||||
inputFocusSessionActive.value = true;
|
||||
}
|
||||
scheduleEnsureFocusedFieldVisible();
|
||||
scheduleEnsureFocusedFieldVisibleRetry();
|
||||
return;
|
||||
}
|
||||
if (prev) {
|
||||
if (!formFieldFocused.value) {
|
||||
endFocusViewportSession();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function isActive(path: string): boolean {
|
||||
if (path === "/about") {
|
||||
return route.path.startsWith("/about");
|
||||
}
|
||||
return route.path === path;
|
||||
}
|
||||
|
||||
async function goTo(path: string): Promise<void> {
|
||||
if (route.path === path) return;
|
||||
await router.push(path);
|
||||
}
|
||||
|
||||
async function goBack(): Promise<void> {
|
||||
if (!canGoBack.value) {
|
||||
return;
|
||||
}
|
||||
router.back();
|
||||
}
|
||||
</script>
|
||||
7
apps/web/src/assets/icons/ai.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12" fill="none">
|
||||
<path
|
||||
d="M3.644 1.5L1.25 10.5H2.56L3.067 8.55H5.564L6.072 10.5H7.382L4.988 1.5H3.644ZM3.365 7.523L4.315 3.873L5.266 7.523H3.365Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path d="M8.25 1.5H9.46V10.5H8.25V1.5Z" fill="white" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 319 B |
8
apps/web/src/assets/icons/move.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="14" viewBox="0 0 8 14" fill="none">
|
||||
<circle cx="2" cy="2" r="1.3" fill="white" />
|
||||
<circle cx="6" cy="2" r="1.3" fill="white" />
|
||||
<circle cx="2" cy="7" r="1.3" fill="white" />
|
||||
<circle cx="6" cy="7" r="1.3" fill="white" />
|
||||
<circle cx="2" cy="12" r="1.3" fill="white" />
|
||||
<circle cx="6" cy="12" r="1.3" fill="white" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 391 B |
2199
apps/web/src/components/TerminalPanel.vue
Normal file
1452
apps/web/src/components/TerminalVoiceInput.vue
Normal file
38
apps/web/src/content/about.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ABOUT_BRAND,
|
||||
ABOUT_HOME_ITEMS,
|
||||
buildAboutInfoRows,
|
||||
getAboutDetailContent,
|
||||
isAboutDetailKey
|
||||
} from "./about";
|
||||
|
||||
describe("about content", () => {
|
||||
it("首页入口与小程序结构一致", () => {
|
||||
expect(ABOUT_HOME_ITEMS.map((item) => item.key)).toEqual([
|
||||
"manual",
|
||||
"feedback",
|
||||
"privacy",
|
||||
"changelog",
|
||||
"app"
|
||||
]);
|
||||
});
|
||||
|
||||
it("可识别合法详情 key", () => {
|
||||
expect(isAboutDetailKey("manual")).toBe(true);
|
||||
expect(isAboutDetailKey("unknown")).toBe(false);
|
||||
});
|
||||
|
||||
it("非法 key 会回退到关于详情", () => {
|
||||
expect(getAboutDetailContent("unknown").title).toBe("关于");
|
||||
});
|
||||
|
||||
it("产品信息可拆成标签和值", () => {
|
||||
const rows = buildAboutInfoRows(getAboutDetailContent("app"));
|
||||
expect(rows[0]).toEqual({
|
||||
key: "row-0",
|
||||
label: "产品名称:",
|
||||
value: ABOUT_BRAND.productName
|
||||
});
|
||||
});
|
||||
});
|
||||
497
apps/web/src/content/about.ts
Normal file
@@ -0,0 +1,497 @@
|
||||
export type AboutDetailKey = "manual" | "feedback" | "privacy" | "changelog" | "app";
|
||||
|
||||
export type AboutSection = {
|
||||
title: string;
|
||||
paragraphs?: string[];
|
||||
bullets?: string[];
|
||||
actionLabel?: string;
|
||||
};
|
||||
|
||||
export type AboutDetailContent = {
|
||||
title: string;
|
||||
lead: string;
|
||||
sections: AboutSection[];
|
||||
};
|
||||
|
||||
export type AboutHomeItem = {
|
||||
key: AboutDetailKey;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
export type AboutInfoRow = {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Web 端先对齐小程序关于页文案。
|
||||
* 这里保留独立 TS 副本,避免把小程序 CommonJS 运行时直接耦合进 Web 构建链。
|
||||
*/
|
||||
export const ABOUT_BRAND = {
|
||||
productName: "RemoteConn",
|
||||
chineseName: "AI矩连",
|
||||
platformLabel: "AI矩连小程序版",
|
||||
version: "v3.0.0",
|
||||
updatedAt: "2026-03-18",
|
||||
updatedAtCompact: "20260318",
|
||||
feedbackEmail: "douboer@gmail.com",
|
||||
summary: "管理服务器并在移动端执行 AI 终端操作。"
|
||||
} as const;
|
||||
|
||||
export const ABOUT_HOME_ITEMS: AboutHomeItem[] = [
|
||||
{
|
||||
key: "manual",
|
||||
title: "使用手册",
|
||||
subtitle: "查看各模块的实际操作说明与推荐使用顺序"
|
||||
},
|
||||
{
|
||||
key: "feedback",
|
||||
title: "问题反馈",
|
||||
subtitle: "查看反馈方式与建议附带的信息"
|
||||
},
|
||||
{
|
||||
key: "privacy",
|
||||
title: "隐私政策",
|
||||
subtitle: "查看信息收集、使用与存储说明"
|
||||
},
|
||||
{
|
||||
key: "changelog",
|
||||
title: "变更记录",
|
||||
subtitle: "查看完整版本历史与当前遗留问题"
|
||||
},
|
||||
{
|
||||
key: "app",
|
||||
title: "关于",
|
||||
subtitle: "查看产品简介、版本信息与联系信息"
|
||||
}
|
||||
];
|
||||
|
||||
const ABOUT_DETAIL_CONTENT: Record<AboutDetailKey, AboutDetailContent> = {
|
||||
manual: {
|
||||
title: "使用手册",
|
||||
lead: "本手册按当前小程序页面结构说明实际操作路径。建议按照“服务器列表 -> 服务器配置 -> 终端 -> 闪念 / 日志 -> 设置 / 插件”的顺序使用,先完成一台服务器配置,再逐步扩展到 AI、语音和插件能力。",
|
||||
sections: [
|
||||
{
|
||||
title: "1. 服务器列表:新增、整理和进入连接",
|
||||
bullets: [
|
||||
"打开首页“服务器”,左上角第一个按钮用于新增服务器,第二个按钮删除已勾选服务器,第三个按钮全选或取消全选。",
|
||||
"顶部搜索框会按名称、主机、用户名、端口和标签过滤服务器;搜索状态下建议先清空关键词再调整顺序。",
|
||||
"点击服务器卡片主体会进入“服务器配置”;长按卡片可拖动排序,便于把常用服务器放到前面。",
|
||||
"每张卡片右侧有三个快捷按钮:复制、AI、连接。复制会生成一份同配置的新服务器;AI 会直接进入终端并自动打开 AI 面板;连接会进入该服务器终端。",
|
||||
"连接中的服务器会在列表中高亮显示;如果该服务器已有可复用会话,再次点击“连接”会直接回到当前终端,而不是重新创建一条会话。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "2. 服务器配置:把一台服务器配到可连接状态",
|
||||
bullets: [
|
||||
"基础信息至少填写名称、主机、端口和用户名;“标签”支持逗号分隔,保存后会显示在服务器卡片底部,便于按环境或项目区分。",
|
||||
"认证信息按实际方式填写:密码模式填写密码;私钥模式填写私钥和口令;证书模式再补证书内容。认证方式切换后,请确认对应字段已经完整填写。",
|
||||
"连接参数里的“AI 工作目录”决定 AI 快速启动时进入的目录。可以手输,也可以点“选择目录”打开远程目录树,展开目录后点“应用”写回表单。",
|
||||
"如需跳板机,打开“跳转主机”开关,再分别填写跳板机主机、端口、用户名和独立认证信息;该配置会用于目录选择和终端连接的整条链路。",
|
||||
"底部按钮从左到右分别是返回、连接、保存、闪念、关于。建议先点“保存”,确认无误后再点“连接”;如果只是临时修改配置未保存,直接连接前页面也会先尝试落库。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "3. 终端:输入、重连、清屏和 AI 快速启动",
|
||||
bullets: [
|
||||
"进入终端后,顶部会显示服务器名、连接状态和延迟。右侧开关在已连接时用于断开,在断开后用于重连;左上角按钮分别是打开 AI 面板和清屏;当 Codex 正在当前会话前台运行时,清屏按钮会禁用。",
|
||||
"点击终端输出区域会激活输入焦点并弹出系统键盘;输入框使用原生键盘代理,回车会把当前内容发送到远端终端。",
|
||||
"右下角键盘按钮可展开触摸工具区,里面提供方向键、Enter、Paste 和常用快捷键,适合在手机上执行命令行导航、补全和翻页操作。",
|
||||
"“Paste”会读取系统剪贴板并发送到当前会话;如果未连接或剪贴板为空,页面会直接提示,不会静默失败。",
|
||||
"点击左上角 Codex 图标可打开“AI 快速启动”面板。这里可以直接执行 `codex --sandbox ...` 或 copilot 命令;前提是服务器配置里的 AI 工作目录存在,且远端已安装对应命令。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "4. 语音与闪念:先记录,再决定发送还是存档",
|
||||
bullets: [
|
||||
"终端右下角悬浮麦克风可拖动位置;点开后会出现语音输入面板,文本框里会显示当前草稿内容。",
|
||||
"按住主语音按钮开始录音,松开后把识别结果写入草稿;下方分类胶囊可以选择本次闪念记录要归入的分类。",
|
||||
"录音完成后,中间的“记录”按钮会把草稿保存到闪念列表;“发送”按钮会把草稿直接发送到终端执行;右侧两个按钮分别用于清空草稿和关闭面板。",
|
||||
"如果你暂时不想把内容发到服务器,优先使用“记录”而不是“发送”;这样可以先在闪念页整理,再决定是否执行。",
|
||||
"未连接终端时仍可记录闪念,但不能发送到会话;适合先记操作思路、排查步骤和待执行命令。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "5. 日志:看连接过程,导出排查信息",
|
||||
bullets: [
|
||||
"日志页按时间倒序展示连接相关记录,每条会显示服务器标识、状态、开始结束时间和摘要。",
|
||||
"顶部“导出脱敏日志”会把当前全部日志整理成文本并复制到剪贴板,适合发给开发者做排查;这里导出的是脱敏后的摘要,不是原始敏感凭据。",
|
||||
"日志页默认每页 15 条,可用“上一页 / 下一页”翻页;如果列表为空,说明当前设备上还没有产生连接日志或日志已被清理。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "6. 闪念:搜索、改分类、编辑和导出",
|
||||
bullets: [
|
||||
"闪念页顶部输入框用于全文搜索;右侧下拉按钮可以按分类过滤,方便只看某一类记录。",
|
||||
"每条记录左滑后可直接“复制”或“删除”;适合快速把某条命令、报错或笔记重新贴回终端或外部沟通工具。",
|
||||
"点击分类标签会弹出快速改分类面板,可把记录移动到其他分类;点击记录卡片主体会打开编辑弹层,支持修改正文和分类。",
|
||||
"底部“导出闪念”会把当前闪念内容复制到剪贴板;如果你需要先做归档,再批量整理,这是最直接的导出方式。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "7. 设置:统一调整外观、连接默认值和分类规则",
|
||||
bullets: [
|
||||
"设置页分为“界面 / 终端 / 连接 / 记录”四个标签。界面标签调整应用主题、背景色、文本色和按钮色;终端标签调整终端配色、字体、字号、行高以及宽字符支持。",
|
||||
"如果终端显示密度不合适,可在“终端”标签调整字号和行高;当前版本修改字号后仍建议断开重连一次,避免出现显示未完全刷新。",
|
||||
"“连接”标签用于设置 SSH 非主动断开后的自动重连、重连次数上限、后台保活时长,以及默认认证方式、默认端口、默认项目路径、默认超时和心跳。",
|
||||
"“记录”标签除了设置记录保留天数,还可以新增闪念分类、设默认分类、删除分类,并通过长按拖动调整分类顺序。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "8. 插件与常见使用顺序",
|
||||
bullets: [
|
||||
"插件页支持粘贴 JSON 导入插件、导出全部插件、启用 / 禁用 / 重载 / 移除插件,并在会话连接后执行插件注册的命令。",
|
||||
"如果插件页里没有可执行命令,通常表示当前没有连接中的会话,或插件本身没有注册任何命令;先回终端建立连接,再回插件页刷新。",
|
||||
"推荐的新用户使用顺序是:先在“服务器”新增一台机器,进入“服务器配置”填完连接信息,保存并连接;连接稳定后,再尝试语音、闪念、AI 和插件能力。",
|
||||
"请仅连接你拥有合法权限的服务器;若出现连接失败、目录加载失败、AI 启动失败或显示异常,先查看日志和闪念记录,再把版本号、复现步骤、截图与日志摘要一并反馈。"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
feedback: {
|
||||
title: "问题反馈",
|
||||
lead: "如果你在使用过程中遇到问题或有改进建议,可通过邮箱反馈联系我们。",
|
||||
sections: [
|
||||
{
|
||||
title: "反馈邮箱",
|
||||
paragraphs: ["douboer@gmail.com"],
|
||||
actionLabel: "复制邮箱"
|
||||
},
|
||||
{
|
||||
title: "建议附带的信息",
|
||||
bullets: ["机型与系统版本", "小程序版本号", "复现步骤", "截图或日志摘要"]
|
||||
},
|
||||
{
|
||||
title: "反馈范围",
|
||||
bullets: ["连接失败", "终端显示异常", "记录或设置异常", "建议与体验反馈"]
|
||||
}
|
||||
]
|
||||
},
|
||||
privacy: {
|
||||
title: "隐私政策",
|
||||
lead: "我们重视你的个人信息与数据安全。为支持同一账号在多设备间同步配置,系统会在服务器侧保存必要的设置、服务器配置与闪念记录;其中 SSH 密码、私钥、证书等敏感凭据不会以明文形式保存,而会在服务端加密后存储。",
|
||||
sections: [
|
||||
{
|
||||
title: "我们会处理的信息",
|
||||
bullets: [
|
||||
"你主动保存的用户设置,例如界面主题、终端显示偏好、默认连接参数、闪念分类与记录保留策略。",
|
||||
"你主动保存的服务器配置,例如名称、标签、主机、端口、用户名、项目路径、跳转主机配置,以及为跨设备同步所需的认证信息。",
|
||||
"你主动保存的闪念记录,例如正文、分类、上下文标签和更新时间。",
|
||||
"其中 SSH 密码、私钥、口令、证书及跳转主机对应凭据属于受保护字段,服务端仅以加密形式保存,用于在你已认证的设备间同步取回。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "信息用途",
|
||||
bullets: [
|
||||
"用于在同一账号下同步设置、服务器配置和闪念记录。",
|
||||
"用于在你更换设备或重新登录时恢复必要的连接配置与使用偏好。",
|
||||
"用于系统排查、稳定性分析和故障定位;此类处理仅限必要范围,不用于画像、营销或其他无关用途。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "信息存储方式",
|
||||
bullets: [
|
||||
"用户设置、服务器配置和闪念记录会同时保存在当前设备本地,并同步保存到服务端存储中,用于跨设备恢复。",
|
||||
"SSH 密码、私钥、口令、证书等敏感凭据不会以明文形式写入服务端数据库,而会在应用层加密后保存。",
|
||||
"系统错误日志、异常堆栈、基础版本信息、设备环境信息和连接状态信息可能在必要的排查场景下被处理。",
|
||||
"我们不会因为展示“关于”页面而额外收集与当前功能无关的个人信息。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "你的权利",
|
||||
bullets: [
|
||||
"你可以在应用内修改或删除自行保存的服务器配置、闪念记录与设置项;相关变更会同步到服务端。",
|
||||
"如需删除已同步的服务端配置数据,可通过后续提供的数据删除能力,或按“问题反馈”页提供的方式联系我们。",
|
||||
"如对隐私说明或数据处理方式有疑问,可通过“问题反馈”页提供的方式联系我们。"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
changelog: {
|
||||
title: "变更记录",
|
||||
lead: "当前页面完整同步仓库 `CHANGELOG.md` 的版本历史,便于直接查看连续版本演进。",
|
||||
sections: [
|
||||
{
|
||||
title: "索引说明",
|
||||
bullets: [
|
||||
"详细发布说明见 release.md。",
|
||||
"若 release.md 未单列某个 vx.y.0,则按 history.md 与 git 历史按连续版本规则补齐。",
|
||||
"x.y.z 的 patch 空档不额外补齐;历史补齐版本只用于保持序列连续。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v3.0.0(2026-03-18)",
|
||||
bullets: [
|
||||
"当前版本新增两项终端交互稳定性修复:高频 stdout 期间的 caret 稳定窗口,以及软键盘仍可见时 shell 输入被动 blur 保护。",
|
||||
"发布说明、README、关于页、当前基线文档、多语言文案与工程描述已统一升级到 v3.0.0。",
|
||||
"当前版本不新增新的同步协议、终端协议或配置字段,继续沿用 v2.9.6 已明确的能力边界。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v2.9.6(2026-03-13)",
|
||||
bullets: [
|
||||
"当前版本仅同步文档与对外口径,不引入新的功能、协议行为或交互变更。",
|
||||
"发布说明、README、关于页、当前基线文档、多语言文案与工程描述已统一升级到 v2.9.6。",
|
||||
"当前版本继续沿用 v2.9.5 已完成的现有终端、同步与时延诊断能力,以及当前语音播报边界口径。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v2.9.5(2026-03-13)",
|
||||
bullets: [
|
||||
"当前版本仅同步文档与对外口径,不引入新的功能、协议行为或交互变更。",
|
||||
"发布说明、README、关于页、当前基线文档、多语言文案与工程描述已统一升级到 v2.9.5。",
|
||||
"新登记一条小程序终端语音播报遗留问题:当前播报文本提取与轮次稳定判定仍不够准确,长时间 Codex 交互时也会额外放大小程序端响应压力,现阶段暂不建议默认使用。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v2.9.4(2026-03-12)",
|
||||
bullets: [
|
||||
"当前版本仅同步文档与对外口径,不引入新的功能、协议行为或交互变更。",
|
||||
"发布说明、README、关于页、当前基线文档、多语言文案与问题闭环记录已统一升级到 v2.9.4。",
|
||||
"当前 Web 与小程序基线继续沿用 v2.9.3 已完成的时延诊断面板与主题对比度收口结果。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v2.9.3(2026-03-11)",
|
||||
bullets: [
|
||||
"小程序时延诊断浮窗已改成单张双轴平滑曲线图,左轴显示网关响应,右轴显示网络时延。",
|
||||
"原“诊断信息”文字卡已删除,顶部改为两张两行摘要卡;同一服务器断开重连后会优先延续最近 30 个采样点。",
|
||||
"时延面板现已跟随终端主题做反相配色,深色终端会额外切换一套更深的蓝橙曲线与指标色,保证浅底上的文字和曲线可读性。",
|
||||
"发布说明、README、关于页、当前基线文档与问题闭环记录统一更新到 v2.9.3。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v2.9.1(2026-03-11)",
|
||||
bullets: [
|
||||
"小程序终端会话续接首次恢复已改为以 `lines + bufferCols / bufferRows` 为准,避免返回服务器列表后再次进入时出现历史区顶部空白与裸露 `5;2H`。",
|
||||
"启动阶段 `bootstrap` 合并配置完成后,首页服务器列表与底栏会立即刷新,不再需要重新进入页面才能看到同步后的配置。",
|
||||
"小程序用户隐私政策与 about 隐私页已按最新审核口径同步,补齐录音用途、处理范围与用户权利说明。",
|
||||
"发布说明、README、关于页、当前基线文档与问题闭环记录统一更新到 v2.9.1。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v2.9.0(2026-03-11)",
|
||||
bullets: [
|
||||
"小程序终端已修复 Codex 持续输出期间底部提示块缺行、状态行被裁掉与区域反复闪动的问题。",
|
||||
"normal buffer viewport 会保留光标行之后仍真实存在的 footer,`CSI ? 2026 h/l` 同步刷新窗口也已完成兼容收口。",
|
||||
"整行统一高亮背景会优先提升到 line 层绘制,`> Use /skills to list available skills` 与代码块不再透出行间底色细线。",
|
||||
"会话续接恢复口径已修正:首次恢复优先使用 `lines + bufferCols / bufferRows` 还原当前屏幕,避免返回服务器列表后再次进入时出现历史区顶部空白与裸露 `5;2H`。",
|
||||
"当前版本最重要的变更聚焦在交互问题修复;发布说明、README、关于页、当前基线文档与问题闭环记录统一更新到 v2.9.0。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v2.8.2(2026-03-10)",
|
||||
bullets: [
|
||||
"服务器列表 connect 按钮与底栏 shell 按钮在活动连接态统一改为高饱和实底高亮,不再依赖描边反馈。",
|
||||
"连接态 SVG 前景色改为运行时跟随界面前景色,保证高亮底色上的图标对比度。",
|
||||
"about 首页、详情页与 about-app 改为按界面配置推导配色;Web about 页同步切到同一套主题变量策略。",
|
||||
"新登记一条 about 反馈遗留问题:当前“反馈”按钮仍只复制邮箱地址,暂不支持直接拉起系统发送邮件组件。",
|
||||
"发布说明、根 README、小程序 README、关于页与当前基线文档统一更新到 v2.8.2。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v2.8.1(2026-03-10)",
|
||||
bullets: [
|
||||
"终端语音区展开按钮默认改为全透明,仅保留 SVG 本体;分类胶囊改为贴文字高度,选中态背景切到更明显的实色。",
|
||||
"录音中的输入框上方新增更显眼的双环脉冲提示,文案更新为“正在收音,松开后发送或记录闪念”。",
|
||||
"终端 VT 当前基线继续收口:OSC 10 / 11 / 12 返回真实 shell 主题色,备用屏与擦除空白位统一继承当前擦除背景。",
|
||||
"此前点击 Codex 连接选项后的首回显迟滞与等待期间按钮阻塞问题已收敛,当前版本不再将其列为遗留问题。",
|
||||
"另记录一个低频终端遗留问题:偶发新连接后首屏只显示光标、提示符稍后才出现;后续再优化连接就绪判定与 prompt 首屏兜底。",
|
||||
"发布说明、根 README、小程序 README、关于页与当前基线文档统一更新到 v2.8.1。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v2.7.1(2026-03-10)",
|
||||
bullets: [
|
||||
"文档与关于页版本口径统一更新到 v2.7.1,并补齐同步 SQLite 的当前边界说明。",
|
||||
"明确 remoteconn-sync.db-wal 是 SQLite 写前日志,文件体积不直接等于当前有效同步数据量。",
|
||||
"当前跨设备同步仍仅覆盖 settings / servers / records;日志、插件运行时日志与终端会话缓冲继续保留本地。",
|
||||
"当前小程序终端仍存在明显交互卡顿:点击 Codex 连接选项后约 10 秒才出现回显,等待期间除上下滑动外其余按钮基本阻塞。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v2.7.0(2026-03-09)",
|
||||
bullets: [
|
||||
"小程序设置、服务器配置与闪念记录接入 Gateway + SQLite 双层持久化,支持同一账号跨设备恢复。",
|
||||
"同步链路补齐 bootstrap 与 settings / servers / records 增量推送;服务器和闪念删除支持 tombstone 合并。",
|
||||
"SSH 密码、私钥、口令与证书等受保护字段改为服务端加密保存;终端会话缓冲仍不纳入第一阶段同步。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v2.6.6(2026-03-09)",
|
||||
bullets: [
|
||||
"文档口径统一更新到 v2.6.6。",
|
||||
"将“通过 npm run mini 生成的 preview 预览包,不作为正式版本地缓存连续性验证依据”记录为当前遗留问题。",
|
||||
"服务器配置、用户设置与闪念记录改为本地 storage + Gateway 同步双层持久化;SSH 密码、私钥、口令与证书等受保护字段在服务端加密保存。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v2.6.5(2026-03-08)",
|
||||
bullets: [
|
||||
"小程序终端 VT P0 基线收口,补齐双缓冲、备用屏幕切换、DSR / CPR / DA1 / DA2 / DECSTR 与基础局部重绘。",
|
||||
"修复 normal buffer 的 live tail 与滚动边界问题,并同步更新当前文档基线到 v2.6.5。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v2.6.1(2026-03-08)",
|
||||
bullets: [
|
||||
"将“修改字号后偶发吃字/显示不完整”收口为已知遗留问题,并在设置页增加“修改字号后建议断开重连”提示。",
|
||||
"收敛数字输入、字体回退与设置页容错逻辑,移除排查阶段的 terminal.wrap 调试输出。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v2.6.0(2026-03-07)",
|
||||
bullets: [
|
||||
"小程序补齐跳转主机、AI 快速启动、后台续接与连接反馈等主链路能力。",
|
||||
"小程序继续补齐终端可用性细节:光标位置计算修复,AI 启动链路正式落地到小程序端。",
|
||||
"SSH relay / 跳板机链路从“可配置”推进到“可经 A 主机直接转发连接 B 主机”。",
|
||||
"文档口径统一升级到 v2.6.0,并将历史 v2.4.0 说明并入当前对外版本。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v2.5.0(历史补齐,2026-03-07)",
|
||||
bullets: [
|
||||
"依据 v2.3.0 -> v2.6.0 之间的历史记录,按 3 小时一个 minor 版本的规则补齐。",
|
||||
"该阶段主要是 v2.6.0 之前的小程序终端链路收口与 AI、跳板机场景串联,详细内容已并入 v2.6.0。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v2.4.0(历史补齐,2026-03-07)",
|
||||
bullets: [
|
||||
"history.md 中明确出现版本号 v2.4.0,后续在 v2.6.0 中并档处理。",
|
||||
"该阶段集中于小程序终端光标、列宽与 cell 模型重构,以及相关分析文档沉淀,详细内容已并入 v2.6.0。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v2.3.0(2026-03-06)",
|
||||
bullets: [
|
||||
"小程序完成一轮大范围能力对齐,补齐 connect / server settings / terminal / logs / records / settings / plugins 主页面链路。",
|
||||
"Web 端继续精修交互与稳定性,文档口径统一到 v2.3.0。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v2.2.0(2026-03-06)",
|
||||
bullets: [
|
||||
"闪念增强定稿,补齐分类、编辑、搜索、过滤、快速改分类与上下文快照。",
|
||||
"全局配置补齐闪念分类治理、默认分类与拖拽排序。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v2.1.0(历史补齐,2026-03-01)",
|
||||
bullets: [
|
||||
"依据 v2.0.0 -> v2.2.0 之间的历史记录,按 3 小时一个 minor 版本的规则补齐。",
|
||||
"该阶段主要完成小程序连接页、服务器配置页、远程目录选择与基础对齐链路,为 v2.2.0 的记录增强和 v2.3.0 的大范围对齐打底。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v2.0.0(2026-02-27)",
|
||||
bullets: [
|
||||
"语音输入交互对齐 Figma,闪念记录闭环上线。",
|
||||
"日志与记录分页、资源缓存治理、文档与发布规范同步统一。",
|
||||
"语音层与键盘工具层的命中区继续收敛,修复工具栏被误判为外部点击后自动折叠的问题。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v1.1.0(2026-02-26)",
|
||||
bullets: [
|
||||
"终端语音输入全链路、Gateway ASR 代理与 Web 稳定性增强。",
|
||||
"终端输入增强为“原生 textarea + 语音输入面板 + 发送确认”组合,并补入 TAB 辅助键。",
|
||||
"路由懒加载失败增加自动恢复,缩放防护补齐到 double-tap / dblclick 等路径。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v1.0.9(2026-02-25)",
|
||||
bullets: [
|
||||
"连接体验治理、服务器列表拖拽排序与移动端防误触优化。",
|
||||
"连接与服务器管理继续收敛:支持连接重试次数与分组;新增服务器改为先进入配置页,未改动不落库。",
|
||||
"导航与配置页体验统一:返回语义按历史栈处理,终端配置页增加预览块,配置页 UI 完成一轮重构。",
|
||||
"终端工具与视觉细节补齐:新增 paste 辅助按钮,修复字体选择,补齐夜间模式与重连提示样式。",
|
||||
"会话恢复体验补强:同连接历史可延续,刷新“我的服务器”页默认不主动断开现有会话。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v1.0.8(2026-02-24)",
|
||||
bullets: [
|
||||
"配置中心稳定性、深浅色模式与网关 runtime 配置落地。",
|
||||
"配置中心结构重组:服务器基础/认证绑定到当前服务器,终端/主题/安全切到全局配置。",
|
||||
"配置与工具区交互继续收敛:点击外部区域可折叠右下角键盘工具条,Figma 配置区交互并入当前基线。",
|
||||
"终端提示文案与初始化输出治理:连接提示更友好,中文输入初始化命令回显默认不展示。"
|
||||
]
|
||||
},
|
||||
{
|
||||
title: "v1.0.6(2026-02-24)",
|
||||
bullets: ["Console 触摸工具区可点击性与误触防护优化。"]
|
||||
},
|
||||
{
|
||||
title: "v1.0.5(2026-02-23)",
|
||||
bullets: ["iPhone 触摸滚动动量阶段性优化。"]
|
||||
},
|
||||
{
|
||||
title: "v1.0.3(2026-02-23)",
|
||||
bullets: ["iOS 触摸焦点状态机稳定性修复。"]
|
||||
},
|
||||
{
|
||||
title: "v1.0.1(2026-02-23)",
|
||||
bullets: [
|
||||
"生产架构版稳定性更新,确立 Web、Gateway 与插件运行时的多包工程基线。",
|
||||
"补齐多服务器与多认证方式管理,最近连接日志可追溯。",
|
||||
"Codex 模式主链路可用:连接后自动切目录并启动 codex。",
|
||||
"主题与界面自定义基础能力上线,含字体、配色与基础配置中心。"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
app: {
|
||||
title: "关于",
|
||||
lead: "",
|
||||
sections: [
|
||||
{
|
||||
title: "产品信息",
|
||||
bullets: [
|
||||
"产品名称:RemoteConn",
|
||||
"中文名称:AI矩连",
|
||||
"平台标识:AI矩连小程序版",
|
||||
"当前版本:v3.0.0",
|
||||
"修改时间:20260318",
|
||||
"数据口径:设置、服务器配置与闪念支持跨设备同步,敏感凭据服务端加密保存",
|
||||
"反馈邮箱:douboer@gmail.com",
|
||||
"更新时间:2026-03-18"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
export function isAboutDetailKey(value: string): value is AboutDetailKey {
|
||||
return value in ABOUT_DETAIL_CONTENT;
|
||||
}
|
||||
|
||||
export function getAboutDetailContent(key: string): AboutDetailContent {
|
||||
if (isAboutDetailKey(key)) {
|
||||
return ABOUT_DETAIL_CONTENT[key];
|
||||
}
|
||||
return ABOUT_DETAIL_CONTENT.app;
|
||||
}
|
||||
|
||||
export function buildAboutInfoRows(content: AboutDetailContent): AboutInfoRow[] {
|
||||
const primarySection = content.sections[0];
|
||||
const bullets = Array.isArray(primarySection?.bullets) ? primarySection.bullets : [];
|
||||
return bullets.map((line, index) => {
|
||||
const text = String(line || "").trim();
|
||||
const matched = text.match(/^([^::]+)[::]\s*(.+)$/);
|
||||
if (!matched) {
|
||||
return {
|
||||
key: `row-${index}`,
|
||||
label: "",
|
||||
value: text
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: `row-${index}`,
|
||||
label: `${matched[1]}:`,
|
||||
value: String(matched[2] ?? "")
|
||||
};
|
||||
});
|
||||
}
|
||||
11
apps/web/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_GATEWAY_URL?: string;
|
||||
readonly VITE_GATEWAY_TOKEN?: string;
|
||||
readonly VITE_ENABLE_PLUGIN_RUNTIME?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
87
apps/web/src/main.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import App from "./App.vue";
|
||||
import { routes } from "./routes";
|
||||
import { installDynamicImportRecovery } from "./utils/dynamicImportGuard";
|
||||
import "./styles/main.css";
|
||||
|
||||
/**
|
||||
* 全局禁止双指缩放:
|
||||
* - iOS Safari: 拦截 gesturestart/gesturechange/gestureend;
|
||||
* - 触屏浏览器: 双触点 touchmove 时阻止默认缩放手势;
|
||||
* - 桌面触控板: Ctrl + wheel 缩放时阻止默认行为。
|
||||
*/
|
||||
function installPinchZoomGuard(): void {
|
||||
const options: AddEventListenerOptions = { passive: false };
|
||||
|
||||
const preventDefault = (event: Event): void => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const onTouchMove = (event: TouchEvent): void => {
|
||||
if (event.touches.length > 1) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
if (event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("gesturestart", preventDefault, options);
|
||||
document.addEventListener("gesturechange", preventDefault, options);
|
||||
document.addEventListener("gestureend", preventDefault, options);
|
||||
document.addEventListener("touchmove", onTouchMove, options);
|
||||
document.addEventListener("wheel", onWheel, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局禁止双击放大:
|
||||
* - 移动端:拦截短时间内连续 touchend(双击手势);
|
||||
* - 桌面端:拦截 dblclick 默认缩放行为。
|
||||
*/
|
||||
function installDoubleTapZoomGuard(): void {
|
||||
const options: AddEventListenerOptions = { passive: false };
|
||||
let lastTouchEndAt = 0;
|
||||
const DOUBLE_TAP_WINDOW_MS = 320;
|
||||
|
||||
document.addEventListener(
|
||||
"touchend",
|
||||
(event: TouchEvent): void => {
|
||||
const now = Date.now();
|
||||
if (now - lastTouchEndAt <= DOUBLE_TAP_WINDOW_MS) {
|
||||
event.preventDefault();
|
||||
}
|
||||
lastTouchEndAt = now;
|
||||
},
|
||||
options
|
||||
);
|
||||
|
||||
document.addEventListener(
|
||||
"dblclick",
|
||||
(event: MouseEvent): void => {
|
||||
event.preventDefault();
|
||||
},
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
installPinchZoomGuard();
|
||||
installDoubleTapZoomGuard();
|
||||
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
});
|
||||
|
||||
installDynamicImportRecovery(router);
|
||||
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
|
||||
app.mount("#app");
|
||||
8
apps/web/src/routes.test.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { routes } from "./routes";
|
||||
|
||||
describe("routes", () => {
|
||||
it("包含关于页路由", () => {
|
||||
expect(routes.some((route) => route.path === "/about/:section?")).toBe(true);
|
||||
});
|
||||
});
|
||||
46
apps/web/src/routes.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { RouteRecordRaw } from "vue-router";
|
||||
|
||||
const pluginRuntimeEnabled = import.meta.env.VITE_ENABLE_PLUGIN_RUNTIME !== "false";
|
||||
|
||||
export const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/",
|
||||
redirect: "/connect"
|
||||
},
|
||||
{
|
||||
path: "/connect",
|
||||
component: () => import("./views/ConnectView.vue")
|
||||
},
|
||||
{
|
||||
path: "/server/:id/settings",
|
||||
component: () => import("./views/ServerSettingsView.vue")
|
||||
},
|
||||
{
|
||||
path: "/terminal",
|
||||
component: () => import("./views/TerminalView.vue")
|
||||
},
|
||||
{
|
||||
path: "/logs",
|
||||
component: () => import("./views/LogsView.vue")
|
||||
},
|
||||
{
|
||||
path: "/records",
|
||||
component: () => import("./views/RecordsView.vue")
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
component: () => import("./views/SettingsView.vue")
|
||||
},
|
||||
{
|
||||
path: "/about/:section?",
|
||||
component: () => import("./views/AboutView.vue")
|
||||
},
|
||||
...(pluginRuntimeEnabled
|
||||
? [
|
||||
{
|
||||
path: "/plugins",
|
||||
component: () => import("./views/PluginsView.vue")
|
||||
}
|
||||
]
|
||||
: [])
|
||||
];
|
||||
94
apps/web/src/services/security/credentialVault.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { EncryptedCredentialPayload } from "@/types/app";
|
||||
import { getSettings } from "@/services/storage/db";
|
||||
|
||||
const SESSION_KEY_STORAGE = "remoteconn_crypto_key_session_v2";
|
||||
const PERSIST_KEY_STORAGE = "remoteconn_crypto_key_persist_v2";
|
||||
const LEGACY_SESSION_KEY_STORAGE = "remoteconn_crypto_key_v1";
|
||||
|
||||
/**
|
||||
* Web 端无法达到系统 Keychain 等级,这里采用会话密钥 + AES-GCM 做“受限存储”。
|
||||
* 重点是避免明文直接落盘,并在 UI 中持续提示风险。
|
||||
*/
|
||||
async function getOrCreateSessionKey(): Promise<CryptoKey> {
|
||||
const remember = await shouldRememberCredentialKey();
|
||||
const encoded = readEncodedKey();
|
||||
|
||||
if (encoded) {
|
||||
// 统一迁移到新 key 名,并按策略决定是否持久化。
|
||||
sessionStorage.setItem(SESSION_KEY_STORAGE, encoded);
|
||||
if (remember) {
|
||||
localStorage.setItem(PERSIST_KEY_STORAGE, encoded);
|
||||
} else {
|
||||
localStorage.removeItem(PERSIST_KEY_STORAGE);
|
||||
}
|
||||
|
||||
const raw = Uint8Array.from(atob(encoded), (s) => s.charCodeAt(0));
|
||||
return await crypto.subtle.importKey("raw", raw, "AES-GCM", true, ["encrypt", "decrypt"]);
|
||||
}
|
||||
|
||||
const raw = crypto.getRandomValues(new Uint8Array(32));
|
||||
const nextEncoded = btoa(String.fromCharCode(...raw));
|
||||
sessionStorage.setItem(SESSION_KEY_STORAGE, nextEncoded);
|
||||
if (remember) {
|
||||
localStorage.setItem(PERSIST_KEY_STORAGE, nextEncoded);
|
||||
}
|
||||
return await crypto.subtle.importKey("raw", raw, "AES-GCM", true, ["encrypt", "decrypt"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取当前凭据密钥保存策略:remember 时允许跨刷新/重开保留密钥。
|
||||
* 若读取设置失败,默认走 remember,避免凭据“看似丢失”。
|
||||
*/
|
||||
async function shouldRememberCredentialKey(): Promise<boolean> {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
return (settings?.credentialMemoryPolicy ?? "remember") === "remember";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function readEncodedKey(): string | null {
|
||||
return (
|
||||
sessionStorage.getItem(SESSION_KEY_STORAGE) ??
|
||||
sessionStorage.getItem(LEGACY_SESSION_KEY_STORAGE) ??
|
||||
localStorage.getItem(PERSIST_KEY_STORAGE)
|
||||
);
|
||||
}
|
||||
|
||||
function encodeBase64(source: Uint8Array): string {
|
||||
return btoa(String.fromCharCode(...source));
|
||||
}
|
||||
|
||||
function decodeBase64ToArrayBuffer(source: string): ArrayBuffer {
|
||||
const bytes = Uint8Array.from(atob(source), (s) => s.charCodeAt(0));
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
}
|
||||
|
||||
export async function encryptCredential(refId: string, value: unknown): Promise<EncryptedCredentialPayload> {
|
||||
const key = await getOrCreateSessionKey();
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const payload = new TextEncoder().encode(JSON.stringify(value));
|
||||
|
||||
const encrypted = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, payload));
|
||||
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: `cred-${crypto.randomUUID()}`,
|
||||
refId,
|
||||
encrypted: encodeBase64(encrypted),
|
||||
iv: encodeBase64(iv),
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
};
|
||||
}
|
||||
|
||||
export async function decryptCredential<T>(payload: EncryptedCredentialPayload): Promise<T> {
|
||||
const key = await getOrCreateSessionKey();
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: decodeBase64ToArrayBuffer(payload.iv) },
|
||||
key,
|
||||
decodeBase64ToArrayBuffer(payload.encrypted)
|
||||
);
|
||||
return JSON.parse(new TextDecoder().decode(new Uint8Array(decrypted))) as T;
|
||||
}
|
||||
21
apps/web/src/services/sessionEventBus.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
type EventName = "connected" | "disconnected" | "stdout" | "stderr" | "latency";
|
||||
|
||||
type Handler = (payload: unknown) => void;
|
||||
|
||||
const listeners = new Map<EventName, Set<Handler>>();
|
||||
|
||||
export function onSessionEvent(eventName: EventName, handler: Handler): () => void {
|
||||
if (!listeners.has(eventName)) {
|
||||
listeners.set(eventName, new Set());
|
||||
}
|
||||
listeners.get(eventName)!.add(handler);
|
||||
return () => listeners.get(eventName)?.delete(handler);
|
||||
}
|
||||
|
||||
export function emitSessionEvent(eventName: EventName, payload: unknown): void {
|
||||
const set = listeners.get(eventName);
|
||||
if (!set) return;
|
||||
for (const handler of set) {
|
||||
handler(payload);
|
||||
}
|
||||
}
|
||||
119
apps/web/src/services/storage/db.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import Dexie, { type EntityTable } from "dexie";
|
||||
import type { CredentialRef, ServerProfile, SessionLog } from "@remoteconn/shared";
|
||||
import type { EncryptedCredentialPayload, GlobalSettings, VoiceRecord } from "@/types/app";
|
||||
import type { PluginPackage, PluginRecord } from "@remoteconn/plugin-runtime";
|
||||
|
||||
interface KnownHostEntity {
|
||||
key: string;
|
||||
fingerprint: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface SettingEntity {
|
||||
key: string;
|
||||
value: GlobalSettings;
|
||||
}
|
||||
|
||||
interface PluginDataEntity {
|
||||
key: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
class RemoteConnDb extends Dexie {
|
||||
public servers!: EntityTable<ServerProfile, "id">;
|
||||
public credentialRefs!: EntityTable<CredentialRef, "id">;
|
||||
public credentials!: EntityTable<EncryptedCredentialPayload, "id">;
|
||||
public sessionLogs!: EntityTable<SessionLog, "sessionId">;
|
||||
public knownHosts!: EntityTable<KnownHostEntity, "key">;
|
||||
public settings!: EntityTable<SettingEntity, "key">;
|
||||
public pluginPackages!: EntityTable<PluginPackage & { id: string }, "id">;
|
||||
public pluginRecords!: EntityTable<PluginRecord & { id: string }, "id">;
|
||||
public pluginData!: EntityTable<PluginDataEntity, "key">;
|
||||
public voiceRecords!: EntityTable<VoiceRecord, "id">;
|
||||
|
||||
public constructor() {
|
||||
super("remoteconn_db");
|
||||
|
||||
this.version(2).stores({
|
||||
servers: "id, name, host, lastConnectedAt",
|
||||
credentialRefs: "id, type, updatedAt",
|
||||
credentials: "id, refId, updatedAt",
|
||||
sessionLogs: "sessionId, serverId, startAt, status",
|
||||
knownHosts: "key, updatedAt",
|
||||
settings: "key",
|
||||
pluginPackages: "id",
|
||||
pluginRecords: "id, status",
|
||||
pluginData: "key"
|
||||
});
|
||||
|
||||
this.version(3).stores({
|
||||
servers: "id, name, host, lastConnectedAt",
|
||||
credentialRefs: "id, type, updatedAt",
|
||||
credentials: "id, refId, updatedAt",
|
||||
sessionLogs: "sessionId, serverId, startAt, status",
|
||||
knownHosts: "key, updatedAt",
|
||||
settings: "key",
|
||||
pluginPackages: "id",
|
||||
pluginRecords: "id, status",
|
||||
pluginData: "key",
|
||||
voiceRecords: "id, createdAt, serverId"
|
||||
});
|
||||
|
||||
this.version(4)
|
||||
.stores({
|
||||
servers: "id, name, host, lastConnectedAt",
|
||||
credentialRefs: "id, type, updatedAt",
|
||||
credentials: "id, refId, updatedAt",
|
||||
sessionLogs: "sessionId, serverId, startAt, status",
|
||||
knownHosts: "key, updatedAt",
|
||||
settings: "key",
|
||||
pluginPackages: "id",
|
||||
pluginRecords: "id, status",
|
||||
pluginData: "key",
|
||||
voiceRecords: "id, createdAt, updatedAt, serverId, category, contextLabel"
|
||||
})
|
||||
.upgrade(async (tx) => {
|
||||
await tx
|
||||
.table("voiceRecords")
|
||||
.toCollection()
|
||||
.modify((row: VoiceRecord & Partial<Record<"updatedAt" | "category" | "contextLabel", string>>) => {
|
||||
row.updatedAt = String(row.updatedAt || row.createdAt || new Date().toISOString());
|
||||
row.category = String(row.category || "未分类");
|
||||
row.contextLabel = String(row.contextLabel || "");
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new RemoteConnDb();
|
||||
async function ensureDbOpen(): Promise<void> {
|
||||
if (!db.isOpen()) {
|
||||
await db.open();
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSettings(): Promise<GlobalSettings | null> {
|
||||
await ensureDbOpen();
|
||||
const row = await db.settings.get("global");
|
||||
return row?.value ?? null;
|
||||
}
|
||||
|
||||
export async function setSettings(value: GlobalSettings): Promise<void> {
|
||||
await ensureDbOpen();
|
||||
await db.settings.put({ key: "global", value });
|
||||
}
|
||||
|
||||
export async function getKnownHosts(): Promise<Record<string, string>> {
|
||||
await ensureDbOpen();
|
||||
const rows = await db.knownHosts.toArray();
|
||||
return Object.fromEntries(rows.map((row) => [row.key, row.fingerprint]));
|
||||
}
|
||||
|
||||
export async function upsertKnownHost(key: string, fingerprint: string): Promise<void> {
|
||||
await ensureDbOpen();
|
||||
await db.knownHosts.put({
|
||||
key,
|
||||
fingerprint,
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
43
apps/web/src/services/storage/pluginFsAdapter.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { PluginFsAdapter, PluginPackage } from "@remoteconn/plugin-runtime";
|
||||
import { db } from "./db";
|
||||
|
||||
/**
|
||||
* Web 端插件存储适配:
|
||||
* - 插件包与记录保存在 IndexedDB
|
||||
* - 提供与插件运行时一致的读写接口
|
||||
*/
|
||||
export class WebPluginFsAdapter implements PluginFsAdapter {
|
||||
public async listPackages(): Promise<PluginPackage[]> {
|
||||
const rows = await db.pluginPackages.toArray();
|
||||
return rows.map(({ id: _id, ...pkg }) => pkg);
|
||||
}
|
||||
|
||||
public async getPackage(pluginId: string): Promise<PluginPackage | null> {
|
||||
const row = await db.pluginPackages.get(pluginId);
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
const { id: _id, ...pkg } = row;
|
||||
return pkg;
|
||||
}
|
||||
|
||||
public async upsertPackage(pluginPackage: PluginPackage): Promise<void> {
|
||||
await db.pluginPackages.put({ id: pluginPackage.manifest.id, ...pluginPackage });
|
||||
}
|
||||
|
||||
public async removePackage(pluginId: string): Promise<void> {
|
||||
await db.pluginPackages.delete(pluginId);
|
||||
}
|
||||
|
||||
public async readStore<T>(key: string, fallback: T): Promise<T> {
|
||||
const row = await db.pluginData.get(key);
|
||||
if (!row) {
|
||||
return fallback;
|
||||
}
|
||||
return row.value as T;
|
||||
}
|
||||
|
||||
public async writeStore<T>(key: string, value: T): Promise<void> {
|
||||
await db.pluginData.put({ key, value });
|
||||
}
|
||||
}
|
||||
16
apps/web/src/services/transport/factory.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { TerminalTransport } from "./terminalTransport";
|
||||
import { GatewayTransport } from "./gatewayTransport";
|
||||
import { IosNativeTransport } from "./iosNativeTransport";
|
||||
|
||||
/**
|
||||
* 统一传输工厂,屏蔽底层差异。
|
||||
*/
|
||||
export function createTransport(
|
||||
mode: "gateway" | "ios-native",
|
||||
options: { gatewayUrl: string; gatewayToken: string }
|
||||
): TerminalTransport {
|
||||
if (mode === "ios-native") {
|
||||
return new IosNativeTransport();
|
||||
}
|
||||
return new GatewayTransport(options.gatewayUrl, options.gatewayToken);
|
||||
}
|
||||
341
apps/web/src/services/transport/gatewayTransport.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import type { GatewayFrame, SessionState, StdinMeta } from "@remoteconn/shared";
|
||||
import type { ConnectParams, TerminalTransport, TransportEvent } from "./terminalTransport";
|
||||
|
||||
/**
|
||||
* 网关传输实现:Web/小程序共用。
|
||||
*/
|
||||
export class GatewayTransport implements TerminalTransport {
|
||||
private static readonly CONNECT_TIMEOUT_MS = 12000;
|
||||
private socket: WebSocket | null = null;
|
||||
private listeners = new Set<(event: TransportEvent) => void>();
|
||||
private pingAt = 0;
|
||||
private heartbeatTimer: number | null = null;
|
||||
private state: SessionState = "idle";
|
||||
|
||||
public constructor(
|
||||
private readonly gatewayUrl: string,
|
||||
private readonly token: string
|
||||
) {}
|
||||
|
||||
public async connect(params: ConnectParams): Promise<void> {
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
throw new Error("会话已连接");
|
||||
}
|
||||
|
||||
this.state = "connecting";
|
||||
|
||||
this.socket = await new Promise<WebSocket>((resolve, reject) => {
|
||||
const endpoints = this.buildEndpoints();
|
||||
const reasons: string[] = [];
|
||||
let index = 0;
|
||||
const candidateHint = `候选地址: ${endpoints.join(", ")}`;
|
||||
|
||||
const tryConnect = (): void => {
|
||||
const endpoint = endpoints[index];
|
||||
if (!endpoint) {
|
||||
reject(new Error(`无法连接网关: ${reasons.join(" | ") || "无可用网关地址"} | ${candidateHint}`));
|
||||
return;
|
||||
}
|
||||
let settled = false;
|
||||
let socket: WebSocket;
|
||||
let timeoutTimer: number | null = null;
|
||||
|
||||
try {
|
||||
socket = new WebSocket(endpoint);
|
||||
} catch {
|
||||
reasons.push(`地址无效: ${endpoint}`);
|
||||
if (index < endpoints.length - 1) {
|
||||
index += 1;
|
||||
tryConnect();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`无法连接网关: ${reasons.join(" | ")} | ${candidateHint}`));
|
||||
return;
|
||||
}
|
||||
|
||||
timeoutTimer = window.setTimeout(() => {
|
||||
fail(`连接超时>${GatewayTransport.CONNECT_TIMEOUT_MS}ms`);
|
||||
}, GatewayTransport.CONNECT_TIMEOUT_MS);
|
||||
|
||||
const clearTimer = (): void => {
|
||||
if (timeoutTimer !== null) {
|
||||
window.clearTimeout(timeoutTimer);
|
||||
timeoutTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const fail = (reason: string): void => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimer();
|
||||
reasons.push(`${reason}: ${endpoint}`);
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// 忽略关闭阶段异常,继续下一个候选地址。
|
||||
}
|
||||
|
||||
if (index < endpoints.length - 1) {
|
||||
index += 1;
|
||||
tryConnect();
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error(`无法连接网关: ${reasons.join(" | ")} | ${candidateHint}`));
|
||||
};
|
||||
|
||||
socket.onopen = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimer();
|
||||
resolve(socket);
|
||||
};
|
||||
socket.onerror = () => fail("网络或协议错误");
|
||||
socket.onclose = (event) => {
|
||||
if (!settled) {
|
||||
fail(`连接关闭 code=${event.code}`);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
tryConnect();
|
||||
});
|
||||
|
||||
this.socket.onmessage = (event) => {
|
||||
const frame = JSON.parse(event.data as string) as GatewayFrame;
|
||||
this.handleFrame(frame);
|
||||
};
|
||||
|
||||
this.socket.onclose = () => {
|
||||
this.stopHeartbeat();
|
||||
this.state = "disconnected";
|
||||
this.emit({ type: "disconnect", reason: "ws_closed" });
|
||||
};
|
||||
|
||||
this.socket.onerror = () => {
|
||||
this.stopHeartbeat();
|
||||
this.state = "error";
|
||||
this.emit({ type: "error", code: "WS_ERROR", message: "WebSocket 异常" });
|
||||
};
|
||||
|
||||
const initFrame: GatewayFrame = {
|
||||
type: "init",
|
||||
payload: {
|
||||
host: params.host,
|
||||
port: params.port,
|
||||
username: params.username,
|
||||
...(params.clientSessionKey ? { clientSessionKey: params.clientSessionKey } : {}),
|
||||
credential: params.credential,
|
||||
...(params.jumpHost ? { jumpHost: params.jumpHost } : {}),
|
||||
knownHostFingerprint: params.knownHostFingerprint,
|
||||
pty: { cols: params.cols, rows: params.rows }
|
||||
}
|
||||
};
|
||||
|
||||
this.sendRaw(initFrame);
|
||||
this.startHeartbeat();
|
||||
this.state = "auth_pending";
|
||||
}
|
||||
|
||||
public async send(data: string, meta?: StdinMeta): Promise<void> {
|
||||
this.sendRaw({
|
||||
type: "stdin",
|
||||
payload: {
|
||||
data,
|
||||
...(meta ? { meta } : {})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async resize(cols: number, rows: number): Promise<void> {
|
||||
this.sendRaw({ type: "resize", payload: { cols, rows } });
|
||||
}
|
||||
|
||||
public async disconnect(reason = "manual"): Promise<void> {
|
||||
this.stopHeartbeat();
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.sendRaw({ type: "control", payload: { action: "disconnect", reason } });
|
||||
this.socket.close();
|
||||
}
|
||||
this.socket = null;
|
||||
this.state = "disconnected";
|
||||
}
|
||||
|
||||
public on(listener: (event: TransportEvent) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
public getState(): SessionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
private sendRaw(frame: GatewayFrame): void {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
throw new Error("网关连接未建立");
|
||||
}
|
||||
this.socket.send(JSON.stringify(frame));
|
||||
}
|
||||
|
||||
private handleFrame(frame: GatewayFrame): void {
|
||||
if (frame.type === "stdout") {
|
||||
this.state = "connected";
|
||||
this.emit({ type: "stdout", data: frame.payload.data });
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === "stderr") {
|
||||
this.emit({ type: "stderr", data: frame.payload.data });
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === "error") {
|
||||
this.state = "error";
|
||||
this.emit({ type: "error", code: frame.payload.code, message: frame.payload.message });
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.type === "control") {
|
||||
if (frame.payload.action === "ping") {
|
||||
this.sendRaw({ type: "control", payload: { action: "pong" } });
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.payload.action === "pong") {
|
||||
if (this.pingAt > 0) {
|
||||
this.emit({ type: "latency", data: Date.now() - this.pingAt });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.payload.action === "connected") {
|
||||
this.state = "connected";
|
||||
this.emit({
|
||||
type: "connected",
|
||||
fingerprint: frame.payload.fingerprint,
|
||||
fingerprintHostPort: frame.payload.fingerprintHostPort,
|
||||
resumed: frame.payload.resumed === true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (frame.payload.action === "disconnect") {
|
||||
this.state = "disconnected";
|
||||
this.stopHeartbeat();
|
||||
this.emit({ type: "disconnect", reason: frame.payload.reason ?? "unknown" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private emit(event: TransportEvent): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat();
|
||||
this.heartbeatTimer = window.setInterval(() => {
|
||||
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
this.pingAt = Date.now();
|
||||
this.sendRaw({ type: "control", payload: { action: "ping" } });
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
window.clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一网关地址构造(含容错候选):
|
||||
* 1) 自动将 http/https 转换为 ws/wss;
|
||||
* 2) 页面非本机访问时,避免把 localhost 误连到客户端本机;
|
||||
* 3) https 页面下,补充 wss 与去端口候选,适配反向代理场景;
|
||||
* 4) 统一补全 /ws/terminal?token=...
|
||||
*/
|
||||
private buildEndpoints(): string[] {
|
||||
const pageIsHttps = window.location.protocol === "https:";
|
||||
const pageHost = window.location.hostname;
|
||||
const pageProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const rawInput = this.gatewayUrl.trim();
|
||||
const fallback = `${pageProtocol}//${pageHost}`;
|
||||
const input = rawInput.length > 0 ? rawInput : fallback;
|
||||
const candidates: string[] = [];
|
||||
const pushCandidate = (next: URL): void => {
|
||||
if (pageIsHttps && next.protocol === "ws:") {
|
||||
return;
|
||||
}
|
||||
candidates.push(finalizeEndpoint(next));
|
||||
};
|
||||
|
||||
let url: URL;
|
||||
try {
|
||||
const maybeUrl = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(input) ? input : `${pageProtocol}//${input}`;
|
||||
url = new URL(maybeUrl);
|
||||
} catch {
|
||||
url = new URL(fallback);
|
||||
}
|
||||
|
||||
if (url.protocol === "http:") url.protocol = "ws:";
|
||||
if (url.protocol === "https:") url.protocol = "wss:";
|
||||
|
||||
const localHosts = new Set(["localhost", "127.0.0.1", "::1"]);
|
||||
const pageIsLocal = localHosts.has(pageHost);
|
||||
const targetIsLocal = localHosts.has(url.hostname);
|
||||
if (!pageIsLocal && targetIsLocal) {
|
||||
url.hostname = pageHost;
|
||||
}
|
||||
|
||||
const finalizeEndpoint = (source: URL): string => {
|
||||
const next = new URL(source.toString());
|
||||
const pathname = next.pathname.replace(/\/+$/, "");
|
||||
next.pathname = pathname.endsWith("/ws/terminal") ? pathname : `${pathname}/ws/terminal`.replace(/\/{2,}/g, "/");
|
||||
next.search = `token=${encodeURIComponent(this.token)}`;
|
||||
return next.toString();
|
||||
};
|
||||
|
||||
// 1) 优先使用用户配置原始地址。
|
||||
pushCandidate(url);
|
||||
|
||||
// 2) 补充同主机不同协议候选(ws <-> wss)。
|
||||
// HTTPS 页面禁止 ws://(混合内容会被浏览器直接拦截)。
|
||||
if (!pageIsHttps && url.protocol === "ws:") {
|
||||
const tlsUrl = new URL(url.toString());
|
||||
tlsUrl.protocol = "wss:";
|
||||
pushCandidate(tlsUrl);
|
||||
} else if (url.protocol === "wss:") {
|
||||
const plainUrl = new URL(url.toString());
|
||||
if (!pageIsHttps) {
|
||||
plainUrl.protocol = "ws:";
|
||||
pushCandidate(plainUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 远端主机时,始终补充“去端口走反向代理(80/443)”候选。
|
||||
// 适配公网仅开放 443、Nginx 反代到内网端口的部署。
|
||||
if (!targetIsLocal) {
|
||||
const noPort = new URL(url.toString());
|
||||
noPort.port = "";
|
||||
pushCandidate(noPort);
|
||||
|
||||
if (!pageIsHttps && noPort.protocol === "ws:") {
|
||||
const noPortTls = new URL(noPort.toString());
|
||||
noPortTls.protocol = "wss:";
|
||||
pushCandidate(noPortTls);
|
||||
} else if (noPort.protocol === "wss:") {
|
||||
if (!pageIsHttps) {
|
||||
const noPortPlain = new URL(noPort.toString());
|
||||
noPortPlain.protocol = "ws:";
|
||||
pushCandidate(noPortPlain);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
}
|
||||
170
apps/web/src/services/transport/iosNativeTransport.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import type { SessionState, StdinMeta } from "@remoteconn/shared";
|
||||
import type { ConnectParams, TerminalTransport, TransportEvent } from "./terminalTransport";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Capacitor?: {
|
||||
Plugins?: {
|
||||
RemoteConnSSH?: {
|
||||
connect(options: ConnectParams): Promise<void>;
|
||||
send(options: { data: string }): Promise<void>;
|
||||
resize(options: { cols: number; rows: number }): Promise<void>;
|
||||
disconnect(options: { reason?: string }): Promise<void>;
|
||||
addListener(
|
||||
eventName: "stdout" | "stderr" | "disconnect" | "latency" | "error" | "connected",
|
||||
listener: (payload: unknown) => void
|
||||
): Promise<{ remove: () => void }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type NativeCredentialPayload =
|
||||
| { type: "password"; password: string }
|
||||
| { type: "privateKey"; privateKey: string; passphrase?: string }
|
||||
| { type: "certificate"; privateKey: string; passphrase?: string; certificate: string };
|
||||
|
||||
interface NativeConnectPayload {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
knownHostFingerprint?: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
credential: NativeCredentialPayload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 ConnectParams 规整为仅包含 JSON 原始类型的对象。
|
||||
* 目的:Capacitor Bridge 在 iOS 侧会对参数做克隆/序列化,`undefined` 或代理对象可能触发 DataCloneError。
|
||||
*/
|
||||
function buildNativeConnectPayload(params: ConnectParams): NativeConnectPayload {
|
||||
const base = {
|
||||
host: String(params.host ?? ""),
|
||||
port: Number(params.port ?? 22),
|
||||
username: String(params.username ?? ""),
|
||||
cols: Number(params.cols ?? 80),
|
||||
rows: Number(params.rows ?? 24)
|
||||
};
|
||||
|
||||
const knownHostFingerprint =
|
||||
typeof params.knownHostFingerprint === "string" && params.knownHostFingerprint.trim().length > 0
|
||||
? params.knownHostFingerprint.trim()
|
||||
: undefined;
|
||||
|
||||
if (params.credential.type === "password") {
|
||||
return {
|
||||
...base,
|
||||
...(knownHostFingerprint ? { knownHostFingerprint } : {}),
|
||||
credential: {
|
||||
type: "password",
|
||||
password: String(params.credential.password ?? "")
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (params.credential.type === "privateKey") {
|
||||
return {
|
||||
...base,
|
||||
...(knownHostFingerprint ? { knownHostFingerprint } : {}),
|
||||
credential: {
|
||||
type: "privateKey",
|
||||
privateKey: String(params.credential.privateKey ?? ""),
|
||||
...(params.credential.passphrase ? { passphrase: String(params.credential.passphrase) } : {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
...(knownHostFingerprint ? { knownHostFingerprint } : {}),
|
||||
credential: {
|
||||
type: "certificate",
|
||||
privateKey: String(params.credential.privateKey ?? ""),
|
||||
certificate: String(params.credential.certificate ?? ""),
|
||||
...(params.credential.passphrase ? { passphrase: String(params.credential.passphrase) } : {})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* iOS 原生 SSH 传输适配。
|
||||
*/
|
||||
export class IosNativeTransport implements TerminalTransport {
|
||||
private state: SessionState = "idle";
|
||||
private listeners = new Set<(event: TransportEvent) => void>();
|
||||
private disposers: Array<() => void> = [];
|
||||
|
||||
public async connect(params: ConnectParams): Promise<void> {
|
||||
const plugin = window.Capacitor?.Plugins?.RemoteConnSSH;
|
||||
if (!plugin) {
|
||||
throw new Error("iOS 原生插件不可用");
|
||||
}
|
||||
|
||||
this.state = "connecting";
|
||||
|
||||
const onStdout = await plugin.addListener("stdout", (payload) => {
|
||||
this.state = "connected";
|
||||
this.emit({ type: "stdout", data: (payload as { data: string }).data });
|
||||
});
|
||||
this.disposers.push(() => onStdout.remove());
|
||||
|
||||
const onStderr = await plugin.addListener("stderr", (payload) => {
|
||||
this.emit({ type: "stderr", data: (payload as { data: string }).data });
|
||||
});
|
||||
this.disposers.push(() => onStderr.remove());
|
||||
|
||||
const onDisconnect = await plugin.addListener("disconnect", (payload) => {
|
||||
this.state = "disconnected";
|
||||
this.emit({ type: "disconnect", reason: (payload as { reason: string }).reason });
|
||||
});
|
||||
this.disposers.push(() => onDisconnect.remove());
|
||||
|
||||
const onLatency = await plugin.addListener("latency", (payload) => {
|
||||
this.emit({ type: "latency", data: (payload as { latency: number }).latency });
|
||||
});
|
||||
this.disposers.push(() => onLatency.remove());
|
||||
|
||||
const onError = await plugin.addListener("error", (payload) => {
|
||||
this.state = "error";
|
||||
const error = payload as { code: string; message: string };
|
||||
this.emit({ type: "error", code: error.code, message: error.message });
|
||||
});
|
||||
this.disposers.push(() => onError.remove());
|
||||
|
||||
await plugin.connect(buildNativeConnectPayload(params));
|
||||
}
|
||||
|
||||
public async send(data: string, _meta?: StdinMeta): Promise<void> {
|
||||
await window.Capacitor?.Plugins?.RemoteConnSSH?.send({ data });
|
||||
}
|
||||
|
||||
public async resize(cols: number, rows: number): Promise<void> {
|
||||
await window.Capacitor?.Plugins?.RemoteConnSSH?.resize({ cols, rows });
|
||||
}
|
||||
|
||||
public async disconnect(reason?: string): Promise<void> {
|
||||
await window.Capacitor?.Plugins?.RemoteConnSSH?.disconnect({ reason });
|
||||
for (const dispose of this.disposers) {
|
||||
dispose();
|
||||
}
|
||||
this.disposers = [];
|
||||
this.state = "disconnected";
|
||||
}
|
||||
|
||||
public on(listener: (event: TransportEvent) => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
public getState(): SessionState {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
private emit(event: TransportEvent): void {
|
||||
for (const listener of this.listeners) {
|
||||
listener(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
36
apps/web/src/services/transport/terminalTransport.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { ResolvedCredential, SessionState, StdinMeta } from "@remoteconn/shared";
|
||||
|
||||
export type TransportEvent =
|
||||
| { type: "stdout"; data: string }
|
||||
| { type: "stderr"; data: string }
|
||||
| { type: "latency"; data: number }
|
||||
| { type: "disconnect"; reason: string }
|
||||
| { type: "connected"; fingerprint?: string; fingerprintHostPort?: string; resumed?: boolean }
|
||||
| { type: "error"; code: string; message: string };
|
||||
|
||||
export interface ConnectParams {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
clientSessionKey?: string;
|
||||
credential: ResolvedCredential;
|
||||
jumpHost?: {
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
credential: ResolvedCredential;
|
||||
knownHostFingerprint?: string;
|
||||
};
|
||||
knownHostFingerprint?: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export interface TerminalTransport {
|
||||
connect(params: ConnectParams): Promise<void>;
|
||||
send(data: string, meta?: StdinMeta): Promise<void>;
|
||||
resize(cols: number, rows: number): Promise<void>;
|
||||
disconnect(reason?: string): Promise<void>;
|
||||
on(listener: (event: TransportEvent) => void): () => void;
|
||||
getState(): SessionState;
|
||||
}
|
||||
27
apps/web/src/stores/appStore.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import type { AppToast } from "@/types/app";
|
||||
|
||||
/**
|
||||
* 全局消息中心。
|
||||
*/
|
||||
export const useAppStore = defineStore("app", () => {
|
||||
const toasts = ref<AppToast[]>([]);
|
||||
|
||||
function notify(level: AppToast["level"], message: string): void {
|
||||
const item: AppToast = {
|
||||
id: crypto.randomUUID(),
|
||||
level,
|
||||
message
|
||||
};
|
||||
toasts.value.push(item);
|
||||
window.setTimeout(() => {
|
||||
toasts.value = toasts.value.filter((x) => x.id !== item.id);
|
||||
}, level === "error" ? 5000 : 3000);
|
||||
}
|
||||
|
||||
return {
|
||||
toasts,
|
||||
notify
|
||||
};
|
||||
});
|
||||
117
apps/web/src/stores/logStore.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, toRaw, ref } from "vue";
|
||||
import { maskHost, maskSensitive, type CommandMarker, type SessionLog } from "@remoteconn/shared";
|
||||
import { db } from "@/services/storage/db";
|
||||
import { nowIso } from "@/utils/time";
|
||||
|
||||
/**
|
||||
* 会话日志存储与导出。
|
||||
*/
|
||||
export const useLogStore = defineStore("log", () => {
|
||||
const logs = ref<SessionLog[]>([]);
|
||||
const loaded = ref(false);
|
||||
let bootstrapPromise: Promise<void> | null = null;
|
||||
|
||||
const latest = computed(() => [...logs.value].sort((a, b) => +new Date(b.startAt) - +new Date(a.startAt)).slice(0, 50));
|
||||
|
||||
async function ensureBootstrapped(): Promise<void> {
|
||||
if (loaded.value) return;
|
||||
if (bootstrapPromise) {
|
||||
await bootstrapPromise;
|
||||
return;
|
||||
}
|
||||
bootstrapPromise = (async () => {
|
||||
logs.value = await db.sessionLogs.toArray();
|
||||
loaded.value = true;
|
||||
})();
|
||||
|
||||
try {
|
||||
await bootstrapPromise;
|
||||
} finally {
|
||||
bootstrapPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
await ensureBootstrapped();
|
||||
}
|
||||
|
||||
async function startLog(serverId: string): Promise<string> {
|
||||
const log: SessionLog = {
|
||||
sessionId: `sess-${crypto.randomUUID()}`,
|
||||
serverId,
|
||||
startAt: nowIso(),
|
||||
status: "connecting",
|
||||
commandMarkers: []
|
||||
};
|
||||
logs.value.unshift(log);
|
||||
await db.sessionLogs.put(log);
|
||||
return log.sessionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dexie/IndexedDB 使用结构化克隆写入数据,Vue 响应式代理对象会触发 DataCloneError。
|
||||
* 这里统一做实体快照,确保入库对象仅包含可序列化的普通 JSON 数据。
|
||||
*/
|
||||
function toSessionLogEntity(log: SessionLog): SessionLog {
|
||||
const raw = toRaw(log);
|
||||
return {
|
||||
...raw,
|
||||
commandMarkers: raw.commandMarkers.map((marker) => ({ ...marker }))
|
||||
};
|
||||
}
|
||||
|
||||
async function markStatus(sessionId: string, status: SessionLog["status"], error?: string): Promise<void> {
|
||||
const target = logs.value.find((item) => item.sessionId === sessionId);
|
||||
if (!target) return;
|
||||
target.status = status;
|
||||
if (status === "disconnected" || status === "error") {
|
||||
target.endAt = nowIso();
|
||||
}
|
||||
if (error) {
|
||||
target.error = error;
|
||||
}
|
||||
await db.sessionLogs.put(toSessionLogEntity(target));
|
||||
}
|
||||
|
||||
async function addMarker(sessionId: string, marker: Omit<CommandMarker, "at">): Promise<void> {
|
||||
const target = logs.value.find((item) => item.sessionId === sessionId);
|
||||
if (!target) return;
|
||||
target.commandMarkers.push({ ...marker, at: nowIso() });
|
||||
await db.sessionLogs.put(toSessionLogEntity(target));
|
||||
}
|
||||
|
||||
function exportLogs(mask = true): string {
|
||||
const rows = logs.value.map((log) => {
|
||||
const commands = log.commandMarkers
|
||||
.map((marker) => {
|
||||
const cmd = mask ? maskSensitive(marker.command) : marker.command;
|
||||
return ` - [${marker.at}] ${cmd} => code:${marker.code}`;
|
||||
})
|
||||
.join("\n");
|
||||
return [
|
||||
`## ${log.sessionId} [${log.status}]`,
|
||||
`- server: ${log.serverId}`,
|
||||
`- start: ${log.startAt}`,
|
||||
`- end: ${log.endAt ?? "--"}`,
|
||||
`- error: ${mask ? maskSensitive(log.error ?? "") : log.error ?? ""}`,
|
||||
`- host: ${mask ? maskHost(log.serverId) : log.serverId}`,
|
||||
"- commands:",
|
||||
commands || " - 无"
|
||||
].join("\n");
|
||||
});
|
||||
|
||||
return [`# RemoteConn Session Export ${nowIso()}`, "", ...rows].join("\n\n");
|
||||
}
|
||||
|
||||
return {
|
||||
logs,
|
||||
latest,
|
||||
ensureBootstrapped,
|
||||
bootstrap,
|
||||
startLog,
|
||||
markStatus,
|
||||
addMarker,
|
||||
exportLogs
|
||||
};
|
||||
});
|
||||
189
apps/web/src/stores/pluginStore.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import { PluginManager, type PluginPackage } from "@remoteconn/plugin-runtime";
|
||||
import { WebPluginFsAdapter } from "@/services/storage/pluginFsAdapter";
|
||||
import { onSessionEvent } from "@/services/sessionEventBus";
|
||||
import { useSessionStore } from "./sessionStore";
|
||||
import { useAppStore } from "./appStore";
|
||||
|
||||
/**
|
||||
* 插件运行时管理。
|
||||
*/
|
||||
export const usePluginStore = defineStore("plugin", () => {
|
||||
const runtimeLogs = ref<string[]>([]);
|
||||
const initialized = ref(false);
|
||||
let bootstrapPromise: Promise<void> | null = null;
|
||||
|
||||
const fsAdapter = new WebPluginFsAdapter();
|
||||
const eventUnsubscribers: Array<() => void> = [];
|
||||
|
||||
const manager = new PluginManager(fsAdapter, {
|
||||
getAppMeta() {
|
||||
return { version: "2.4.0", platform: "web" as const };
|
||||
},
|
||||
session: {
|
||||
async send(input) {
|
||||
const sessionStore = useSessionStore();
|
||||
await sessionStore.sendCommand(input, "plugin", "manual");
|
||||
},
|
||||
on(eventName, handler) {
|
||||
return onSessionEvent(eventName, handler);
|
||||
}
|
||||
},
|
||||
showNotice(message, level) {
|
||||
const appStore = useAppStore();
|
||||
appStore.notify(level, message);
|
||||
}
|
||||
}, {
|
||||
appVersion: "2.4.0",
|
||||
mountStyle(pluginId, css) {
|
||||
const style = document.createElement("style");
|
||||
style.dataset.pluginId = pluginId;
|
||||
style.textContent = css;
|
||||
document.head.append(style);
|
||||
return () => style.remove();
|
||||
},
|
||||
logger(level, pluginId, message) {
|
||||
runtimeLogs.value.unshift(`[${new Date().toLocaleTimeString("zh-CN", { hour12: false })}] [${level}] [${pluginId}] ${message}`);
|
||||
if (runtimeLogs.value.length > 300) {
|
||||
runtimeLogs.value.splice(300);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const records = computed(() => manager.listRecords());
|
||||
|
||||
const commands = computed(() => {
|
||||
const session = useSessionStore();
|
||||
return manager.listCommands(session.connected ? "connected" : "disconnected");
|
||||
});
|
||||
|
||||
async function ensureBootstrapped(): Promise<void> {
|
||||
if (initialized.value) return;
|
||||
if (bootstrapPromise) {
|
||||
await bootstrapPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
bootstrapPromise = (async () => {
|
||||
|
||||
await manager.bootstrap();
|
||||
await ensureSamplePlugin();
|
||||
|
||||
eventUnsubscribers.push(
|
||||
onSessionEvent("connected", () => {
|
||||
// 保持 computed 触发
|
||||
runtimeLogs.value = [...runtimeLogs.value];
|
||||
})
|
||||
);
|
||||
|
||||
initialized.value = true;
|
||||
})();
|
||||
|
||||
try {
|
||||
await bootstrapPromise;
|
||||
} finally {
|
||||
bootstrapPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
await ensureBootstrapped();
|
||||
}
|
||||
|
||||
async function ensureSamplePlugin(): Promise<void> {
|
||||
const packages = await fsAdapter.listPackages();
|
||||
if (packages.length > 0) return;
|
||||
|
||||
await importPackages([
|
||||
{
|
||||
manifest: {
|
||||
id: "codex-shortcuts",
|
||||
name: "Codex Shortcuts",
|
||||
version: "0.1.0",
|
||||
minAppVersion: "0.1.0",
|
||||
description: "提供常用 Codex 快捷命令",
|
||||
entry: "main.js",
|
||||
style: "styles.css",
|
||||
permissions: ["commands.register", "session.write", "ui.notice"]
|
||||
},
|
||||
mainJs: `
|
||||
module.exports = {
|
||||
onload(ctx) {
|
||||
ctx.commands.register({
|
||||
id: "codex-doctor",
|
||||
title: "Codex Doctor",
|
||||
when: "connected",
|
||||
async handler() {
|
||||
await ctx.session.send("codex --doctor");
|
||||
}
|
||||
});
|
||||
ctx.ui.showNotice("插件 codex-shortcuts 已加载", "info");
|
||||
}
|
||||
};
|
||||
`.trim(),
|
||||
stylesCss: `.plugin-chip[data-plugin-id="codex-shortcuts"] { border-color: rgba(95,228,255,0.7); }`
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
async function importPackages(payload: PluginPackage[]): Promise<void> {
|
||||
for (const pkg of payload) {
|
||||
await manager.installPackage(pkg);
|
||||
}
|
||||
}
|
||||
|
||||
async function importJson(raw: string): Promise<void> {
|
||||
const parsed = JSON.parse(raw) as PluginPackage | PluginPackage[];
|
||||
const items = Array.isArray(parsed) ? parsed : [parsed];
|
||||
await importPackages(items);
|
||||
}
|
||||
|
||||
async function exportJson(): Promise<string> {
|
||||
const packages = await fsAdapter.listPackages();
|
||||
return JSON.stringify(packages, null, 2);
|
||||
}
|
||||
|
||||
async function enable(pluginId: string): Promise<void> {
|
||||
await manager.enable(pluginId);
|
||||
}
|
||||
|
||||
async function disable(pluginId: string): Promise<void> {
|
||||
await manager.disable(pluginId);
|
||||
}
|
||||
|
||||
async function reload(pluginId: string): Promise<void> {
|
||||
await manager.reload(pluginId);
|
||||
}
|
||||
|
||||
async function remove(pluginId: string): Promise<void> {
|
||||
await manager.remove(pluginId);
|
||||
}
|
||||
|
||||
async function runCommand(commandId: string): Promise<void> {
|
||||
await manager.runCommand(commandId);
|
||||
}
|
||||
|
||||
function dispose(): void {
|
||||
for (const off of eventUnsubscribers) {
|
||||
off();
|
||||
}
|
||||
eventUnsubscribers.length = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
runtimeLogs,
|
||||
records,
|
||||
commands,
|
||||
ensureBootstrapped,
|
||||
bootstrap,
|
||||
importJson,
|
||||
exportJson,
|
||||
enable,
|
||||
disable,
|
||||
reload,
|
||||
remove,
|
||||
runCommand,
|
||||
dispose
|
||||
};
|
||||
});
|
||||
164
apps/web/src/stores/serverStore.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import type { ServerProfile } from "@/types/app";
|
||||
|
||||
const { dbState, dbMock } = vi.hoisted(() => {
|
||||
const state = {
|
||||
servers: [] as ServerProfile[]
|
||||
};
|
||||
|
||||
const cloneServer = (server: ServerProfile): ServerProfile => ({
|
||||
...server,
|
||||
projectPresets: [...server.projectPresets],
|
||||
tags: [...server.tags],
|
||||
jumpHost: server.jumpHost ? { ...server.jumpHost } : undefined
|
||||
});
|
||||
|
||||
const upsertServer = (server: ServerProfile): void => {
|
||||
const index = state.servers.findIndex((item) => item.id === server.id);
|
||||
if (index >= 0) {
|
||||
state.servers[index] = cloneServer(server);
|
||||
} else {
|
||||
state.servers.push(cloneServer(server));
|
||||
}
|
||||
};
|
||||
|
||||
const db = {
|
||||
servers: {
|
||||
toArray: vi.fn(async () => state.servers.map((item) => cloneServer(item))),
|
||||
add: vi.fn(async (server: ServerProfile) => {
|
||||
state.servers.push(cloneServer(server));
|
||||
}),
|
||||
put: vi.fn(async (server: ServerProfile) => {
|
||||
upsertServer(server);
|
||||
}),
|
||||
bulkPut: vi.fn(async (servers: ServerProfile[]) => {
|
||||
servers.forEach((server) => upsertServer(server));
|
||||
}),
|
||||
delete: vi.fn(async (serverId: string) => {
|
||||
state.servers = state.servers.filter((item) => item.id !== serverId);
|
||||
})
|
||||
},
|
||||
credentialRefs: {
|
||||
toArray: vi.fn(async () => [])
|
||||
},
|
||||
credentials: {
|
||||
where: vi.fn(() => ({
|
||||
equals: vi.fn(() => ({
|
||||
first: vi.fn(async () => null),
|
||||
delete: vi.fn(async () => {})
|
||||
}))
|
||||
})),
|
||||
put: vi.fn(async () => {})
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
dbState: state,
|
||||
dbMock: db
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/services/storage/db", () => ({
|
||||
db: dbMock
|
||||
}));
|
||||
|
||||
vi.mock("@/services/security/credentialVault", () => ({
|
||||
decryptCredential: vi.fn(async () => ({})),
|
||||
encryptCredential: vi.fn(async () => ({
|
||||
id: "enc-1",
|
||||
refId: "enc-1",
|
||||
encrypted: "",
|
||||
iv: "",
|
||||
createdAt: "",
|
||||
updatedAt: ""
|
||||
}))
|
||||
}));
|
||||
|
||||
import { useServerStore } from "./serverStore";
|
||||
|
||||
function makeServer(id: string, sortOrder?: number): ServerProfile {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
host: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
projectPath: "~",
|
||||
projectPresets: [],
|
||||
tags: [],
|
||||
timeoutSeconds: 20,
|
||||
heartbeatSeconds: 15,
|
||||
transportMode: "gateway",
|
||||
jumpHost: {
|
||||
enabled: false,
|
||||
host: "",
|
||||
port: 22,
|
||||
username: "",
|
||||
authType: "password"
|
||||
},
|
||||
...(sortOrder !== undefined ? { sortOrder } : {})
|
||||
};
|
||||
}
|
||||
|
||||
describe("serverStore", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
dbState.servers = [];
|
||||
dbMock.servers.toArray.mockClear();
|
||||
dbMock.servers.add.mockClear();
|
||||
dbMock.servers.put.mockClear();
|
||||
dbMock.servers.bulkPut.mockClear();
|
||||
dbMock.servers.delete.mockClear();
|
||||
dbMock.credentialRefs.toArray.mockClear();
|
||||
});
|
||||
|
||||
it("启动时按 sortOrder 恢复顺序并回填连续排序值", async () => {
|
||||
dbState.servers = [makeServer("srv-b", 2), makeServer("srv-a"), makeServer("srv-c", 0)];
|
||||
|
||||
const store = useServerStore();
|
||||
await store.ensureBootstrapped();
|
||||
|
||||
expect(store.servers.map((item) => item.id)).toEqual(["srv-c", "srv-b", "srv-a"]);
|
||||
expect(dbMock.servers.bulkPut).toHaveBeenCalledTimes(1);
|
||||
expect(store.servers.map((item) => item.sortOrder)).toEqual([0, 1, 2]);
|
||||
});
|
||||
|
||||
it("支持服务器上下移动并持久化顺序", async () => {
|
||||
dbState.servers = [makeServer("srv-1", 0), makeServer("srv-2", 1), makeServer("srv-3", 2)];
|
||||
|
||||
const store = useServerStore();
|
||||
await store.ensureBootstrapped();
|
||||
expect(dbMock.servers.bulkPut).toHaveBeenCalledTimes(0);
|
||||
|
||||
const movedDown = await store.moveServerDown("srv-1");
|
||||
expect(movedDown).toBe(true);
|
||||
expect(store.servers.map((item) => item.id)).toEqual(["srv-2", "srv-1", "srv-3"]);
|
||||
expect(store.servers.map((item) => item.sortOrder)).toEqual([0, 1, 2]);
|
||||
|
||||
const movedUp = await store.moveServerUp("srv-1");
|
||||
expect(movedUp).toBe(true);
|
||||
expect(store.servers.map((item) => item.id)).toEqual(["srv-1", "srv-2", "srv-3"]);
|
||||
|
||||
const topBoundary = await store.moveServerUp("srv-1");
|
||||
const bottomBoundary = await store.moveServerDown("srv-3");
|
||||
expect(topBoundary).toBe(false);
|
||||
expect(bottomBoundary).toBe(false);
|
||||
});
|
||||
|
||||
it("支持按指定 id 顺序重排", async () => {
|
||||
dbState.servers = [makeServer("srv-1", 0), makeServer("srv-2", 1), makeServer("srv-3", 2)];
|
||||
|
||||
const store = useServerStore();
|
||||
await store.ensureBootstrapped();
|
||||
|
||||
const changed = await store.applyServerOrder(["srv-3", "srv-1", "srv-2"]);
|
||||
expect(changed).toBe(true);
|
||||
expect(store.servers.map((item) => item.id)).toEqual(["srv-3", "srv-1", "srv-2"]);
|
||||
expect(store.servers.map((item) => item.sortOrder)).toEqual([0, 1, 2]);
|
||||
|
||||
const unchanged = await store.applyServerOrder(["srv-3", "srv-1", "srv-2"]);
|
||||
expect(unchanged).toBe(false);
|
||||
});
|
||||
});
|
||||
471
apps/web/src/stores/serverStore.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, toRaw, ref } from "vue";
|
||||
import { DEFAULT_JUMP_HOST } from "@remoteconn/shared";
|
||||
import type { CredentialRef, JumpHostProfile, ResolvedCredential, ServerProfile } from "@/types/app";
|
||||
import { db } from "@/services/storage/db";
|
||||
import { decryptCredential, encryptCredential } from "@/services/security/credentialVault";
|
||||
import { nowIso } from "@/utils/time";
|
||||
|
||||
interface ServerCredentialInput {
|
||||
type: CredentialRef["type"];
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
passphrase?: string;
|
||||
certificate?: string;
|
||||
}
|
||||
|
||||
interface ServerCredentialBundleInput {
|
||||
target: ServerCredentialInput;
|
||||
jump?: ServerCredentialInput | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务器与凭据管理。
|
||||
*/
|
||||
export const useServerStore = defineStore("server", () => {
|
||||
const servers = ref<ServerProfile[]>([]);
|
||||
const credentialRefs = ref<CredentialRef[]>([]);
|
||||
const selectedServerId = ref<string>("");
|
||||
const loaded = ref(false);
|
||||
let bootstrapPromise: Promise<void> | null = null;
|
||||
|
||||
function cloneJumpHost(input?: Partial<JumpHostProfile> | null): JumpHostProfile {
|
||||
return {
|
||||
...DEFAULT_JUMP_HOST,
|
||||
...(input ?? {}),
|
||||
enabled: input?.enabled === true,
|
||||
host: String(input?.host ?? "").trim(),
|
||||
port: Number(input?.port ?? DEFAULT_JUMP_HOST.port) || DEFAULT_JUMP_HOST.port,
|
||||
username: String(input?.username ?? "").trim(),
|
||||
authType:
|
||||
input?.authType === "privateKey" || input?.authType === "certificate" ? input.authType : DEFAULT_JUMP_HOST.authType
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCredentialInput(
|
||||
value: Partial<ServerCredentialInput> | null | undefined,
|
||||
fallbackType: CredentialRef["type"] = "password"
|
||||
): ServerCredentialInput {
|
||||
const type = value?.type === "privateKey" || value?.type === "certificate" || value?.type === "password" ? value.type : fallbackType;
|
||||
return {
|
||||
type,
|
||||
password: String(value?.password ?? ""),
|
||||
privateKey: String(value?.privateKey ?? ""),
|
||||
passphrase: String(value?.passphrase ?? ""),
|
||||
certificate: String(value?.certificate ?? "")
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容两类密文结构:
|
||||
* 1. 历史版本:直接保存单份 `ServerCredentialInput`;
|
||||
* 2. 新版本:保存 `{ target, jump }` 凭据包。
|
||||
*/
|
||||
function normalizeCredentialBundle(
|
||||
value: unknown,
|
||||
fallbackType: CredentialRef["type"],
|
||||
jumpAuthType: CredentialRef["type"] | null = null
|
||||
): ServerCredentialBundleInput {
|
||||
const source = value && typeof value === "object" ? (value as Partial<ServerCredentialBundleInput & ServerCredentialInput>) : {};
|
||||
const hasTarget = source && typeof source === "object" && "target" in source;
|
||||
const target = hasTarget
|
||||
? normalizeCredentialInput(source.target, fallbackType)
|
||||
: normalizeCredentialInput(source as Partial<ServerCredentialInput>, fallbackType);
|
||||
const jumpRaw = hasTarget ? source.jump : null;
|
||||
return {
|
||||
target,
|
||||
jump: jumpAuthType ? normalizeCredentialInput(jumpRaw, jumpAuthType) : null
|
||||
};
|
||||
}
|
||||
|
||||
function toResolvedCredential(input: ServerCredentialInput): ResolvedCredential {
|
||||
if (input.type === "password") {
|
||||
return {
|
||||
type: "password",
|
||||
password: input.password ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
if (input.type === "privateKey") {
|
||||
return {
|
||||
type: "privateKey",
|
||||
privateKey: input.privateKey ?? "",
|
||||
passphrase: input.passphrase
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "certificate",
|
||||
privateKey: input.privateKey ?? "",
|
||||
passphrase: input.passphrase,
|
||||
certificate: input.certificate ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
const selectedServer = computed(() => servers.value.find((item) => item.id === selectedServerId.value));
|
||||
|
||||
/**
|
||||
* 规范化排序值:
|
||||
* - 非数字、NaN、负值都视为“缺失排序”;
|
||||
* - 仅保留非负整数,避免浮点或异常值污染排序稳定性。
|
||||
*/
|
||||
function normalizeSortOrder(value: unknown): number | null {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return null;
|
||||
}
|
||||
const normalized = Math.floor(value);
|
||||
if (normalized < 0) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按持久化排序字段恢复列表顺序:
|
||||
* - 优先按 sortOrder 升序;
|
||||
* - 缺失 sortOrder 的历史数据保留原始读取顺序;
|
||||
* - 排序值冲突时回退到原始顺序,保证稳定排序。
|
||||
*/
|
||||
function sortServersByStoredOrder(input: ServerProfile[]): ServerProfile[] {
|
||||
return input
|
||||
.map((server, index) => ({
|
||||
server,
|
||||
index,
|
||||
sortOrder: normalizeSortOrder(server.sortOrder)
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.sortOrder === null && b.sortOrder === null) {
|
||||
return a.index - b.index;
|
||||
}
|
||||
if (a.sortOrder === null) {
|
||||
return 1;
|
||||
}
|
||||
if (b.sortOrder === null) {
|
||||
return -1;
|
||||
}
|
||||
if (a.sortOrder !== b.sortOrder) {
|
||||
return a.sortOrder - b.sortOrder;
|
||||
}
|
||||
return a.index - b.index;
|
||||
})
|
||||
.map((entry) => entry.server);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将当前数组顺序重写为连续 sortOrder,并回写到数据库。
|
||||
* 约束:
|
||||
* - 不改变入参数组的相对顺序;
|
||||
* - 所有项强制回填 sortOrder,保证刷新后顺序稳定可恢复。
|
||||
*/
|
||||
async function persistServerOrder(nextServers: ServerProfile[]): Promise<void> {
|
||||
const ordered = nextServers.map((server, index) => {
|
||||
const entity = toServerEntity(server);
|
||||
return {
|
||||
...entity,
|
||||
sortOrder: index
|
||||
};
|
||||
});
|
||||
servers.value = ordered;
|
||||
await db.servers.bulkPut(ordered.map((item) => toServerEntity(item)));
|
||||
}
|
||||
|
||||
async function ensureBootstrapped(): Promise<void> {
|
||||
if (loaded.value) return;
|
||||
if (bootstrapPromise) {
|
||||
await bootstrapPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
bootstrapPromise = (async () => {
|
||||
const storedServers = await db.servers.toArray();
|
||||
credentialRefs.value = await db.credentialRefs.toArray();
|
||||
|
||||
if (storedServers.length === 0) {
|
||||
const sample = buildDefaultServer();
|
||||
await persistServerOrder([sample]);
|
||||
} else {
|
||||
const sortedServers = sortServersByStoredOrder(storedServers);
|
||||
const needsPersist = sortedServers.some((server, index) => {
|
||||
const current = storedServers[index];
|
||||
return server.id !== current?.id || normalizeSortOrder(server.sortOrder) !== index;
|
||||
});
|
||||
if (needsPersist) {
|
||||
await persistServerOrder(sortedServers);
|
||||
} else {
|
||||
servers.value = sortedServers.map((server) => toServerEntity(server));
|
||||
}
|
||||
}
|
||||
|
||||
selectedServerId.value = servers.value[0]?.id ?? "";
|
||||
loaded.value = true;
|
||||
})();
|
||||
|
||||
try {
|
||||
await bootstrapPromise;
|
||||
} finally {
|
||||
bootstrapPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
await ensureBootstrapped();
|
||||
}
|
||||
|
||||
function buildDefaultServer(): ServerProfile {
|
||||
return {
|
||||
id: `srv-${crypto.randomUUID()}`,
|
||||
name: "新服务器",
|
||||
host: "",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
projectPath: "~/workspace",
|
||||
projectPresets: ["~/workspace"],
|
||||
tags: [],
|
||||
timeoutSeconds: 20,
|
||||
heartbeatSeconds: 15,
|
||||
transportMode: "gateway",
|
||||
jumpHost: cloneJumpHost(),
|
||||
sortOrder: 0,
|
||||
lastConnectedAt: ""
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅创建“新服务器草稿”快照,不写入列表与数据库。
|
||||
* 用于“新增服务器先进入配置页,保存后再落库”的流程。
|
||||
*/
|
||||
function createServerDraft(): ServerProfile {
|
||||
return buildDefaultServer();
|
||||
}
|
||||
|
||||
/**
|
||||
* 将服务器对象转换为可安全写入 IndexedDB 的纯数据实体。
|
||||
* 目的:避免 Vue Proxy 透传到 Dexie 触发 DataCloneError。
|
||||
*/
|
||||
function toServerEntity(server: ServerProfile): ServerProfile {
|
||||
const raw = toRaw(server);
|
||||
return {
|
||||
...raw,
|
||||
projectPresets: [...raw.projectPresets],
|
||||
tags: [...raw.tags],
|
||||
jumpHost: cloneJumpHost(raw.jumpHost)
|
||||
};
|
||||
}
|
||||
|
||||
async function createServer(): Promise<void> {
|
||||
const sample = createServerDraft();
|
||||
await persistServerOrder([sample, ...servers.value]);
|
||||
selectedServerId.value = sample.id;
|
||||
}
|
||||
|
||||
async function saveServer(server: ServerProfile): Promise<void> {
|
||||
const nextServers = [...servers.value];
|
||||
const index = servers.value.findIndex((item) => item.id === server.id);
|
||||
if (index >= 0) {
|
||||
nextServers[index] = server;
|
||||
} else {
|
||||
nextServers.unshift(server);
|
||||
}
|
||||
await persistServerOrder(nextServers);
|
||||
}
|
||||
|
||||
async function deleteServer(serverId: string): Promise<void> {
|
||||
const nextServers = servers.value.filter((item) => item.id !== serverId);
|
||||
await db.servers.delete(serverId);
|
||||
await persistServerOrder(nextServers);
|
||||
if (selectedServerId.value === serverId) {
|
||||
selectedServerId.value = servers.value[0]?.id ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将指定服务器上移一位。
|
||||
* 返回:
|
||||
* - true: 已成功移动并持久化;
|
||||
* - false: 不存在或已在顶部,无需移动。
|
||||
*/
|
||||
async function moveServerUp(serverId: string): Promise<boolean> {
|
||||
const index = servers.value.findIndex((item) => item.id === serverId);
|
||||
if (index <= 0) {
|
||||
return false;
|
||||
}
|
||||
const nextServers = [...servers.value];
|
||||
const previous = nextServers[index - 1];
|
||||
const current = nextServers[index];
|
||||
if (!previous || !current) {
|
||||
return false;
|
||||
}
|
||||
nextServers[index - 1] = current;
|
||||
nextServers[index] = previous;
|
||||
await persistServerOrder(nextServers);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将指定服务器下移一位。
|
||||
* 返回:
|
||||
* - true: 已成功移动并持久化;
|
||||
* - false: 不存在或已在底部,无需移动。
|
||||
*/
|
||||
async function moveServerDown(serverId: string): Promise<boolean> {
|
||||
const index = servers.value.findIndex((item) => item.id === serverId);
|
||||
if (index < 0 || index >= servers.value.length - 1) {
|
||||
return false;
|
||||
}
|
||||
const nextServers = [...servers.value];
|
||||
const current = nextServers[index];
|
||||
const next = nextServers[index + 1];
|
||||
if (!current || !next) {
|
||||
return false;
|
||||
}
|
||||
nextServers[index] = next;
|
||||
nextServers[index + 1] = current;
|
||||
await persistServerOrder(nextServers);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按传入 ID 顺序重排服务器列表并持久化。
|
||||
* 规则:
|
||||
* - `orderedIds` 中不存在/重复的项会被忽略;
|
||||
* - 未出现在 `orderedIds` 的服务器按原顺序追加到末尾;
|
||||
* - 若顺序无变化,返回 false。
|
||||
*/
|
||||
async function applyServerOrder(orderedIds: string[]): Promise<boolean> {
|
||||
const byId = new Map(servers.value.map((server) => [server.id, server] as const));
|
||||
const seen = new Set<string>();
|
||||
const head: ServerProfile[] = [];
|
||||
|
||||
for (const id of orderedIds) {
|
||||
if (!id || seen.has(id)) {
|
||||
continue;
|
||||
}
|
||||
const matched = byId.get(id);
|
||||
if (!matched) {
|
||||
continue;
|
||||
}
|
||||
seen.add(id);
|
||||
head.push(matched);
|
||||
}
|
||||
|
||||
const tail = servers.value.filter((server) => !seen.has(server.id));
|
||||
const nextServers = [...head, ...tail];
|
||||
|
||||
if (
|
||||
nextServers.length === servers.value.length &&
|
||||
nextServers.every((server, index) => server.id === servers.value[index]?.id)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await persistServerOrder(nextServers);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function saveCredential(
|
||||
refId: string,
|
||||
payload: ServerCredentialInput,
|
||||
jumpPayload?: ServerCredentialInput | null
|
||||
): Promise<CredentialRef> {
|
||||
const exists = credentialRefs.value.find((item) => item.id === refId);
|
||||
const now = nowIso();
|
||||
|
||||
const ref: CredentialRef = {
|
||||
id: refId,
|
||||
type: payload.type,
|
||||
secureStoreKey: `web:credential:${refId}`,
|
||||
createdAt: exists?.createdAt ?? now,
|
||||
updatedAt: now
|
||||
};
|
||||
|
||||
await db.credentialRefs.put(ref);
|
||||
await db.credentials.where("refId").equals(refId).delete();
|
||||
const encrypted = await encryptCredential(refId, {
|
||||
target: normalizeCredentialInput(payload, payload.type),
|
||||
jump: jumpPayload ? normalizeCredentialInput(jumpPayload, jumpPayload.type) : null
|
||||
} satisfies ServerCredentialBundleInput);
|
||||
await db.credentials.put(encrypted);
|
||||
|
||||
const idx = credentialRefs.value.findIndex((item) => item.id === refId);
|
||||
if (idx >= 0) {
|
||||
credentialRefs.value[idx] = ref;
|
||||
} else {
|
||||
credentialRefs.value.push(ref);
|
||||
}
|
||||
|
||||
return ref;
|
||||
}
|
||||
|
||||
async function resolveCredential(refId: string): Promise<ResolvedCredential> {
|
||||
const bundle = await resolveCredentialBundle(refId);
|
||||
return bundle.target;
|
||||
}
|
||||
|
||||
async function resolveCredentialBundle(
|
||||
refId: string,
|
||||
jumpAuthType: CredentialRef["type"] | null = null
|
||||
): Promise<{ target: ResolvedCredential; jump: ResolvedCredential | null }> {
|
||||
const ref = credentialRefs.value.find((item) => item.id === refId);
|
||||
if (!ref) {
|
||||
throw new Error("凭据引用不存在");
|
||||
}
|
||||
|
||||
const payload = await db.credentials.where("refId").equals(refId).first();
|
||||
if (!payload) {
|
||||
throw new Error("未找到凭据内容");
|
||||
}
|
||||
|
||||
const decrypted = await decryptCredential<ServerCredentialBundleInput | ServerCredentialInput>(payload);
|
||||
const bundle = normalizeCredentialBundle(decrypted, ref.type, jumpAuthType);
|
||||
return {
|
||||
target: toResolvedCredential(bundle.target),
|
||||
jump: bundle.jump ? toResolvedCredential(bundle.jump) : null
|
||||
};
|
||||
}
|
||||
|
||||
async function getCredentialInput(refId: string): Promise<ServerCredentialInput | null> {
|
||||
const bundle = await getCredentialBundleInput(refId);
|
||||
return bundle?.target ?? null;
|
||||
}
|
||||
|
||||
async function getCredentialBundleInput(refId: string): Promise<ServerCredentialBundleInput | null> {
|
||||
const payload = await db.credentials.where("refId").equals(refId).first();
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
const ref = credentialRefs.value.find((item) => item.id === refId);
|
||||
const fallbackType = ref?.type ?? "password";
|
||||
const decrypted = await decryptCredential<ServerCredentialBundleInput | ServerCredentialInput>(payload);
|
||||
return normalizeCredentialBundle(decrypted, fallbackType, "password");
|
||||
}
|
||||
|
||||
async function markConnected(serverId: string): Promise<void> {
|
||||
const target = servers.value.find((item) => item.id === serverId);
|
||||
if (!target) return;
|
||||
target.lastConnectedAt = nowIso();
|
||||
await db.servers.put(toServerEntity(target));
|
||||
}
|
||||
|
||||
return {
|
||||
servers,
|
||||
credentialRefs,
|
||||
selectedServerId,
|
||||
selectedServer,
|
||||
ensureBootstrapped,
|
||||
bootstrap,
|
||||
createServerDraft,
|
||||
createServer,
|
||||
saveServer,
|
||||
deleteServer,
|
||||
moveServerUp,
|
||||
moveServerDown,
|
||||
applyServerOrder,
|
||||
saveCredential,
|
||||
resolveCredential,
|
||||
resolveCredentialBundle,
|
||||
getCredentialInput,
|
||||
getCredentialBundleInput,
|
||||
markConnected
|
||||
};
|
||||
});
|
||||
788
apps/web/src/stores/sessionStore.test.ts
Normal file
@@ -0,0 +1,788 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import type { ServerProfile } from "@/types/app";
|
||||
|
||||
type MockTransportEvent = {
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type MockResolvedCredential =
|
||||
| { type: "password"; password: string }
|
||||
| { type: "privateKey"; privateKey: string; passphrase?: string }
|
||||
| { type: "certificate"; privateKey: string; certificate: string; passphrase?: string };
|
||||
|
||||
type MockResolvedCredentialBundle = {
|
||||
target: MockResolvedCredential;
|
||||
jump: MockResolvedCredential | null;
|
||||
};
|
||||
|
||||
const {
|
||||
settingsStoreMock,
|
||||
serverStoreMock,
|
||||
logStoreMock,
|
||||
appStoreMock,
|
||||
createTransportMock,
|
||||
transportMock,
|
||||
emitSessionEventMock,
|
||||
listeners,
|
||||
sessionStorageState
|
||||
} = vi.hoisted(() => {
|
||||
const listenersRef: { value: ((event: MockTransportEvent) => Promise<void> | void) | null } = {
|
||||
value: null
|
||||
};
|
||||
const sessionStorageMap = new Map<string, string>();
|
||||
|
||||
const transport = {
|
||||
on: vi.fn((handler: (event: MockTransportEvent) => Promise<void> | void) => {
|
||||
listenersRef.value = handler;
|
||||
return () => {
|
||||
listenersRef.value = null;
|
||||
};
|
||||
}),
|
||||
connect: vi.fn(async () => {}),
|
||||
send: vi.fn(async () => {}),
|
||||
disconnect: vi.fn(async () => {}),
|
||||
resize: vi.fn(async () => {})
|
||||
};
|
||||
|
||||
return {
|
||||
settingsStoreMock: {
|
||||
settings: {
|
||||
autoReconnect: true,
|
||||
reconnectLimit: 2,
|
||||
terminalBufferMaxEntries: 5000,
|
||||
terminalBufferMaxBytes: 4 * 1024 * 1024,
|
||||
gatewayUrl: "ws://127.0.0.1:8787/ws/terminal",
|
||||
gatewayToken: "dev-token"
|
||||
},
|
||||
gatewayUrl: "ws://127.0.0.1:8787/ws/terminal",
|
||||
gatewayToken: "dev-token",
|
||||
knownHosts: {},
|
||||
verifyAndPersistHostFingerprint: vi.fn(async () => true)
|
||||
},
|
||||
serverStoreMock: {
|
||||
servers: [] as ServerProfile[],
|
||||
resolveCredential: vi.fn(async () => ({ type: "password", password: "secret" })),
|
||||
resolveCredentialBundle: vi.fn(
|
||||
async (): Promise<MockResolvedCredentialBundle> => ({
|
||||
target: { type: "password", password: "secret" },
|
||||
jump: null
|
||||
})
|
||||
),
|
||||
markConnected: vi.fn(async () => {})
|
||||
},
|
||||
logStoreMock: {
|
||||
startLog: vi.fn(async () => "session-log-1"),
|
||||
markStatus: vi.fn(async () => {}),
|
||||
addMarker: vi.fn(async () => {})
|
||||
},
|
||||
appStoreMock: {
|
||||
notify: vi.fn()
|
||||
},
|
||||
createTransportMock: vi.fn(() => transport),
|
||||
transportMock: transport,
|
||||
emitSessionEventMock: vi.fn(),
|
||||
listeners: listenersRef,
|
||||
sessionStorageState: {
|
||||
map: sessionStorageMap,
|
||||
clear: () => sessionStorageMap.clear(),
|
||||
getItem: (key: string) => sessionStorageMap.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
sessionStorageMap.set(key, value);
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@remoteconn/shared", () => ({
|
||||
allStates: () => [
|
||||
"idle",
|
||||
"connecting",
|
||||
"auth_pending",
|
||||
"connected",
|
||||
"reconnecting",
|
||||
"disconnected",
|
||||
"error"
|
||||
],
|
||||
buildCdCommand: (projectPath: string) => `cd ${projectPath}`,
|
||||
buildCodexPlan: (options: {
|
||||
projectPath: string;
|
||||
sandbox: "read-only" | "workspace-write" | "danger-full-access";
|
||||
resumeLast?: boolean;
|
||||
}) => [
|
||||
{
|
||||
step: "cd",
|
||||
command: `cd ${options.projectPath}`,
|
||||
markerType: "cd"
|
||||
},
|
||||
{
|
||||
step: "check",
|
||||
command: "command -v codex",
|
||||
markerType: "check"
|
||||
},
|
||||
{
|
||||
step: "run",
|
||||
command: options.resumeLast
|
||||
? `codex resume --last --sandbox ${options.sandbox}`
|
||||
: `codex --sandbox ${options.sandbox}`,
|
||||
markerType: "run"
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
vi.mock("./settingsStore", () => ({
|
||||
useSettingsStore: () => settingsStoreMock
|
||||
}));
|
||||
|
||||
vi.mock("./serverStore", () => ({
|
||||
useServerStore: () => serverStoreMock
|
||||
}));
|
||||
|
||||
vi.mock("./logStore", () => ({
|
||||
useLogStore: () => logStoreMock
|
||||
}));
|
||||
|
||||
vi.mock("./appStore", () => ({
|
||||
useAppStore: () => appStoreMock
|
||||
}));
|
||||
|
||||
vi.mock("@/services/transport/factory", () => ({
|
||||
createTransport: createTransportMock
|
||||
}));
|
||||
|
||||
vi.mock("@/services/sessionEventBus", () => ({
|
||||
emitSessionEvent: emitSessionEventMock
|
||||
}));
|
||||
|
||||
vi.mock("@/utils/feedback", () => ({
|
||||
formatActionError: (_prefix: string, error: unknown) => String(error),
|
||||
toFriendlyDisconnectReason: (reason: string) => reason,
|
||||
toFriendlyError: (message: string) => message
|
||||
}));
|
||||
|
||||
import { useSessionStore } from "./sessionStore";
|
||||
|
||||
function setupWindowSessionStorage(): void {
|
||||
const sessionStorage = {
|
||||
getItem: (key: string) => sessionStorageState.getItem(key),
|
||||
setItem: (key: string, value: string) => sessionStorageState.setItem(key, value)
|
||||
};
|
||||
|
||||
const windowMock = {
|
||||
sessionStorage,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
};
|
||||
|
||||
const documentMock = {
|
||||
visibilityState: "visible",
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
};
|
||||
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: windowMock
|
||||
});
|
||||
|
||||
Object.defineProperty(globalThis, "document", {
|
||||
configurable: true,
|
||||
value: documentMock
|
||||
});
|
||||
}
|
||||
|
||||
describe("sessionStore", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
sessionStorageState.clear();
|
||||
listeners.value = null;
|
||||
|
||||
transportMock.on.mockClear();
|
||||
transportMock.connect.mockClear();
|
||||
transportMock.send.mockClear();
|
||||
transportMock.disconnect.mockClear();
|
||||
transportMock.resize.mockClear();
|
||||
|
||||
createTransportMock.mockClear();
|
||||
emitSessionEventMock.mockClear();
|
||||
|
||||
appStoreMock.notify.mockClear();
|
||||
logStoreMock.startLog.mockClear();
|
||||
logStoreMock.markStatus.mockClear();
|
||||
serverStoreMock.resolveCredential.mockClear();
|
||||
serverStoreMock.resolveCredentialBundle.mockClear();
|
||||
serverStoreMock.markConnected.mockClear();
|
||||
settingsStoreMock.settings.autoReconnect = true;
|
||||
settingsStoreMock.settings.reconnectLimit = 2;
|
||||
settingsStoreMock.knownHosts = {};
|
||||
serverStoreMock.servers = [];
|
||||
|
||||
setupWindowSessionStorage();
|
||||
});
|
||||
|
||||
it("启动时恢复快照并自动重连", async () => {
|
||||
const server: ServerProfile = {
|
||||
id: "srv-1",
|
||||
name: "mini",
|
||||
host: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "gavin",
|
||||
authType: "password",
|
||||
projectPath: "~",
|
||||
projectPresets: [],
|
||||
tags: [],
|
||||
timeoutSeconds: 20,
|
||||
heartbeatSeconds: 15,
|
||||
transportMode: "gateway"
|
||||
};
|
||||
|
||||
serverStoreMock.servers = [server];
|
||||
|
||||
sessionStorageState.setItem(
|
||||
"remoteconn_session_snapshot_v1",
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
savedAt: Date.now(),
|
||||
activeConnectionKey: "srv-1::snapshot",
|
||||
lines: ["restored-line"],
|
||||
currentServerId: "srv-1",
|
||||
reconnectServerId: "srv-1"
|
||||
})
|
||||
);
|
||||
|
||||
const store = useSessionStore();
|
||||
await store.ensureBootstrapped();
|
||||
|
||||
expect(createTransportMock).toHaveBeenCalledTimes(1);
|
||||
expect(transportMock.connect).toHaveBeenCalledTimes(1);
|
||||
expect(store.currentServerId).toBe("srv-1");
|
||||
expect(store.lines).toContain("restored-line");
|
||||
});
|
||||
|
||||
it("刷新恢复连接不受 autoReconnect 开关影响", async () => {
|
||||
const server: ServerProfile = {
|
||||
id: "srv-reload",
|
||||
name: "reload",
|
||||
host: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "gavin",
|
||||
authType: "password",
|
||||
projectPath: "~",
|
||||
projectPresets: [],
|
||||
tags: [],
|
||||
timeoutSeconds: 20,
|
||||
heartbeatSeconds: 15,
|
||||
transportMode: "gateway"
|
||||
};
|
||||
|
||||
serverStoreMock.servers = [server];
|
||||
settingsStoreMock.settings.autoReconnect = false;
|
||||
|
||||
sessionStorageState.setItem(
|
||||
"remoteconn_session_snapshot_v1",
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
savedAt: Date.now(),
|
||||
activeConnectionKey: "srv-reload::snapshot",
|
||||
lines: ["reloaded"],
|
||||
currentServerId: "srv-reload",
|
||||
reconnectServerId: "srv-reload"
|
||||
})
|
||||
);
|
||||
|
||||
const store = useSessionStore();
|
||||
await store.ensureBootstrapped();
|
||||
|
||||
expect(transportMock.connect).toHaveBeenCalledTimes(1);
|
||||
expect(store.currentServerId).toBe("srv-reload");
|
||||
});
|
||||
|
||||
it("刷新后若旧 SSH 未续上且快照记得 Codex 前台,应自动执行 codex resume --last", async () => {
|
||||
const server: ServerProfile = {
|
||||
id: "srv-codex-resume",
|
||||
name: "codex-resume",
|
||||
host: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "gavin",
|
||||
authType: "password",
|
||||
projectPath: "~/workspace/remoteconn",
|
||||
projectPresets: [],
|
||||
tags: [],
|
||||
timeoutSeconds: 20,
|
||||
heartbeatSeconds: 15,
|
||||
transportMode: "gateway"
|
||||
};
|
||||
|
||||
serverStoreMock.servers = [server];
|
||||
sessionStorageState.setItem(
|
||||
"remoteconn_session_snapshot_v1",
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
savedAt: Date.now(),
|
||||
activeConnectionKey: "srv-codex-resume::snapshot",
|
||||
lines: ["codex-running"],
|
||||
currentServerId: "srv-codex-resume",
|
||||
reconnectServerId: "srv-codex-resume",
|
||||
activeAiProvider: "codex",
|
||||
codexSandboxMode: "danger-full-access"
|
||||
})
|
||||
);
|
||||
|
||||
const store = useSessionStore();
|
||||
await store.ensureBootstrapped();
|
||||
await listeners.value?.({ type: "connected" });
|
||||
|
||||
expect(
|
||||
transportMock.send.mock.calls.some((args: unknown[]) =>
|
||||
String(args.at(0) ?? "").includes("codex resume --last --sandbox danger-full-access")
|
||||
)
|
||||
).toBe(true);
|
||||
expect(appStoreMock.notify).toHaveBeenCalledWith("info", "检测到上次 Codex 会话,正在尝试恢复");
|
||||
expect(store.currentServerId).toBe("srv-codex-resume");
|
||||
});
|
||||
|
||||
it("网关已续上旧 SSH 时,不应重复执行 codex resume --last", async () => {
|
||||
const server: ServerProfile = {
|
||||
id: "srv-codex-resumed",
|
||||
name: "codex-resumed",
|
||||
host: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "gavin",
|
||||
authType: "password",
|
||||
projectPath: "~/workspace/remoteconn",
|
||||
projectPresets: [],
|
||||
tags: [],
|
||||
timeoutSeconds: 20,
|
||||
heartbeatSeconds: 15,
|
||||
transportMode: "gateway"
|
||||
};
|
||||
|
||||
serverStoreMock.servers = [server];
|
||||
sessionStorageState.setItem(
|
||||
"remoteconn_session_snapshot_v1",
|
||||
JSON.stringify({
|
||||
version: 2,
|
||||
savedAt: Date.now(),
|
||||
activeConnectionKey: "srv-codex-resumed::snapshot",
|
||||
lines: ["codex-running"],
|
||||
currentServerId: "srv-codex-resumed",
|
||||
reconnectServerId: "srv-codex-resumed",
|
||||
activeAiProvider: "codex",
|
||||
codexSandboxMode: "danger-full-access"
|
||||
})
|
||||
);
|
||||
|
||||
const store = useSessionStore();
|
||||
await store.ensureBootstrapped();
|
||||
await listeners.value?.({ type: "connected", resumed: true });
|
||||
|
||||
expect(
|
||||
transportMock.send.mock.calls.some((args: unknown[]) =>
|
||||
String(args.at(0) ?? "").includes("codex resume --last --sandbox")
|
||||
)
|
||||
).toBe(false);
|
||||
expect(store.currentServerId).toBe("srv-codex-resumed");
|
||||
});
|
||||
|
||||
it("Copilot 前台态会点亮 AI 按钮,并在退出标记到达后自动解除", async () => {
|
||||
const server: ServerProfile = {
|
||||
id: "srv-copilot",
|
||||
name: "copilot",
|
||||
host: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "gavin",
|
||||
authType: "password",
|
||||
projectPath: "~/workspace/remoteconn",
|
||||
projectPresets: [],
|
||||
tags: [],
|
||||
timeoutSeconds: 20,
|
||||
heartbeatSeconds: 15,
|
||||
transportMode: "gateway"
|
||||
};
|
||||
|
||||
serverStoreMock.servers = [server];
|
||||
|
||||
const store = useSessionStore();
|
||||
await store.connect(server);
|
||||
await listeners.value?.({ type: "connected" });
|
||||
|
||||
await store.runCopilot(server.projectPath, "copilot --allow-all");
|
||||
|
||||
expect(
|
||||
transportMock.send.mock.calls.some((args: unknown[]) => {
|
||||
const command = String(args.at(0) ?? "");
|
||||
return command.includes("copilot --allow-all") && command.includes("ai-exit=copilot");
|
||||
})
|
||||
).toBe(true);
|
||||
expect(store.activeAiProvider).toBe("copilot");
|
||||
expect(store.isServerAiActive("srv-copilot")).toBe(true);
|
||||
|
||||
await listeners.value?.({ type: "stdout", data: "\u001b]633;RemoteConn;ai-exit=copilot\u0007" });
|
||||
|
||||
expect(store.activeAiProvider).toBe("");
|
||||
expect(store.isServerAiActive("srv-copilot")).toBe(false);
|
||||
});
|
||||
|
||||
it("ios-native 已完成兼容初始化后,中文输入不重复注入兼容命令", async () => {
|
||||
const server: ServerProfile = {
|
||||
id: "srv-2",
|
||||
name: "ios",
|
||||
host: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "gavin",
|
||||
authType: "password",
|
||||
projectPath: "~",
|
||||
projectPresets: [],
|
||||
tags: [],
|
||||
timeoutSeconds: 20,
|
||||
heartbeatSeconds: 15,
|
||||
transportMode: "ios-native"
|
||||
};
|
||||
|
||||
serverStoreMock.servers = [server];
|
||||
|
||||
const store = useSessionStore();
|
||||
await store.connect(server);
|
||||
|
||||
expect(listeners.value).toBeTypeOf("function");
|
||||
await listeners.value?.({ type: "connected" });
|
||||
|
||||
const shellCompatCalls = transportMock.send.mock.calls.filter((args: unknown[]) =>
|
||||
String(args.at(0) ?? "").includes("setopt MULTIBYTE PRINT_EIGHT_BIT")
|
||||
);
|
||||
expect(shellCompatCalls).toHaveLength(1);
|
||||
|
||||
await store.sendInput("中文");
|
||||
|
||||
const shellCompatCallsAfterInput = transportMock.send.mock.calls.filter((args: unknown[]) =>
|
||||
String(args.at(0) ?? "").includes("setopt MULTIBYTE PRINT_EIGHT_BIT")
|
||||
);
|
||||
expect(shellCompatCallsAfterInput).toHaveLength(1);
|
||||
expect(transportMock.send).toHaveBeenLastCalledWith("中文", undefined);
|
||||
});
|
||||
|
||||
it("启用跳转主机后应先连接基础信息服务器,再跳转到目标主机", async () => {
|
||||
const server: ServerProfile = {
|
||||
id: "srv-jump",
|
||||
name: "jump",
|
||||
host: "base.example.com",
|
||||
port: 22,
|
||||
username: "base-user",
|
||||
authType: "password",
|
||||
projectPath: "~",
|
||||
projectPresets: [],
|
||||
tags: [],
|
||||
timeoutSeconds: 20,
|
||||
heartbeatSeconds: 15,
|
||||
transportMode: "gateway",
|
||||
jumpHost: {
|
||||
enabled: true,
|
||||
host: "target.example.com",
|
||||
port: 2200,
|
||||
username: "target-user",
|
||||
authType: "privateKey"
|
||||
}
|
||||
};
|
||||
|
||||
serverStoreMock.servers = [server];
|
||||
settingsStoreMock.knownHosts = {
|
||||
"base.example.com:22": "base-fingerprint",
|
||||
"target.example.com:2200": "target-fingerprint"
|
||||
};
|
||||
serverStoreMock.resolveCredentialBundle.mockResolvedValueOnce({
|
||||
target: { type: "password", password: "base-secret" },
|
||||
jump: { type: "privateKey", privateKey: "target-key" }
|
||||
});
|
||||
|
||||
const store = useSessionStore();
|
||||
await store.connect(server);
|
||||
|
||||
expect(transportMock.connect).toHaveBeenCalledTimes(1);
|
||||
expect(transportMock.connect).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
host: "target.example.com",
|
||||
port: 2200,
|
||||
username: "target-user",
|
||||
credential: { type: "privateKey", privateKey: "target-key" },
|
||||
knownHostFingerprint: "target-fingerprint",
|
||||
jumpHost: {
|
||||
host: "base.example.com",
|
||||
port: 22,
|
||||
username: "base-user",
|
||||
credential: { type: "password", password: "base-secret" },
|
||||
knownHostFingerprint: "base-fingerprint"
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("同服务器手动重连应保留输出历史;切换服务器应隔离历史", async () => {
|
||||
const serverA: ServerProfile = {
|
||||
id: "srv-a",
|
||||
name: "A",
|
||||
host: "10.0.0.1",
|
||||
port: 22,
|
||||
username: "gavin",
|
||||
authType: "password",
|
||||
projectPath: "~",
|
||||
projectPresets: [],
|
||||
tags: [],
|
||||
timeoutSeconds: 20,
|
||||
heartbeatSeconds: 15,
|
||||
transportMode: "gateway"
|
||||
};
|
||||
const serverB = {
|
||||
...serverA,
|
||||
id: "srv-b",
|
||||
name: "B",
|
||||
host: "10.0.0.2"
|
||||
};
|
||||
|
||||
serverStoreMock.servers = [serverA, serverB];
|
||||
|
||||
const store = useSessionStore();
|
||||
await store.connect(serverA);
|
||||
await listeners.value?.({ type: "connected" });
|
||||
await listeners.value?.({ type: "stdout", data: "history-from-a\r\n" });
|
||||
expect(store.lines.join("")).toContain("history-from-a");
|
||||
|
||||
await store.disconnect("manual", true);
|
||||
await store.connect(serverA);
|
||||
await listeners.value?.({ type: "connected" });
|
||||
expect(store.lines.join("")).toContain("history-from-a");
|
||||
|
||||
await store.connect(serverB);
|
||||
await listeners.value?.({ type: "connected" });
|
||||
expect(store.lines.join("")).not.toContain("history-from-a");
|
||||
});
|
||||
|
||||
it("ws_closed 断开后应进入可续接态,并在手动断开时清除", async () => {
|
||||
const server: ServerProfile = {
|
||||
id: "srv-resume",
|
||||
name: "resume",
|
||||
host: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "gavin",
|
||||
authType: "password",
|
||||
projectPath: "~",
|
||||
projectPresets: [],
|
||||
tags: [],
|
||||
timeoutSeconds: 20,
|
||||
heartbeatSeconds: 15,
|
||||
transportMode: "gateway"
|
||||
};
|
||||
|
||||
serverStoreMock.servers = [server];
|
||||
settingsStoreMock.settings.autoReconnect = false;
|
||||
|
||||
const store = useSessionStore();
|
||||
await store.connect(server);
|
||||
await listeners.value?.({ type: "connected" });
|
||||
expect(store.isServerResumable(server.id)).toBe(false);
|
||||
|
||||
await listeners.value?.({ type: "disconnect", reason: "ws_closed" });
|
||||
expect(store.isServerResumable(server.id)).toBe(true);
|
||||
|
||||
await store.disconnect("manual", true);
|
||||
expect(store.isServerResumable(server.id)).toBe(false);
|
||||
});
|
||||
|
||||
it("手动断开时即使底层回报 ws_closed,也不应触发自动重连", async () => {
|
||||
vi.useFakeTimers();
|
||||
setupWindowSessionStorage();
|
||||
|
||||
try {
|
||||
const server: ServerProfile = {
|
||||
id: "srv-manual-no-reconnect",
|
||||
name: "manual-no-reconnect",
|
||||
host: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "gavin",
|
||||
authType: "password",
|
||||
projectPath: "~",
|
||||
projectPresets: [],
|
||||
tags: [],
|
||||
timeoutSeconds: 20,
|
||||
heartbeatSeconds: 15,
|
||||
transportMode: "gateway"
|
||||
};
|
||||
|
||||
serverStoreMock.servers = [server];
|
||||
|
||||
const store = useSessionStore();
|
||||
await store.connect(server);
|
||||
await listeners.value?.({ type: "connected" });
|
||||
|
||||
transportMock.disconnect.mockImplementationOnce(async () => {
|
||||
await listeners.value?.({ type: "disconnect", reason: "ws_closed" });
|
||||
});
|
||||
|
||||
await store.disconnect("manual", true);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
||||
expect(transportMock.connect).toHaveBeenCalledTimes(1);
|
||||
expect(store.state).toBe("disconnected");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("Codex 预检命令回显包含 token 时不应误报目录不存在或未安装", async () => {
|
||||
const server: ServerProfile = {
|
||||
id: "srv-codex-ok",
|
||||
name: "codex-ok",
|
||||
host: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "gavin",
|
||||
authType: "password",
|
||||
projectPath: "~/workspace",
|
||||
projectPresets: [],
|
||||
tags: [],
|
||||
timeoutSeconds: 20,
|
||||
heartbeatSeconds: 15,
|
||||
transportMode: "gateway"
|
||||
};
|
||||
|
||||
serverStoreMock.servers = [server];
|
||||
|
||||
const store = useSessionStore();
|
||||
await store.connect(server);
|
||||
await listeners.value?.({ type: "connected" });
|
||||
|
||||
const launchedPromise = store.runCodex(server.projectPath, "workspace-write");
|
||||
const bootstrapCommand = String((transportMock.send.mock.calls.at(-1) ?? []).join(" "));
|
||||
expect(bootstrapCommand.startsWith('sh -lc "')).toBe(true);
|
||||
|
||||
// 模拟 shell 回显“整条 bootstrap 命令”(包含 token 字面量),随后输出 READY。
|
||||
await listeners.value?.({
|
||||
type: "stdout",
|
||||
data:
|
||||
"__rc_codex_path_ok=1; __rc_codex_bin_ok=1; [ \"$__rc_codex_path_ok\" -eq 1 ] || printf '__RC_CODEX_DIR_MISSING__\\n'; " +
|
||||
"[ \"$__rc_codex_bin_ok\" -eq 1 ] || printf '__RC_CODEX_BIN_MISSING__\\n';\r\n" +
|
||||
"__RC_CODEX_READY__\r\nCodex started\r\n"
|
||||
});
|
||||
|
||||
const launched = await launchedPromise;
|
||||
expect(launched).toBe(true);
|
||||
|
||||
const warnMessages = appStoreMock.notify.mock.calls
|
||||
.filter((args: unknown[]) => args[0] === "warn")
|
||||
.map((args: unknown[]) => String(args[1] ?? ""));
|
||||
|
||||
expect(warnMessages.some((message) => message.includes("codex工作目录"))).toBe(false);
|
||||
expect(warnMessages.some((message) => message.includes("服务器未装codex"))).toBe(false);
|
||||
});
|
||||
|
||||
it("Codex 预检收到失败 token 行时应返回失败并提示原因", async () => {
|
||||
const server: ServerProfile = {
|
||||
id: "srv-codex-missing",
|
||||
name: "codex-missing",
|
||||
host: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "gavin",
|
||||
authType: "password",
|
||||
projectPath: "~/workspace",
|
||||
projectPresets: [],
|
||||
tags: [],
|
||||
timeoutSeconds: 20,
|
||||
heartbeatSeconds: 15,
|
||||
transportMode: "gateway"
|
||||
};
|
||||
|
||||
serverStoreMock.servers = [server];
|
||||
|
||||
const store = useSessionStore();
|
||||
await store.connect(server);
|
||||
await listeners.value?.({ type: "connected" });
|
||||
|
||||
const launchedPromise = store.runCodex(server.projectPath, "workspace-write");
|
||||
const bootstrapCommand = String((transportMock.send.mock.calls.at(-1) ?? []).join(" "));
|
||||
expect(bootstrapCommand.startsWith('sh -lc "')).toBe(true);
|
||||
await listeners.value?.({ type: "stdout", data: "__RC_CODEX_BIN_MISSING__\r\n" });
|
||||
|
||||
const launched = await launchedPromise;
|
||||
expect(launched).toBe(false);
|
||||
expect(appStoreMock.notify).toHaveBeenCalledWith("warn", "服务器未装codex");
|
||||
});
|
||||
|
||||
it("Codex 前台态时 clearTerminal 不应清空当前缓冲", async () => {
|
||||
const server: ServerProfile = {
|
||||
id: "srv-codex-clear-guard",
|
||||
name: "codex-clear-guard",
|
||||
host: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "gavin",
|
||||
authType: "password",
|
||||
projectPath: "~/workspace",
|
||||
projectPresets: [],
|
||||
tags: [],
|
||||
timeoutSeconds: 20,
|
||||
heartbeatSeconds: 15,
|
||||
transportMode: "gateway"
|
||||
};
|
||||
|
||||
serverStoreMock.servers = [server];
|
||||
|
||||
const store = useSessionStore();
|
||||
await store.connect(server);
|
||||
await listeners.value?.({ type: "connected" });
|
||||
await listeners.value?.({ type: "stdout", data: "before-clear\r\n" });
|
||||
|
||||
const launchedPromise = store.runCodex(server.projectPath, "workspace-write");
|
||||
await listeners.value?.({ type: "stdout", data: "__RC_CODEX_READY__\r\n" });
|
||||
|
||||
const launched = await launchedPromise;
|
||||
expect(launched).toBe(true);
|
||||
expect(store.activeAiProvider).toBe("codex");
|
||||
|
||||
const previousLines = [...store.lines];
|
||||
const previousRevision = store.outputRevision;
|
||||
|
||||
store.clearTerminal();
|
||||
|
||||
expect(store.lines).toEqual(previousLines);
|
||||
expect(store.outputRevision).toBe(previousRevision);
|
||||
});
|
||||
|
||||
it("命令回显包含 READY 字面量但无 READY token 行时,不应提前判定成功", async () => {
|
||||
const server: ServerProfile = {
|
||||
id: "srv-codex-ready-literal",
|
||||
name: "codex-ready-literal",
|
||||
host: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "gavin",
|
||||
authType: "password",
|
||||
projectPath: "~",
|
||||
projectPresets: [],
|
||||
tags: [],
|
||||
timeoutSeconds: 20,
|
||||
heartbeatSeconds: 15,
|
||||
transportMode: "gateway"
|
||||
};
|
||||
|
||||
serverStoreMock.servers = [server];
|
||||
|
||||
const store = useSessionStore();
|
||||
await store.connect(server);
|
||||
await listeners.value?.({ type: "connected" });
|
||||
|
||||
const launchedPromise = store.runCodex(server.projectPath, "workspace-write");
|
||||
|
||||
// 仅回显脚本字面量(包含 READY token 文本,但不是独立 token 行)。
|
||||
await listeners.value?.({
|
||||
type: "stdout",
|
||||
data:
|
||||
'__rc_codex_path_ok=1; __rc_codex_bin_ok=1; if [ "$__rc_codex_path_ok" -eq 1 ] && [ "$__rc_codex_bin_ok" -eq 1 ]; ' +
|
||||
"then printf '__RC_CODEX_READY__\\n'; codex --sandbox workspace-write; fi\r\n"
|
||||
});
|
||||
// 随后给出真实失败 token 行,应返回失败并提示未安装。
|
||||
await listeners.value?.({ type: "stdout", data: "__RC_CODEX_BIN_MISSING__\r\n" });
|
||||
|
||||
const launched = await launchedPromise;
|
||||
expect(launched).toBe(false);
|
||||
expect(appStoreMock.notify).toHaveBeenCalledWith("warn", "服务器未装codex");
|
||||
});
|
||||
});
|
||||
1436
apps/web/src/stores/sessionStore.ts
Normal file
91
apps/web/src/stores/settingsStore.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import { verifyHostKey } from "@remoteconn/shared";
|
||||
import type { GlobalSettings } from "@/types/app";
|
||||
import { defaultSettings, normalizeGlobalSettings, resolveGatewayUrl, resolveGatewayToken } from "@/utils/defaults";
|
||||
import { getKnownHosts, getSettings, setSettings, upsertKnownHost } from "@/services/storage/db";
|
||||
|
||||
/**
|
||||
* 设置与主题管理。
|
||||
*/
|
||||
export const useSettingsStore = defineStore("settings", () => {
|
||||
const settings = ref<GlobalSettings>(normalizeGlobalSettings(defaultSettings));
|
||||
const knownHosts = ref<Record<string, string>>({});
|
||||
const loaded = ref(false);
|
||||
let bootstrapPromise: Promise<void> | null = null;
|
||||
|
||||
const themeVars = computed(() => ({
|
||||
"--bg": settings.value.uiBgColor,
|
||||
"--accent": settings.value.uiAccentColor,
|
||||
"--text": settings.value.uiTextColor,
|
||||
"--btn": settings.value.uiBtnColor,
|
||||
"--shell-bg": settings.value.shellBgColor,
|
||||
"--shell-text": settings.value.shellTextColor,
|
||||
"--shell-accent": settings.value.shellAccentColor
|
||||
}));
|
||||
|
||||
async function ensureBootstrapped(): Promise<void> {
|
||||
if (loaded.value) return;
|
||||
if (bootstrapPromise) {
|
||||
await bootstrapPromise;
|
||||
return;
|
||||
}
|
||||
bootstrapPromise = (async () => {
|
||||
settings.value = normalizeGlobalSettings(await getSettings());
|
||||
knownHosts.value = await getKnownHosts();
|
||||
loaded.value = true;
|
||||
})();
|
||||
|
||||
try {
|
||||
await bootstrapPromise;
|
||||
} finally {
|
||||
bootstrapPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
await ensureBootstrapped();
|
||||
}
|
||||
|
||||
async function save(next: GlobalSettings): Promise<void> {
|
||||
const normalized = normalizeGlobalSettings(next);
|
||||
settings.value = normalized;
|
||||
await setSettings({ ...normalized });
|
||||
}
|
||||
|
||||
/** 运行时推导网关 URL,不从持久化设置读取 */
|
||||
const gatewayUrl = computed(() => resolveGatewayUrl(settings.value));
|
||||
/** 运行时推导网关 Token,不从持久化设置读取 */
|
||||
const gatewayToken = computed(() => resolveGatewayToken(settings.value));
|
||||
|
||||
async function verifyAndPersistHostFingerprint(hostPort: string, incomingFingerprint: string): Promise<boolean> {
|
||||
const result = await verifyHostKey({
|
||||
hostPort,
|
||||
incomingFingerprint,
|
||||
policy: settings.value.hostKeyPolicy,
|
||||
knownHosts: knownHosts.value,
|
||||
onConfirm: async ({ hostPort: host, fingerprint, reason }) => {
|
||||
return window.confirm(`${reason}\n主机: ${host}\n指纹: ${fingerprint}\n是否信任并继续?`);
|
||||
}
|
||||
});
|
||||
|
||||
if (result.accepted && result.updated[hostPort]) {
|
||||
knownHosts.value = { ...result.updated };
|
||||
await upsertKnownHost(hostPort, result.updated[hostPort]);
|
||||
}
|
||||
|
||||
return result.accepted;
|
||||
}
|
||||
|
||||
return {
|
||||
settings,
|
||||
knownHosts,
|
||||
themeVars,
|
||||
gatewayUrl,
|
||||
gatewayToken,
|
||||
ensureBootstrapped,
|
||||
bootstrap,
|
||||
save,
|
||||
verifyAndPersistHostFingerprint
|
||||
};
|
||||
});
|
||||
212
apps/web/src/stores/voiceRecordStore.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import type { VoiceRecord } from "@/types/app";
|
||||
|
||||
const { dbState, dbMock } = vi.hoisted(() => {
|
||||
const state = {
|
||||
voiceRecords: [] as VoiceRecord[]
|
||||
};
|
||||
|
||||
const cloneRecord = (item: VoiceRecord): VoiceRecord => ({
|
||||
...item
|
||||
});
|
||||
|
||||
const upsertRecord = (item: VoiceRecord): void => {
|
||||
const index = state.voiceRecords.findIndex((row) => row.id === item.id);
|
||||
if (index >= 0) {
|
||||
state.voiceRecords[index] = cloneRecord(item);
|
||||
} else {
|
||||
state.voiceRecords.push(cloneRecord(item));
|
||||
}
|
||||
};
|
||||
|
||||
const db = {
|
||||
voiceRecords: {
|
||||
toArray: vi.fn(async () => state.voiceRecords.map((row) => cloneRecord(row))),
|
||||
put: vi.fn(async (item: VoiceRecord) => {
|
||||
upsertRecord(item);
|
||||
}),
|
||||
delete: vi.fn(async (id: string) => {
|
||||
state.voiceRecords = state.voiceRecords.filter((row) => row.id !== id);
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
dbState: state,
|
||||
dbMock: db
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/services/storage/db", () => ({
|
||||
db: dbMock
|
||||
}));
|
||||
|
||||
import { useVoiceRecordStore } from "./voiceRecordStore";
|
||||
|
||||
describe("voiceRecordStore", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
dbState.voiceRecords = [];
|
||||
dbMock.voiceRecords.toArray.mockClear();
|
||||
dbMock.voiceRecords.put.mockClear();
|
||||
dbMock.voiceRecords.delete.mockClear();
|
||||
});
|
||||
|
||||
it("启动后按 createdAt 倒序输出 latest", async () => {
|
||||
dbState.voiceRecords = [
|
||||
{
|
||||
id: "r1",
|
||||
content: "one",
|
||||
createdAt: "2026-02-27T00:00:01.000Z",
|
||||
updatedAt: "2026-02-27T00:00:01.000Z",
|
||||
serverId: "s1",
|
||||
category: "问题",
|
||||
contextLabel: "alpha-demo"
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
content: "two",
|
||||
createdAt: "2026-02-27T00:00:03.000Z",
|
||||
updatedAt: "2026-02-27T00:00:03.000Z",
|
||||
serverId: "s1",
|
||||
category: "优化",
|
||||
contextLabel: "alpha-demo"
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
content: "three",
|
||||
createdAt: "2026-02-27T00:00:02.000Z",
|
||||
updatedAt: "2026-02-27T00:00:02.000Z",
|
||||
serverId: "s2",
|
||||
category: "灵感",
|
||||
contextLabel: "beta-api"
|
||||
}
|
||||
];
|
||||
|
||||
const store = useVoiceRecordStore();
|
||||
await store.ensureBootstrapped();
|
||||
|
||||
expect(store.latest.map((item) => item.id)).toEqual(["r2", "r3", "r1"]);
|
||||
});
|
||||
|
||||
it("addRecord 写入前会 trim,空文本不入库", async () => {
|
||||
const store = useVoiceRecordStore();
|
||||
await store.ensureBootstrapped();
|
||||
|
||||
const empty = await store.addRecord(" ", "s1");
|
||||
expect(empty).toBeNull();
|
||||
expect(dbMock.voiceRecords.put).toHaveBeenCalledTimes(0);
|
||||
|
||||
const created = await store.addRecord(" hello world ", "s1", {
|
||||
category: "新需求",
|
||||
contextLabel: "alpha-demo"
|
||||
});
|
||||
expect(created).not.toBeNull();
|
||||
expect(created?.content).toBe("hello world");
|
||||
expect(created?.category).toBe("新需求");
|
||||
expect(created?.contextLabel).toBe("alpha-demo");
|
||||
expect(dbMock.voiceRecords.put).toHaveBeenCalledTimes(1);
|
||||
expect(store.latest[0]?.content).toBe("hello world");
|
||||
});
|
||||
|
||||
it("removeRecord 会更新内存并持久化删除", async () => {
|
||||
dbState.voiceRecords = [
|
||||
{
|
||||
id: "r1",
|
||||
content: "one",
|
||||
createdAt: "2026-02-27T00:00:01.000Z",
|
||||
updatedAt: "2026-02-27T00:00:01.000Z",
|
||||
serverId: "s1",
|
||||
category: "问题",
|
||||
contextLabel: "alpha-demo"
|
||||
}
|
||||
];
|
||||
|
||||
const store = useVoiceRecordStore();
|
||||
await store.ensureBootstrapped();
|
||||
|
||||
await store.removeRecord("r1");
|
||||
expect(dbMock.voiceRecords.delete).toHaveBeenCalledWith("r1");
|
||||
expect(store.records.length).toBe(0);
|
||||
});
|
||||
|
||||
it("updateRecord 会更新内容、分类和 updatedAt", async () => {
|
||||
dbState.voiceRecords = [
|
||||
{
|
||||
id: "r1",
|
||||
content: "old",
|
||||
createdAt: "2026-02-27T00:00:01.000Z",
|
||||
updatedAt: "2026-02-27T00:00:01.000Z",
|
||||
serverId: "s1",
|
||||
category: "问题",
|
||||
contextLabel: "alpha-demo"
|
||||
}
|
||||
];
|
||||
|
||||
const store = useVoiceRecordStore();
|
||||
await store.ensureBootstrapped();
|
||||
|
||||
const updated = await store.updateRecord({
|
||||
id: "r1",
|
||||
content: " new content ",
|
||||
category: "优化"
|
||||
});
|
||||
|
||||
expect(updated?.content).toBe("new content");
|
||||
expect(updated?.category).toBe("优化");
|
||||
expect(updated?.updatedAt).not.toBe("2026-02-27T00:00:01.000Z");
|
||||
expect(dbMock.voiceRecords.put).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("search 会按关键字与分类过滤", async () => {
|
||||
dbState.voiceRecords = [
|
||||
{
|
||||
id: "r1",
|
||||
content: "修正连接超时",
|
||||
createdAt: "2026-02-27T00:00:01.000Z",
|
||||
updatedAt: "2026-02-27T00:00:01.000Z",
|
||||
serverId: "s1",
|
||||
category: "优化",
|
||||
contextLabel: "alpha-demo"
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
content: "记录新的终端问题",
|
||||
createdAt: "2026-02-27T00:00:02.000Z",
|
||||
updatedAt: "2026-02-27T00:00:02.000Z",
|
||||
serverId: "s2",
|
||||
category: "问题",
|
||||
contextLabel: "beta-api"
|
||||
}
|
||||
];
|
||||
|
||||
const store = useVoiceRecordStore();
|
||||
await store.ensureBootstrapped();
|
||||
|
||||
expect(store.search({ keyword: "alpha" }).map((item) => item.id)).toEqual(["r1"]);
|
||||
expect(store.search({ category: "问题" }).map((item) => item.id)).toEqual(["r2"]);
|
||||
});
|
||||
|
||||
it("exportRecords 会包含分类、上下文和更新时间", async () => {
|
||||
dbState.voiceRecords = [
|
||||
{
|
||||
id: "r1",
|
||||
content: "修正连接超时",
|
||||
createdAt: "2026-02-27T00:00:01.000Z",
|
||||
updatedAt: "2026-02-27T00:00:03.000Z",
|
||||
serverId: "s1",
|
||||
category: "优化",
|
||||
contextLabel: "alpha-demo"
|
||||
}
|
||||
];
|
||||
|
||||
const store = useVoiceRecordStore();
|
||||
await store.ensureBootstrapped();
|
||||
|
||||
const exported = store.exportRecords();
|
||||
expect(exported).toContain("updatedAt");
|
||||
expect(exported).toContain("category: 优化");
|
||||
expect(exported).toContain("contextLabel: alpha-demo");
|
||||
});
|
||||
});
|
||||
183
apps/web/src/stores/voiceRecordStore.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref, toRaw } from "vue";
|
||||
import type { VoiceRecord } from "@/types/app";
|
||||
import { db } from "@/services/storage/db";
|
||||
import { nowIso } from "@/utils/time";
|
||||
import { DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK } from "@/utils/defaults";
|
||||
|
||||
interface AddVoiceRecordInput {
|
||||
category?: string;
|
||||
contextLabel?: string;
|
||||
}
|
||||
|
||||
interface UpdateVoiceRecordInput {
|
||||
id: string;
|
||||
content: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
interface SearchVoiceRecordInput {
|
||||
keyword?: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 闪念记录存储与导出。
|
||||
*/
|
||||
export const useVoiceRecordStore = defineStore("voiceRecord", () => {
|
||||
const records = ref<VoiceRecord[]>([]);
|
||||
const loaded = ref(false);
|
||||
let bootstrapPromise: Promise<void> | null = null;
|
||||
|
||||
const latest = computed(() => [...records.value].sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt)));
|
||||
|
||||
function normalizeCategory(value: string): string {
|
||||
const normalized = String(value ?? "").trim();
|
||||
return normalized || DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将历史记录补齐到最新结构,避免 UI 层反复做兼容判断。
|
||||
*/
|
||||
function normalizeRecord(item: VoiceRecord): VoiceRecord {
|
||||
const raw = toRaw(item);
|
||||
const createdAt = String(raw.createdAt || nowIso());
|
||||
return {
|
||||
id: String(raw.id),
|
||||
content: String(raw.content ?? "").trim(),
|
||||
createdAt,
|
||||
updatedAt: String(raw.updatedAt || createdAt),
|
||||
serverId: String(raw.serverId ?? ""),
|
||||
category: normalizeCategory(raw.category),
|
||||
contextLabel: String(raw.contextLabel ?? "")
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureBootstrapped(): Promise<void> {
|
||||
if (loaded.value) return;
|
||||
if (bootstrapPromise) {
|
||||
await bootstrapPromise;
|
||||
return;
|
||||
}
|
||||
bootstrapPromise = (async () => {
|
||||
records.value = (await db.voiceRecords.toArray()).map((item) => normalizeRecord(item));
|
||||
loaded.value = true;
|
||||
})();
|
||||
|
||||
try {
|
||||
await bootstrapPromise;
|
||||
} finally {
|
||||
bootstrapPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
await ensureBootstrapped();
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一做实体快照,避免 Vue Proxy 直接写入 IndexedDB 触发 DataCloneError。
|
||||
*/
|
||||
function toVoiceRecordEntity(item: VoiceRecord): VoiceRecord {
|
||||
return normalizeRecord(item);
|
||||
}
|
||||
|
||||
async function addRecord(content: string, serverId = "", options: AddVoiceRecordInput = {}): Promise<VoiceRecord | null> {
|
||||
const normalizedContent = String(content ?? "").trim();
|
||||
if (!normalizedContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = nowIso();
|
||||
const next: VoiceRecord = {
|
||||
id: `voice-${crypto.randomUUID()}`,
|
||||
content: normalizedContent,
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
serverId: String(serverId || ""),
|
||||
category: normalizeCategory(options.category ?? DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK),
|
||||
contextLabel: String(options.contextLabel ?? "")
|
||||
};
|
||||
records.value.unshift(next);
|
||||
await db.voiceRecords.put(toVoiceRecordEntity(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
async function updateRecord(payload: UpdateVoiceRecordInput): Promise<VoiceRecord | null> {
|
||||
const recordId = String(payload.id || "");
|
||||
const normalizedContent = String(payload.content ?? "").trim();
|
||||
if (!recordId || !normalizedContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const index = records.value.findIndex((item) => item.id === recordId);
|
||||
if (index < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = records.value[index];
|
||||
if (!current) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const next: VoiceRecord = {
|
||||
...current,
|
||||
content: normalizedContent,
|
||||
category: normalizeCategory(payload.category),
|
||||
updatedAt: nowIso()
|
||||
};
|
||||
records.value[index] = next;
|
||||
await db.voiceRecords.put(toVoiceRecordEntity(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
async function removeRecord(recordId: string): Promise<void> {
|
||||
const nextId = String(recordId || "");
|
||||
if (!nextId) return;
|
||||
records.value = records.value.filter((item) => item.id !== nextId);
|
||||
await db.voiceRecords.delete(nextId);
|
||||
}
|
||||
|
||||
function search(input: SearchVoiceRecordInput = {}): VoiceRecord[] {
|
||||
const keyword = String(input.keyword ?? "").trim().toLowerCase();
|
||||
const category = String(input.category ?? "").trim();
|
||||
return latest.value.filter((item) => {
|
||||
if (category && item.category !== category) {
|
||||
return false;
|
||||
}
|
||||
if (!keyword) {
|
||||
return true;
|
||||
}
|
||||
const haystack = [item.content, item.category, item.contextLabel, item.createdAt].join(" ").toLowerCase();
|
||||
return haystack.includes(keyword);
|
||||
});
|
||||
}
|
||||
|
||||
function exportRecords(): string {
|
||||
const rows = latest.value.map((item) => {
|
||||
return [
|
||||
`## ${item.id}`,
|
||||
`- createdAt: ${item.createdAt}`,
|
||||
`- updatedAt: ${item.updatedAt}`,
|
||||
`- serverId: ${item.serverId || "--"}`,
|
||||
`- category: ${item.category}`,
|
||||
`- contextLabel: ${item.contextLabel || "--"}`,
|
||||
`- content:`,
|
||||
item.content
|
||||
].join("\n");
|
||||
});
|
||||
return [`# RemoteConn Voice Records Export ${nowIso()}`, "", ...rows].join("\n\n");
|
||||
}
|
||||
|
||||
return {
|
||||
records,
|
||||
latest,
|
||||
ensureBootstrapped,
|
||||
bootstrap,
|
||||
addRecord,
|
||||
updateRecord,
|
||||
removeRecord,
|
||||
search,
|
||||
exportRecords
|
||||
};
|
||||
});
|
||||
3315
apps/web/src/styles/main.css
Normal file
149
apps/web/src/types/app.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type {
|
||||
CommandMarker,
|
||||
CredentialRef,
|
||||
HostKeyPolicy,
|
||||
JumpHostProfile,
|
||||
ResolvedCredential,
|
||||
ServerProfile,
|
||||
SessionLog,
|
||||
SessionState,
|
||||
ThemePreset
|
||||
} from "@remoteconn/shared";
|
||||
|
||||
export type {
|
||||
ServerProfile,
|
||||
CredentialRef,
|
||||
SessionLog,
|
||||
SessionState,
|
||||
ResolvedCredential,
|
||||
CommandMarker,
|
||||
HostKeyPolicy,
|
||||
JumpHostProfile,
|
||||
ThemePreset
|
||||
};
|
||||
|
||||
/**
|
||||
* \u5168\u5c40\u8bbe\u7f6e\uff08\u57df\u6536\u655b\u7248\uff09\u3002
|
||||
*/
|
||||
export interface GlobalSettings {
|
||||
// \u2500\u2500 UI \u5916\u89c2 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
||||
/** 界面语言仅用于配置保真,本轮 Web 端不消费该字段切换文案 */
|
||||
uiLanguage: "zh-Hans" | "zh-Hant" | "en" | "ja" | "ko";
|
||||
uiThemePreset: ThemePreset;
|
||||
/** 界面明暗模式,影响预设色板的 dark/light 变体选择 */
|
||||
uiThemeMode: "dark" | "light";
|
||||
uiAccentColor: string;
|
||||
uiBgColor: string;
|
||||
uiTextColor: string;
|
||||
uiBtnColor: string;
|
||||
|
||||
// \u2500\u2500 Shell \u663e\u793a \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
||||
shellThemePreset: ThemePreset;
|
||||
/** 终端明暗模式,影响终端预设色板的 dark/light 变体选择 */
|
||||
shellThemeMode: "dark" | "light";
|
||||
shellBgColor: string;
|
||||
shellTextColor: string;
|
||||
shellAccentColor: string;
|
||||
shellFontFamily: string;
|
||||
shellFontSize: number;
|
||||
shellLineHeight: number;
|
||||
unicode11: boolean;
|
||||
|
||||
// \u2500\u2500 \u7ec8\u7aef\u7f13\u51b2 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
||||
terminalBufferMaxEntries: number;
|
||||
terminalBufferMaxBytes: number;
|
||||
|
||||
// \u2500\u2500 \u8fde\u63a5\u7b56\u7565 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
||||
autoReconnect: boolean;
|
||||
reconnectLimit: number;
|
||||
hostKeyPolicy: HostKeyPolicy;
|
||||
credentialMemoryPolicy: "remember" | "forget";
|
||||
gatewayConnectTimeoutMs: number;
|
||||
waitForConnectedTimeoutMs: number;
|
||||
|
||||
// \u2500\u2500 \u670d\u52a1\u5668\u914d\u7f6e\u9884\u586b \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
||||
defaultAuthType: "password" | "key";
|
||||
defaultPort: number;
|
||||
defaultProjectPath: string;
|
||||
defaultTimeoutSeconds: number;
|
||||
defaultHeartbeatSeconds: number;
|
||||
defaultTransportMode: "gateway" | string;
|
||||
|
||||
// \u2500\u2500 \u65e5\u5fd7 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
||||
logRetentionDays: number;
|
||||
maskSecrets: boolean;
|
||||
voiceRecordCategories: string[];
|
||||
voiceRecordDefaultCategory: string;
|
||||
|
||||
// \u2500\u2500 \u5df2\u5e9f\u5f03\u5b57\u6bb5\uff08\u517c\u5bb9\u4fdd\u7559\uff0c\u4e0b\u4e00\u4e2a\u7248\u672c\u7a97\u53e3\u5220\u9664\uff09\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
||||
/** @deprecated \u8bf7\u4f7f\u7528 shellFontFamily */
|
||||
fontFamily?: string;
|
||||
/** @deprecated \u8bf7\u4f7f\u7528 shellFontSize */
|
||||
fontSize?: number;
|
||||
/** @deprecated \u8bf7\u4f7f\u7528 shellLineHeight */
|
||||
lineHeight?: number;
|
||||
/** @deprecated \u8bf7\u4f7f\u7528 uiThemePreset / shellThemePreset */
|
||||
themePreset?: string;
|
||||
/** @deprecated \u8bf7\u4f7f\u7528 uiAccentColor / shellAccentColor */
|
||||
accentColor?: string;
|
||||
/** @deprecated \u8bf7\u4f7f\u7528 uiBgColor / shellBgColor */
|
||||
bgColor?: string;
|
||||
/** @deprecated \u8bf7\u4f7f\u7528 uiTextColor / shellTextColor */
|
||||
textColor?: string;
|
||||
/** @deprecated UI \u52a8\u6548\u53c2\u6570\u5df2\u79fb\u9664\uff0c\u6682\u4fdd\u7559\u907f\u514d\u65e7\u6570\u636e\u62a5\u9519 */
|
||||
liquidAlpha?: number;
|
||||
/** @deprecated UI \u52a8\u6548\u53c2\u6570\u5df2\u79fb\u9664\uff0c\u6682\u4fdd\u7559\u907f\u514d\u65e7\u6570\u636e\u62a5\u9519 */
|
||||
blurRadius?: number;
|
||||
/** @deprecated UI \u52a8\u6548\u53c2\u6570\u5df2\u79fb\u9664\uff0c\u6682\u4fdd\u7559\u907f\u514d\u65e7\u6570\u636e\u62a5\u9519 */
|
||||
motionDuration?: number;
|
||||
/** @deprecated \u7f51\u5173 URL \u5df2\u4ece\u7528\u6237\u914d\u7f6e\u79fb\u9664\uff0c\u6539\u7531\u6784\u5efa\u65f6\u6ce8\u5165\u6216\u8fd0\u7ef4\u4e0b\u53d1 */
|
||||
gatewayUrl?: string;
|
||||
/** @deprecated \u7f51\u5173 Token \u5df2\u4ece\u7528\u6237\u914d\u7f6e\u79fb\u9664\uff0c\u6539\u7531\u6784\u5efa\u65f6\u6ce8\u5165\u6216\u8fd0\u7ef4\u4e0b\u53d1 */
|
||||
gatewayToken?: string;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* 凭据密文。
|
||||
*/
|
||||
export interface EncryptedCredentialPayload {
|
||||
id: string;
|
||||
refId: string;
|
||||
encrypted: string;
|
||||
iv: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AppToast {
|
||||
id: string;
|
||||
level: "info" | "warn" | "error";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SessionCommandResult {
|
||||
code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export interface SessionContext {
|
||||
state: SessionState;
|
||||
currentServerId?: string;
|
||||
currentSessionId?: string;
|
||||
latencyMs?: number;
|
||||
connectedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 闪念记录(语音输入区 record 按钮写入)。
|
||||
*/
|
||||
export interface VoiceRecord {
|
||||
id: string;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
serverId: string;
|
||||
category: string;
|
||||
contextLabel: string;
|
||||
}
|
||||
84
apps/web/src/utils/defaults.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultSettings, normalizeGlobalSettings, resolveGatewayUrl, resolveGatewayToken } from "./defaults";
|
||||
|
||||
describe("default settings", () => {
|
||||
it("包含 UI/Shell 域前缀字段、终端缓冲阈值", () => {
|
||||
expect(defaultSettings.uiLanguage).toBe("zh-Hans");
|
||||
expect(defaultSettings.uiBgColor.length).toBeGreaterThan(0);
|
||||
expect(defaultSettings.uiAccentColor.length).toBeGreaterThan(0);
|
||||
expect(defaultSettings.shellFontFamily.length).toBeGreaterThan(0);
|
||||
expect(["dark", "light"]).toContain(defaultSettings.shellThemeMode);
|
||||
expect(defaultSettings.shellFontSize).toBeGreaterThanOrEqual(12);
|
||||
expect(defaultSettings.terminalBufferMaxBytes).toBeGreaterThan(0);
|
||||
expect(defaultSettings.terminalBufferMaxEntries).toBeGreaterThan(0);
|
||||
expect(defaultSettings.autoReconnect).toBe(true);
|
||||
expect(defaultSettings.voiceRecordCategories.length).toBeGreaterThan(0);
|
||||
expect(defaultSettings.voiceRecordDefaultCategory.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("resolveGatewayUrl 返回非空字符串", () => {
|
||||
expect(resolveGatewayUrl().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("resolveGatewayToken 返回非空字符串", () => {
|
||||
expect(resolveGatewayToken().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("可将旧版 fontFamily 迁移到 shellFontFamily", () => {
|
||||
const normalized = normalizeGlobalSettings({
|
||||
fontFamily: "Menlo",
|
||||
terminalBufferMaxBytes: Number.NaN,
|
||||
terminalBufferMaxEntries: 1
|
||||
});
|
||||
expect(normalized.shellFontFamily).toBe("Menlo");
|
||||
expect(normalized.terminalBufferMaxBytes).toBe(defaultSettings.terminalBufferMaxBytes);
|
||||
expect(normalized.terminalBufferMaxEntries).toBeGreaterThanOrEqual(200);
|
||||
});
|
||||
|
||||
it("可将旧版颜色字段迁移到域前缀字段", () => {
|
||||
const normalized = normalizeGlobalSettings({
|
||||
bgColor: "#112233",
|
||||
textColor: "#aabbcc",
|
||||
accentColor: "#ff0000"
|
||||
});
|
||||
expect(normalized.uiBgColor).toBe("#112233");
|
||||
expect(normalized.shellBgColor).toBe("#112233");
|
||||
expect(normalized.uiTextColor).toBe("#aabbcc");
|
||||
expect(normalized.shellTextColor).toBe("#aabbcc");
|
||||
expect(normalized.uiAccentColor).toBe("#ff0000");
|
||||
expect(normalized.shellAccentColor).toBe("#ff0000");
|
||||
});
|
||||
|
||||
it("可将旧版 credentialMemoryPolicy=session 迁移到 forget", () => {
|
||||
const normalized = normalizeGlobalSettings({
|
||||
credentialMemoryPolicy: "session" as "remember"
|
||||
});
|
||||
expect(normalized.credentialMemoryPolicy).toBe("forget");
|
||||
});
|
||||
|
||||
it("可将旧版 themePreset 映射到新 ThemePreset", () => {
|
||||
const normalized = normalizeGlobalSettings({ themePreset: "sunrise" });
|
||||
expect(normalized.uiThemePreset).toBe("焰岩");
|
||||
expect(normalized.shellThemePreset).toBe("焰岩");
|
||||
});
|
||||
|
||||
it("shellThemeMode 非法值会回退到 dark", () => {
|
||||
const normalized = normalizeGlobalSettings({ shellThemeMode: "invalid" as "dark" });
|
||||
expect(normalized.shellThemeMode).toBe("dark");
|
||||
});
|
||||
|
||||
it("会归一化闪念分类并保证默认分类有效", () => {
|
||||
const normalized = normalizeGlobalSettings({
|
||||
voiceRecordCategories: ["", "问题", "问题", "灵感"],
|
||||
voiceRecordDefaultCategory: "不存在"
|
||||
});
|
||||
expect(normalized.voiceRecordCategories).toEqual(["未分类", "问题", "灵感"]);
|
||||
expect(normalized.voiceRecordDefaultCategory).toBe("未分类");
|
||||
});
|
||||
|
||||
it("会保留合法的新界面语言并拦截非法值", () => {
|
||||
expect(normalizeGlobalSettings({ uiLanguage: "ja" }).uiLanguage).toBe("ja");
|
||||
expect(normalizeGlobalSettings({ uiLanguage: "ko" }).uiLanguage).toBe("ko");
|
||||
expect(normalizeGlobalSettings({ uiLanguage: "invalid" as "ja" }).uiLanguage).toBe("zh-Hans");
|
||||
});
|
||||
});
|
||||
277
apps/web/src/utils/defaults.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
import type { GlobalSettings, ThemePreset } from "@/types/app";
|
||||
import { pickShellAccentColor } from "@remoteconn/shared";
|
||||
|
||||
const MIN_TERMINAL_BUFFER_MAX_ENTRIES = 200;
|
||||
const MAX_TERMINAL_BUFFER_MAX_ENTRIES = 50_000;
|
||||
const MIN_TERMINAL_BUFFER_MAX_BYTES = 64 * 1024;
|
||||
const MAX_TERMINAL_BUFFER_MAX_BYTES = 64 * 1024 * 1024;
|
||||
const UI_LANGUAGE_VALUES = new Set<GlobalSettings["uiLanguage"]>(["zh-Hans", "zh-Hant", "en", "ja", "ko"]);
|
||||
const DEFAULT_SHELL_BG_COLOR = "#192b4d";
|
||||
const DEFAULT_SHELL_TEXT_COLOR = "#e6f0ff";
|
||||
export const DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK = "未分类";
|
||||
export const DEFAULT_VOICE_RECORD_CATEGORIES = ["未分类", "优化", "新需求", "问题", "灵感"] as const;
|
||||
export const DEFAULT_VOICE_RECORD_DEFAULT_CATEGORY = "优化";
|
||||
|
||||
function normalizeInteger(value: number, fallback: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) {
|
||||
return fallback;
|
||||
}
|
||||
const normalized = Math.round(value);
|
||||
if (normalized < min) {
|
||||
return min;
|
||||
}
|
||||
if (normalized > max) {
|
||||
return max;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 界面语言归一化:
|
||||
* 1. Web 端当前只负责保真,不负责真正切语言;
|
||||
* 2. 仍需限制合法枚举,避免无效值在跨端同步中持续扩散。
|
||||
*/
|
||||
function normalizeUiLanguage(value: unknown): GlobalSettings["uiLanguage"] {
|
||||
const normalized = String(value ?? "").trim() as GlobalSettings["uiLanguage"];
|
||||
return UI_LANGUAGE_VALUES.has(normalized) ? normalized : "zh-Hans";
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化闪念分类列表:
|
||||
* 1. 去空白、去重、保序;
|
||||
* 2. 强制保留“未分类”兜底项;
|
||||
* 3. 限制最多 10 项,避免配置面板无限增长。
|
||||
*/
|
||||
function normalizeVoiceRecordCategories(value: unknown): string[] {
|
||||
const source = Array.isArray(value) ? value : [];
|
||||
const seen = new Set<string>();
|
||||
const next: string[] = [];
|
||||
|
||||
for (const entry of source) {
|
||||
const normalized = String(entry ?? "").trim();
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(normalized);
|
||||
next.push(normalized);
|
||||
if (next.length >= 10) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!seen.has(DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK)) {
|
||||
next.unshift(DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK);
|
||||
}
|
||||
|
||||
return next.slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化默认闪念分类:
|
||||
* 1. 优先使用合法且存在于分类列表中的配置值;
|
||||
* 2. 否则回退到预设默认分类;
|
||||
* 3. 若预设默认分类不在列表中,则回退到分类列表首项。
|
||||
*/
|
||||
function normalizeVoiceRecordDefaultCategory(value: unknown, categories: string[]): string {
|
||||
const normalized = String(value ?? "").trim();
|
||||
if (normalized && categories.includes(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
if (categories.includes(DEFAULT_VOICE_RECORD_DEFAULT_CATEGORY)) {
|
||||
return DEFAULT_VOICE_RECORD_DEFAULT_CATEGORY;
|
||||
}
|
||||
return categories[0] ?? DEFAULT_VOICE_RECORD_CATEGORY_FALLBACK;
|
||||
}
|
||||
|
||||
/**
|
||||
* 推导默认网关地址:
|
||||
* 1) 若显式配置了 VITE_GATEWAY_URL,优先使用;
|
||||
* 2) 浏览器环境下根据当前站点自动推导;
|
||||
* 3) 默认走同域 80/443(由反向代理承接)。
|
||||
*/
|
||||
function resolveDefaultGatewayUrl(): string {
|
||||
const envGateway = import.meta.env.VITE_GATEWAY_URL?.trim();
|
||||
if (envGateway) {
|
||||
return envGateway;
|
||||
}
|
||||
|
||||
if (typeof window === "undefined") {
|
||||
return "ws://localhost:8787";
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const hostname = window.location.hostname;
|
||||
return `${protocol}//${hostname}`;
|
||||
}
|
||||
|
||||
export const defaultSettings: GlobalSettings = {
|
||||
// ── UI 外观 ──────────────────────────────────────────────────────────────
|
||||
uiLanguage: "zh-Hans",
|
||||
uiThemePreset: "tide",
|
||||
uiThemeMode: "dark" as "dark" | "light",
|
||||
uiAccentColor: "#5bd2ff",
|
||||
uiBgColor: "#192b4d",
|
||||
uiTextColor: "#e6f0ff",
|
||||
uiBtnColor: "#adb9cd",
|
||||
|
||||
// ── Shell 显示 ────────────────────────────────────────────────────────────
|
||||
shellThemePreset: "tide",
|
||||
shellThemeMode: "dark" as "dark" | "light",
|
||||
shellBgColor: DEFAULT_SHELL_BG_COLOR,
|
||||
shellTextColor: DEFAULT_SHELL_TEXT_COLOR,
|
||||
shellAccentColor: pickShellAccentColor(DEFAULT_SHELL_BG_COLOR, DEFAULT_SHELL_TEXT_COLOR),
|
||||
shellFontFamily: "JetBrains Mono",
|
||||
shellFontSize: 15,
|
||||
shellLineHeight: 1.4,
|
||||
unicode11: true,
|
||||
|
||||
// ── 终端缓冲 ─────────────────────────────────────────────────────────────
|
||||
terminalBufferMaxEntries: 5000,
|
||||
terminalBufferMaxBytes: 4 * 1024 * 1024,
|
||||
|
||||
// ── 连接策略 ─────────────────────────────────────────────────────────────
|
||||
autoReconnect: true,
|
||||
reconnectLimit: 3,
|
||||
hostKeyPolicy: "strict",
|
||||
credentialMemoryPolicy: "remember",
|
||||
gatewayConnectTimeoutMs: 12000,
|
||||
waitForConnectedTimeoutMs: 15000,
|
||||
|
||||
// ── 服务器配置预填 ────────────────────────────────────────────────────────
|
||||
defaultAuthType: "password",
|
||||
defaultPort: 22,
|
||||
defaultProjectPath: "~/workspace",
|
||||
defaultTimeoutSeconds: 20,
|
||||
defaultHeartbeatSeconds: 15,
|
||||
defaultTransportMode: "gateway",
|
||||
|
||||
// ── 日志 ─────────────────────────────────────────────────────────────────
|
||||
logRetentionDays: 30,
|
||||
maskSecrets: true,
|
||||
voiceRecordCategories: [...DEFAULT_VOICE_RECORD_CATEGORIES],
|
||||
voiceRecordDefaultCategory: DEFAULT_VOICE_RECORD_DEFAULT_CATEGORY
|
||||
};
|
||||
|
||||
/**
|
||||
* 全局配置归一化:
|
||||
* - 为历史版本缺失字段补齐默认值;
|
||||
* - 将旧版废弃字段迁移到新域前缀字段(仅首次,不覆盖已有新字段);
|
||||
* - 对终端缓冲阈值做边界收敛,避免 NaN/异常值导致缓冲策略失效。
|
||||
*/
|
||||
export function normalizeGlobalSettings(raw: Partial<GlobalSettings> | null | undefined): GlobalSettings {
|
||||
const r = raw ?? {};
|
||||
const merged: GlobalSettings = {
|
||||
...defaultSettings,
|
||||
...r
|
||||
};
|
||||
|
||||
// ── 旧字段迁移(取旧值兜底,不覆盖已存在的新字段)────────────────────────
|
||||
// fontFamily → shellFontFamily
|
||||
if (!r.shellFontFamily && r.fontFamily) {
|
||||
merged.shellFontFamily = r.fontFamily;
|
||||
}
|
||||
// fontSize → shellFontSize
|
||||
if (!r.shellFontSize && r.fontSize !== undefined) {
|
||||
merged.shellFontSize = r.fontSize;
|
||||
}
|
||||
// lineHeight → shellLineHeight
|
||||
if (!r.shellLineHeight && r.lineHeight !== undefined) {
|
||||
merged.shellLineHeight = r.lineHeight;
|
||||
}
|
||||
// accentColor → uiAccentColor / shellAccentColor
|
||||
if (!r.uiAccentColor && r.accentColor) {
|
||||
merged.uiAccentColor = r.accentColor;
|
||||
}
|
||||
if (!r.shellAccentColor && r.accentColor) {
|
||||
merged.shellAccentColor = r.accentColor;
|
||||
}
|
||||
// bgColor → uiBgColor / shellBgColor
|
||||
if (!r.uiBgColor && r.bgColor) {
|
||||
merged.uiBgColor = r.bgColor;
|
||||
}
|
||||
if (!r.shellBgColor && r.bgColor) {
|
||||
merged.shellBgColor = r.bgColor;
|
||||
}
|
||||
// textColor → uiTextColor / shellTextColor
|
||||
if (!r.uiTextColor && r.textColor) {
|
||||
merged.uiTextColor = r.textColor;
|
||||
}
|
||||
if (!r.shellTextColor && r.textColor) {
|
||||
merged.shellTextColor = r.textColor;
|
||||
}
|
||||
// themePreset → uiThemePreset / shellThemePreset(仅映射合法值)
|
||||
const legacyThemeMap: Record<string, ThemePreset> = {
|
||||
tide: "tide",
|
||||
mint: "tide", // mint 无对应新预设,兜底 tide
|
||||
sunrise: "焰岩" // sunrise 映射到焰岩暖色系
|
||||
};
|
||||
if (!r.uiThemePreset && r.themePreset) {
|
||||
merged.uiThemePreset = legacyThemeMap[r.themePreset] ?? "tide";
|
||||
}
|
||||
if (!r.shellThemePreset && r.themePreset) {
|
||||
merged.shellThemePreset = legacyThemeMap[r.themePreset] ?? "tide";
|
||||
}
|
||||
// shellThemeMode 非法值兜底
|
||||
if (merged.shellThemeMode !== "dark" && merged.shellThemeMode !== "light") {
|
||||
merged.shellThemeMode = "dark";
|
||||
}
|
||||
// credentialMemoryPolicy: "session" → "forget"(旧枚举值 session 对应 forget)
|
||||
if ((merged.credentialMemoryPolicy as string) === "session") {
|
||||
merged.credentialMemoryPolicy = "forget";
|
||||
}
|
||||
// uiThemeMode 非法值兜底
|
||||
if (merged.uiThemeMode !== "dark" && merged.uiThemeMode !== "light") {
|
||||
merged.uiThemeMode = "dark";
|
||||
}
|
||||
merged.uiLanguage = normalizeUiLanguage(merged.uiLanguage);
|
||||
|
||||
// ── 数值边界收敛 ─────────────────────────────────────────────────────────
|
||||
merged.terminalBufferMaxEntries = normalizeInteger(
|
||||
merged.terminalBufferMaxEntries,
|
||||
defaultSettings.terminalBufferMaxEntries,
|
||||
MIN_TERMINAL_BUFFER_MAX_ENTRIES,
|
||||
MAX_TERMINAL_BUFFER_MAX_ENTRIES
|
||||
);
|
||||
merged.terminalBufferMaxBytes = normalizeInteger(
|
||||
merged.terminalBufferMaxBytes,
|
||||
defaultSettings.terminalBufferMaxBytes,
|
||||
MIN_TERMINAL_BUFFER_MAX_BYTES,
|
||||
MAX_TERMINAL_BUFFER_MAX_BYTES
|
||||
);
|
||||
|
||||
merged.voiceRecordCategories = normalizeVoiceRecordCategories(merged.voiceRecordCategories);
|
||||
merged.voiceRecordDefaultCategory = normalizeVoiceRecordDefaultCategory(
|
||||
merged.voiceRecordDefaultCategory,
|
||||
merged.voiceRecordCategories
|
||||
);
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据设置推导 gatewayUrl(运行时,不持久化到用户设置)。
|
||||
* 优先级:构建时 VITE_GATEWAY_URL > 旧版持久化数据残留 > 自动推导同域地址。
|
||||
*/
|
||||
export function resolveGatewayUrl(settings?: Pick<GlobalSettings, "gatewayUrl">): string {
|
||||
// 兼容旧版持久化数据中残留的 gatewayUrl 字段
|
||||
if (settings?.gatewayUrl) {
|
||||
return settings.gatewayUrl;
|
||||
}
|
||||
return resolveDefaultGatewayUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据设置推导 gatewayToken(运行时,不持久化到用户设置)。
|
||||
* 优先级:构建时 VITE_GATEWAY_TOKEN > 旧版持久化数据残留 > 开发占位符。
|
||||
*/
|
||||
export function resolveGatewayToken(settings?: Pick<GlobalSettings, "gatewayToken">): string {
|
||||
const envToken = import.meta.env.VITE_GATEWAY_TOKEN?.trim();
|
||||
if (envToken) {
|
||||
return envToken;
|
||||
}
|
||||
// 兼容旧版持久化数据中残留的 gatewayToken 字段
|
||||
if (settings?.gatewayToken) {
|
||||
return settings.gatewayToken;
|
||||
}
|
||||
return "remoteconn-dev-token";
|
||||
}
|
||||
60
apps/web/src/utils/dynamicImportGuard.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearDynamicImportRetryMark,
|
||||
isDynamicImportFailure,
|
||||
shouldRetryDynamicImportReload
|
||||
} from "./dynamicImportGuard";
|
||||
|
||||
interface MemoryStorage {
|
||||
map: Map<string, string>;
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
function createMemoryStorage(): MemoryStorage {
|
||||
const map = new Map<string, string>();
|
||||
return {
|
||||
map,
|
||||
getItem: (key: string) => map.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
map.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
map.delete(key);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("dynamicImportGuard", () => {
|
||||
it("可识别 Safari 的模块脚本导入失败文案", () => {
|
||||
expect(isDynamicImportFailure(new TypeError("Importing a module script failed."))).toBe(true);
|
||||
});
|
||||
|
||||
it("可识别 Chromium 的动态模块加载失败文案", () => {
|
||||
expect(isDynamicImportFailure(new Error("Failed to fetch dynamically imported module"))).toBe(true);
|
||||
});
|
||||
|
||||
it("非动态导入错误不应误判", () => {
|
||||
expect(isDynamicImportFailure(new Error("network timeout"))).toBe(false);
|
||||
expect(isDynamicImportFailure("")).toBe(false);
|
||||
});
|
||||
|
||||
it("重试窗口内仅允许一次自动刷新", () => {
|
||||
const storage = createMemoryStorage();
|
||||
const now = 1_000_000;
|
||||
|
||||
expect(shouldRetryDynamicImportReload(storage, now, 15_000)).toBe(true);
|
||||
expect(shouldRetryDynamicImportReload(storage, now + 5_000, 15_000)).toBe(false);
|
||||
expect(shouldRetryDynamicImportReload(storage, now + 16_000, 15_000)).toBe(true);
|
||||
});
|
||||
|
||||
it("清理重试标记后可重新允许刷新", () => {
|
||||
const storage = createMemoryStorage();
|
||||
const now = 2_000_000;
|
||||
|
||||
expect(shouldRetryDynamicImportReload(storage, now, 15_000)).toBe(true);
|
||||
clearDynamicImportRetryMark(storage);
|
||||
expect(shouldRetryDynamicImportReload(storage, now + 1_000, 15_000)).toBe(true);
|
||||
});
|
||||
});
|
||||
118
apps/web/src/utils/dynamicImportGuard.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { Router } from "vue-router";
|
||||
|
||||
const RETRY_MARK_KEY = "remoteconn:dynamic-import-retry-at";
|
||||
const RETRY_WINDOW_MS = 15_000;
|
||||
|
||||
interface SessionStorageLike {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否属于“动态模块脚本加载失败”:
|
||||
* - Safari 常见文案:Importing a module script failed;
|
||||
* - Chromium 常见文案:Failed to fetch dynamically imported module;
|
||||
* - Firefox 常见文案:error loading dynamically imported module。
|
||||
*/
|
||||
export function isDynamicImportFailure(error: unknown): boolean {
|
||||
const message = extractErrorMessage(error).toLowerCase();
|
||||
if (!message) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const patterns = [
|
||||
"importing a module script failed",
|
||||
"failed to fetch dynamically imported module",
|
||||
"error loading dynamically imported module"
|
||||
];
|
||||
|
||||
return patterns.some((pattern) => message.includes(pattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* 决定是否允许本次自动刷新:
|
||||
* - 15 秒窗口内仅允许一次,避免 chunk 持续不可用时陷入刷新循环;
|
||||
* - 超过窗口后允许再次尝试,适配短时发布抖动场景。
|
||||
*/
|
||||
export function shouldRetryDynamicImportReload(
|
||||
storage: SessionStorageLike,
|
||||
now = Date.now(),
|
||||
retryWindowMs = RETRY_WINDOW_MS
|
||||
): boolean {
|
||||
const raw = storage.getItem(RETRY_MARK_KEY);
|
||||
const lastRetryAt = Number(raw);
|
||||
if (Number.isFinite(lastRetryAt) && now - lastRetryAt < retryWindowMs) {
|
||||
return false;
|
||||
}
|
||||
storage.setItem(RETRY_MARK_KEY, String(now));
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 在路由成功就绪后清理重试标记:
|
||||
* - 让后续真实的新一轮发布故障仍可触发一次自动恢复;
|
||||
* - 避免旧标记长期驻留导致后续无法自动恢复。
|
||||
*/
|
||||
export function clearDynamicImportRetryMark(storage: SessionStorageLike): void {
|
||||
storage.removeItem(RETRY_MARK_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 安装动态导入失败恢复逻辑:
|
||||
* - 监听 router.onError;
|
||||
* - 命中动态模块加载失败时自动刷新当前目标路由;
|
||||
* - sessionStorage 不可用时降级为仅输出错误,不抛出二次异常。
|
||||
*/
|
||||
export function installDynamicImportRecovery(router: Router): void {
|
||||
router.onError((error, to) => {
|
||||
if (!isDynamicImportFailure(error)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = safeSessionStorage();
|
||||
if (!storage) {
|
||||
console.error("[router] 动态模块加载失败,且 sessionStorage 不可用", error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldRetryDynamicImportReload(storage)) {
|
||||
clearDynamicImportRetryMark(storage);
|
||||
console.error("[router] 动态模块加载失败,已达单次自动恢复上限", error);
|
||||
return;
|
||||
}
|
||||
|
||||
const fallbackUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
const target = typeof to.fullPath === "string" && to.fullPath.length > 0 ? to.fullPath : fallbackUrl;
|
||||
window.location.assign(target);
|
||||
});
|
||||
|
||||
void router.isReady().then(() => {
|
||||
const storage = safeSessionStorage();
|
||||
if (storage) {
|
||||
clearDynamicImportRetryMark(storage);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function extractErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message ?? "";
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
if (error && typeof error === "object" && "message" in error) {
|
||||
const maybeMessage = (error as { message?: unknown }).message;
|
||||
return typeof maybeMessage === "string" ? maybeMessage : "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function safeSessionStorage(): SessionStorageLike | null {
|
||||
try {
|
||||
return window.sessionStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
131
apps/web/src/utils/feedback.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
function asMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return String(error.message || "");
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeText(input: string): string {
|
||||
return input.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function toFriendlyDisconnectReason(reason: string | undefined): string {
|
||||
const raw = String(reason ?? "").trim();
|
||||
if (!raw) return "连接已关闭";
|
||||
|
||||
const map: Record<string, string> = {
|
||||
manual: "你已主动断开连接",
|
||||
switch: "切换连接目标,已断开当前会话",
|
||||
host_key_rejected: "主机指纹未被信任,连接已断开",
|
||||
auth_failed: "认证失败,连接被服务器拒绝",
|
||||
rate_limit: "连接过于频繁,请稍后重试",
|
||||
shell_closed: "远端 Shell 已关闭",
|
||||
connection_closed: "服务器连接已关闭",
|
||||
ws_error: "网关连接异常",
|
||||
ws_closed: "网关连接已断开",
|
||||
ws_peer_normal_close: "客户端已关闭连接",
|
||||
unknown: "连接已关闭"
|
||||
};
|
||||
|
||||
return map[raw] ?? `连接已关闭(${raw})`;
|
||||
}
|
||||
|
||||
export function toFriendlyConnectionError(error: unknown): string {
|
||||
const message = asMessage(error);
|
||||
const lower = normalizeText(message);
|
||||
|
||||
if (lower.includes("rate_limit") || message.includes("连接过于频繁")) {
|
||||
return "连接过于频繁,请稍后重试。";
|
||||
}
|
||||
|
||||
if (lower.includes("auth_failed") || message.includes("token 无效")) {
|
||||
return "网关鉴权失败,请联系管理员检查网关令牌。";
|
||||
}
|
||||
|
||||
if (message.includes("SSH 认证失败")) {
|
||||
return "SSH 认证失败。请检查账号/凭据,若服务器仅允许公钥认证,请改用私钥方式。";
|
||||
}
|
||||
|
||||
if (message.includes("主机指纹") && message.includes("信任")) {
|
||||
return "主机指纹校验未通过,请确认主机身份后重试。";
|
||||
}
|
||||
|
||||
if (message.includes("Timed out while waiting for handshake") || message.includes("连接超时") || lower.includes("timeout")) {
|
||||
return "连接超时。请检查服务器地址、端口和网络连通性。";
|
||||
}
|
||||
|
||||
if (message.includes("无法连接网关") || lower.includes("ws_closed") || lower.includes("websocket")) {
|
||||
return "无法连接网关,请检查网关地址、服务状态与网络策略。";
|
||||
}
|
||||
|
||||
if (message.includes("凭据读取失败")) {
|
||||
return "凭据读取失败,请在服务器设置页重新保存后重试。";
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
return "连接失败,请稍后重试。";
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
export function toFriendlyError(error: unknown): string {
|
||||
const message = asMessage(error);
|
||||
const lower = normalizeText(message);
|
||||
|
||||
if (!message) {
|
||||
return "操作失败,请稍后重试。";
|
||||
}
|
||||
|
||||
if (
|
||||
lower.includes("ws_") ||
|
||||
lower.includes("websocket") ||
|
||||
lower.includes("auth_failed") ||
|
||||
lower.includes("rate_limit") ||
|
||||
message.includes("连接") ||
|
||||
message.includes("网关") ||
|
||||
message.includes("SSH")
|
||||
) {
|
||||
return toFriendlyConnectionError(message);
|
||||
}
|
||||
|
||||
if (message.includes("密码不能为空") || message.includes("私钥内容不能为空") || message.includes("证书模式下")) {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (lower.includes("json") && (lower.includes("parse") || lower.includes("unexpected token"))) {
|
||||
return "配置内容不是有效的 JSON,请检查格式后重试。";
|
||||
}
|
||||
|
||||
if (message.includes("会话未连接")) {
|
||||
return "当前会话未连接,请先建立连接。";
|
||||
}
|
||||
|
||||
if (message.includes("凭据读取失败")) {
|
||||
return "凭据读取失败,请在服务器设置页重新保存后重试。";
|
||||
}
|
||||
|
||||
if (message.includes("未找到凭据内容") || message.includes("凭据引用不存在")) {
|
||||
return "未找到可用凭据,请在服务器设置页重新填写并保存。";
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
export function formatActionError(action: string, error: unknown): string {
|
||||
const detail = toFriendlyError(error);
|
||||
return `${action}:${detail}`;
|
||||
}
|
||||
|
||||
export function toToastTitle(level: "info" | "warn" | "error"): string {
|
||||
if (level === "error") return "错误";
|
||||
if (level === "warn") return "注意";
|
||||
return "提示";
|
||||
}
|
||||
42
apps/web/src/utils/rememberedState.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readRememberedEnum, writeRememberedEnum } from "./rememberedState";
|
||||
|
||||
describe("rememberedState", () => {
|
||||
it("可读取允许列表内的持久化值", () => {
|
||||
const storage = {
|
||||
getItem: (key: string) => (key === "k" ? "shell" : null)
|
||||
} as unknown as Storage;
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: storage
|
||||
});
|
||||
|
||||
const value = readRememberedEnum("k", ["ui", "shell", "log"] as const);
|
||||
expect(value).toBe("shell");
|
||||
});
|
||||
|
||||
it("读取到不在允许列表中的值时返回 null", () => {
|
||||
const storage = {
|
||||
getItem: () => "unknown"
|
||||
} as unknown as Storage;
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: storage
|
||||
});
|
||||
|
||||
const value = readRememberedEnum("k", ["ui", "shell", "log"] as const);
|
||||
expect(value).toBeNull();
|
||||
});
|
||||
|
||||
it("localStorage 不可用时应静默降级", () => {
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
get() {
|
||||
throw new Error("storage blocked");
|
||||
}
|
||||
});
|
||||
|
||||
expect(readRememberedEnum("k", ["ui", "shell"] as const)).toBeNull();
|
||||
expect(() => writeRememberedEnum("k", "ui")).not.toThrow();
|
||||
});
|
||||
});
|
||||
20
apps/web/src/utils/rememberedState.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export function readRememberedEnum<T extends string>(
|
||||
storageKey: string,
|
||||
allowedValues: readonly T[]
|
||||
): T | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(storageKey);
|
||||
if (!raw) return null;
|
||||
return allowedValues.includes(raw as T) ? (raw as T) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeRememberedEnum(storageKey: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(storageKey, value);
|
||||
} catch {
|
||||
// 忽略本地存储不可用场景(如隐私模式限制)
|
||||
}
|
||||
}
|
||||
67
apps/web/src/utils/themeChrome.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyThemeChromeColor, resolveThemeChromeColor } from "./themeChrome";
|
||||
|
||||
type FakeMeta = {
|
||||
name: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
type FakeDocument = {
|
||||
head: {
|
||||
appendChild: (node: FakeMeta) => void;
|
||||
};
|
||||
querySelector: (selector: string) => FakeMeta | null;
|
||||
createElement: (tagName: string) => FakeMeta;
|
||||
metas: FakeMeta[];
|
||||
};
|
||||
|
||||
function createFakeDocument(initialMetas: FakeMeta[] = []): FakeDocument {
|
||||
const metas = [...initialMetas];
|
||||
return {
|
||||
metas,
|
||||
head: {
|
||||
appendChild: (node) => {
|
||||
metas.push(node);
|
||||
}
|
||||
},
|
||||
querySelector: (selector) => {
|
||||
if (selector !== 'meta[name="theme-color"]') {
|
||||
return null;
|
||||
}
|
||||
return metas.find((meta) => meta.name === "theme-color") ?? null;
|
||||
},
|
||||
createElement: (tagName) => {
|
||||
if (tagName !== "meta") {
|
||||
throw new Error(`unexpected tag: ${tagName}`);
|
||||
}
|
||||
return { name: "", content: "" };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("themeChrome", () => {
|
||||
it("优先使用当前主题背景色作为宿主顶栏颜色", () => {
|
||||
expect(resolveThemeChromeColor({ "--bg": " #f6fbff " })).toBe("#f6fbff");
|
||||
});
|
||||
|
||||
it("背景色缺失时回退默认值", () => {
|
||||
expect(resolveThemeChromeColor({})).toBe("#192b4d");
|
||||
expect(resolveThemeChromeColor({ "--bg": " " }, "#ffffff")).toBe("#ffffff");
|
||||
});
|
||||
|
||||
it("不存在 theme-color meta 时应自动创建", () => {
|
||||
const doc = createFakeDocument();
|
||||
applyThemeChromeColor("#102030", doc);
|
||||
|
||||
expect(doc.metas).toHaveLength(1);
|
||||
expect(doc.metas[0]).toEqual({ name: "theme-color", content: "#102030" });
|
||||
});
|
||||
|
||||
it("已存在 theme-color meta 时应更新内容而不是重复插入", () => {
|
||||
const doc = createFakeDocument([{ name: "theme-color", content: "#000000" }]);
|
||||
applyThemeChromeColor("#abcdef", doc);
|
||||
|
||||
expect(doc.metas).toHaveLength(1);
|
||||
expect(doc.metas[0]?.content).toBe("#abcdef");
|
||||
});
|
||||
});
|
||||
52
apps/web/src/utils/themeChrome.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
type MetaLike = {
|
||||
name: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type ThemeChromeDocument<TMeta extends MetaLike> = {
|
||||
head: {
|
||||
appendChild: (node: TMeta) => unknown;
|
||||
};
|
||||
querySelector: (selector: string) => TMeta | null;
|
||||
createElement: (tagName: string) => TMeta;
|
||||
};
|
||||
|
||||
/**
|
||||
* 提取用于浏览器/宿主顶栏的主题色。
|
||||
* 约束:
|
||||
* - 优先使用当前 UI 背景色,保证“顶部工具栏上方区域”跟随界面主题;
|
||||
* - 若运行时变量缺失,回退到稳定深色默认值,避免写入空字符串。
|
||||
*/
|
||||
export function resolveThemeChromeColor(
|
||||
themeVars: Record<string, string | undefined>,
|
||||
fallback = "#192b4d"
|
||||
): string {
|
||||
const nextColor = themeVars["--bg"]?.trim();
|
||||
return nextColor && nextColor.length > 0 ? nextColor : fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步浏览器/宿主顶栏颜色。
|
||||
* 说明:
|
||||
* - Android Chrome、部分 WebView/PWA 会读取 meta[name="theme-color"] 渲染顶部宿主栏;
|
||||
* - 若主题切换后不更新这个 meta,用户会看到“工具栏上方颜色锁死”。
|
||||
*/
|
||||
export function applyThemeChromeColor<TMeta extends MetaLike>(
|
||||
themeColor: string,
|
||||
doc: ThemeChromeDocument<TMeta>
|
||||
): void {
|
||||
const nextColor = themeColor.trim();
|
||||
if (!nextColor) {
|
||||
return;
|
||||
}
|
||||
const targetDocument = doc;
|
||||
let themeMeta = targetDocument.querySelector('meta[name="theme-color"]');
|
||||
if (!themeMeta) {
|
||||
themeMeta = targetDocument.createElement("meta");
|
||||
themeMeta.name = "theme-color";
|
||||
targetDocument.head.appendChild(themeMeta);
|
||||
}
|
||||
if (themeMeta.content !== nextColor) {
|
||||
themeMeta.content = nextColor;
|
||||
}
|
||||
}
|
||||
25
apps/web/src/utils/themePresetLabels.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildThemePresetOptions, getThemePresetLabel } from "./themePresetLabels";
|
||||
|
||||
describe("themePresetLabels", () => {
|
||||
it("会为已确认主题输出新的中文展示名", () => {
|
||||
expect(getThemePresetLabel("tide")).toBe("潮汐");
|
||||
expect(getThemePresetLabel("暮砂")).toBe("沙丘");
|
||||
expect(getThemePresetLabel("靛雾")).toBe("岚雾");
|
||||
});
|
||||
|
||||
it("未重命名主题继续回退内部值,并保留共享预设顺序", () => {
|
||||
const options = buildThemePresetOptions();
|
||||
|
||||
expect(getThemePresetLabel("绛霓")).toBe("绛霓");
|
||||
expect(options.slice(0, 7)).toEqual([
|
||||
{ label: "潮汐", value: "tide" },
|
||||
{ label: "沙丘", value: "暮砂" },
|
||||
{ label: "棱光", value: "霓潮" },
|
||||
{ label: "苔影", value: "苔暮" },
|
||||
{ label: "余烬", value: "焰岩" },
|
||||
{ label: "陶土", value: "岩陶" },
|
||||
{ label: "岚雾", value: "靛雾" }
|
||||
]);
|
||||
});
|
||||
});
|
||||
34
apps/web/src/utils/themePresetLabels.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ThemePreset } from "@/types/app";
|
||||
import { THEME_PRESETS } from "@remoteconn/shared";
|
||||
|
||||
export type ThemePresetOption = {
|
||||
label: string;
|
||||
value: ThemePreset;
|
||||
};
|
||||
|
||||
const THEME_PRESET_LABEL_OVERRIDES: Partial<Record<ThemePreset, string>> = Object.freeze({
|
||||
tide: "潮汐",
|
||||
暮砂: "沙丘",
|
||||
霓潮: "棱光",
|
||||
苔暮: "苔影",
|
||||
焰岩: "余烬",
|
||||
岩陶: "陶土",
|
||||
靛雾: "岚雾"
|
||||
});
|
||||
|
||||
/**
|
||||
* Web 设置页只改展示名,不改持久化值,避免影响既有配置兼容性。
|
||||
*/
|
||||
export function getThemePresetLabel(preset: ThemePreset): string {
|
||||
return THEME_PRESET_LABEL_OVERRIDES[preset] ?? preset;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主题选项顺序直接复用共享预设定义,避免 Web 侧再维护一份平行枚举。
|
||||
*/
|
||||
export function buildThemePresetOptions(): ThemePresetOption[] {
|
||||
return (Object.keys(THEME_PRESETS) as ThemePreset[]).map((preset) => ({
|
||||
value: preset,
|
||||
label: getThemePresetLabel(preset)
|
||||
}));
|
||||
}
|
||||
17
apps/web/src/utils/time.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function nowIso(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export function formatDateTime(input: string | Date): string {
|
||||
const date = input instanceof Date ? input : new Date(input);
|
||||
return date.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
export function formatDurationMs(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return "0s";
|
||||
const seconds = Math.round(ms / 1000);
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
if (m <= 0) return `${s}s`;
|
||||
return `${m}m ${s}s`;
|
||||
}
|
||||
23
apps/web/src/utils/useRememberedEnumRef.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { onMounted, watch, type Ref } from "vue";
|
||||
import { readRememberedEnum, writeRememberedEnum } from "@/utils/rememberedState";
|
||||
|
||||
interface UseRememberedEnumRefOptions<T extends string> {
|
||||
storageKey: string;
|
||||
allowedValues: readonly T[];
|
||||
target: Ref<T>;
|
||||
}
|
||||
|
||||
export function useRememberedEnumRef<T extends string>(options: UseRememberedEnumRefOptions<T>): void {
|
||||
const { storageKey, allowedValues, target } = options;
|
||||
|
||||
onMounted(() => {
|
||||
const saved = readRememberedEnum(storageKey, allowedValues);
|
||||
if (saved) {
|
||||
target.value = saved;
|
||||
}
|
||||
});
|
||||
|
||||
watch(target, (value) => {
|
||||
writeRememberedEnum(storageKey, value);
|
||||
});
|
||||
}
|
||||
195
apps/web/src/views/AboutView.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<section class="page-root about-page-web">
|
||||
<div class="page-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<button
|
||||
v-if="isDetailView"
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
title="返回关于首页"
|
||||
aria-label="返回关于首页"
|
||||
@click="goAboutHome"
|
||||
>
|
||||
<span class="icon-mask" style="--icon: url("/icons/back.svg")" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="toolbar-spacer"></div>
|
||||
<h2 class="page-title">{{ pageTitle }}</h2>
|
||||
<div class="toolbar-right about-toolbar-actions">
|
||||
<button
|
||||
v-if="isDetailView"
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
title="分享当前页面"
|
||||
aria-label="分享当前页面"
|
||||
@click="shareCurrentPage"
|
||||
>
|
||||
<span class="icon-mask" style="--icon: url("/icons/share.svg")" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="about-scroll-web surface-scroll">
|
||||
<div class="about-shell-web">
|
||||
<div class="about-bg-orb-web about-bg-orb-web-left"></div>
|
||||
<div class="about-bg-orb-web about-bg-orb-web-right"></div>
|
||||
|
||||
<div v-if="!isDetailView" class="about-stack-web">
|
||||
<section class="about-hero-web">
|
||||
<img class="about-home-logo-web" src="/brand/logo.svg" alt="RemoteConn 标志" />
|
||||
<img class="about-home-wordmark-web" src="/brand/remoteconn.svg" alt="RemoteConn" />
|
||||
<img class="about-home-submark-web" src="/brand/ai矩连.svg" alt="AI 矩连" />
|
||||
<p class="about-home-version-web">{{ ABOUT_BRAND.version }}</p>
|
||||
<p class="about-home-summary-web">{{ ABOUT_BRAND.summary }}</p>
|
||||
</section>
|
||||
|
||||
<section class="about-card-list-web" aria-label="关于页面入口">
|
||||
<button
|
||||
v-for="item in ABOUT_HOME_ITEMS"
|
||||
:key="item.key"
|
||||
class="about-entry-web"
|
||||
type="button"
|
||||
@click="openDetail(item.key)"
|
||||
>
|
||||
<span class="about-entry-main-web">
|
||||
<span class="about-entry-title-web">{{ item.title }}</span>
|
||||
</span>
|
||||
<span class="about-entry-arrow-web" aria-hidden="true">›</span>
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeDetailKey === 'app'" class="about-app-stack-web">
|
||||
<section class="about-app-brand-web">
|
||||
<img class="about-home-logo-web" src="/brand/logo.svg" alt="RemoteConn 标志" />
|
||||
<img class="about-home-wordmark-web" src="/brand/remoteconn.svg" alt="RemoteConn" />
|
||||
<img class="about-home-submark-web" src="/brand/ai矩连.svg" alt="AI 矩连" />
|
||||
<p class="about-app-version-web">{{ appVersionLine }}</p>
|
||||
</section>
|
||||
|
||||
<article class="about-app-card-web">
|
||||
<h3 class="about-app-card-title-web">{{ activeContent.sections[0]?.title }}</h3>
|
||||
<p v-if="activeContent.lead" class="about-app-card-lead-web">{{ activeContent.lead }}</p>
|
||||
<div class="about-app-info-list-web">
|
||||
<div v-for="row in aboutInfoRows" :key="row.key" class="about-app-info-row-web">
|
||||
<span v-if="row.label" class="about-app-info-label-web">{{ row.label }}</span>
|
||||
<span class="about-app-info-value-web">{{ row.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="about-app-footer-web">
|
||||
<button class="about-link-btn-web" type="button" @click="openDetail('manual')">使用说明</button>
|
||||
<button class="about-link-btn-web" type="button" @click="openDetail('privacy')">隐私政策</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="about-stack-web">
|
||||
<div class="detail-chip-web">{{ ABOUT_BRAND.chineseName }}</div>
|
||||
<article class="detail-card-web">
|
||||
<h3 class="detail-title-web">{{ activeContent.title }}</h3>
|
||||
<p class="detail-lead-web">{{ activeContent.lead }}</p>
|
||||
</article>
|
||||
|
||||
<article
|
||||
v-for="section in activeContent.sections"
|
||||
:key="section.title"
|
||||
class="detail-card-web detail-section-list-web"
|
||||
>
|
||||
<div class="detail-section-head-web">
|
||||
<h4 class="detail-section-title-web">{{ section.title }}</h4>
|
||||
<button
|
||||
v-if="section.actionLabel"
|
||||
class="detail-action-btn-web"
|
||||
type="button"
|
||||
@click="copyFeedbackEmail"
|
||||
>
|
||||
{{ section.actionLabel }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-for="paragraph in section.paragraphs" :key="paragraph" class="detail-paragraph-web">
|
||||
{{ paragraph }}
|
||||
</p>
|
||||
<div v-if="section.bullets?.length" class="detail-bullet-list-web">
|
||||
<div v-for="bullet in section.bullets" :key="bullet" class="detail-bullet-row-web">
|
||||
<span class="detail-bullet-dot-web">•</span>
|
||||
<span class="detail-bullet-text-web">{{ bullet }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import {
|
||||
ABOUT_BRAND,
|
||||
ABOUT_HOME_ITEMS,
|
||||
buildAboutInfoRows,
|
||||
getAboutDetailContent,
|
||||
isAboutDetailKey,
|
||||
type AboutDetailKey
|
||||
} from "@/content/about";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const appStore = useAppStore();
|
||||
|
||||
const activeDetailKey = computed<AboutDetailKey | null>(() => {
|
||||
const raw = String(route.params.section ?? "").trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
return isAboutDetailKey(raw) ? raw : null;
|
||||
});
|
||||
|
||||
const isDetailView = computed(() => activeDetailKey.value !== null);
|
||||
const activeContent = computed(() => getAboutDetailContent(activeDetailKey.value ?? "app"));
|
||||
const pageTitle = computed(() => (activeDetailKey.value ? activeContent.value.title : "关于"));
|
||||
const aboutInfoRows = computed(() => buildAboutInfoRows(getAboutDetailContent("app")));
|
||||
const appVersionLine = computed(() => `${ABOUT_BRAND.version} · web · ${ABOUT_BRAND.updatedAtCompact}`);
|
||||
|
||||
function openDetail(key: AboutDetailKey): Promise<void> {
|
||||
return router.push(`/about/${key}`).then(() => undefined);
|
||||
}
|
||||
|
||||
function goAboutHome(): Promise<void> {
|
||||
return router.push("/about").then(() => undefined);
|
||||
}
|
||||
|
||||
async function copyFeedbackEmail(): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(ABOUT_BRAND.feedbackEmail);
|
||||
appStore.notify("info", "反馈邮箱已复制");
|
||||
} catch {
|
||||
appStore.notify("error", "复制邮箱失败,请手动复制");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Web 端优先走原生 share,浏览器不支持时回退为复制当前链接。
|
||||
*/
|
||||
async function shareCurrentPage(): Promise<void> {
|
||||
const sharePayload = {
|
||||
title: `${ABOUT_BRAND.productName} ${ABOUT_BRAND.version}`,
|
||||
text: ABOUT_BRAND.summary,
|
||||
url: window.location.href
|
||||
};
|
||||
|
||||
try {
|
||||
if (navigator.share) {
|
||||
await navigator.share(sharePayload);
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(sharePayload.url);
|
||||
appStore.notify("info", "页面链接已复制");
|
||||
} catch {
|
||||
appStore.notify("error", "分享失败,请稍后重试");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
657
apps/web/src/views/ConnectView.vue
Normal file
@@ -0,0 +1,657 @@
|
||||
<template>
|
||||
<section class="page-root server-manager-page">
|
||||
<div class="page-toolbar server-manager-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<button class="icon-btn" type="button" title="新增服务器" aria-label="新增服务器" @click="create">
|
||||
<span
|
||||
class="icon-mask"
|
||||
style="--icon: url("/icons/create.svg")"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
title="删除已选服务器"
|
||||
aria-label="删除已选服务器"
|
||||
:disabled="selectedServerIds.length === 0"
|
||||
@click="remove"
|
||||
>
|
||||
<span
|
||||
class="icon-mask"
|
||||
style="--icon: url("/icons/delete.svg")"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
:title="isAllSelected ? '取消全选服务器' : '全选服务器'"
|
||||
:aria-label="isAllSelected ? '取消全选服务器' : '全选服务器'"
|
||||
:disabled="!serverStore.servers.length"
|
||||
@click="toggleSelectAllServers"
|
||||
>
|
||||
<span
|
||||
class="icon-mask"
|
||||
style="--icon: url("/icons/selectall.svg")"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="toolbar-spacer"></div>
|
||||
<h2 class="page-title">我的服务器</h2>
|
||||
</div>
|
||||
|
||||
<div class="server-manager-content">
|
||||
<div class="server-search-wrap">
|
||||
<div class="server-search-shell">
|
||||
<input v-model="searchKeyword" class="server-search-input" type="search" placeholder="搜索服务器" />
|
||||
<button class="server-search-btn" type="button" title="搜索服务器" aria-label="搜索服务器">
|
||||
<span
|
||||
class="icon-mask"
|
||||
style="--icon: url("/icons/search.svg")"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="server-list-scroll surface-scroll">
|
||||
<div class="server-list-stack">
|
||||
<article
|
||||
v-for="item in filteredServers"
|
||||
:key="item.id"
|
||||
class="server-list-row"
|
||||
:class="{
|
||||
active: item.id === serverStore.selectedServerId,
|
||||
'is-dragging': draggingServerId === item.id,
|
||||
'is-drag-over': dragOverServerId === item.id
|
||||
}"
|
||||
:data-server-id="item.id"
|
||||
:style="dragRowStyle(item.id)"
|
||||
>
|
||||
<div class="server-row-check" @click.stop>
|
||||
<input
|
||||
:id="`server-check-${item.id}`"
|
||||
class="server-check-input"
|
||||
type="checkbox"
|
||||
:checked="selectedServerIds.includes(item.id)"
|
||||
@change="onServerCheckChanged(item.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="server-info server-info-clickable" @click="openServerSettings(item.id)">
|
||||
<div class="server-info-top">
|
||||
<p class="server-name">{{ item.name }}</p>
|
||||
<div class="server-row-actions" @click.stop>
|
||||
<button
|
||||
class="server-copy-btn"
|
||||
type="button"
|
||||
title="复制服务器配置"
|
||||
aria-label="复制服务器配置"
|
||||
@click.stop="copyServer(item)"
|
||||
>
|
||||
<span
|
||||
class="icon-mask"
|
||||
style="--icon: url("/icons/copy.svg")"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
</button>
|
||||
<button
|
||||
class="server-ai-btn"
|
||||
:class="{ 'is-connected': isServerAiActive(item.id) }"
|
||||
type="button"
|
||||
:disabled="isConnectActionBlocked(item.id)"
|
||||
title="AI 快速启动"
|
||||
aria-label="AI 快速启动"
|
||||
@click.stop="openCodexForServer(item)"
|
||||
>
|
||||
<span
|
||||
class="icon-mask server-ai-icon"
|
||||
:style="{ '--icon': `url(${aiIcon})` }"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
</button>
|
||||
<button
|
||||
class="connect-icon-btn"
|
||||
:class="{
|
||||
'is-connected': isServerConnected(item.id) || isServerResumable(item.id),
|
||||
'is-connecting': isServerConnecting(item.id)
|
||||
}"
|
||||
type="button"
|
||||
:disabled="isConnectActionBlocked(item.id)"
|
||||
:title="connectButtonTitle(item.id)"
|
||||
:aria-label="connectButtonTitle(item.id)"
|
||||
@click.stop="quickConnect(item)"
|
||||
>
|
||||
<span
|
||||
class="icon-mask"
|
||||
style="--icon: url("/icons/connect.svg")"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
</button>
|
||||
<button
|
||||
class="server-move-btn"
|
||||
type="button"
|
||||
:disabled="!canDragReorder(item.id)"
|
||||
title="拖拽手柄调整顺序"
|
||||
aria-label="拖拽调整顺序"
|
||||
@pointerdown.stop="onMoveHandlePointerDown(item.id, $event)"
|
||||
>
|
||||
<span
|
||||
class="icon-mask server-move-icon"
|
||||
:style="{ '--icon': `url(${moveIcon})` }"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="server-info-meta">
|
||||
<p class="server-main">{{ item.username }}@{{ item.host }}:{{ item.port }}</p>
|
||||
<p class="server-auth">{{ item.authType }}</p>
|
||||
</div>
|
||||
|
||||
<p class="server-recent">最近连接: {{ formatLastConnected(item.lastConnectedAt) }}</p>
|
||||
|
||||
<div v-if="resolvedDisplayTags(item).length > 0" class="server-tags">
|
||||
<span
|
||||
v-for="tag in resolvedDisplayTags(item)"
|
||||
:key="`${item.id}-${tag.type}-${tag.label}`"
|
||||
class="server-tag"
|
||||
:class="{ 'server-tag-project': tag.type === 'project' }"
|
||||
>
|
||||
{{ tag.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<p v-if="filteredServers.length === 0" class="server-empty-tip">暂无匹配服务器</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import type { ServerProfile } from "@/types/app";
|
||||
import { useServerStore } from "@/stores/serverStore";
|
||||
import { useSessionStore } from "@/stores/sessionStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { formatActionError } from "@/utils/feedback";
|
||||
|
||||
type ServerDisplayTag = {
|
||||
type: "project" | "tag";
|
||||
label: string;
|
||||
};
|
||||
|
||||
const serverStore = useServerStore();
|
||||
const sessionStore = useSessionStore();
|
||||
const appStore = useAppStore();
|
||||
const router = useRouter();
|
||||
|
||||
const selectedServerIds = ref<string[]>([]);
|
||||
const searchKeyword = ref("");
|
||||
const connectingServerId = ref("");
|
||||
const waitingConnectStates = new Set(["connecting", "auth_pending", "reconnecting"]);
|
||||
const draggingServerId = ref("");
|
||||
const dragOverServerId = ref("");
|
||||
const dragPointerId = ref<number | null>(null);
|
||||
const dragStartClientX = ref(0);
|
||||
const dragStartClientY = ref(0);
|
||||
const dragOffsetX = ref(0);
|
||||
const dragOffsetY = ref(0);
|
||||
const aiIcon = "/assets/icons/ai.svg";
|
||||
const moveIcon = "/assets/icons/move.svg";
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([serverStore.ensureBootstrapped(), sessionStore.ensureBootstrapped()]);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
teardownPointerDragListeners();
|
||||
resetDragState();
|
||||
});
|
||||
|
||||
const filteredServers = computed(() => {
|
||||
// 服务器管理页保留搜索框,并按名称/地址/用户/标签进行过滤。
|
||||
const keyword = searchKeyword.value.trim().toLowerCase();
|
||||
if (!keyword) return serverStore.servers;
|
||||
return serverStore.servers.filter((item) => {
|
||||
const haystack = [
|
||||
item.name,
|
||||
item.host,
|
||||
item.username,
|
||||
String(item.port),
|
||||
item.authType,
|
||||
resolvedDisplayTags(item)
|
||||
.map((tag) => tag.label)
|
||||
.join(" ")
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(keyword);
|
||||
});
|
||||
});
|
||||
|
||||
const isAllSelected = computed(
|
||||
() =>
|
||||
serverStore.servers.length > 0 &&
|
||||
selectedServerIds.value.length === serverStore.servers.length &&
|
||||
serverStore.servers.every((item) => selectedServerIds.value.includes(item.id))
|
||||
);
|
||||
|
||||
watch(
|
||||
() => serverStore.servers.map((item) => item.id),
|
||||
(ids) => {
|
||||
selectedServerIds.value = selectedServerIds.value.filter((id) => ids.includes(id));
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function create(): Promise<void> {
|
||||
selectedServerIds.value = [];
|
||||
await router.push("/server/new/settings");
|
||||
}
|
||||
|
||||
async function remove(): Promise<void> {
|
||||
const targets = [...selectedServerIds.value];
|
||||
if (targets.length === 0) return;
|
||||
if (!window.confirm(`确认删除已选服务器(${targets.length} 台)吗?`)) return;
|
||||
|
||||
for (const serverId of targets) {
|
||||
await serverStore.deleteServer(serverId);
|
||||
}
|
||||
|
||||
selectedServerIds.value = [];
|
||||
appStore.notify("info", `已删除 ${targets.length} 台服务器`);
|
||||
}
|
||||
|
||||
function toggleServerChecked(serverId: string, checked: boolean): void {
|
||||
if (checked) {
|
||||
if (!selectedServerIds.value.includes(serverId)) {
|
||||
selectedServerIds.value = [...selectedServerIds.value, serverId];
|
||||
}
|
||||
return;
|
||||
}
|
||||
selectedServerIds.value = selectedServerIds.value.filter((id) => id !== serverId);
|
||||
}
|
||||
|
||||
function onServerCheckChanged(serverId: string, event: Event): void {
|
||||
const target = event.target as HTMLInputElement | null;
|
||||
toggleServerChecked(serverId, target?.checked ?? false);
|
||||
}
|
||||
|
||||
function toggleSelectAllServers(): void {
|
||||
if (isAllSelected.value) {
|
||||
selectedServerIds.value = [];
|
||||
return;
|
||||
}
|
||||
selectedServerIds.value = serverStore.servers.map((item) => item.id);
|
||||
}
|
||||
|
||||
async function quickConnect(server: ServerProfile): Promise<void> {
|
||||
if (isConnectActionBlocked(server.id)) return;
|
||||
setActiveServer(server.id);
|
||||
|
||||
// 已连接同一服务器:直接进入终端复用现有会话,不做断开重连。
|
||||
if (isServerConnected(server.id)) {
|
||||
await router.push("/terminal");
|
||||
return;
|
||||
}
|
||||
|
||||
connectingServerId.value = server.id;
|
||||
try {
|
||||
appStore.notify("info", `正在连接: ${server.username}@${server.host}:${server.port}`);
|
||||
const connectTask = sessionStore.connect({
|
||||
...server,
|
||||
projectPresets: [...server.projectPresets],
|
||||
tags: [...server.tags],
|
||||
jumpHost: server.jumpHost ? { ...server.jumpHost } : undefined
|
||||
});
|
||||
await router.push("/terminal");
|
||||
await connectTask;
|
||||
} catch (error) {
|
||||
appStore.notify("error", formatActionError("连接失败", error));
|
||||
} finally {
|
||||
connectingServerId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function openCodexForServer(server: ServerProfile): Promise<void> {
|
||||
if (isConnectActionBlocked(server.id)) return;
|
||||
setActiveServer(server.id);
|
||||
|
||||
if (isServerConnected(server.id)) {
|
||||
await router.push({ path: "/terminal", query: { openCodex: "1" } });
|
||||
return;
|
||||
}
|
||||
|
||||
connectingServerId.value = server.id;
|
||||
try {
|
||||
appStore.notify("info", `正在连接: ${server.username}@${server.host}:${server.port}`);
|
||||
const connectTask = sessionStore.connect({
|
||||
...server,
|
||||
projectPresets: [...server.projectPresets],
|
||||
tags: [...server.tags],
|
||||
jumpHost: server.jumpHost ? { ...server.jumpHost } : undefined
|
||||
});
|
||||
await router.push({ path: "/terminal", query: { openCodex: "1" } });
|
||||
await connectTask;
|
||||
} catch (error) {
|
||||
appStore.notify("error", formatActionError("连接失败", error));
|
||||
} finally {
|
||||
connectingServerId.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制服务器配置:
|
||||
* 1) 新建独立服务器 ID,避免覆盖原配置;
|
||||
* 2) 名称按“原服务器名+copy”生成;
|
||||
* 3) 同步复制凭据,确保复制项可直接用于连接。
|
||||
*/
|
||||
async function copyServer(server: ServerProfile): Promise<void> {
|
||||
const copiedServerId = `srv-${crypto.randomUUID()}`;
|
||||
const copiedServerName = `${String(server.name || "未命名服务器")}copy`;
|
||||
const copiedServer: ServerProfile = {
|
||||
...server,
|
||||
id: copiedServerId,
|
||||
name: copiedServerName,
|
||||
projectPresets: [...server.projectPresets],
|
||||
tags: [...server.tags],
|
||||
jumpHost: server.jumpHost ? { ...server.jumpHost } : undefined,
|
||||
lastConnectedAt: ""
|
||||
};
|
||||
|
||||
try {
|
||||
await serverStore.saveServer(copiedServer);
|
||||
const credentialSnapshot = await serverStore.getCredentialBundleInput(server.id);
|
||||
if (credentialSnapshot) {
|
||||
await serverStore.saveCredential(
|
||||
copiedServerId,
|
||||
{
|
||||
...credentialSnapshot.target
|
||||
},
|
||||
credentialSnapshot.jump ? { ...credentialSnapshot.jump } : null
|
||||
);
|
||||
}
|
||||
setActiveServer(copiedServerId);
|
||||
appStore.notify("info", `已复制服务器: ${copiedServerName}`);
|
||||
} catch (error) {
|
||||
appStore.notify("error", formatActionError("复制服务器失败", error));
|
||||
}
|
||||
}
|
||||
|
||||
function canDragReorder(serverId: string): boolean {
|
||||
return serverStore.servers.some((item) => item.id === serverId);
|
||||
}
|
||||
|
||||
function resetDragState(): void {
|
||||
draggingServerId.value = "";
|
||||
dragOverServerId.value = "";
|
||||
dragPointerId.value = null;
|
||||
dragStartClientX.value = 0;
|
||||
dragStartClientY.value = 0;
|
||||
dragOffsetX.value = 0;
|
||||
dragOffsetY.value = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅在当前被拖拽的卡片上注入位移变量,用于视觉跟手。
|
||||
*/
|
||||
function dragRowStyle(serverId: string): Record<string, string> | undefined {
|
||||
if (draggingServerId.value !== serverId) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
"--drag-x": `${dragOffsetX.value}px`,
|
||||
"--drag-y": `${dragOffsetY.value}px`
|
||||
};
|
||||
}
|
||||
|
||||
function teardownPointerDragListeners(): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
window.removeEventListener("pointermove", onPointerDragMove);
|
||||
window.removeEventListener("pointerup", onPointerDragUp);
|
||||
window.removeEventListener("pointercancel", onPointerDragCancel);
|
||||
if (typeof document !== "undefined") {
|
||||
document.removeEventListener("pointermove", onPointerDragMove, true);
|
||||
document.removeEventListener("pointerup", onPointerDragUp, true);
|
||||
document.removeEventListener("pointercancel", onPointerDragCancel, true);
|
||||
}
|
||||
}
|
||||
|
||||
function setupPointerDragListeners(): void {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
teardownPointerDragListeners();
|
||||
// 部分 WebView/浏览器对 window 冒泡阶段的 pointer 事件投递不稳定,这里同时监听
|
||||
// window 与 document(capture) 做兼容兜底,确保拖拽过程可持续接收 move/up。
|
||||
window.addEventListener("pointermove", onPointerDragMove, { passive: false });
|
||||
window.addEventListener("pointerup", onPointerDragUp, { passive: false });
|
||||
window.addEventListener("pointercancel", onPointerDragCancel, { passive: false });
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("pointermove", onPointerDragMove, { passive: false, capture: true });
|
||||
document.addEventListener("pointerup", onPointerDragUp, { passive: false, capture: true });
|
||||
document.addEventListener("pointercancel", onPointerDragCancel, { passive: false, capture: true });
|
||||
}
|
||||
}
|
||||
|
||||
function onMoveHandlePointerDown(serverId: string, event: PointerEvent): void {
|
||||
if (!canDragReorder(serverId)) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
draggingServerId.value = serverId;
|
||||
dragOverServerId.value = "";
|
||||
dragPointerId.value = event.pointerId;
|
||||
dragStartClientX.value = event.clientX;
|
||||
dragStartClientY.value = event.clientY;
|
||||
dragOffsetX.value = 0;
|
||||
dragOffsetY.value = 0;
|
||||
setupPointerDragListeners();
|
||||
}
|
||||
|
||||
function resolveCardIdByPoint(clientX: number, clientY: number): string {
|
||||
if (typeof document === "undefined") {
|
||||
return "";
|
||||
}
|
||||
const target = document.elementFromPoint(clientX, clientY) as HTMLElement | null;
|
||||
const card = target?.closest<HTMLElement>("[data-server-id]");
|
||||
if (card?.dataset.serverId && card.dataset.serverId !== draggingServerId.value) {
|
||||
return card.dataset.serverId;
|
||||
}
|
||||
|
||||
// elementFromPoint 在少数环境可能命中浮层/伪元素,兜底按 y 坐标命中行元素。
|
||||
const rows = Array.from(document.querySelectorAll<HTMLElement>(".server-list-row[data-server-id]"));
|
||||
for (const row of rows) {
|
||||
if (row.dataset.serverId === draggingServerId.value) {
|
||||
continue;
|
||||
}
|
||||
const rect = row.getBoundingClientRect();
|
||||
if (clientY >= rect.top && clientY <= rect.bottom) {
|
||||
return row.dataset.serverId ?? "";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function onPointerDragMove(event: PointerEvent): void {
|
||||
if (!draggingServerId.value || dragPointerId.value !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
// 记录手势位移,让卡片产生“跟手移动”的视觉反馈。
|
||||
dragOffsetX.value = event.clientX - dragStartClientX.value;
|
||||
dragOffsetY.value = event.clientY - dragStartClientY.value;
|
||||
const targetServerId = resolveCardIdByPoint(event.clientX, event.clientY);
|
||||
if (!targetServerId || targetServerId === draggingServerId.value) {
|
||||
dragOverServerId.value = "";
|
||||
return;
|
||||
}
|
||||
dragOverServerId.value = targetServerId;
|
||||
}
|
||||
|
||||
async function applyReorderByIds(sourceServerId: string, targetServerId: string): Promise<void> {
|
||||
const nextIds = serverStore.servers.map((item) => item.id);
|
||||
const sourceIndex = nextIds.indexOf(sourceServerId);
|
||||
const targetIndex = nextIds.indexOf(targetServerId);
|
||||
if (sourceIndex < 0 || targetIndex < 0) {
|
||||
return;
|
||||
}
|
||||
// 拖拽落到目标项时:
|
||||
// - 向下拖拽:插入到目标项后方;
|
||||
// - 向上拖拽:插入到目标项前方。
|
||||
// 这样可避免“拖到相邻下一项却无变化”的问题。
|
||||
nextIds.splice(sourceIndex, 1);
|
||||
const normalizedTargetIndex = nextIds.indexOf(targetServerId);
|
||||
if (normalizedTargetIndex < 0) {
|
||||
return;
|
||||
}
|
||||
const insertIndex = sourceIndex < targetIndex ? normalizedTargetIndex + 1 : normalizedTargetIndex;
|
||||
nextIds.splice(insertIndex, 0, sourceServerId);
|
||||
|
||||
try {
|
||||
await serverStore.applyServerOrder(nextIds);
|
||||
} catch (error) {
|
||||
appStore.notify("error", formatActionError("调整服务器顺序失败", error));
|
||||
}
|
||||
}
|
||||
|
||||
async function onPointerDragUp(event: PointerEvent): Promise<void> {
|
||||
if (dragPointerId.value !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const sourceServerId = draggingServerId.value;
|
||||
const targetServerId = dragOverServerId.value || resolveCardIdByPoint(event.clientX, event.clientY);
|
||||
teardownPointerDragListeners();
|
||||
resetDragState();
|
||||
if (!sourceServerId || !targetServerId || sourceServerId === targetServerId) {
|
||||
return;
|
||||
}
|
||||
await applyReorderByIds(sourceServerId, targetServerId);
|
||||
}
|
||||
|
||||
function onPointerDragCancel(event: PointerEvent): void {
|
||||
if (dragPointerId.value !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
teardownPointerDragListeners();
|
||||
resetDragState();
|
||||
}
|
||||
|
||||
function isServerConnected(serverId: string): boolean {
|
||||
return sessionStore.state === "connected" && sessionStore.currentServerId === serverId;
|
||||
}
|
||||
|
||||
function isServerResumable(serverId: string): boolean {
|
||||
return sessionStore.isServerResumable(serverId);
|
||||
}
|
||||
|
||||
function isServerAiActive(serverId: string): boolean {
|
||||
return sessionStore.isServerAiActive(serverId);
|
||||
}
|
||||
|
||||
function isServerConnecting(serverId: string): boolean {
|
||||
return waitingConnectStates.has(sessionStore.state) && sessionStore.currentServerId === serverId;
|
||||
}
|
||||
|
||||
function isConnectActionBlocked(serverId: string): boolean {
|
||||
if (isServerConnected(serverId)) {
|
||||
return false;
|
||||
}
|
||||
if (connectingServerId.value) {
|
||||
return true;
|
||||
}
|
||||
return waitingConnectStates.has(sessionStore.state);
|
||||
}
|
||||
|
||||
function connectButtonTitle(serverId: string): string {
|
||||
if (isServerConnected(serverId)) {
|
||||
return "进入当前会话";
|
||||
}
|
||||
if (isServerResumable(serverId)) {
|
||||
return "恢复会话";
|
||||
}
|
||||
if (isServerConnecting(serverId)) {
|
||||
return "连接中";
|
||||
}
|
||||
return "连接服务器";
|
||||
}
|
||||
|
||||
function setActiveServer(serverId: string): void {
|
||||
serverStore.selectedServerId = serverId;
|
||||
}
|
||||
|
||||
async function openServerSettings(serverId: string): Promise<void> {
|
||||
if (!serverId) return;
|
||||
setActiveServer(serverId);
|
||||
await router.push(`/server/${encodeURIComponent(serverId)}/settings`);
|
||||
}
|
||||
|
||||
function formatLastConnected(lastConnectedAt?: string): string {
|
||||
return lastConnectedAt || "无连接";
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取 projectPath 的最后一级目录名:
|
||||
* 1. 先裁掉首尾空白与尾部斜杠;
|
||||
* 2. 同时兼容 / 与 \ 两类路径分隔符;
|
||||
* 3. 仅返回短目录名,避免卡片底部胶囊过长。
|
||||
*/
|
||||
function resolveProjectDirectoryName(projectPath?: string): string {
|
||||
const normalized = String(projectPath || "")
|
||||
.trim()
|
||||
.replace(/[\\/]+$/g, "");
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
const segments = normalized.split(/[\\/]+/).filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return normalized === "~" ? "~" : "";
|
||||
}
|
||||
return segments[segments.length - 1] || "";
|
||||
}
|
||||
|
||||
function resolvedTags(server: ServerProfile): string[] {
|
||||
if (server.tags.length > 0) return server.tags;
|
||||
|
||||
// 对齐原型示例:老数据未配置 tags 时,按服务器名称提供展示级回退标签。
|
||||
if (server.name.includes("生产")) return ["prod", "beijing"];
|
||||
if (server.name.includes("测试")) return ["test", "杭州"];
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务器卡片底部展示标签:
|
||||
* 1. 项目目录胶囊固定放在最前面;
|
||||
* 2. 其后继续展示用户 tags;
|
||||
* 3. 模板依据 type 区分 project/tag 的底色。
|
||||
*/
|
||||
function resolvedDisplayTags(server: ServerProfile): ServerDisplayTag[] {
|
||||
const displayTags: ServerDisplayTag[] = [];
|
||||
const projectDirectoryName = resolveProjectDirectoryName(server.projectPath);
|
||||
if (projectDirectoryName) {
|
||||
displayTags.push({
|
||||
type: "project",
|
||||
label: `pro:${projectDirectoryName}`
|
||||
});
|
||||
}
|
||||
for (const tag of resolvedTags(server)) {
|
||||
displayTags.push({
|
||||
type: "tag",
|
||||
label: tag
|
||||
});
|
||||
}
|
||||
return displayTags;
|
||||
}
|
||||
</script>
|
||||
108
apps/web/src/views/LogsView.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<section class="page-root logs-page">
|
||||
<div class="page-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
title="返回上一页"
|
||||
aria-label="返回上一页"
|
||||
:disabled="!canGoBack"
|
||||
@click="goBack"
|
||||
>
|
||||
<span class="icon-mask" style="--icon: url('/icons/back.svg')" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="toolbar-spacer"></div>
|
||||
<h2 class="page-title">日志</h2>
|
||||
</div>
|
||||
|
||||
<article class="surface-panel">
|
||||
<div class="actions">
|
||||
<button class="btn" @click="download">导出脱敏日志</button>
|
||||
<span class="settings-save-status">共 {{ totalLogs }} 条</span>
|
||||
</div>
|
||||
<div class="surface-scroll list-stack">
|
||||
<article v-for="item in pagedLogs" :key="item.sessionId" class="log-item">
|
||||
<div class="item-title">{{ item.sessionId }} · {{ item.status }}</div>
|
||||
<div class="item-sub">server: {{ item.serverId }}</div>
|
||||
<div class="item-sub">{{ item.startAt }} -> {{ item.endAt ?? '--' }}</div>
|
||||
<div class="item-sub">commands: {{ item.commandMarkers.length }} · error: {{ item.error ?? '-' }}</div>
|
||||
</article>
|
||||
<p v-if="pagedLogs.length === 0" class="server-empty-tip">暂无日志</p>
|
||||
</div>
|
||||
<div class="records-pagination">
|
||||
<button class="btn" type="button" :disabled="currentPage <= 1" @click="currentPage -= 1">上一页</button>
|
||||
<span class="records-pagination-text">第 {{ currentPage }} / {{ totalPages }} 页</span>
|
||||
<button class="btn" type="button" :disabled="currentPage >= totalPages" @click="currentPage += 1">下一页</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useLogStore } from "@/stores/logStore";
|
||||
|
||||
const logStore = useLogStore();
|
||||
const router = useRouter();
|
||||
const canGoBack = ref(false);
|
||||
const pageSize = 15;
|
||||
const currentPage = ref(1);
|
||||
const sortedLogs = computed(() => [...logStore.logs].sort((a, b) => +new Date(b.startAt) - +new Date(a.startAt)));
|
||||
const totalLogs = computed(() => sortedLogs.value.length);
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalLogs.value / pageSize)));
|
||||
const pagedLogs = computed(() => {
|
||||
const start = (currentPage.value - 1) * pageSize;
|
||||
const end = start + pageSize;
|
||||
return sortedLogs.value.slice(start, end);
|
||||
});
|
||||
|
||||
/**
|
||||
* 统一“返回”语义:仅允许返回历史上一页。
|
||||
*/
|
||||
function syncCanGoBack(): void {
|
||||
if (typeof window === "undefined") {
|
||||
canGoBack.value = false;
|
||||
return;
|
||||
}
|
||||
canGoBack.value = window.history.length > 1;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
syncCanGoBack();
|
||||
window.addEventListener("popstate", syncCanGoBack);
|
||||
await logStore.ensureBootstrapped();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("popstate", syncCanGoBack);
|
||||
});
|
||||
|
||||
watch(totalPages, (nextPages) => {
|
||||
if (currentPage.value > nextPages) {
|
||||
currentPage.value = nextPages;
|
||||
}
|
||||
});
|
||||
|
||||
function download(): void {
|
||||
const content = logStore.exportLogs(true);
|
||||
const blob = new Blob([content], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `remoteconn-logs-${Date.now()}.txt`;
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function goBack(): Promise<void> {
|
||||
if (!canGoBack.value) {
|
||||
return;
|
||||
}
|
||||
router.back();
|
||||
}
|
||||
</script>
|
||||
113
apps/web/src/views/PluginsView.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<section class="page-root plugins-page">
|
||||
<div class="page-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
title="返回上一页"
|
||||
aria-label="返回上一页"
|
||||
:disabled="!canGoBack"
|
||||
@click="goBack"
|
||||
>
|
||||
<span class="icon-mask" style="--icon: url('/icons/back.svg')" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="toolbar-spacer"></div>
|
||||
<h2 class="page-title">插件管理</h2>
|
||||
</div>
|
||||
|
||||
<article class="surface-panel surface-scroll">
|
||||
<h3>插件列表</h3>
|
||||
<div class="list-stack">
|
||||
<article v-for="item in pluginStore.records" :key="item.id" class="plugin-item">
|
||||
<div class="item-title">{{ item.id }} · {{ item.status }}</div>
|
||||
<div class="item-sub">errorCount: {{ item.errorCount }} · {{ item.lastError || '-' }}</div>
|
||||
<div class="actions">
|
||||
<button class="btn" @click="pluginStore.enable(item.id)">启用</button>
|
||||
<button class="btn" @click="pluginStore.disable(item.id)">禁用</button>
|
||||
<button class="btn" @click="pluginStore.reload(item.id)">重载</button>
|
||||
<button class="btn danger" @click="pluginStore.remove(item.id)">移除</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<h3>导入插件 JSON</h3>
|
||||
<textarea v-model="pluginJson" class="textarea" rows="8" placeholder='[{"manifest":...,"mainJs":"...","stylesCss":"..."}]' />
|
||||
<div class="actions">
|
||||
<button class="btn" @click="importPlugin">导入</button>
|
||||
<button class="btn" @click="exportPlugin">导出全部</button>
|
||||
</div>
|
||||
|
||||
<h3>运行时日志</h3>
|
||||
<pre class="log-box">{{ pluginStore.runtimeLogs.join('\n') }}</pre>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { usePluginStore } from "@/stores/pluginStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { formatActionError } from "@/utils/feedback";
|
||||
|
||||
const pluginStore = usePluginStore();
|
||||
const appStore = useAppStore();
|
||||
const router = useRouter();
|
||||
const canGoBack = ref(false);
|
||||
|
||||
const pluginJson = ref("");
|
||||
|
||||
/**
|
||||
* 统一“返回”语义:仅允许返回历史上一页。
|
||||
*/
|
||||
function syncCanGoBack(): void {
|
||||
if (typeof window === "undefined") {
|
||||
canGoBack.value = false;
|
||||
return;
|
||||
}
|
||||
canGoBack.value = window.history.length > 1;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
syncCanGoBack();
|
||||
window.addEventListener("popstate", syncCanGoBack);
|
||||
await pluginStore.ensureBootstrapped();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("popstate", syncCanGoBack);
|
||||
});
|
||||
|
||||
async function importPlugin(): Promise<void> {
|
||||
if (!pluginJson.value.trim()) return;
|
||||
try {
|
||||
await pluginStore.importJson(pluginJson.value);
|
||||
pluginJson.value = "";
|
||||
appStore.notify("info", "插件导入成功");
|
||||
} catch (error) {
|
||||
appStore.notify("error", formatActionError("导入失败", error));
|
||||
}
|
||||
}
|
||||
|
||||
async function exportPlugin(): Promise<void> {
|
||||
const raw = await pluginStore.exportJson();
|
||||
const blob = new Blob([raw], { type: "application/json;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `remoteconn-plugins-${Date.now()}.json`;
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function goBack(): Promise<void> {
|
||||
if (!canGoBack.value) {
|
||||
return;
|
||||
}
|
||||
router.back();
|
||||
}
|
||||
</script>
|
||||
1010
apps/web/src/views/RecordsView.vue
Normal file
952
apps/web/src/views/ServerSettingsView.vue
Normal file
@@ -0,0 +1,952 @@
|
||||
<template>
|
||||
<section class="page-root server-settings-page">
|
||||
<div class="server-settings-layout">
|
||||
<div class="page-toolbar server-settings-topbar">
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
title="返回上一页"
|
||||
aria-label="返回上一页"
|
||||
:disabled="!canGoBack"
|
||||
@click="goBack"
|
||||
>
|
||||
<img src="/icons/back.svg" alt="返回上一页" />
|
||||
</button>
|
||||
<div class="toolbar-spacer"></div>
|
||||
<h2 class="page-title">服务器设置</h2>
|
||||
</div>
|
||||
|
||||
<article v-if="ready" class="server-settings-content surface-scroll">
|
||||
<div class="server-settings-form">
|
||||
<section class="settings-section-card">
|
||||
<div class="settings-section-headline">
|
||||
<div>
|
||||
<h3 class="server-settings-section-title">基础信息</h3>
|
||||
<p class="settings-section-copy">用于标识并定位目标服务器</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<label class="field">
|
||||
<span>名称</span>
|
||||
<input v-model="form.name" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>主机</span>
|
||||
<input v-model="form.host" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>端口</span>
|
||||
<input v-model="portInput" class="input" type="text" inputmode="numeric" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>用户名</span>
|
||||
<input v-model="form.username" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>认证方式</span>
|
||||
<div class="field-control">
|
||||
<div class="pill-scroll">
|
||||
<div class="pill-row">
|
||||
<button
|
||||
v-for="option in SERVER_AUTH_TYPE_OPTIONS"
|
||||
:key="`server-auth-${option.value}`"
|
||||
class="pill-option"
|
||||
:class="{ active: form.authType === option.value }"
|
||||
type="button"
|
||||
@click="form.authType = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field wide">
|
||||
<span>标签(逗号分隔)</span>
|
||||
<div class="field-control field-control--stack">
|
||||
<input
|
||||
v-model="tagText"
|
||||
class="input"
|
||||
placeholder="prod,beijing"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
@blur="syncTags"
|
||||
/>
|
||||
<div v-if="orderedTags.length > 0" class="server-tag-order-list">
|
||||
<div v-for="(tag, index) in orderedTags" :key="`tag-order-${tag}-${index}`" class="server-tag-order-item">
|
||||
<span class="server-tag-order-text">{{ tag }}</span>
|
||||
<div class="server-tag-order-actions">
|
||||
<button
|
||||
class="server-tag-order-btn"
|
||||
type="button"
|
||||
:disabled="index === 0"
|
||||
title="标签上移"
|
||||
aria-label="标签上移"
|
||||
@click.prevent="moveTagUp(index)"
|
||||
>
|
||||
上移
|
||||
</button>
|
||||
<button
|
||||
class="server-tag-order-btn"
|
||||
type="button"
|
||||
:disabled="index === orderedTags.length - 1"
|
||||
title="标签下移"
|
||||
aria-label="标签下移"
|
||||
@click.prevent="moveTagDown(index)"
|
||||
>
|
||||
下移
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section-card">
|
||||
<div class="settings-section-headline">
|
||||
<div>
|
||||
<h3 class="server-settings-section-title">认证参数</h3>
|
||||
<p class="settings-section-copy">按认证方式填写密码或密钥材料</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<label v-if="form.authType === 'password'" class="field wide">
|
||||
<span>密码</span>
|
||||
<input v-model="credential.password" class="input" type="password" autocomplete="new-password" />
|
||||
</label>
|
||||
<template v-else>
|
||||
<label class="field wide">
|
||||
<span>私钥内容</span>
|
||||
<div class="field-control field-control--stack">
|
||||
<textarea
|
||||
v-model="credential.privateKey"
|
||||
class="textarea"
|
||||
rows="5"
|
||||
:placeholder="hasPersistedPrivateKey ? '' : '粘贴 OpenSSH 私钥'"
|
||||
/>
|
||||
<p v-if="isPrivateKeyMaskedInput" class="item-sub">
|
||||
已保存私钥,当前为掩码回显。直接粘贴新私钥可覆盖当前值。
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>passphrase</span>
|
||||
<input v-model="credential.passphrase" class="input" type="password" autocomplete="new-password" />
|
||||
</label>
|
||||
<label v-if="form.authType === 'certificate'" class="field">
|
||||
<span>证书内容</span>
|
||||
<div class="field-control field-control--stack">
|
||||
<textarea v-model="credential.certificate" class="textarea" rows="3" />
|
||||
<p v-if="hasPersistedCertificate && !(credential.certificate ?? '').trim()" class="item-sub">
|
||||
已保存证书内容。出于安全原因不回显明文,留空将沿用,填写新证书将覆盖。
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section-card">
|
||||
<div class="settings-section-headline">
|
||||
<div>
|
||||
<h3 class="server-settings-section-title">连接参数</h3>
|
||||
<p class="settings-section-copy">定义连接路径与工作目录</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-grid">
|
||||
<label class="field">
|
||||
<span>传输方式</span>
|
||||
<div class="field-control">
|
||||
<div class="segmented-control">
|
||||
<button
|
||||
v-for="option in TRANSPORT_MODE_OPTIONS"
|
||||
:key="`transport-mode-${option.value}`"
|
||||
class="segmented-option"
|
||||
:class="{ active: form.transportMode === option.value }"
|
||||
type="button"
|
||||
@click="form.transportMode = option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field wide">
|
||||
<span>codex工作目录</span>
|
||||
<input v-model="form.projectPath" class="input" placeholder="~/workspace/project" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-section-card">
|
||||
<div class="settings-section-headline">
|
||||
<div>
|
||||
<h3 class="server-settings-section-title">跳转主机</h3>
|
||||
<p class="settings-section-copy">从基础信息中配置的服务器跳转至该服务器</p>
|
||||
</div>
|
||||
<button
|
||||
class="server-settings-switch"
|
||||
:class="{ active: form.jumpHost?.enabled === true }"
|
||||
type="button"
|
||||
@click="
|
||||
form.jumpHost = normalizeJumpHost({
|
||||
...(form.jumpHost || { ...DEFAULT_JUMP_HOST }),
|
||||
enabled: form.jumpHost?.enabled !== true
|
||||
})
|
||||
"
|
||||
>
|
||||
<span class="server-settings-switch-knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="form.jumpHost?.enabled" class="field-grid">
|
||||
<label class="field">
|
||||
<span>跳转主机</span>
|
||||
<input v-model="form.jumpHost.host" class="input" placeholder="bastion.example.com" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>跳转端口</span>
|
||||
<input v-model="jumpPortInput" class="input" type="text" inputmode="numeric" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>跳转用户名</span>
|
||||
<input v-model="form.jumpHost.username" class="input" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>认证方式</span>
|
||||
<div class="field-control">
|
||||
<div class="pill-scroll">
|
||||
<div class="pill-row">
|
||||
<button
|
||||
v-for="option in SERVER_AUTH_TYPE_OPTIONS"
|
||||
:key="`jump-auth-${option.value}`"
|
||||
class="pill-option"
|
||||
:class="{ active: form.jumpHost.authType === option.value }"
|
||||
type="button"
|
||||
@click="form.jumpHost = normalizeJumpHost({ ...form.jumpHost, authType: option.value })"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<label v-if="form.jumpHost.authType === 'password'" class="field wide">
|
||||
<span>密码</span>
|
||||
<input v-model="jumpCredential.password" class="input" type="password" autocomplete="new-password" />
|
||||
</label>
|
||||
<template v-else>
|
||||
<label class="field wide">
|
||||
<span>私钥内容</span>
|
||||
<div class="field-control field-control--stack">
|
||||
<textarea
|
||||
v-model="jumpCredential.privateKey"
|
||||
class="textarea"
|
||||
rows="5"
|
||||
:placeholder="hasPersistedJumpPrivateKey ? '' : '粘贴跳转主机 OpenSSH 私钥'"
|
||||
/>
|
||||
<p v-if="isJumpPrivateKeyMaskedInput" class="item-sub">
|
||||
已保存跳转主机私钥,当前为掩码回显。直接粘贴新私钥可覆盖当前值。
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>passphrase</span>
|
||||
<input
|
||||
v-model="jumpCredential.passphrase"
|
||||
class="input"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
</label>
|
||||
<label v-if="form.jumpHost.authType === 'certificate'" class="field">
|
||||
<span>证书内容</span>
|
||||
<div class="field-control field-control--stack">
|
||||
<textarea v-model="jumpCredential.certificate" class="textarea" rows="3" />
|
||||
<p v-if="hasPersistedJumpCertificate && !(jumpCredential.certificate ?? '').trim()" class="item-sub">
|
||||
已保存跳转主机证书内容。留空将沿用,填写新证书将覆盖。
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article v-else class="server-settings-content surface-scroll">
|
||||
<p class="item-sub">未找到服务器,请返回上一页或使用右下角导航按钮重新选择。</p>
|
||||
</article>
|
||||
|
||||
<div class="server-settings-bottom bottom-bar">
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
title="返回上一页"
|
||||
aria-label="返回上一页"
|
||||
:disabled="!canGoBack"
|
||||
@click="goBack"
|
||||
>
|
||||
<span class="icon-mask" style="--icon: url('/icons/back.svg')" aria-hidden="true"></span>
|
||||
</button>
|
||||
<div class="bottom-right-actions">
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
:disabled="isConnecting"
|
||||
title="使用当前配置连接"
|
||||
aria-label="使用当前配置连接"
|
||||
@click="connect"
|
||||
>
|
||||
<span class="icon-mask" style="--icon: url('/icons/connect.svg')" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
:disabled="isConnecting"
|
||||
title="保存服务器配置"
|
||||
aria-label="保存服务器配置"
|
||||
@click="saveWithFeedback"
|
||||
>
|
||||
<span class="icon-mask" style="--icon: url('/icons/save.svg')" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, toRaw, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { DEFAULT_JUMP_HOST } from "@remoteconn/shared";
|
||||
import type { ServerProfile } from "@/types/app";
|
||||
import { useServerStore } from "@/stores/serverStore";
|
||||
import { useSessionStore } from "@/stores/sessionStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import { formatActionError, toFriendlyError } from "@/utils/feedback";
|
||||
|
||||
const NEW_SERVER_ROUTE_ID = "new";
|
||||
|
||||
type CredentialInput = {
|
||||
password: string;
|
||||
privateKey: string;
|
||||
passphrase: string;
|
||||
certificate: string;
|
||||
};
|
||||
type CredentialBundleInput = {
|
||||
target: CredentialInput;
|
||||
jump: CredentialInput;
|
||||
};
|
||||
type ChoiceOption<T extends string> = { label: string; value: T };
|
||||
|
||||
const SERVER_AUTH_TYPE_OPTIONS: Array<ChoiceOption<"password" | "privateKey" | "certificate">> = [
|
||||
{ label: "密码", value: "password" },
|
||||
{ label: "私钥", value: "privateKey" },
|
||||
{ label: "证书", value: "certificate" }
|
||||
];
|
||||
|
||||
const TRANSPORT_MODE_OPTIONS: Array<ChoiceOption<"gateway" | "ios-native">> = [
|
||||
{ label: "网关", value: "gateway" },
|
||||
{ label: "iOS 原生", value: "ios-native" }
|
||||
];
|
||||
|
||||
const serverStore = useServerStore();
|
||||
const sessionStore = useSessionStore();
|
||||
const appStore = useAppStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const ready = ref(false);
|
||||
const tagText = ref("");
|
||||
const portInput = ref("22");
|
||||
const jumpPortInput = ref("22");
|
||||
const isConnecting = ref(false);
|
||||
const canGoBack = ref(false);
|
||||
const initialServerSnapshot = ref<ServerProfile | null>(null);
|
||||
const initialCredentialSnapshot = ref<CredentialBundleInput | null>(null);
|
||||
const persistedCredentialSnapshot = ref<CredentialBundleInput | null>(null);
|
||||
const PRIVATE_KEY_MASK = "●●●●●●●●●●●●●●●●";
|
||||
|
||||
const form = reactive<ServerProfile>({
|
||||
id: "",
|
||||
name: "",
|
||||
host: "",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
projectPath: "~/workspace",
|
||||
projectPresets: [],
|
||||
tags: [],
|
||||
timeoutSeconds: 20,
|
||||
heartbeatSeconds: 15,
|
||||
transportMode: "gateway",
|
||||
jumpHost: { ...DEFAULT_JUMP_HOST }
|
||||
});
|
||||
|
||||
const credential = reactive<CredentialInput>({
|
||||
password: "",
|
||||
privateKey: "",
|
||||
passphrase: "",
|
||||
certificate: ""
|
||||
});
|
||||
|
||||
const jumpCredential = reactive<CredentialInput>({
|
||||
password: "",
|
||||
privateKey: "",
|
||||
passphrase: "",
|
||||
certificate: ""
|
||||
});
|
||||
|
||||
/**
|
||||
* Vue Router 已对 path param 做过解码,这里不能再次 decodeURIComponent,
|
||||
* 否则含 `%` 的历史服务器 ID 会被二次解码,导致匹配不到服务器。
|
||||
*/
|
||||
const serverId = computed(() => {
|
||||
const raw = route.params.id;
|
||||
if (Array.isArray(raw)) {
|
||||
return String(raw[0] ?? "");
|
||||
}
|
||||
return String(raw ?? "");
|
||||
});
|
||||
|
||||
const isCreateMode = computed(() => serverId.value === NEW_SERVER_ROUTE_ID);
|
||||
const serverWatchKey = computed(() => (isCreateMode.value ? "" : serverStore.servers.map((item) => item.id).join(",")));
|
||||
|
||||
const hasCreateDraftChanges = computed(() => {
|
||||
if (!ready.value || !isCreateMode.value) return false;
|
||||
if (!initialServerSnapshot.value || !initialCredentialSnapshot.value) return false;
|
||||
|
||||
const currentServer = buildServerSnapshot();
|
||||
const currentCredential = buildCredentialSnapshot();
|
||||
return (
|
||||
createServerSignature(currentServer) !== createServerSignature(initialServerSnapshot.value) ||
|
||||
createCredentialSignature(currentCredential) !== createCredentialSignature(initialCredentialSnapshot.value)
|
||||
);
|
||||
});
|
||||
|
||||
const hasPersistedPrivateKey = computed(() => Boolean(persistedCredentialSnapshot.value?.target.privateKey?.trim()));
|
||||
const hasPersistedCertificate = computed(() => Boolean(persistedCredentialSnapshot.value?.target.certificate?.trim()));
|
||||
const isPrivateKeyMaskedInput = computed(() => {
|
||||
return hasPersistedPrivateKey.value && (credential.privateKey ?? "").trim() === PRIVATE_KEY_MASK;
|
||||
});
|
||||
const hasPersistedJumpPrivateKey = computed(() => Boolean(persistedCredentialSnapshot.value?.jump.privateKey?.trim()));
|
||||
const hasPersistedJumpCertificate = computed(() => Boolean(persistedCredentialSnapshot.value?.jump.certificate?.trim()));
|
||||
const isJumpPrivateKeyMaskedInput = computed(() => {
|
||||
return hasPersistedJumpPrivateKey.value && (jumpCredential.privateKey ?? "").trim() === PRIVATE_KEY_MASK;
|
||||
});
|
||||
const orderedTags = computed(() => parseTags(tagText.value));
|
||||
|
||||
watch(
|
||||
[() => serverId.value, () => serverWatchKey.value],
|
||||
async ([id]) => {
|
||||
await loadServer(id);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
/**
|
||||
* 统一“返回”语义:仅允许返回历史上一页。
|
||||
*/
|
||||
function syncCanGoBack(): void {
|
||||
if (typeof window === "undefined") {
|
||||
canGoBack.value = false;
|
||||
return;
|
||||
}
|
||||
canGoBack.value = window.history.length > 1;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
syncCanGoBack();
|
||||
window.addEventListener("popstate", syncCanGoBack);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("popstate", syncCanGoBack);
|
||||
});
|
||||
|
||||
function parseTags(value: string): string[] {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizePortInput(value: string | number | null | undefined, fallback = 22): number {
|
||||
const raw = String(value ?? "").trim();
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
const digitsOnly = raw.replace(/[^\d]/g, "");
|
||||
const parsed = Number(digitsOnly);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.min(65535, Math.max(1, Math.round(parsed)));
|
||||
}
|
||||
|
||||
function createEmptyCredential(): CredentialInput {
|
||||
return {
|
||||
password: "",
|
||||
privateKey: "",
|
||||
passphrase: "",
|
||||
certificate: ""
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyCredentialBundle(): CredentialBundleInput {
|
||||
return {
|
||||
target: createEmptyCredential(),
|
||||
jump: createEmptyCredential()
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCredentialInput(next: Partial<CredentialInput> | null | undefined): CredentialInput {
|
||||
return {
|
||||
password: next?.password ?? "",
|
||||
privateKey: next?.privateKey ?? "",
|
||||
passphrase: next?.passphrase ?? "",
|
||||
certificate: next?.certificate ?? ""
|
||||
};
|
||||
}
|
||||
|
||||
function assignCredentialState(target: CredentialInput, next: Partial<CredentialInput> | null | undefined): void {
|
||||
Object.assign(target, {
|
||||
password: next?.password ?? "",
|
||||
privateKey: next?.privateKey ?? "",
|
||||
passphrase: next?.passphrase ?? "",
|
||||
certificate: next?.certificate ?? ""
|
||||
});
|
||||
}
|
||||
|
||||
function assignCredential(next: Partial<CredentialInput> | null | undefined): void {
|
||||
assignCredentialState(credential, next);
|
||||
}
|
||||
|
||||
function assignJumpCredential(next: Partial<CredentialInput> | null | undefined): void {
|
||||
assignCredentialState(jumpCredential, next);
|
||||
}
|
||||
|
||||
function normalizeJumpHost(
|
||||
input: Partial<NonNullable<ServerProfile["jumpHost"]>> | ServerProfile["jumpHost"]
|
||||
): NonNullable<ServerProfile["jumpHost"]> {
|
||||
return {
|
||||
...DEFAULT_JUMP_HOST,
|
||||
...(input ?? {}),
|
||||
enabled: input?.enabled === true,
|
||||
host: String(input?.host ?? "").trim(),
|
||||
port: Number(input?.port ?? DEFAULT_JUMP_HOST.port) || DEFAULT_JUMP_HOST.port,
|
||||
username: String(input?.username ?? "").trim(),
|
||||
authType:
|
||||
input?.authType === "privateKey" || input?.authType === "certificate" ? input.authType : DEFAULT_JUMP_HOST.authType
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeServerSnapshot(server: ServerProfile): ServerProfile {
|
||||
return {
|
||||
...server,
|
||||
projectPresets: [...server.projectPresets],
|
||||
tags: [...server.tags],
|
||||
jumpHost: normalizeJumpHost(server.jumpHost)
|
||||
};
|
||||
}
|
||||
|
||||
function createServerSignature(server: ServerProfile): string {
|
||||
return JSON.stringify({
|
||||
id: server.id,
|
||||
name: server.name,
|
||||
host: server.host,
|
||||
port: server.port,
|
||||
username: server.username,
|
||||
authType: server.authType,
|
||||
projectPath: server.projectPath,
|
||||
projectPresets: [...server.projectPresets],
|
||||
tags: [...server.tags],
|
||||
timeoutSeconds: server.timeoutSeconds,
|
||||
heartbeatSeconds: server.heartbeatSeconds,
|
||||
transportMode: server.transportMode,
|
||||
jumpHost: normalizeJumpHost(server.jumpHost),
|
||||
lastConnectedAt: server.lastConnectedAt ?? ""
|
||||
});
|
||||
}
|
||||
|
||||
function buildCredentialSnapshot(): CredentialBundleInput {
|
||||
return {
|
||||
target: {
|
||||
password: credential.password ?? "",
|
||||
privateKey: credential.privateKey ?? "",
|
||||
passphrase: credential.passphrase ?? "",
|
||||
certificate: credential.certificate ?? ""
|
||||
},
|
||||
jump: {
|
||||
password: jumpCredential.password ?? "",
|
||||
privateKey: jumpCredential.privateKey ?? "",
|
||||
passphrase: jumpCredential.passphrase ?? "",
|
||||
certificate: jumpCredential.certificate ?? ""
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createCredentialSignature(value: CredentialBundleInput): string {
|
||||
return JSON.stringify({
|
||||
target: value.target,
|
||||
jump: value.jump
|
||||
});
|
||||
}
|
||||
|
||||
function markPristine(server: ServerProfile, credentialSnapshot: CredentialBundleInput): void {
|
||||
initialServerSnapshot.value = normalizeServerSnapshot(server);
|
||||
initialCredentialSnapshot.value = {
|
||||
target: { ...credentialSnapshot.target },
|
||||
jump: { ...credentialSnapshot.jump }
|
||||
};
|
||||
}
|
||||
|
||||
function fillFormFromServer(server: ServerProfile): void {
|
||||
Object.assign(form, normalizeServerSnapshot(server));
|
||||
tagText.value = server.tags.join(",");
|
||||
portInput.value = String(server.port ?? 22);
|
||||
jumpPortInput.value = String(server.jumpHost?.port ?? DEFAULT_JUMP_HOST.port);
|
||||
}
|
||||
|
||||
async function loadServer(id: string): Promise<void> {
|
||||
ready.value = false;
|
||||
if (!id) return;
|
||||
// 兜底确保服务器列表已加载,避免首屏/直达路由时出现“未找到服务器”的误判。
|
||||
await serverStore.ensureBootstrapped();
|
||||
|
||||
if (id === NEW_SERVER_ROUTE_ID) {
|
||||
const draft = serverStore.createServerDraft();
|
||||
fillFormFromServer(draft);
|
||||
const emptyCredential = createEmptyCredentialBundle();
|
||||
persistedCredentialSnapshot.value = null;
|
||||
assignCredential(emptyCredential.target);
|
||||
assignJumpCredential(emptyCredential.jump);
|
||||
markPristine(draft, emptyCredential);
|
||||
ready.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* 容错匹配:
|
||||
* 1) 主路径按服务器 id 精确匹配;
|
||||
* 2) 兼容历史链接中的编码差异(decode/encode 变体);
|
||||
* 3) 兼容极少量旧数据把 name 用作路由参数的情况。
|
||||
*/
|
||||
const idCandidates = new Set<string>([id]);
|
||||
try {
|
||||
idCandidates.add(decodeURIComponent(id));
|
||||
} catch {
|
||||
// 忽略非法编码输入,继续用原始值匹配。
|
||||
}
|
||||
try {
|
||||
idCandidates.add(encodeURIComponent(id));
|
||||
} catch {
|
||||
// encode 出错的概率极低,忽略即可。
|
||||
}
|
||||
|
||||
const target =
|
||||
serverStore.servers.find((item) => idCandidates.has(item.id)) ??
|
||||
serverStore.servers.find((item) => idCandidates.has(item.name)) ??
|
||||
serverStore.servers.find((item) => item.id === serverStore.selectedServerId);
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
fillFormFromServer(target);
|
||||
try {
|
||||
const saved = await serverStore.getCredentialBundleInput(target.id);
|
||||
const normalizedSaved: CredentialBundleInput = {
|
||||
target: normalizeCredentialInput(saved?.target),
|
||||
jump: normalizeCredentialInput(saved?.jump)
|
||||
};
|
||||
persistedCredentialSnapshot.value = normalizedSaved;
|
||||
/**
|
||||
* 安全约束:私钥/证书内容不在表单中回显给用户,
|
||||
* 仅在用户显式输入新内容时才覆盖;留空表示沿用已保存内容。
|
||||
*/
|
||||
assignCredential({
|
||||
password: normalizedSaved.target.password,
|
||||
privateKey: normalizedSaved.target.privateKey?.trim() ? PRIVATE_KEY_MASK : "",
|
||||
passphrase: normalizedSaved.target.passphrase,
|
||||
certificate: ""
|
||||
});
|
||||
assignJumpCredential({
|
||||
password: normalizedSaved.jump.password,
|
||||
privateKey: normalizedSaved.jump.privateKey?.trim() ? PRIVATE_KEY_MASK : "",
|
||||
passphrase: normalizedSaved.jump.passphrase,
|
||||
certificate: ""
|
||||
});
|
||||
} catch (error) {
|
||||
// 会话密钥丢失或密文损坏时,仍允许用户进入设置页并重新录入凭据。
|
||||
persistedCredentialSnapshot.value = null;
|
||||
assignCredential(createEmptyCredential());
|
||||
assignJumpCredential(createEmptyCredential());
|
||||
appStore.notify("warn", `凭据读取失败:${toFriendlyError(error)}`);
|
||||
}
|
||||
serverStore.selectedServerId = target.id;
|
||||
markPristine(buildServerSnapshot(), buildCredentialSnapshot());
|
||||
if (id !== target.id) {
|
||||
// 使用标准 id 回写路由,避免后续刷新再次命中异常参数。
|
||||
await router.replace(`/server/${encodeURIComponent(target.id)}/settings`);
|
||||
}
|
||||
ready.value = true;
|
||||
}
|
||||
|
||||
function syncTags(): void {
|
||||
form.tags = parseTags(tagText.value);
|
||||
}
|
||||
|
||||
function applyOrderedTags(tags: string[]): void {
|
||||
const normalized = tags.map((item) => item.trim()).filter(Boolean);
|
||||
tagText.value = normalized.join(",");
|
||||
form.tags = [...normalized];
|
||||
}
|
||||
|
||||
function moveTagUp(index: number): void {
|
||||
const tags = [...orderedTags.value];
|
||||
if (index <= 0 || index >= tags.length) {
|
||||
return;
|
||||
}
|
||||
const current = tags[index];
|
||||
const previous = tags[index - 1];
|
||||
if (current === undefined || previous === undefined) {
|
||||
return;
|
||||
}
|
||||
tags[index - 1] = current;
|
||||
tags[index] = previous;
|
||||
applyOrderedTags(tags);
|
||||
}
|
||||
|
||||
function moveTagDown(index: number): void {
|
||||
const tags = [...orderedTags.value];
|
||||
if (index < 0 || index >= tags.length - 1) {
|
||||
return;
|
||||
}
|
||||
const current = tags[index];
|
||||
const next = tags[index + 1];
|
||||
if (current === undefined || next === undefined) {
|
||||
return;
|
||||
}
|
||||
tags[index] = next;
|
||||
tags[index + 1] = current;
|
||||
applyOrderedTags(tags);
|
||||
}
|
||||
|
||||
function validateCredentialInput(
|
||||
authType: ServerProfile["authType"],
|
||||
current: CredentialInput,
|
||||
persisted: CredentialInput,
|
||||
maskEnabled: boolean
|
||||
): string | null {
|
||||
const privateKeyTrimmed = (current.privateKey ?? "").trim();
|
||||
const privateKeyMasked = maskEnabled && privateKeyTrimmed === PRIVATE_KEY_MASK;
|
||||
const hasInputPrivateKey = Boolean(privateKeyTrimmed) && !privateKeyMasked;
|
||||
const hasSavedPrivateKey = Boolean(persisted.privateKey?.trim());
|
||||
const hasInputCertificate = Boolean(current.certificate?.trim());
|
||||
const hasSavedCertificate = Boolean(persisted.certificate?.trim());
|
||||
const hasEffectivePrivateKey = hasInputPrivateKey || hasSavedPrivateKey;
|
||||
const hasEffectiveCertificate = hasInputCertificate || hasSavedCertificate;
|
||||
|
||||
if (authType === "password") {
|
||||
return current.password?.trim() ? null : "密码不能为空";
|
||||
}
|
||||
if (authType === "privateKey") {
|
||||
return hasEffectivePrivateKey ? null : "私钥内容不能为空";
|
||||
}
|
||||
if (!hasEffectivePrivateKey) {
|
||||
return "证书模式下私钥内容不能为空";
|
||||
}
|
||||
if (!hasEffectiveCertificate) {
|
||||
return "证书模式下证书内容不能为空";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateCredential(): string | null {
|
||||
const targetPersisted = persistedCredentialSnapshot.value?.target ?? createEmptyCredential();
|
||||
const targetError = validateCredentialInput(form.authType, credential, targetPersisted, hasPersistedPrivateKey.value);
|
||||
if (targetError) {
|
||||
return targetError;
|
||||
}
|
||||
if (!form.jumpHost?.enabled) {
|
||||
return null;
|
||||
}
|
||||
const jumpPersisted = persistedCredentialSnapshot.value?.jump ?? createEmptyCredential();
|
||||
const jumpError = validateCredentialInput(
|
||||
form.jumpHost.authType,
|
||||
jumpCredential,
|
||||
jumpPersisted,
|
||||
hasPersistedJumpPrivateKey.value
|
||||
);
|
||||
return jumpError ? `跳板机${jumpError}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装“最终落库凭据”:
|
||||
* - 私钥/证书输入框留空时,沿用已保存值(用于“安全不回显”场景);
|
||||
* - 用户显式输入新内容时,优先使用新值覆盖。
|
||||
*/
|
||||
function buildCredentialPayloadForSave(authType: ServerProfile["authType"], draft: CredentialInput): CredentialInput {
|
||||
const persisted = persistedCredentialSnapshot.value?.target ?? createEmptyCredential();
|
||||
const merged: CredentialInput = { ...draft };
|
||||
const privateKeyTrimmed = merged.privateKey.trim();
|
||||
const privateKeyMasked = Boolean(persisted.privateKey?.trim()) && privateKeyTrimmed === PRIVATE_KEY_MASK;
|
||||
if ((authType === "privateKey" || authType === "certificate") && (!privateKeyTrimmed || privateKeyMasked)) {
|
||||
merged.privateKey = persisted.privateKey ?? "";
|
||||
}
|
||||
if (authType === "certificate" && !merged.certificate.trim()) {
|
||||
merged.certificate = persisted.certificate ?? "";
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function buildJumpCredentialPayloadForSave(authType: ServerProfile["authType"], draft: CredentialInput): CredentialInput {
|
||||
const persisted = persistedCredentialSnapshot.value?.jump ?? createEmptyCredential();
|
||||
const merged: CredentialInput = { ...draft };
|
||||
const privateKeyTrimmed = merged.privateKey.trim();
|
||||
const privateKeyMasked = Boolean(persisted.privateKey?.trim()) && privateKeyTrimmed === PRIVATE_KEY_MASK;
|
||||
if ((authType === "privateKey" || authType === "certificate") && (!privateKeyTrimmed || privateKeyMasked)) {
|
||||
merged.privateKey = persisted.privateKey ?? "";
|
||||
}
|
||||
if (authType === "certificate" && !merged.certificate.trim()) {
|
||||
merged.certificate = persisted.certificate ?? "";
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将响应式表单对象转换为纯数据快照,避免把 Vue Proxy 写入 IndexedDB/桥接层。
|
||||
*/
|
||||
function buildServerSnapshot(): ServerProfile {
|
||||
const raw = toRaw(form);
|
||||
const tags = parseTags(tagText.value);
|
||||
return {
|
||||
...raw,
|
||||
port: normalizePortInput(portInput.value, 22),
|
||||
projectPresets: [...raw.projectPresets],
|
||||
tags,
|
||||
jumpHost: normalizeJumpHost({
|
||||
...raw.jumpHost,
|
||||
port: normalizePortInput(jumpPortInput.value, DEFAULT_JUMP_HOST.port)
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
async function save(showToast = true): Promise<boolean> {
|
||||
if (!ready.value || !form.id) return false;
|
||||
if (isCreateMode.value && !hasCreateDraftChanges.value) {
|
||||
if (showToast) {
|
||||
appStore.notify("info", "配置无改动,未新增服务器");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const credentialError = validateCredential();
|
||||
if (credentialError) {
|
||||
throw new Error(`保存失败:${credentialError}`);
|
||||
}
|
||||
|
||||
const serverSnapshot = buildServerSnapshot();
|
||||
const credentialSnapshot = buildCredentialSnapshot();
|
||||
const credentialPayload = buildCredentialPayloadForSave(serverSnapshot.authType, credentialSnapshot.target);
|
||||
const jumpCredentialPayload = buildJumpCredentialPayloadForSave(
|
||||
serverSnapshot.jumpHost?.authType ?? "password",
|
||||
credentialSnapshot.jump
|
||||
);
|
||||
syncTags();
|
||||
|
||||
await serverStore.saveServer(serverSnapshot);
|
||||
serverStore.selectedServerId = serverSnapshot.id;
|
||||
await serverStore.saveCredential(serverSnapshot.id, {
|
||||
type: serverSnapshot.authType,
|
||||
password: credentialPayload.password,
|
||||
privateKey: credentialPayload.privateKey,
|
||||
passphrase: credentialPayload.passphrase,
|
||||
certificate: credentialPayload.certificate
|
||||
}, {
|
||||
type: serverSnapshot.jumpHost?.authType ?? "password",
|
||||
password: jumpCredentialPayload.password,
|
||||
privateKey: jumpCredentialPayload.privateKey,
|
||||
passphrase: jumpCredentialPayload.passphrase,
|
||||
certificate: jumpCredentialPayload.certificate
|
||||
});
|
||||
persistedCredentialSnapshot.value = {
|
||||
target: { ...credentialPayload },
|
||||
jump: { ...jumpCredentialPayload }
|
||||
};
|
||||
markPristine(serverSnapshot, credentialSnapshot);
|
||||
|
||||
if (isCreateMode.value) {
|
||||
await router.replace(`/server/${encodeURIComponent(serverSnapshot.id)}/settings`);
|
||||
}
|
||||
if (showToast) {
|
||||
appStore.notify("info", "服务器配置已保存");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function saveWithFeedback(): Promise<void> {
|
||||
try {
|
||||
await save(true);
|
||||
} catch (error) {
|
||||
appStore.notify("error", formatActionError("保存失败", error));
|
||||
}
|
||||
}
|
||||
|
||||
async function connect(): Promise<void> {
|
||||
if (!ready.value || isConnecting.value) return;
|
||||
isConnecting.value = true;
|
||||
try {
|
||||
await sessionStore.ensureBootstrapped();
|
||||
const createModeBeforeSave = isCreateMode.value;
|
||||
const saved = await save(false);
|
||||
if (createModeBeforeSave && !saved) {
|
||||
throw new Error("新增服务器配置无改动,请先填写配置后再连接");
|
||||
}
|
||||
const serverSnapshot = buildServerSnapshot();
|
||||
appStore.notify("info", `正在连接: ${serverSnapshot.username}@${serverSnapshot.host}:${serverSnapshot.port}`);
|
||||
const connectTask = sessionStore.connect(serverSnapshot);
|
||||
await router.push("/terminal");
|
||||
await connectTask;
|
||||
} catch (error) {
|
||||
appStore.notify("error", formatActionError("连接失败", error));
|
||||
} finally {
|
||||
isConnecting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmLeaveForCreateDraft(): Promise<boolean> {
|
||||
if (!isCreateMode.value || !hasCreateDraftChanges.value) {
|
||||
return true;
|
||||
}
|
||||
const shouldSave = window.confirm("检测到新增服务器配置已改动,是否保存后返回?");
|
||||
if (!shouldSave) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
await save(true);
|
||||
return true;
|
||||
} catch (error) {
|
||||
appStore.notify("error", formatActionError("保存失败", error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function goBack(): Promise<void> {
|
||||
if (!canGoBack.value) {
|
||||
return;
|
||||
}
|
||||
const canLeave = await confirmLeaveForCreateDraft();
|
||||
if (!canLeave) {
|
||||
return;
|
||||
}
|
||||
router.back();
|
||||
}
|
||||
</script>
|
||||
1046
apps/web/src/views/SettingsView.vue
Normal file
339
apps/web/src/views/TerminalView.vue
Normal file
@@ -0,0 +1,339 @@
|
||||
<template>
|
||||
<section class="page-root terminal-page">
|
||||
<div class="page-toolbar terminal-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<button
|
||||
class="icon-btn terminal-toolbar-ai-btn"
|
||||
:class="{ 'is-connected': !!sessionStore.activeAiProvider }"
|
||||
type="button"
|
||||
title="AI 快速启动"
|
||||
aria-label="AI 快速启动"
|
||||
@click="openCodexDialog"
|
||||
>
|
||||
<span class="icon-mask" style="--icon: url("/icons/codex.svg")" aria-hidden="true"></span>
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
title="清屏"
|
||||
aria-label="清屏"
|
||||
:disabled="clearActionDisabled"
|
||||
@click="sessionStore.clearTerminal"
|
||||
>
|
||||
<span class="icon-mask" style="--icon: url("/icons/clear.svg")" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="toolbar-spacer"></div>
|
||||
<div class="terminal-toolbar-actions">
|
||||
<h2 class="page-title terminal-title">{{ terminalTitle }}</h2>
|
||||
<span class="state-chip" :class="`state-${sessionStore.state}`">{{ sessionStore.state }}</span>
|
||||
<span class="state-chip">{{ sessionStore.latencyMs }}ms</span>
|
||||
<span class="terminal-toolbar-divider" aria-hidden="true"></span>
|
||||
<button
|
||||
class="terminal-connection-switch"
|
||||
:class="connectionActionIsReconnect ? 'is-reconnect' : 'is-disconnect'"
|
||||
:disabled="connectionActionDisabled"
|
||||
:aria-label="connectionActionIsReconnect ? '重连' : '断开'"
|
||||
@click="handleConnectionAction"
|
||||
>
|
||||
<span class="terminal-connection-switch-label">{{
|
||||
connectionActionIsReconnect ? "重连" : "断开"
|
||||
}}</span>
|
||||
<span class="terminal-connection-switch-knob" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<article class="terminal-surface">
|
||||
<section class="surface-panel terminal-card">
|
||||
<Suspense>
|
||||
<template #default>
|
||||
<AsyncTerminalPanel />
|
||||
</template>
|
||||
<template #fallback>
|
||||
<div class="terminal-loading">正在初始化终端…</div>
|
||||
</template>
|
||||
</Suspense>
|
||||
|
||||
<div v-if="pluginRuntimeEnabled" class="plugin-chips">
|
||||
<button
|
||||
v-for="commandItem in pluginCommands"
|
||||
:key="commandItem.id"
|
||||
class="plugin-chip"
|
||||
:data-plugin-id="commandItem.id.split(':')[0]"
|
||||
@click="runPluginCommand(commandItem.id)"
|
||||
>
|
||||
{{ commandItem.title }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
<div v-if="codexDialogOpen" class="codex-dialog-mask" @click.self="closeCodexDialog">
|
||||
<div class="codex-dialog" role="dialog" aria-modal="true" aria-label="AI 快速启动">
|
||||
<h3 class="codex-dialog-title">AI 快速启动</h3>
|
||||
<p class="codex-dialog-hint">点击按钮自动切换项目目录启动AICoding</p>
|
||||
|
||||
<section class="ai-launch-card">
|
||||
<h4 class="ai-launch-card-title">Codex</h4>
|
||||
<div class="ai-launch-actions">
|
||||
<button
|
||||
v-for="option in codexSandboxOptions"
|
||||
:key="option.value"
|
||||
class="btn"
|
||||
type="button"
|
||||
@click="runCodexCommand(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ai-launch-card">
|
||||
<h4 class="ai-launch-card-title">Copilot</h4>
|
||||
<div class="ai-launch-actions">
|
||||
<button class="btn" type="button" @click="runCopilotCommand('copilot')">copilot</button>
|
||||
<button class="btn" type="button" @click="runCopilotCommand('copilot --experimental')">
|
||||
copilot --experimental
|
||||
</button>
|
||||
<button class="btn" type="button" @click="runCopilotCommand('copilot --allow-all')">
|
||||
copilot --allow-all
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="codex-dialog-actions">
|
||||
<button class="btn" type="button" @click="closeCodexDialog">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import type { ServerProfile } from "@/types/app";
|
||||
import { useSessionStore } from "@/stores/sessionStore";
|
||||
import { useServerStore } from "@/stores/serverStore";
|
||||
import { useAppStore } from "@/stores/appStore";
|
||||
import "xterm/css/xterm.css";
|
||||
import { formatActionError } from "@/utils/feedback";
|
||||
|
||||
const AsyncTerminalPanel = defineAsyncComponent(() => import("@/components/TerminalPanel.vue"));
|
||||
|
||||
type PluginCommandItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type PluginStoreLike = {
|
||||
commands: PluginCommandItem[];
|
||||
ensureBootstrapped: () => Promise<void>;
|
||||
runCommand: (commandId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const sessionStore = useSessionStore();
|
||||
const serverStore = useServerStore();
|
||||
const appStore = useAppStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const pluginRuntimeEnabled = import.meta.env.VITE_ENABLE_PLUGIN_RUNTIME !== "false";
|
||||
const pluginStore = ref<PluginStoreLike | null>(null);
|
||||
const reconnectStates = new Set(["idle", "disconnected", "error"]);
|
||||
const waitingConnectStates = new Set(["connecting", "auth_pending", "reconnecting"]);
|
||||
const codexDialogOpen = ref(false);
|
||||
type CopilotCommand = "copilot" | "copilot --experimental" | "copilot --allow-all";
|
||||
const codexSandboxOptions = [
|
||||
{ value: "read-only", label: "codex --sandbox read-only" },
|
||||
{ value: "workspace-write", label: "codex --sandbox workspace-write" },
|
||||
{ value: "danger-full-access", label: "codex --sandbox danger-full-access" }
|
||||
] as const;
|
||||
|
||||
const terminalTitle = computed(() => serverStore.selectedServer?.name ?? "remoteconn");
|
||||
const connectionActionIsReconnect = computed(() => reconnectStates.has(sessionStore.state));
|
||||
const clearActionDisabled = computed(
|
||||
() => sessionStore.state === "connected" && sessionStore.activeAiProvider === "codex"
|
||||
);
|
||||
const pluginCommands = computed(() => pluginStore.value?.commands ?? []);
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([serverStore.ensureBootstrapped(), sessionStore.ensureBootstrapped()]);
|
||||
if (route.query.openCodex === "1") {
|
||||
codexDialogOpen.value = true;
|
||||
await router.replace({ path: "/terminal", query: {} });
|
||||
}
|
||||
|
||||
if (!pluginRuntimeEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { usePluginStore } = await import("@/stores/pluginStore");
|
||||
pluginStore.value = usePluginStore() as unknown as PluginStoreLike;
|
||||
await pluginStore.value.ensureBootstrapped();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
sessionStore.cancelReconnect("leave_terminal_page");
|
||||
});
|
||||
|
||||
async function runPluginCommand(commandId: string): Promise<void> {
|
||||
if (!pluginStore.value) {
|
||||
return;
|
||||
}
|
||||
await pluginStore.value.runCommand(commandId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开态时需要有可重连的目标服务器,避免“重连”按钮点击后无效。
|
||||
* 优先使用当前会话记录的服务器 ID,其次使用当前选中的服务器。
|
||||
*/
|
||||
const connectionActionDisabled = computed(() => {
|
||||
if (!connectionActionIsReconnect.value) {
|
||||
return false;
|
||||
}
|
||||
return !resolveReconnectServer();
|
||||
});
|
||||
|
||||
function openCodexDialog(): void {
|
||||
codexDialogOpen.value = true;
|
||||
}
|
||||
|
||||
function closeCodexDialog(): void {
|
||||
codexDialogOpen.value = false;
|
||||
}
|
||||
|
||||
async function runCodexCommand(
|
||||
sandbox: "read-only" | "workspace-write" | "danger-full-access"
|
||||
): Promise<void> {
|
||||
// 交互要求:点击命令按钮后立即关闭窗口,执行结果通过 toast 和终端输出反馈。
|
||||
codexDialogOpen.value = false;
|
||||
await runCodex(sandbox);
|
||||
}
|
||||
|
||||
async function runCodex(sandbox: "read-only" | "workspace-write" | "danger-full-access"): Promise<boolean> {
|
||||
try {
|
||||
const server = await ensureConnectedForAi();
|
||||
if (!server) {
|
||||
return false;
|
||||
}
|
||||
const launched = await sessionStore.runCodex(server.projectPath, sandbox);
|
||||
return launched;
|
||||
} catch (error) {
|
||||
appStore.notify("error", formatActionError("Codex 启动失败", error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function runCopilotCommand(command: CopilotCommand): Promise<void> {
|
||||
// 交互要求:点击命令按钮后立即关闭窗口,执行结果通过 toast 和终端输出反馈。
|
||||
codexDialogOpen.value = false;
|
||||
await runCopilot(command);
|
||||
}
|
||||
|
||||
async function runCopilot(command: CopilotCommand): Promise<boolean> {
|
||||
try {
|
||||
const server = await ensureConnectedForAi();
|
||||
if (!server) {
|
||||
return false;
|
||||
}
|
||||
return await sessionStore.runCopilot(server.projectPath, command);
|
||||
} catch (error) {
|
||||
appStore.notify("error", formatActionError("Copilot 启动失败", error));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造可重连目标:
|
||||
* 1) 当前会话绑定的服务器优先;
|
||||
* 2) 若会话 ID 丢失,则退化到当前选中服务器;
|
||||
* 3) 返回纯数据快照,避免把响应式对象直接传入会话层。
|
||||
*/
|
||||
function resolveReconnectServer(): ServerProfile | null {
|
||||
const targetId = sessionStore.currentServerId || serverStore.selectedServerId;
|
||||
const target = serverStore.servers.find((item) => item.id === targetId) ?? serverStore.selectedServer;
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...target,
|
||||
projectPresets: [...target.projectPresets],
|
||||
tags: [...target.tags],
|
||||
jumpHost: target.jumpHost ? { ...target.jumpHost } : undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待会话进入 connected:
|
||||
* 1) 连接链路是异步事件驱动(connect() 返回时可能仍在 auth_pending);
|
||||
* 2) 这里用轻量轮询等待最终状态,避免“刚点连接就发命令”触发会话未连接;
|
||||
* 3) 明确超时与失败态,避免无限等待。
|
||||
*/
|
||||
function waitForConnected(timeoutMs = 15_000): Promise<void> {
|
||||
if (sessionStore.state === "connected") {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const startedAt = Date.now();
|
||||
const check = (): void => {
|
||||
if (sessionStore.state === "connected") {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (!waitingConnectStates.has(sessionStore.state)) {
|
||||
reject(new Error(`连接未就绪,当前状态: ${sessionStore.state}`));
|
||||
return;
|
||||
}
|
||||
if (Date.now() - startedAt > timeoutMs) {
|
||||
reject(new Error("等待会话连接超时"));
|
||||
return;
|
||||
}
|
||||
window.setTimeout(check, 120);
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 启动前自动确保连接可用:
|
||||
* - 断开态:先按“重连”逻辑自动重连;
|
||||
* - 连接中:直接等待 connected;
|
||||
* - 已连接:直接返回当前目标服务器。
|
||||
*/
|
||||
async function ensureConnectedForAi(): Promise<ServerProfile | null> {
|
||||
const target = resolveReconnectServer();
|
||||
if (!target) {
|
||||
appStore.notify("warn", "未找到可连接的服务器");
|
||||
return null;
|
||||
}
|
||||
if (sessionStore.state === "connected") {
|
||||
return target;
|
||||
}
|
||||
if (!waitingConnectStates.has(sessionStore.state)) {
|
||||
appStore.notify("info", `正在连接: ${target.username}@${target.host}:${target.port}`);
|
||||
await sessionStore.connect(target);
|
||||
}
|
||||
await waitForConnected();
|
||||
return target;
|
||||
}
|
||||
|
||||
async function handleConnectionAction(): Promise<void> {
|
||||
if (connectionActionIsReconnect.value) {
|
||||
const target = resolveReconnectServer();
|
||||
if (!target) {
|
||||
appStore.notify("warn", "未找到可重连的服务器");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
appStore.notify("info", `正在重连: ${target.username}@${target.host}:${target.port}`);
|
||||
await sessionStore.connect(target);
|
||||
} catch (error) {
|
||||
appStore.notify("error", formatActionError("重连失败", error));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await sessionStore.disconnect("manual", true);
|
||||
}
|
||||
</script>
|
||||
15
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "preserve",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client", "node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@remoteconn/shared": ["../../packages/shared/src/index.ts"],
|
||||
"@remoteconn/plugin-runtime": ["../../packages/plugin-runtime/src/index.ts"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts"]
|
||||
}
|
||||
16
apps/web/vite.config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url))
|
||||
}
|
||||
}
|
||||
});
|
||||