同步完整源码至远程:扁平化重构现状 + 未入库源文件
- 补入库:server/internal/db/dump.go、redis 分页/扫描预算测试、opsxdb-mcp main_test、 packages/opsxdb-mcp package-lock、docs 审计与对比文档等 37 个新文件 - 含结构扁平化进行中的 80 处修改与 155 处旧结构删除(server/ src/ src-tauri/ 为当前有效源) - 构建产物(bin/target/dist)与签名私钥仍按规则排除
No files matched your search
@@ -26,4 +26,5 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path fill="#ea4646" d="M295.526 392.14c19.456-16.486 40.704-33.177 62.67-47.615-16.692 55.04-19.866 173.568 106.905 193.536 95.232 15.001 119.296 45.21 108.083 195.942-19.558 9.216-39.014 16.896-58.88 24.064 12.544-62.464 26.47-174.592-64.307-192.819-110.541-22.272-167.936-36.864-154.47-173.107"/><path fill="#ea4646" d="M373.248 334.643a889 889 0 0 1 58.368-33.536c-21.504 100.864-14.336 188.416 103.782 206.848 69.99 10.957 124.263 36.25 110.234 186.368a714 714 0 0 1-54.374 31.027c10.393-59.904 10.65-180.838-72.192-193.587-124.57-19.2-162.253-53.248-145.818-197.12"/><path fill="#ea4646" d="M449.075 292.71c19.61-8.755 47.053-20.48 57.856-23.552-21.35 75.93-10.496 195.994 86.835 211.968 90.983 14.9 132.813 65.69 123.188 162.356-18.484 14.694-35.482 27.238-54.426 39.936 16.128-93.082-18.432-172.237-82.79-181.556-138.138-20.07-153.6-77.875-130.663-209.152"/><path fill="#1a2781" d="M267.008 418.918c-89.702 84.685-137.78 197.786-100.915 280.064 41.984 93.799 195.891 111.002 315.494 68.71 25.395-110.796 38.298-172.338-63.488-191.897-148.173-28.57-149.913-87.193-151.091-156.877m484.3 193.536c87.758-85.81 148.583-202.547 104.346-289.075-50.022-97.792-207.974-100.454-316.006-64.46-31.744 86.527-23.04 194.355 65.946 207.308 129.228 18.74 134.348 72.858 145.715 146.227"/><path fill="#de1a1b" d="M295.526 392.09c19.456-16.538 40.704-33.178 62.67-47.668-16.692 55.092-19.866 173.62 106.905 193.536 95.232 15.053 119.296 45.261 108.083 195.994-19.558 9.216-39.014 16.896-58.88 24.064 12.544-62.515 26.47-174.592-64.307-192.87-110.541-22.221-167.936-36.864-154.47-173.056"/><path fill="#de1a1b" d="M373.248 334.643c18.842-11.98 37.427-22.323 58.368-33.536-21.504 100.864-14.336 188.416 103.782 206.848 69.99 10.957 124.263 36.25 110.234 186.368-18.534 11.879-35.533 21.095-54.374 31.027 10.393-59.904 10.65-180.838-72.192-193.587-124.57-19.2-162.253-53.248-145.818-197.12"/><path fill="#de1a1b" d="M449.075 292.71c19.61-8.755 47.053-20.48 57.856-23.552-21.35 75.93-10.496 195.994 86.835 211.968 90.983 14.9 132.813 65.69 123.188 162.407-18.484 14.592-35.482 27.136-54.426 39.885 16.128-93.03-18.432-172.237-82.79-181.556-138.138-20.07-153.6-77.875-130.663-209.152"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="2500" preserveAspectRatio="xMinYMin meet" viewBox="0 0 256 256"><path fill="#FFF" d="M255.96 134.393c0-21.521-13.373-40.117-33.223-47.43a75 75 0 0 0 1.253-13.791c0-39.909-32.386-72.295-72.295-72.295-23.193 0-44.923 11.074-58.505 30.088-6.686-5.224-14.835-7.94-23.402-7.94-21.104 0-38.446 17.133-38.446 38.446 0 4.597.836 9.194 2.298 13.373C13.582 81.739 0 100.962 0 122.274c0 21.522 13.373 40.327 33.431 47.64q-1.253 6.582-1.253 13.79c0 39.7 32.386 72.087 72.086 72.087 23.402 0 44.924-11.283 58.505-30.088 6.686 5.223 15.044 8.149 23.611 8.149 21.104 0 38.446-17.134 38.446-38.446 0-4.597-.836-9.194-2.298-13.373 19.64-7.104 33.431-26.327 33.431-47.64z"/><path fill="#F4BD19" d="m100.085 110.364 57.043 26.119 57.669-50.565a64.3 64.3 0 0 0 1.253-12.746c0-35.52-28.834-64.355-64.355-64.355-21.313 0-41.162 10.447-53.072 27.998l-9.612 49.73 11.074 23.82z"/><path fill="#3CBEB1" d="M40.953 170.75c-.835 4.179-1.253 8.567-1.253 12.955 0 35.52 29.043 64.564 64.564 64.564 21.522 0 41.372-10.656 53.49-28.208l9.403-49.729-12.746-24.238-57.251-26.118z"/><path fill="#E9478C" d="m40.536 71.918 39.073 9.194 8.775-44.506c-5.432-4.179-11.91-6.268-18.805-6.268-16.925 0-30.924 13.79-30.924 30.924 0 3.552.627 7.313 1.88 10.656z"/><path fill="#2C458F" d="M37.192 81.32c-17.551 5.642-29.67 22.567-29.67 40.954 0 17.97 11.074 34.059 27.79 40.327l54.953-49.73-10.03-21.52-43.043-10.03z"/><path fill="#95C63D" d="M167.784 219.852c5.432 4.18 11.91 6.478 18.596 6.478 16.925 0 30.924-13.79 30.924-30.924q0-5.642-1.88-10.657l-39.073-9.193z"/><path fill="#176655" d="m175.724 165.317 43.043 10.03c17.551-5.85 29.67-22.566 29.67-40.954 0-17.97-11.074-33.849-27.79-40.326l-56.415 49.311 11.492 21.94z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="209.961" height="200" class="icon" viewBox="0 0 1075 1024"><path fill="#9f53e6" d="M170.24 333.005a20.5 20.5 0 0 0-3.84 8.601h-.205v84.122a19.87 19.87 0 0 0 11.367 16.691l269.363 158.26a136.45 136.45 0 0 0 125.542 0l269.517-157.697a19.87 19.87 0 0 0 11.366-16.742l1.024-83.968-217.241-116.838-160.461 92.979a19.35 19.35 0 0 0-11.315 16.691v83.814c2.048 5.888 6.093 10.957 11.315 14.336 2.56-35.84 42.496-58.265 42.496-58.265l116.94-66.765 152.577 82.893-216.218 128.307a137.47 137.47 0 0 1-125.491 0l-226.509-133.58 226.407-133.53a126.5 126.5 0 0 1 62.873-15.207V153.6a126.8 126.8 0 0 0-62.873 15.155l-269.21 158.31a20.5 20.5 0 0 0-7.373 5.94zm679.22 236.851a19.9 19.9 0 0 0 3.378-9.882l-.307-83.302a19.87 19.87 0 0 1-11.366 16.691L572.109 651.571a136.5 136.5 0 0 1-125.491 0L177.05 492.75a19.87 19.87 0 0 1-11.367-16.691v83.456a19.87 19.87 0 0 0 11.367 16.69l269.568 158.414a137.42 137.42 0 0 0 125.49 0l269.364-157.901a19.97 19.97 0 0 0 7.987-6.81zm3.378 119.654a19.92 19.92 0 0 1-11.366 16.692L572.109 863.949a136.5 136.5 0 0 1-125.491 0L177.05 705.74a19.87 19.87 0 0 1-11.367-16.743v-83.456a19.71 19.71 0 0 0 11.367 16.692l269.568 158.412a137.47 137.47 0 0 0 125.49 0l269.057-157.9a19.56 19.56 0 0 0 11.366-16.692z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M507.2 308.1 458.3 96c-2.7-11.4-13.2-23.3-25.1-26.5L210.5.9c-2.8-.9-6-.9-9.6-.9-9.6 0-19.2 3.6-26.1 9.6L15.3 158.2C6.6 166 2 181.5 4.8 192.4l52.1 227.2c2.7 11.4 13.2 23.3 25.1 26.5L290.5 511c2.7.9 5.9.9 9.6.9 9.6 0 19.2-3.6 26-9.6l170.5-158.6c8.8-9.1 13.4-23.7 10.6-35.6M233.8 51.2 387 98.3c6 1.8 6 4.6 0 6l-80.4 18.3c-6 1.8-14.6-.9-18.8-5.5L232 56.7c-5-5.1-4.1-7.3 1.8-5.5m95.6 274.3c1.8 6-2.3 9.6-8.2 7.8l-165-51.2c-6-1.8-7.3-6.8-2.8-11l126.2-117.5c4.6-4.6 9.6-2.8 11 3.2zM57.4 176 191.3 51.2c4.6-4.6 11.4-3.6 16 .4l67.2 72.7c4.6 4.6 3.6 11.4-.5 16L140.1 265.2c-4.6 4.6-11.4 3.6-16-.5L56.9 192c-4.5-5-4.1-12.4.5-16m32.5 197.5L54.7 218c-1.8-5.9.9-7.3 5-2.7l55.8 60.3c4.6 4.6 5.9 13.3 4.6 19.2l-24.2 78.6c-1.9 6.5-4.7 6.5-6 .1M286 464.9l-175.1-54.4c-6.1-2-9.6-8.5-7.8-14.6l29.3-94.2c2-6.1 8.4-9.6 14.6-7.8l175.1 53.9c6 1.8 9.6 8.2 7.8 14.6l-29.3 94.2c-2.1 6.1-8.4 9.6-14.6 8.3m155.4-127.5-117 108.8c-4.6 4.6-6.9 2.8-5-3.2l24.2-78.6c1.8-6 8.2-12.4 14.6-13.3l80.5-18.3c5.9-2.3 6.8.4 2.7 4.6m12.8-23.8-96.4 22c-6 1.8-12.4-2.3-13.7-8.2L302.9 149c-1.8-6 2.3-12.4 8.2-13.7l96.5-22c5.9-1.8 12.3 2.3 13.7 8.2l41.2 178.3c1.3 7-2.4 12.9-8.3 13.8" style="fill:#4aabf4"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path fill="#c9151e" d="M339.2 204.8v236.083l-148.531 73.421 148.48 69.99v239.104h130.457V650.342L833.28 823.398V687.872L469.606 514.304 833.28 338.381V209.05L469.606 377.702V204.8z"/></svg>
|
||||
|
Before Width: | Height: | Size: 292 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 85.79 512.01 340.29"><style>.st0{fill-rule:evenodd;clip-rule:evenodd}.st0,.st3{fill:#002b64}</style><path d="M500.7 86.5c-7.9.3-5.4 2.5-22.5 6.7-17.3 4.2-38.4 2.9-57 10.7-55.5 23.3-66.6 102.8-117.1 131.3-37.6 21.3-75.8 23-109.9 33.6-22.5 7.1-47 21.5-67.5 39-15.8 13.6-16.2 25.6-32.8 42.7C76.3 368.9 23.6 351 0 379c7.7 7.7 11.1 9.9 26.2 7.9-3.1 5.9-21.6 10.9-18 19.7 3.8 9.2 48.4 15.4 88.9-9.1 18.9-11.4 33.9-27.9 63.2-31.9 38.2-5.1 82 3.2 125.9 9.6-6.6 19.5-19.7 32.5-30.2 47.8-3.2 3.5 6.6 3.9 17.7 1.8 20.1-5 34.5-9 49.8-17.8 18.6-10.9 21.4-38.7 44.4-44.7 12.7 19.6 47.3 24.2 68.9 8.5-18.9-5.4-24.1-45.6-17.7-63.2 6-16.7 12-43.6 18.1-65.8 6.5-23.8 8.9-53.8 16.8-65.8 11.9-18.2 25-24.5 36.5-34.7C501.9 131 512.3 121 512 97.4c-.1-7.6-4-11.8-11.2-11.6z" class="st0"/><path d="M16.5 405.5c28.8 4.1 46.4 0 69.8-10.1 19.7-8.6 38.7-26.2 62.1-33.6 34.2-11 71.8 0 108.5 2.2 8.9.5 17.8.5 26.5-.4 13.6-8.4 13.4-39.6 26.6-42.7-.4 43.9-18.4 70.3-37.3 95.7 39.9-7 63.5-29.9 79.5-60.7 4.9-9.3 9-19.3 12.7-29.9 5.7 4.4 2.5 17.7 5.3 24.8 27.4-15.2 43-50.1 53.5-85.2 12-40.7 16.9-82 24.7-94 7.5-11.8 19.3-19 30.2-26.5 12.2-8.6 23.1-17.5 24.9-33.9-12.9-1.2-15.8-4.2-17.7-10.7-6.4 3.6-12.4 4.4-19.1 4.6-5.8.2-12.2-.1-20 .7-64.4 6.6-72.6 77.8-113.9 117.9-3 2.9-6.3 5.7-9.7 8.2-14.5 10.8-32.2 18.5-48.4 24.7-26.4 10.1-51.5 10.8-76.3 19.5-18.2 6.4-36.7 15.7-51.5 25.9-3.7 2.6-7.3 5.2-10.5 7.8-8.8 7.2-14.5 15.1-20.1 23.3-5.8 8.4-11.3 17.1-19.7 25.4-13.7 13.4-64.9 3.9-82.9 16.4-2 1.4-3.6 3-4.7 5 9.8 4.5 16.4 1.7 27.7 3 1.5 10.7-23.3 17.1-19.7 22z" style="fill:#c49a6c"/><path d="M406.7 325.8c.8 12.3 7.9 36.7 14.2 42.7-12.4 3-33.6-2-39-10.7 2.8-12.6 17.4-24.1 24.9-31.9z" style="fill-rule:evenodd;clip-rule:evenodd;fill:#c49a6c"/><path d="M423.8 132.1c9.1 7.9 28.3 1.6 24.8-14.2-14.1-1.2-22.3 3.7-24.8 14.2" class="st0"/><path d="M486.5 112.2c-2.4 5.1-7.1 11.6-7.1 24.6 0 2.2-1.7 3.8-1.7.3.1-12.7 3.5-18.1 7-25.3 1.7-3 2.7-1.8 1.8.4" class="st3"/><path d="M486.5 112.2c-2.8 4.8-9.7 13.7-10.9 26.6-.2 2.2-2 3.6-1.7.2 1.2-12.6 6.7-20.5 10.9-27.3 1.9-2.9 2.8-1.6 1.7.5m-2.9-2.9c-3.2 4.6-13.9 15.2-16.1 28-.4 2.2-2.3 3.4-1.7 0 2.3-12.4 11.4-22.2 16.2-28.8 2.1-2.6 2.9-1.2 1.6.8m-2.8-2.8c-3.9 4.1-16.5 17.7-20.5 29.9-.7 2.1-2.8 3-1.7-.2 4-12 15.1-24.9 20.7-30.8 2.5-2.3 3-.8 1.5.9z" class="st3"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="141.14 0 229.74 512.1"><path d="M366.8 203.8c-27-119-83.2-150.7-97.5-173.1-5.9-9.9-11.2-20.1-15.7-30.7-.8 10.6-2.1 17.2-11.1 25.2-18 16.1-94.7 78.5-101.1 213.7-6 126.1 92.7 203.8 105.7 211.8 10 4.9 22.2.1 28.2-4.4 47.5-32.6 112.5-119.6 91.5-242.5" style="fill:#10aa50"/><path d="M257.7 383.7c-2.5 31.2-4.3 49.3-10.6 67.2 0 0 4.1 29.7 7 61.2h10.3c2.5-22.2 6.2-44.2 11.2-65.9-13.2-6.6-17.4-35.2-17.9-62.5" style="fill:#b8c4c2"/><path d="M275.7 446.1c-13.5-6.2-17.4-35.3-18-62.5 3.3-44.8 4.3-89.7 2.9-134.5-.7-23.5.3-218-5.8-246.5 4.2 9.6 9 19 14.5 28 14.3 22.4 70.6 54.1 97.5 173.1 21 122.8-43.6 209.6-91.1 242.4" style="fill:#12924f"/></svg>
|
||||
|
Before Width: | Height: | Size: 710 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="800" width="1200" viewBox="-58.46985 -50.32625 506.7387 301.9575"><path d="M0 183.354h12.842v-50.711l19.88 44.208c2.346 5.35 5.557 7.244 11.854 7.244 6.298 0 9.385-1.893 11.732-7.244l19.88-44.208v50.71H89.03v-50.627c0-4.94-1.976-7.327-6.05-8.561-9.755-3.046-16.3-.412-19.264 6.174l-19.51 43.63-18.892-43.63c-2.84-6.586-9.508-9.22-19.263-6.174C1.976 125.399 0 127.787 0 132.725v50.629zm99.708-41.276h12.838v27.938c-.12 1.518.487 5.08 7.522 5.19 3.589.057 27.7 0 27.925 0v-33.264h12.868c.059 0-.013 45.364-.012 45.557.07 11.188-13.882 13.618-20.313 13.806h-40.55v-8.64c.072 0 40.52.009 40.622-.001 8.265-.873 7.289-4.981 7.288-6.364v-3.368H120.6c-12.7-.117-20.786-5.66-20.886-12.035-.01-.591.274-28.54-.007-28.82z" fill="#00618a"/><path d="M170.758 183.354h36.92c4.322 0 8.52-.905 11.855-2.47 5.556-2.551 8.273-6.008 8.273-10.537v-9.384c0-3.705-3.087-7.163-9.138-9.467-3.211-1.236-7.162-1.894-10.99-1.894h-15.56c-5.184 0-7.654-1.564-8.271-5.021-.124-.412-.124-.742-.124-1.153v-5.844c0-.33 0-.66.124-1.071.617-2.634 1.976-3.376 6.544-3.787.37 0 .864-.083 1.235-.083H228.3v-8.56h-36.057c-5.186 0-7.903.329-10.372 1.069-7.656 2.388-10.99 6.175-10.99 12.76v7.492c0 5.763 6.545 10.702 17.534 11.854 1.236.082 2.47.164 3.704.164h13.336c.495 0 .989 0 1.359.083 4.075.33 5.803 1.07 7.039 2.552.74.74.987 1.482.987 2.305v7.49c0 .907-.617 2.06-1.851 3.047-1.112.988-2.964 1.647-5.434 1.812-.494 0-.864.082-1.358.082h-35.44v8.56zm137.16-14.9c0 8.808 6.544 13.748 19.757 14.736 1.234.082 2.47.164 3.704.164h33.464v-8.561h-33.711c-7.532 0-10.372-1.894-10.372-6.421v-44.29h-12.842v44.372zm-71.916.449V138.38c0-7.754 5.445-12.457 16.209-13.942a24.63 24.63 0 013.465-.247h24.376c1.237 0 2.352.083 3.589.247 10.765 1.485 16.209 6.188 16.209 13.942v30.522c0 6.29-2.312 9.658-7.641 11.852l12.65 11.418h-14.911l-10.234-9.238-10.303.653h-13.735c-2.351 0-4.825-.331-7.548-1.073-8.166-2.227-12.126-6.517-12.126-13.612zm13.86-.742c0 .413.124.825.248 1.32.742 3.547 4.083 5.527 9.156 5.527h11.667l-10.717-9.675h14.91l9.348 8.438c1.722-.918 2.856-2.322 3.252-4.125.123-.412.123-.825.123-1.237v-29.285c0-.33 0-.743-.123-1.156-.743-3.3-4.084-5.196-9.033-5.196h-19.427c-5.691 0-9.403 2.475-9.403 6.352v29.037z" fill="#e48e00"/><path d="M376.14 109.88c-7.893-.214-13.923.52-19.078 2.694-1.465.618-3.801.634-4.04 2.47.805.844.93 2.104 1.57 3.142 1.23 1.992 3.309 4.661 5.163 6.061 2.026 1.53 4.114 3.165 6.286 4.49 3.863 2.355 8.176 3.7 11.896 6.06 2.192 1.391 4.37 3.143 6.509 4.713 1.057.776 1.768 1.983 3.142 2.47v-.225c-.722-.919-.908-2.183-1.571-3.143l-2.918-2.918c-2.853-3.787-6.475-7.113-10.325-9.876-3.07-2.204-9.942-5.18-11.223-8.754l-.225-.225c2.177-.245 4.726-1.033 6.735-1.57 3.375-.906 6.39-.672 9.876-1.572 1.57-.449 3.142-.899 4.714-1.347v-.898c-1.762-1.808-3.018-4.2-4.94-5.836-5.025-4.28-10.511-8.556-16.16-12.121-3.133-1.978-7.005-3.263-10.325-4.939-1.117-.563-3.08-.856-3.817-1.796-1.744-2.223-2.694-5.042-4.04-7.632-2.817-5.425-5.584-11.351-8.08-17.06-1.703-3.892-2.815-7.73-4.938-11.223-10.192-16.757-21.163-26.87-38.158-36.812-3.616-2.114-7.97-2.95-12.571-4.04l-7.407-.45c-1.507-.63-3.075-2.473-4.49-3.367-5.63-3.556-20.07-11.293-24.24-1.122-2.633 6.42 3.935 12.685 6.284 15.938 1.649 2.282 3.76 4.84 4.938 7.407.775 1.687.909 3.378 1.572 5.163 1.632 4.397 3.05 9.18 5.162 13.243 1.067 2.056 2.243 4.222 3.592 6.06.827 1.129 2.244 1.626 2.468 3.368-1.386 1.94-1.465 4.95-2.244 7.407-3.508 11.062-2.185 24.81 2.918 32.997 1.566 2.513 5.254 7.903 10.325 5.836 4.436-1.807 3.445-7.406 4.714-12.345.287-1.12.11-1.944.673-2.694v.225l4.04 8.08c2.99 4.815 8.298 9.848 12.795 13.244 2.332 1.761 4.168 4.806 7.183 5.836v-.225h-.225c-.584-.91-1.498-1.288-2.244-2.02-1.757-1.722-3.71-3.863-5.163-5.836-4.09-5.553-7.705-11.63-10.998-17.957-1.573-3.02-2.94-6.354-4.266-9.428-.511-1.185-.505-2.977-1.57-3.592-1.452 2.253-3.591 4.075-4.714 6.735-1.796 4.252-2.028 9.437-2.693 14.814-.394.141-.22.044-.45.224-3.126-.754-4.225-3.972-5.386-6.733-2.94-6.982-3.485-18.225-.9-26.262.67-2.08 3.694-8.63 2.47-10.55-.584-1.917-2.512-3.026-3.591-4.49-1.335-1.811-2.668-4.195-3.592-6.286-2.405-5.444-3.528-11.555-6.06-17.058-1.21-2.631-3.257-5.293-4.938-7.632-1.861-2.59-3.945-4.5-5.387-7.633-.513-1.113-1.21-2.895-.45-4.04.243-.772.584-1.095 1.348-1.347 1.302-1.003 4.928.334 6.284.898 3.6 1.495 6.604 2.919 9.653 4.938 1.464.971 2.944 2.848 4.713 3.368h2.02c3.16.726 6.7.225 9.652 1.122 5.218 1.586 9.894 4.052 14.14 6.734 12.939 8.169 23.517 19.798 30.753 33.67 1.164 2.233 1.668 4.365 2.693 6.734 2.069 4.778 4.675 9.694 6.733 14.366 2.054 4.66 4.056 9.365 6.959 13.244 1.527 2.039 7.422 3.133 10.1 4.264 1.879.794 4.956 1.621 6.735 2.693 3.398 2.05 6.69 4.49 9.877 6.735 1.593 1.122 6.489 3.583 6.733 5.611z" fill="#00618a"/><path d="M275.807 24.359c-1.646-.03-2.81.18-4.041.448v.225h.225c.785 1.613 2.17 2.651 3.142 4.04a6815.3 6815.3 0 002.245 4.714l.224-.224c1.39-.98 2.027-2.547 2.02-4.938-.557-.586-.64-1.322-1.122-2.021-.642-.932-1.884-1.46-2.693-2.244z" fill="#00618a" fill-ruLine truncated
|
||||
|
Before Width: | Height: | Size: 5.4 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path fill="#0181fd" d="M906.598 322.509v265.113a791.9 791.9 0 0 0-333.516 39.783 1051.55 1051.55 0 0 1-418.919 54.886V416.768c96.205 9.216 195.687.82 293.53-27.034 13.21-3.788 26.726-8.09 39.936-12.39a1055 1055 0 0 1 419.225-54.886h-.256z"/><path fill="#ffa005" d="M496.589 256a591 591 0 0 1-44.851 17.05c-7.066 2.406-14.08 4.608-21.35 7.014-5.684 1.638-11.367 3.533-17.306 5.12-86.784 24.064-174.592 30.566-259.482 21.094v88.628a770.3 770.3 0 0 0 303.77-31.079l22.732-7.27a762 762 0 0 0 43.52-16.487L496.332 256z"/><path fill="#07c846" d="M563.866 748.39a616 616 0 0 1 44.902-17.05c7.014-2.406 14.029-4.607 21.35-7.014 5.632-1.587 11.316-3.481 17.255-5.12a685.6 685.6 0 0 1 259.481-21.094v-88.627a770.3 770.3 0 0 0-286.72 25.958c-5.734 1.587-11.417 3.226-17.05 5.12l-22.732 7.27a762 762 0 0 0-43.52 16.487l27.34 84.07z"/></svg>
|
||||
|
Before Width: | Height: | Size: 931 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path fill="#1f80c4" d="M158.208 333.005a20.5 20.5 0 0 0-3.942 8.601h-.154v84.122a19.87 19.87 0 0 0 11.366 16.691l269.364 158.26a136.45 136.45 0 0 0 125.49 0l269.569-157.697a19.92 19.92 0 0 0 11.366-16.742l1.024-83.968-217.293-116.838-160.41 92.979a19.35 19.35 0 0 0-11.366 16.691v83.814c2.1 5.888 6.144 10.957 11.367 14.336 2.56-35.84 42.496-58.265 42.496-58.265l116.94-66.765 152.577 82.893-216.27 128.307a137.47 137.47 0 0 1-125.44 0l-226.508-133.58 226.406-133.53a126.4 126.4 0 0 1 62.823-15.207V153.6a126.8 126.8 0 0 0-62.823 15.155l-269.312 158.31a20.5 20.5 0 0 0-7.321 5.94zm679.117 236.851a19.87 19.87 0 0 0 3.43-9.882l-.358-83.302a19.87 19.87 0 0 1-11.367 16.691L560.026 651.571a136.45 136.45 0 0 1-125.543 0L164.966 492.75a19.87 19.87 0 0 1-11.366-16.743v83.456a19.87 19.87 0 0 0 11.366 16.692l269.517 158.412a137.42 137.42 0 0 0 125.543 0l269.312-157.9a19.87 19.87 0 0 0 7.987-6.81m3.43 119.654a19.87 19.87 0 0 1-11.366 16.692L560.026 863.949a136.45 136.45 0 0 1-125.543 0L164.966 705.74a19.87 19.87 0 0 1-11.366-16.794v-83.456a19.71 19.71 0 0 0 11.366 16.691l269.517 158.413a137.47 137.47 0 0 0 125.543 0l269.004-157.9a19.56 19.56 0 0 0 11.367-16.692l.358 83.456z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Oracle</title><path fill="#C74634" d="M16.412 4.412h-8.82a7.588 7.588 0 0 0-.008 15.176h8.828a7.588 7.588 0 0 0 0-15.176zm-.193 12.502H7.786a4.915 4.915 0 0 1 0-9.828h8.433a4.914 4.914 0 1 1 0 9.828z"/></svg>
|
||||
|
Before Width: | Height: | Size: 286 B |
|
Before Width: | Height: | Size: 3.8 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 512 512"><style>.st2{fill:none;stroke:#fff;stroke-width:12.4651;stroke-linecap:round;stroke-linejoin:round}</style><g id="Layer_x0020_3"><path d="M378.5 372.5c3.2-26.9 2.3-30.8 22.3-26.5l5.1.4c15.4.7 35.5-2.5 47.4-8 25.5-11.8 40.6-31.5 15.5-26.4-57.3 11.8-61.2-7.6-61.2-7.6 60.5-89.7 85.8-203.6 63.9-231.5C411.9-3 308.8 33 307.1 33.9l-.5.1c-11.3-2.3-24-3.8-38.2-4-25.9-.4-45.6 6.8-60.5 18.1 0 0-183.8-75.7-175.2 95.2 1.8 36.4 52.1 275.2 112.1 203 21.9-26.4 43.1-48.7 43.1-48.7 10.5 7 23.1 10.6 36.3 9.3l1-.9c-.3 3.3-.2 6.5.4 10.3-15.5 17.3-10.9 20.3-41.8 26.7-31.3 6.4-12.9 17.9-.9 20.9 14.5 3.6 48.2 8.8 70.9-23l-.9 3.6c6.1 4.9 5.7 34.9 6.5 56.3.9 21.4 2.3 41.5 6.7 53.3s9.5 42.2 50.1 33.5c34-7.3 59.9-17.7 62.3-115.1" style="stroke:#000;stroke-width:37.3953"/><path d="M468.7 312.1c-57.3 11.8-61.2-7.6-61.2-7.6C468 214.8 493.3 100.9 471.4 73 411.9-3 308.8 33 307.1 33.9l-.6.1c-11.3-2.3-24-3.7-38.2-4-25.9-.4-45.6 6.8-60.5 18.1 0 0-183.8-75.7-175.2 95.2 1.8 36.4 52.1 275.2 112.1 203 21.9-26.4 43.1-48.7 43.1-48.7 10.5 7 23.1 10.6 36.3 9.3l1-.9c-.3 3.3-.2 6.5.4 10.3-15.5 17.3-10.9 20.3-41.8 26.7-31.3 6.4-12.9 17.9-.9 20.9 14.5 3.6 48.2 8.8 70.9-23l-.9 3.6c6.1 4.9 10.3 31.6 9.6 55.8s-1.2 40.8 3.6 53.8 9.5 42.2 50.1 33.5c33.9-7.3 51.5-26.1 54-57.6 1.7-22.4 5.7-19 5.9-39l3.2-9.5c3.6-30.3.6-40.1 21.5-35.5l5.1.4c15.4.7 35.5-2.5 47.4-8 25.5-11.7 40.5-31.4 15.5-26.3" style="fill:#336791"/><path d="M256.3 329.5c-1.6 56.4.4 113.2 5.9 126.9 5.5 13.8 17.3 40.6 58 31.9 33.9-7.3 46.3-21.4 51.6-52.4 3.9-22.9 11.6-86.4 12.5-99.4M207.6 46.9S23.7-28.3 32.2 142.7c1.8 36.4 52.1 275.2 112.1 203 21.9-26.4 41.8-47.1 41.8-47.1M306.9 33.2c-6.4 2 102.3-39.7 164.1 39.2 21.8 27.9-3.5 141.8-63.9 231.5" class="st2"/><path d="M407 303.9s3.9 19.4 61.2 7.6c25.1-5.2 10 14.5-15.5 26.4-20.9 9.7-67.7 12.2-68.5-1.2-1.9-34.7 24.8-24.2 22.8-32.8-1.7-7.8-13.6-15.5-21.5-34.5-6.9-16.7-94.3-144.4 24.2-125.5 4.3-.9-30.9-112.7-141.8-114.5S160.7 165.8 160.7 165.8" style="fill:none;stroke:#fff;stroke-width:12.4651;stroke-linecap:round;stroke-linejoin:bevel"/><path d="M225.2 315.7c-15.5 17.3-10.9 20.3-41.8 26.7-31.3 6.4-12.9 17.9-.9 20.9 14.5 3.6 48.2 8.8 70.9-23 6.9-9.7 0-25.1-9.5-29.1-4.6-2-10.8-4.4-18.7 4.5" class="st2"/><path d="M224.2 315.4c-1.6-10.2 3.3-22.2 8.6-36.4 7.9-21.2 26.1-42.4 11.5-109.7-10.8-50.1-83.6-10.4-83.6-3.6s3.3 34.5-1.2 66.7c-5.9 42 26.7 77.6 64.3 73.9" class="st2"/><path d="M206.9 164.7c-.3 2.3 4.3 8.5 10.2 9.3 6 .8 11.1-4 11.4-6.3s-4.2-4.9-10.2-5.7c-6-.9-11.1.3-11.4 2.7z" style="fill:#fff;stroke:#fff;stroke-width:4.155"/><path d="M388.4 159.9c.3 2.3-4.2 8.5-10.2 9.3s-11.1-4-11.4-6.3 4.3-4.9 10.2-5.7 11.1.4 11.4 2.7z" style="fill:#fff;stroke:#fff;stroke-width:2.0775"/><path d="M409.8 143.9c1 18.2-3.9 30.6-4.5 50-.9 28.2 13.4 60.4-8.2 92.7" class="st2"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMinYMin meet" viewBox="0 0.58 256 218.59"><path fill="#912626" d="M245.97 168.943c-13.662 7.121-84.434 36.22-99.501 44.075s-23.437 7.78-35.34 2.09c-11.902-5.69-87.216-36.112-100.783-42.597C3.566 169.271 0 166.535 0 163.951v-25.876s98.05-21.345 113.879-27.024c15.828-5.679 21.32-5.884 34.79-.95 13.472 4.936 94.018 19.468 107.331 24.344l-.006 25.51c.002 2.558-3.07 5.364-10.024 8.988"/><path fill="#C6302B" d="M245.965 143.22c-13.661 7.118-84.431 36.218-99.498 44.072-15.066 7.857-23.436 7.78-35.338 2.09-11.903-5.686-87.214-36.113-100.78-42.594-13.566-6.485-13.85-10.948-.524-16.166 13.326-5.22 88.224-34.605 104.055-40.284 15.828-5.677 21.319-5.884 34.789-.948 13.471 4.934 83.819 32.935 97.13 37.81 13.316 4.881 13.827 8.9.166 16.02"/><path fill="#912626" d="M245.97 127.074c-13.662 7.122-84.434 36.22-99.501 44.078-15.067 7.853-23.437 7.777-35.34 2.087-11.903-5.687-87.216-36.112-100.783-42.597C3.566 127.402 0 124.67 0 122.085V96.206s98.05-21.344 113.879-27.023c15.828-5.679 21.32-5.885 34.79-.95C162.142 73.168 242.688 87.697 256 92.574l-.006 25.513c.002 2.557-3.07 5.363-10.024 8.987"/><path fill="#C6302B" d="M245.965 101.351c-13.661 7.12-84.431 36.218-99.498 44.075-15.066 7.854-23.436 7.777-35.338 2.087-11.903-5.686-87.214-36.112-100.78-42.594-13.566-6.483-13.85-10.947-.524-16.167C23.151 83.535 98.05 54.148 113.88 48.47c15.828-5.678 21.319-5.884 34.789-.949 13.471 4.934 83.819 32.933 97.13 37.81 13.316 4.88 13.827 8.9.166 16.02"/><path fill="#912626" d="M245.97 83.653c-13.662 7.12-84.434 36.22-99.501 44.078-15.067 7.854-23.437 7.777-35.34 2.087-11.903-5.687-87.216-36.113-100.783-42.595C3.566 83.98 0 81.247 0 78.665v-25.88s98.05-21.343 113.879-27.021c15.828-5.68 21.32-5.884 34.79-.95C162.142 29.749 242.688 44.278 256 49.155l-.006 25.512c.002 2.555-3.07 5.361-10.024 8.986"/><path fill="#C6302B" d="M245.965 57.93c-13.661 7.12-84.431 36.22-99.498 44.074-15.066 7.854-23.436 7.777-35.338 2.09C99.227 98.404 23.915 67.98 10.35 61.497-3.217 55.015-3.5 50.55 9.825 45.331 23.151 40.113 98.05 10.73 113.88 5.05c15.828-5.679 21.319-5.883 34.789-.948s83.819 32.934 97.13 37.811c13.316 4.876 13.827 8.897.166 16.017"/><path fill="#FFF" d="m159.283 32.757-22.01 2.285-4.927 11.856-7.958-13.23-25.415-2.284 18.964-6.839-5.69-10.498 17.755 6.944 16.738-5.48-4.524 10.855zm-28.251 57.518L89.955 73.238l58.86-9.035zm-56.95-50.928c17.375 0 31.46 5.46 31.46 12.194 0 6.736-14.085 12.195-31.46 12.195s-31.46-5.46-31.46-12.195c0-6.734 14.085-12.194 31.46-12.194"/><path fill="#621B1C" d="m185.295 35.998 34.836 13.766-34.806 13.753z"/><path fill="#9A2928" d="m146.755 51.243 38.54-15.245.03 27.519-3.779 1.478z"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.6 KiB |
@@ -1,7 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img">
|
||||
<title>SQLite</title>
|
||||
<rect x="4" y="4" width="120" height="120" rx="22" fill="#E8F6FC"/>
|
||||
<g transform="translate(8 8) scale(0.875)">
|
||||
<defs><linearGradient id="sqlite-dark-a" x1="-15.615" x2="-6.741" y1="-9.108" y2="-9.108" gradientTransform="rotate(90 -90.486 64.634) scale(9.2712)" gradientUnits="userSpaceOnUse"><stop stop-color="#B8ECFF" offset="0"/><stop stop-color="#5EC4F0" offset=".92"/><stop stop-color="#5EC4F0" offset="1"/></linearGradient></defs><path d="M69.5 99.176c-.059-.73-.094-1.2-.094-1.2S67.2 83.087 64.57 78.642c-.414-.707.043-3.594 1.207-7.88.68 1.169 3.54 6.192 4.118 7.81.648 1.824.78 2.347.78 2.347s-1.57-8.082-4.144-12.797a162.286 162.286 0 012.004-6.265c.973 1.71 3.313 5.859 3.828 7.3.102.293.192.543.27.774.023-.137.05-.274.074-.414-.59-2.504-1.75-6.86-3.336-10.082 3.52-18.328 15.531-42.824 27.84-53.754H16.9c-5.387 0-9.789 4.406-9.789 9.789v88.57c0 5.383 4.406 9.789 9.79 9.789h52.897a118.657 118.657 0 01-.297-14.652" fill="#5EC4F0"/><path d="M65.777 70.762c.68 1.168 3.54 6.188 4.117 7.809.649 1.824.781 2.347.781 2.347s-1.57-8.082-4.144-12.797a164.535 164.535 0 012.004-6.27c.887 1.567 2.922 5.169 3.652 6.872l.082-.961c-.648-2.496-1.633-5.766-2.898-8.328 3.242-16.871 13.68-38.97 24.926-50.898H16.899a6.94 6.94 0 00-6.934 6.933v82.11c17.527-6.731 38.664-12.88 56.855-12.614-.672-2.605-1.441-4.96-2.25-6.324-.414-.707.043-3.597 1.207-7.879" fill="url(#sqlite-dark-a)"/><path d="M115.95 2.781c-5.5-4.906-12.164-2.933-18.734 2.899a44.347 44.347 0 00-2.914 2.859c-11.25 11.926-21.684 34.023-24.926 50.895 1.262 2.563 2.25 5.832 2.894 8.328.168.64.32 1.242.442 1.754.285 1.207.437 1.996.437 1.996s-.101-.383-.515-1.582c-.078-.23-.168-.484-.27-.773-.043-.125-.105-.274-.172-.434-.734-1.703-2.765-5.305-3.656-6.867-.762 2.25-1.437 4.36-2.004 6.265 2.578 4.715 4.149 12.797 4.149 12.797s-.137-.523-.782-2.347c-.578-1.621-3.441-6.64-4.117-7.809-1.164 4.281-1.625 7.172-1.207 7.88.809 1.362 1.574 3.722 2.25 6.323 1.524 5.867 2.586 13.012 2.586 13.012s.031.469.094 1.2a118.653 118.653 0 00.297 14.651c.504 6.11 1.453 11.363 2.664 14.172l.828-.449c-1.781-5.535-2.504-12.793-2.188-21.156.48-12.793 3.422-28.215 8.856-44.289 9.191-24.27 21.938-43.738 33.602-53.035-10.633 9.602-25.023 40.684-29.332 52.195-4.82 12.891-8.238 24.984-10.301 36.574 3.55-10.863 15.047-15.53 15.047-15.53s5.637-6.958 12.227-16.888c-3.95.903-10.43 2.442-12.598 3.352-3.2 1.344-4.067 1.8-4.067 1.8s10.371-6.312 19.27-9.171c12.234-19.27 25.562-46.648 12.141-58.621" fill="#3BA3D8"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0.03 0 633.27 511.9"><linearGradient id="a" x1="62.23" x2="62.483" y1="81.873" y2="81.873" gradientTransform="scale(115.9464 -115.9464)rotate(30.578 187.04 -69.817)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#909ca9"/><stop offset="1" style="stop-color:#ededee"/></linearGradient><path d="m429.3 163-69.9 22.8-60.8 26.9-17 4.5c-4.3 4.1-8.9 8.3-13.8 12.5-5.4 4.7-10.4 8.9-14.3 11.9-4.3 3.4-10.6 9.7-13.8 13.7-4.8 6-8.6 12.4-10.3 17.3-2.9 8.9-1.5 17.9 4.1 26.2 7.2 10.6 21.5 21.4 38.2 28.7 8.5 3.8 22.8 8.6 33.6 11.3 17.9 4.5 52.6 9.5 71.7 10.2 3.9.2 9 .2 9.3 0 .4-.3 3.4-5.9 6.8-13 11.7-23.9 20.2-46.4 24.8-65.6 2.7-11.6 4.9-27.1 6.3-45.5.4-5.1.5-22.3.2-28.1-.5-9.5-1.3-17.2-2.6-24.8-.2-1.1-.3-2.1-.2-2.2.2-.2.8-.4 9.5-2.9zm-15.9 9.4c.6 0 2.3 16.2 2.7 26.5.1 2.2.1 3.6-.1 3.6-.4 0-8.9-5-15-8.8-5.3-3.3-15.3-10-16.9-11.3-.5-.4-.5-.4 3.9-1.9 7.4-2.5 24.8-8.1 25.4-8.1m-35.6 11.7c.5 0 1.7.7 4.6 2.5 10.9 6.8 25.7 15.1 32 17.8 2 .8 2.2.5-2.3 3.6-9.6 6.6-21.6 13-36.3 19.5-2.6 1.2-4.8 2.1-4.8 2.1-.1 0 .2-1.3.6-2.9 3.5-13.2 5.6-26.5 5.7-37.2 0-5.3 0-5.3.5-5.4-.2 0-.1 0 0 0m-7.4 2.8c.3.3.1 12.2-.3 15.4-1 7.7-2.4 14.9-4.9 23-.6 2-1.1 3.6-1.2 3.7-.2.3-7.5-6.8-9.8-9.6-4.1-4.8-7.3-9.5-9.7-14.2-1.2-2.4-3.1-7-3-7.2.8-.5 28.7-11.3 28.9-11.1m-34.6 13.6c.1 0 .1 0 .2.1s.5 1 .8 1.9c1.7 4.6 5.5 11.4 8.8 15.7 3.6 4.8 8.3 9.8 12.2 13.2 1.3 1.1 2.4 2.1 2.6 2.2.3.3.4.3-8.2 3.5-10 3.8-20.9 7.6-33.4 11.6-3 1-6 1.9-8.9 2.9-.5.2-.3-.1 1.1-2.3 6.2-9.7 15.6-28.7 20.9-42.1.9-2.3 1.8-4.7 2-5.2.2-.7.5-1 1.2-1.4.2 0 .6-.1.7-.1m-10.5 4.4c.2.1-2.5 5.8-5.2 10.9-5.1 10-10.7 19.8-18.2 31.8-1.3 2.1-2.5 4-2.6 4.2-.3.4-.4.3-1.2-1.4-1.8-3.5-3.3-8.1-4.1-12.3-.8-4.1-.6-11.3.3-15.8.7-3.3.6-3.2 2.2-4 6.7-3.4 28.5-13.6 28.8-13.4m91.1 3.7v2.2c0 11.8-1.3 28-3.1 39.8-.3 2.1-.6 3.8-.6 3.8s-1.5-.4-3.3-1c-7.9-2.5-16.5-6.1-24.3-10.4-5.1-2.8-12.6-7.4-12.4-7.6.1-.1 2.3-1.2 4.9-2.6 10.4-5.4 20.3-11.2 28.9-17 3.2-2.2 8.1-5.7 9.1-6.6zm-131.1 15.6c.2 0 .2.4-.2 2.3-.2 1.4-.5 3.9-.6 5.7-.4 7.7.8 13.4 4.7 21.3 1.1 2.2 1.9 4 1.9 4-.4.3-35.4 10.6-46.4 13.6-3.3.9-6.1 1.7-6.3 1.7-.4.1-.4.1-.3-.8 1.2-7.8 7.1-17.9 15.4-26.5 5.5-5.7 9.9-9 17.4-13.3 5.4-3.1 13.7-7.7 14.3-7.9 0-.1.1-.1.1-.1m82.5 14.8c.1-.1 1.3.6 2.9 1.5 11.3 6.6 27.1 12.6 40.5 15.8l1.2.3-1.7 1c-7 3.9-30.1 13.5-53.8 22.4-3.4 1.3-6.8 2.5-7.5 2.8-.6.3-1.2.4-1.2.4 0-.1 1-1.9 2.2-4.2 6.6-12.4 13.3-27.4 16.7-37.9zm-8.4 2.7c.1.1-.4 1.2-.9 2.4-4.6 11.2-10.6 23.3-18.3 37l-3.6 6.3c-.1 0-1.6-1-3.5-2.1-11.2-6.9-21.1-15.3-27.6-23.5l-1-1.2 4.8-1.3c17.2-4.7 31.9-9.8 46.4-16 2-.9 3.7-1.6 3.7-1.6m52.2 18.3s.1 0 0 0c.1 1.2-2.6 12.1-4.9 20-1.8 6.6-3.4 11.8-6.3 20.9-1.3 4-2.4 7.3-2.4 7.3-.1 0-.4-.1-.7-.2-15.7-2.9-29.8-6.8-43-12.1-3.7-1.5-9-3.9-9.3-4.1-.1-.1 3.1-1.6 7.1-3.3 24.1-10.5 49-22.5 57.6-27.6 1-.6 1.8-.9 1.9-.9m-120.5 4.1c.1.1-6.6 9.8-16 22.9-3.3 4.6-7.1 10-8.5 12s-3.6 5.2-4.8 7.1l-2.2 3.4-2.4-2c-2.8-2.3-7.7-7.3-9.8-10-4.5-5.7-7.6-11.6-8.8-17.1-.6-2.5-.6-3.8-.1-4 .8-.2 14.9-3.5 28.1-6.6 7.3-1.7 15.9-3.7 18.9-4.4s5.5-1.3 5.6-1.3m6.7 2.6 1.7 1.9c7.6 8.5 15.4 14.8 24.8 20.3 1.7 1 3 1.8 2.9 1.9-.4.3-32.7 11.7-47.6 16.9-8.4 3-15.3 5.3-15.4 5.3s-.5-.3-1.1-.7l-1-.7 1.5-2.2c5-7.2 11.2-15.1 24.8-31.5zm42.2 30.2c.1-.1 2.4.8 5.2 1.9 6.9 2.6 12.3 4.2 19.6 6.1 9 2.3 22 4.5 29.7 5.1 1.2.1 1.8.2 1.6.4-.4.2-8.1 2.8-13.8 4.6-9.1 2.9-36.8 11-59.5 17.6-4.2 1.2-7.8 2.2-8 2.3-.5.1-2.3-.4-2.3-.6 0-.1 1.3-1.7 2.8-3.5 7.6-9.1 15.2-19.2 21.5-28.9 1.7-2.8 3.2-5 3.2-5m-9.3.2c.1.1-3.7 6.1-10.3 16.4-2.8 4.4-5.9 9.4-7 11.1s-2.6 4.3-3.5 5.8l-1.5 2.7-.8-.2c-1.9-.5-15.3-5.2-18.8-6.7-4.4-1.7-8.9-3.9-12.3-5.7-4.2-2.3-9.5-5.7-9.1-5.8.1-.1 7.3-2 16.1-4.4 23.2-6.3 36-9.9 44.4-12.6 1.4-.2 2.8-.6 2.8-.6m65.8 15.5c.3.5-8.3 24.3-11.4 31.8-.7 1.7-.9 2.1-1.3 2.1-.9-.1-13.3-1.8-20.9-2.9-13.2-2-35.3-5.9-40.8-7.1l-1.3-.3 7.9-1.8c16.9-3.8 25.1-5.9 33.3-8.4 10.4-3.2 20.7-7.1 31.1-12 1.7-.8 3-1.4 3.4-1.4" style="fill:url(#a)"/><linearGradient id="b" x1="61.44" x2="61.693" y1="73.151" y2="73.151" gradientTransform="matrix(80.3196 0 0 -80.3196 -4653.717 5908.5)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#939fab"/><stop offset="1" style="stop-color:#dcdee1"/></linearGradient><path d="M357.2 0c-1.2-.2-19.9 6.6-32 11.5-16.3 6.7-29 13-36.8 18.5-2.9 2.1-6.6 5.7-7.1 7.1-.2.5-.3 1.2-.3 1.8l7.1 6.7L305 51l40.1 7.2 45.9 7.9.5-4c-.2 0-.3-.1-.4-.1l-6-1-1.2-2.2c-6.2-11-13.1-24.6-17.1-33.8-3.1-7.1-6.1-15.4-7.7-21.3-1.2-3.4-1.3-3.6-1.9-3.7m-.8 2.7c.1.1.3 1.5.5 3.3.9 7.4 2.5 14.5 5.1 22.3 2 5.8 2 5.5-.3 4.8-5.4-1.5-29.5-5.7-47-8.1-2.8-.4-5.2-.7-5.2-.8-.2-.2 12.6-6.9 18.3-9.6C335 11.3 354.9 3 356.4 2.7m-51 23.7 2.1.7c11.2 3.8 39.4 9.2 54.9 10.5 1.7.2 3.2.3 3.3.3.1.1-1.4.8-3.3 1.7-7.5 3.8-15.8 8.4-21.5 11.9-1.7 1.1-3.2 1.9-3.4 1.9s-1.3-.2-2.5-.4l-2.1-.3-5.3-5.2c-9.3-9-16.6-16-19.4-18.7zm-2.1 1.6 7.5 9.3c4.1 5.1 8.2 10.1 9.1 11.3.9 1.1 1.6 2 1.6 2.1-.2.2-10.8-1.9-16.4-3.2-5.8-1.3-8.1-2-11.7-3.1l-2.9-1v-.7c.1-3.5 4.5-8.8 12.2-14.2zm63.4 12.7c.2 0 .5.5 1.1 1.9 1.8 4 7.4 14.6 8.8 16.7.4.7 1.2.Line truncated
|
||||
|
Before Width: | Height: | Size: 14 KiB |
@@ -1,15 +0,0 @@
|
||||
<?xml version="1.0"?>
|
||||
<!-- Copyright Tencent Cloud. Sourced from TencentCloud/tencentcloud-monitor-grafana-app under Apache License 2.0. -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 286.65 207.43">
|
||||
<defs>
|
||||
<style>.cls-1{fill:#00a3ff;}.cls-2{fill:#00c8dc;}.cls-3{fill:#006eff;}</style>
|
||||
</defs>
|
||||
<title>Tencent Cloud</title>
|
||||
<g id="图层_2" data-name="图层 2">
|
||||
<g id="图层_1-2" data-name="图层 1">
|
||||
<path class="cls-1" d="M248.41,169.83c-5.11,5-14.21,10.79-29.16,11.3-6.91.23-15,.26-18.67.26h-80.3l57.59-55.94c2.65-2.57,8.61-8.27,13.75-12.9,11.28-10.15,21.43-12.2,28.61-12.13a40.46,40.46,0,0,1,28.18,69.41m19.23-75.5a66.15,66.15,0,0,0-47.31-20c-16.07,0-29.91,5.54-42,15.38-5.27,4.29-10.79,9.42-17.74,16.17-3.45,3.36-103.53,100.46-103.53,100.46a156.54,156.54,0,0,0,18.9,1c6,0,121,0,125.82,0,9.69,0,16,0,22.76-.5,15.53-1.13,30.21-6.83,42.07-18.48A66.49,66.49,0,0,0,267.64,94.33Z"/>
|
||||
<path class="cls-2" d="M106.18,88.09C94.42,79.28,81.25,74.36,66.32,74.38A66.5,66.5,0,0,0,20,188.44a64.54,64.54,0,0,0,37.05,17.94l25.76-25c-4.16,0-10.14-.07-15.45-.25-14.94-.51-24-6.25-29.15-11.3a40.46,40.46,0,0,1,28.18-69.41c7,0,16.62,2,27.43,11,5.16,4.28,16.62,14.36,21.65,18.89a.62.62,0,0,0,.87,0L134.11,113a.64.64,0,0,0,0-1c-8.53-7.71-20.62-18.5-27.91-23.92"/>
|
||||
<path class="cls-3" d="M227.3,59.36a89.08,89.08,0,0,0-172,16,65.32,65.32,0,0,1,11-.93,64.36,64.36,0,0,1,15,1.76,2.8,2.8,0,0,1,.28.06A63,63,0,0,1,200.56,62.78a.58.58,0,0,0,.7.38,76.32,76.32,0,0,1,25.14-2.62c.85.07,1.18-.41.9-1.18"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024"><path fill="#e80230" d="M855.654 512.154c0 56.422-.153 112.896.256 169.267 0 9.062-2.713 13.773-10.854 17.92a74510 74510 0 0 0-321.024 169.984c-7.68 3.993-13.824 4.813-22.22.358a62695 62695 0 0 0-323.84-170.7c-7.527-3.943-9.882-8.602-9.882-17.05.256-113.255.256-226.56 0-339.866 0-8.448 2.713-13.107 10.035-16.998A92518 92518 0 0 0 501.914 154.47c7.526-3.993 13.21-4.352 20.992-.256A65522 65522 0 0 0 847.462 326.4c7.783 4.096 8.295 9.267 8.295 16.538a25193 25193 0 0 0-.103 169.216M489.472 727.552V709.12c0-101.888.154-203.776-.358-305.664 0-11.622 2.713-17.971 13.363-23.194 28.774-14.182 56.832-29.696 85.248-44.697 3.174-1.946 6.246-4.096 9.216-6.349-27.648-14.643-53.402-28.672-79.616-41.83-3.482-1.741-9.728-.666-13.568 1.33a21905 21905 0 0 0-166.81 87.604c-13.517 7.168-32 11.878-38.502 23.398-6.912 12.186-2.355 31.13-2.714 47.104-.153 13.517 0 26.983 0 42.701l107.879-56.73v16.897c0 73.37 0 146.79.153 220.262 0 4.915 1.331 12.493 4.455 14.285 25.958 14.54 52.377 28.057 81.254 43.213zm85.248-22.528c4.915-2.15 8.397-3.43 11.674-5.12 20.787-11.008 41.37-22.323 62.464-32.666 8.857-4.352 12.185-9.523 12.134-19.814-.41-81.664-.205-163.328-.205-245.094 0-4.352-.46-8.704-.717-14.95-25.753 13.567-49.766 26.93-74.393 38.911-8.96 4.352-11.11 9.882-11.06 19.098.257 64.717 0 129.433 0 194.048.103 21.197.103 42.291.103 65.536v.102z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="45.02 22.32 53.115 57.774"><style><![CDATA[.I{stroke-width:.5}.J{stroke-miterlimit:10}.K{stroke:#000}.L{fill:none}.M{fill:#f9c89a}.N{fill:#4f2e0e}.O{fill:#6d9758}.P{stroke-width:.1}]]></style><g transform="matrix(.159884 0 0 .159884 34.20631 17.28392)"><g class="K I"><ellipse cx="299.73" cy="339.813" rx="10.957" ry="5.14" fill="#331e09"/><g stroke-miterlimit="10"><path d="M217.706 319.453s4.363 18.614 4.944 24.142 11.635 8.145 12.798 1.745 5.236-22.105 5.236-22.105zm69.518 4.072s5.816 13.962 5.816 21.814 13.96 9.017 15.125 3.78 2.908-25.887 2.908-25.887z" class="M"/><path d="M234.77 336.03c-2.327.97-8.92 1.746-11.44.777s.195 9.113.97 12.217 8.92 2.52 8.92 2.52z" fill="#a15813"/></g><g class="N"><ellipse cx="220.979" cy="344.03" rx="4.872" ry="11.636"/><ellipse cx="236.249" cy="345.268" rx="5.018" ry="11.416"/></g><g stroke-miterlimit="10"><path d="M202.145 377.77L202 364.1c-.145-7.562 7.854-17.16 24.142-16.725s23.85 9.453 23.85 18.18-.728 15.707-1.6 17.452-42.6 2.327-44.2.728-2.036-5.964-2.036-5.964z" fill="#9e5613"/><path d="M202.145 389.55c-2.763-.727-3.78-3.636-3.927-6.398s2.182-5.1 3.927-5.382c1.308 1.746 3.926 2.474 6.253 2.9s32.14.727 35.63.436 4.363-2.326 4.946-3.197c2.473.87 3.5 3.488 3.635 5.96s-1.018 4.8-3.5 5.673-15.562 2.9-24.578 2.763-22.396-2.764-22.396-2.764z" class="N"/><path d="M310.638 342.43s-7.854 4.653-17.9 1.164c-6.544-.873-8.3-.873-8.145 2.327s1.02 11.926-2.035 15.56-6.255 13.1-1.6 17.452 71.988 3.927 74.607 3.2 4.218-5.382 4.218-5.382.87-9.9.58-15.124-7.562-14.834-19.925-14.397-17.45 9.162-22.978 10.325c-4.654-4.362-6.4-9.452-6.4-10.472s-.434-4.653-.434-4.653z" fill="#9e5613"/><path d="M295.585 358.355c4.363 0 7.5-1.963 7.854-6.472s-.727-6.98-3.928-7.198-7.126-.873-9.016-2.108-2.1-3.5-1.3-4.437 2.18-1.817 2.18-1.817l-.5-1.38s-7.2 2.763-6.544 10.47 4.508 12.943 11.27 12.943zm7.927-13.67c2.398.945 2.907 2.764 2.545 4.727s-1.164 7.636 2.98 7.417 5.236-8.3 4.873-11.998-2.764-7.272-3.854-8.145l-.218 1.1s.824.58.85 2.036-2.45 4.216-7.175 4.872z" class="N"/><path d="M317.473 357.556c-2.23 4.85-4.363 20.363-2.424 24.626" class="L"/><path d="M280.97 378.935s-3.346 2.036-3.054 5.234 2.763 3.928 6.4 4.8 38.103 1.9 44.356 2.18 27.632-1.3 31.268-2.763 6.836-7.854-.145-11.636c-2.765 3.055-4.945 3.637-9.454 4.22s-25.597.727-34.322.436l-31.995-.727c-2.18-.146-3.053-1.745-3.053-1.745z" class="N"/></g><g stroke-miterlimit="10" class="M"><path d="M265.554 115.412c-1.02 1.163-5.817 6.7-3.5 11.343s15.56 25.015 15.56 25.015 29.232-14.834 28.36-21.815-6.836-15.27-6.836-15.27zm71.9 105.63c.97-.97 3.1-5.72-2.618-7.466-4.556-.582-7.853 1.552-8.822 2.62s2.035 6.787 3.877 6.98 7.563-2.133 7.563-2.133z"/><path d="M341.42 226.28c.873-1.26 2.715-4.46-1.55-6.593s-9.988.098-11.442 2.327 2.424 7.175 3.2 7.175 9.792-2.9 9.792-2.9z"/><path d="M331.87 202.816l-5.38 9.454s3.005 3.927 4.168 8.87-.776 6.7.872 6.4 11.248-2.908 13.1-1.746 4.363 5.915 2.618 9.114-6.787 9.792-13.865 11.538-15.608-1.455-18.8-5.915-8.24-18.8-8.436-21.33 3.2-10.18 3.2-10.18l13.186-5.914z"/><path d="M325.035 211.542c2.423 1.357 6.303 4.363 7.27 9.502s-1.066 11.635-4.362 14.35"/><path d="M341.7 234.423s-2.328 3.298-7.758 2.52-5.04-7.562-2.424-9.404 6.786-3.296 9.6-2.812 4.167 1.745 4.167 1.745"/><path d="M334.343 228.897l2.617 4.848s-1.65 1.357-3.588.484-1.94-2.327-1.455-3.587 2.426-1.745 2.426-1.745z"/></g><g stroke-miterlimit="10" class="O"><path d="M314.466 123.992s10.764 5.672 12.5 12.653c5.235 1.745 11.392 9.428 10.8 15.052 5.623 2.327 8.12 5.587 8.12 9.27 3.104 2.715 7.744 9.678 2.12 19.76s-8.927 17.055-8.927 17.055l-22.495-1.944-1.94-6.8 10.47-19.392s-4.654-7.562-10.085-7.757c-1.356 5.817-2.714 14.155-2.714 14.155l.58 31.995-2.908 1.745-.582 7.175 7.175 14.93 2.52 2.327-6.594 10.278 1.553 2.9 8.92 21.912-.194 2.715 5.237 6.786-.195 2.908 2.9 7.175-5.624 12.798-3.5 6.98 3.297 10.083s-9.113 8.34-24.24 8.145-25.4-7.95-25.4-7.95l-2.908-1.744s3.297-19.002 2.52-29.668-3.104-26.76-3.104-30.444-.97-14.155-.97-14.155l-1.553 33.546c-.193 11.247-1.745 26.565-3.683 29.667s-4.072 4.072-4.072 4.072l2.132 3.686.582 4.458-2.327 2.9-4.267-1.165s-8.337 9.697-23.462 7.953-24.432-9.308-25.402-15.514c-3.5-2.52-3.878-4.266-3.878-4.266l9.9-11.44-5.624-3.296 4.654-8.34s3.297-8.145 3.878-10.86 2.327-9.307 2.327-9.307l-4.848.193 7.95-12.4-.58-8.533 14.542-30.83-.582-5.042-1.95-2.715-1.587-10.762 1.02-5.818s5.03-6.107 5.6-11.15-2.417-10.083-2.417-10.083l-33.98 22.3-4.87.388-13.973 6.205-3.303-1.358s-1.166-1.938-1.554-5.04-.001-17.064 1.162-19.003 3.877-1.94 3.877-1.94 4.46-.775 7.95-3.103 25.014-18.228 30.25-22.3 23.463-19.778 23.463-19.778 6.205-4.848 9.695-6 5.815-.97 5.815-.97l.195 16.677 15.706 17.8 17.065-10.23 13.574-12.604z"/><path d="M262.936 115.993l-2.327 1.455-10.036 16.724 10.9 1.02-3.927 7.562 20.65 10.907 1.744-2.036-14.98-18.47c-3.5-4.363-4.5-14.107-.582-16.725s-1.453-.437-1.453-.437"/><path d="M272.242 155.915l31.196-5.2-3.673-13.3 13.344 1.7 1.164-15.417-13.816-8.87-1.3-.145s3.34Line truncated
|
||||
|
Before Width: | Height: | Size: 14 KiB |
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"name": "@opsxdb/ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.20.3",
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/language": "^6.12.4",
|
||||
"@codemirror/state": "^6.7.5",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.43.12",
|
||||
"sql-formatter": "^15.8.2",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.10",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"typescript": "^5.8.2",
|
||||
"vitest": "^3.0.9"
|
||||
}
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { callAi, getAiConfig, saveAiConfig, schemaContextFor, type AiAction } from "./api";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
|
||||
const ACTIONS: { id: AiAction; label: string }[] = [
|
||||
{ id: "generate", label: "生成" },
|
||||
{ id: "explain", label: "解释" },
|
||||
{ id: "optimize", label: "优化" },
|
||||
{ id: "fix", label: "修复" },
|
||||
{ id: "dialect", label: "方言" },
|
||||
{ id: "sample", label: "样例" },
|
||||
];
|
||||
|
||||
type ContentSeg = { kind: "text" | "code"; text: string };
|
||||
|
||||
/** R2-F7:拆分 AI 回复中的 ``` 代码块(通常 sql);块外文本维持纯文本 */
|
||||
function splitContent(content: string): ContentSeg[] {
|
||||
const segs: ContentSeg[] = [];
|
||||
const re = /```[a-zA-Z]*\r?\n?([\s\S]*?)```/g;
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(content))) {
|
||||
if (m.index > last) segs.push({ kind: "text", text: content.slice(last, m.index) });
|
||||
segs.push({ kind: "code", text: m[1].replace(/\n$/, "") });
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last < content.length) segs.push({ kind: "text", text: content.slice(last) });
|
||||
return segs.length ? segs : [{ kind: "text", text: content }];
|
||||
}
|
||||
|
||||
/** R2-F7:等宽代码块 + 顶部「复制」「插入编辑器」小按钮 */
|
||||
function CodeBlock({ code }: { code: string }) {
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const copy = async () => {
|
||||
await navigator.clipboard.writeText(code);
|
||||
notify({ kind: "success", title: "已复制", message: "SQL 已复制", mode: "toast", durationMs: 1200 });
|
||||
};
|
||||
const insertToEditor = () => {
|
||||
const st = useWorkspaceStore.getState();
|
||||
const tab = st.tabs.find((t) => t.id === st.activeTabId);
|
||||
const cur = tab?.sql ?? "";
|
||||
st.updateActiveSql(cur.trim() ? `${cur.replace(/\s+$/, "")}\n\n${code}` : code);
|
||||
notify({ kind: "success", title: "已插入编辑器", message: "SQL 已追加到当前查询", mode: "toast", durationMs: 1400 });
|
||||
};
|
||||
return (
|
||||
<div className="my-1 overflow-hidden rounded border" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
<div
|
||||
className="flex items-center justify-end gap-1.5 border-b px-1.5 py-0.5"
|
||||
style={{ borderColor: "var(--ops-border-soft)" }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] hover:underline"
|
||||
style={{ color: "var(--ops-accent)" }}
|
||||
onClick={() => void copy()}
|
||||
>
|
||||
复制
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] hover:underline"
|
||||
style={{ color: "var(--ops-accent)" }}
|
||||
onClick={insertToEditor}
|
||||
>
|
||||
插入编辑器
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
className="m-0 overflow-auto whitespace-pre-wrap break-all px-2 py-1 font-mono"
|
||||
style={{
|
||||
fontSize: "var(--ops-text-xs)",
|
||||
background: "var(--ops-editor-bg)",
|
||||
color: "var(--ops-text)",
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** R2-F7:AI 消息渲染 —— 代码块走等宽块,其余纯文本(无代码块时整体不变) */
|
||||
function MessageContent({ content }: { content: string }) {
|
||||
const segs = splitContent(content);
|
||||
if (segs.length === 1 && segs[0].kind === "text") return <>{content}</>;
|
||||
return (
|
||||
<>
|
||||
{segs.map((s, i) =>
|
||||
s.kind === "code" ? <CodeBlock key={i} code={s.text} /> : <span key={i}>{s.text}</span>,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** C4:AI 设置(baseURL / API Key / model + 保存/测试),配置持久化在服务端 ~/.opsxdb/ai.json */
|
||||
function AiSettings({ onClose }: { onClose: () => void }) {
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const [provider, setProvider] = useState("openai");
|
||||
const [baseURL, setBaseURL] = useState("");
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [hasKey, setHasKey] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
getAiConfig()
|
||||
.then((cfg) => {
|
||||
if (!alive) return;
|
||||
setProvider(cfg.provider || "stub");
|
||||
setBaseURL(cfg.baseURL || "");
|
||||
setModel(cfg.model || "");
|
||||
setHasKey(!!cfg.hasKey);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await saveAiConfig({
|
||||
provider,
|
||||
baseURL: baseURL.trim(),
|
||||
// 留空表示不改动已保存的 key(服务端掩码不回传明文)
|
||||
apiKey: apiKey.trim() || undefined,
|
||||
model: model.trim(),
|
||||
});
|
||||
if (model.trim()) set("aiModel", model.trim());
|
||||
setHasKey(hasKey || !!apiKey.trim());
|
||||
notify({ kind: "success", title: "AI 配置", message: "已保存", mode: "toast", durationMs: 1400 });
|
||||
return true;
|
||||
} catch (e) {
|
||||
notify({ kind: "error", title: "AI 配置", message: e instanceof Error ? e.message : String(e), mode: "modal" });
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const test = async () => {
|
||||
if (!(await save())) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await callAi({ action: "ask", prompt: "用一句话回答:连接正常", model: model.trim() || undefined });
|
||||
notify({
|
||||
kind: "success",
|
||||
title: "AI 连接正常",
|
||||
message: res.content.slice(0, 120) || "(空响应)",
|
||||
mode: "toast",
|
||||
durationMs: 4000,
|
||||
});
|
||||
} catch (e) {
|
||||
notify({ kind: "error", title: "AI 连接失败", message: e instanceof Error ? e.message : String(e), mode: "modal" });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5 border-b px-1.5 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
className="ops-input py-0.5 text-[11px]"
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
aria-label="Provider"
|
||||
>
|
||||
<option value="stub">stub(离线占位)</option>
|
||||
<option value="openai">openai(OpenAI 兼容)</option>
|
||||
</select>
|
||||
<input
|
||||
className="ops-input min-w-0 flex-1 py-0.5 text-[11px]"
|
||||
placeholder="模型,如 gpt-4o-mini"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
aria-label="模型"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
className="ops-input w-full py-0.5 text-[11px]"
|
||||
placeholder="Base URL,如 https://api.openai.com/v1"
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
aria-label="Base URL"
|
||||
/>
|
||||
<input
|
||||
className="ops-input w-full py-0.5 text-[11px]"
|
||||
type="password"
|
||||
placeholder={hasKey ? "API Key(已配置,留空不改动)" : "API Key"}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
aria-label="API Key"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<button type="button" className="ops-btn-primary h-6 px-2 text-[11px]" disabled={busy} onClick={() => void save()}>
|
||||
保存
|
||||
</button>
|
||||
<button type="button" className="ops-btn-secondary h-6 px-2 text-[11px]" disabled={busy} onClick={() => void test()}>
|
||||
{busy ? "…" : "测试"}
|
||||
</button>
|
||||
<button type="button" className="ops-btn-ghost ml-auto h-6 px-2 text-[11px]" onClick={onClose}>
|
||||
收起
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AiPanel() {
|
||||
const messages = useWorkspaceStore((s) => s.aiMessages);
|
||||
const ask = useWorkspaceStore((s) => s.aiAsk);
|
||||
const model = useWorkspaceStore((s) => s.aiModel);
|
||||
const busy = useWorkspaceStore((s) => s.aiBusy);
|
||||
const connections = useWorkspaceStore((s) => s.connections);
|
||||
const activeConnectionId = useWorkspaceStore((s) => s.activeConnectionId);
|
||||
const tabs = useWorkspaceStore((s) => s.tabs);
|
||||
const activeTabId = useWorkspaceStore((s) => s.activeTabId);
|
||||
const result = useWorkspaceStore((s) => s.result);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const pushAi = useWorkspaceStore((s) => s.pushAi);
|
||||
const updateActiveSql = useWorkspaceStore((s) => s.updateActiveSql);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
/** C4:真实配置的模型清单(来自 /api/ai/config) */
|
||||
const [cfgModel, setCfgModel] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
getAiConfig()
|
||||
.then((cfg) => {
|
||||
if (!alive) return;
|
||||
if (cfg.model) {
|
||||
setCfgModel(cfg.model);
|
||||
const cur = useWorkspaceStore.getState().aiModel;
|
||||
if (!cur || cur === "opsxdb-stub") set("aiModel", cfg.model);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [set]);
|
||||
|
||||
const conn = connections.find((c) => c.id === activeConnectionId);
|
||||
const tab = tabs.find((t) => t.id === activeTabId);
|
||||
const sql = tab?.sql ?? "";
|
||||
|
||||
const run = async (action: AiAction, prompt?: string) => {
|
||||
const text = (prompt ?? ask).trim();
|
||||
if (action === "ask" && !text) return;
|
||||
set("aiBusy", true);
|
||||
if (action === "ask" || action === "generate") {
|
||||
pushAi("user", text || ACTIONS.find((a) => a.id === action)?.label || action);
|
||||
} else {
|
||||
pushAi("user", `${ACTIONS.find((a) => a.id === action)?.label || action}`);
|
||||
}
|
||||
try {
|
||||
// C8:注入当前连接的库表列上下文(按 connId 隔离,截断 4000 字符,见 api.ts)
|
||||
const schema = schemaContextFor(conn?.id, conn?.database || tab?.database);
|
||||
const res = await callAi({
|
||||
action,
|
||||
prompt: text,
|
||||
sql,
|
||||
driver: conn?.driver,
|
||||
database: conn?.database || tab?.database,
|
||||
error: result?.error,
|
||||
schema: schema || undefined,
|
||||
model: model && model !== "opsxdb-stub" ? model : undefined,
|
||||
});
|
||||
pushAi("assistant", res.content);
|
||||
if (res.sql) updateActiveSql(res.sql);
|
||||
if (action === "ask" || action === "generate") set("aiAsk", "");
|
||||
} catch (e) {
|
||||
pushAi("assistant", `失败:${e instanceof Error ? e.message : String(e)}`);
|
||||
} finally {
|
||||
set("aiBusy", false);
|
||||
}
|
||||
};
|
||||
|
||||
const modelOptions = Array.from(new Set([cfgModel, model, "opsxdb-stub"].filter(Boolean)));
|
||||
|
||||
return (
|
||||
<aside className="ops-panel flex h-full w-full min-w-0 flex-col border-l">
|
||||
<div className="ops-section-label justify-between border-b gap-1">
|
||||
<span>AI</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
className="ops-input h-6 py-0 text-[10px] normal-case tracking-normal"
|
||||
value={model}
|
||||
onChange={(e) => set("aiModel", e.target.value)}
|
||||
title="模型(来自 /api/ai/config,可在设置中修改)"
|
||||
>
|
||||
{modelOptions.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[12px]"
|
||||
title="AI 设置"
|
||||
aria-label="AI 设置"
|
||||
onClick={() => setShowSettings(!showSettings)}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-close-faint"
|
||||
title="关闭"
|
||||
aria-label="关闭 AI"
|
||||
onClick={() => {
|
||||
set("showAi", false);
|
||||
useWorkspaceStore.getState().persistPrefs();
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSettings && <AiSettings onClose={() => setShowSettings(false)} />}
|
||||
|
||||
<div className="grid grid-cols-2 gap-1 border-b px-1.5 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<select
|
||||
className="ops-input py-0.5 text-[11px]"
|
||||
value={activeConnectionId ?? ""}
|
||||
onChange={(e) => set("activeConnectionId", e.target.value || null)}
|
||||
>
|
||||
<option value="">连接</option>
|
||||
{connections.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
className="ops-input py-0.5 text-[11px]"
|
||||
placeholder="库"
|
||||
value={conn?.database ?? ""}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex flex-wrap gap-1 border-b px-1.5 py-1"
|
||||
style={{ borderColor: "var(--ops-border)" }}
|
||||
>
|
||||
{ACTIONS.map((a) => (
|
||||
<button
|
||||
key={a.id}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void run(a.id, ask)}
|
||||
className="ops-btn-ai"
|
||||
title={a.label}
|
||||
>
|
||||
{a.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-1.5 overflow-auto p-1.5">
|
||||
{messages.length === 0 && (
|
||||
<div
|
||||
className="grid h-full place-items-center px-2 text-center text-[12px]"
|
||||
style={{ color: "var(--ops-muted)" }}
|
||||
>
|
||||
描述需求,或点上方快捷操作
|
||||
</div>
|
||||
)}
|
||||
{messages.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="rounded border px-2 py-1.5 text-[12px] whitespace-pre-wrap"
|
||||
style={
|
||||
m.role === "user"
|
||||
? {
|
||||
borderColor: "color-mix(in srgb, var(--ops-accent) 35%, var(--ops-border))",
|
||||
background: "color-mix(in srgb, var(--ops-accent) 8%, transparent)",
|
||||
}
|
||||
: {
|
||||
borderColor: "var(--ops-border)",
|
||||
background: "var(--ops-surface-2)",
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="mb-0.5 text-[10px]"
|
||||
style={{ color: "var(--ops-muted)" }}
|
||||
>
|
||||
{m.role === "user" ? "你" : "AI"} · {m.at}
|
||||
</div>
|
||||
<MessageContent content={m.content} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t p-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
className="ops-input flex-1"
|
||||
placeholder="描述查询…"
|
||||
value={ask}
|
||||
disabled={busy}
|
||||
onChange={(e) => set("aiAsk", e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void run("ask");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="ops-btn-primary" disabled={busy} onClick={() => void run("ask")}>
|
||||
{busy ? "…" : "发送"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,558 +0,0 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { formKindOf, type DriverDef } from "./catalog";
|
||||
import type { SavedConnection } from "./store";
|
||||
|
||||
const COLORS = ["#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6", "#64748b"];
|
||||
|
||||
type TabId = "general" | "advanced" | "ssl" | "ssh";
|
||||
|
||||
const TABS: { id: TabId; label: string }[] = [
|
||||
{ id: "general", label: "常规" },
|
||||
{ id: "advanced", label: "高级" },
|
||||
{ id: "ssl", label: "SSL" },
|
||||
{ id: "ssh", label: "SSH" },
|
||||
];
|
||||
|
||||
function cx(...xs: Array<string | false | null | undefined>) {
|
||||
return xs.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[7rem_1fr] items-center gap-x-2" style={{ minHeight: 30 }}>
|
||||
<div className="text-right text-[12px] leading-none" style={{ color: "var(--ops-muted)" }}>
|
||||
{label}
|
||||
</div>
|
||||
<div className="min-w-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordInput({
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
type={show ? "text" : "password"}
|
||||
className="ops-input h-7 w-full pr-8 text-[12.5px]"
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 rounded px-1 text-[11px]"
|
||||
style={{ color: "var(--ops-muted)" }}
|
||||
onClick={() => setShow((s) => !s)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{show ? "隐" : "显"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type ConnectionFormProps = {
|
||||
selected: DriverDef;
|
||||
form: Partial<SavedConnection>;
|
||||
setForm: (next: Partial<SavedConnection>) => void;
|
||||
dsn: string;
|
||||
setDsn: (v: string) => void;
|
||||
onApplyDsn: () => void;
|
||||
};
|
||||
|
||||
export function ConnectionForm({ selected, form, setForm, dsn, setDsn, onApplyDsn }: ConnectionFormProps) {
|
||||
const [tab, setTab] = useState<TabId>("general");
|
||||
const kind = formKindOf(selected.id);
|
||||
const patch = (p: Partial<SavedConnection>) => setForm({ ...form, ...p });
|
||||
|
||||
const text = (key: keyof SavedConnection, ph = "") => (
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder={ph}
|
||||
value={String(form[key] ?? "")}
|
||||
onChange={(e) => patch({ [key]: e.target.value } as Partial<SavedConnection>)}
|
||||
/>
|
||||
);
|
||||
|
||||
const portInput = (
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder={String(selected.defaultPort)}
|
||||
value={form.port === undefined || form.port === null ? "" : String(form.port)}
|
||||
onChange={(e) => patch({ port: Number(e.target.value) || 0 })}
|
||||
/>
|
||||
);
|
||||
|
||||
const general = () => {
|
||||
if (kind === "file") {
|
||||
return (
|
||||
<>
|
||||
<Row label="名称">{text("name", "我的数据库")}</Row>
|
||||
<Row label="文件路径">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder={selected.hints?.database ?? "/path/to/demo.sqlite"}
|
||||
value={form.database ?? ""}
|
||||
onChange={(e) => patch({ database: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "redis") {
|
||||
const mode = form.redisMode ?? "standalone";
|
||||
return (
|
||||
<>
|
||||
<Row label="名称">{text("name", "Redis 连接")}</Row>
|
||||
<Row label="部署模式">
|
||||
<div className="flex flex-wrap items-center gap-3 text-[12px]">
|
||||
{(
|
||||
[
|
||||
["standalone", "单机"],
|
||||
["sentinel", "Sentinel"],
|
||||
["cluster", "Cluster"],
|
||||
] as const
|
||||
).map(([v, label]) => (
|
||||
<label key={v} className="flex items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
name="redisMode"
|
||||
checked={mode === v}
|
||||
onChange={() => patch({ redisMode: v })}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</Row>
|
||||
{mode === "standalone" && (
|
||||
<>
|
||||
<Row label="主机">{text("host", selected.hints?.host ?? "127.0.0.1")}</Row>
|
||||
<Row label="端口">{portInput}</Row>
|
||||
</>
|
||||
)}
|
||||
{mode === "sentinel" && (
|
||||
<>
|
||||
<Row label="Sentinel 节点">
|
||||
<textarea
|
||||
className="ops-input min-h-[64px] w-full py-1 text-[12.5px]"
|
||||
placeholder={"127.0.0.1:26379\n127.0.0.1:26380"}
|
||||
value={form.redisNodes ?? ""}
|
||||
onChange={(e) => patch({ redisNodes: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="主节点名">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder="mymaster"
|
||||
value={form.masterName ?? ""}
|
||||
onChange={(e) => patch({ masterName: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="Sentinel 密码">
|
||||
<PasswordInput
|
||||
value={form.sentinelPassword ?? ""}
|
||||
onChange={(v) => patch({ sentinelPassword: v })}
|
||||
/>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
{mode === "cluster" && (
|
||||
<Row label="种子节点">
|
||||
<textarea
|
||||
className="ops-input min-h-[64px] w-full py-1 text-[12.5px]"
|
||||
placeholder={"127.0.0.1:7000\n127.0.0.1:7001"}
|
||||
value={form.redisNodes ?? ""}
|
||||
onChange={(e) => patch({ redisNodes: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
<Row label="密码">
|
||||
<PasswordInput value={form.password ?? ""} onChange={(v) => patch({ password: v })} />
|
||||
</Row>
|
||||
<Row label="用户名">{text("user", "ACL ≥6 可选")}</Row>
|
||||
{mode !== "cluster" && (
|
||||
<Row label="DB 索引">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder="0"
|
||||
value={form.database ?? "0"}
|
||||
onChange={(e) => patch({ database: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
<Row label="键分隔符">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder=":"
|
||||
value={form.keySeparator ?? ":"}
|
||||
onChange={(e) => patch({ keySeparator: e.target.value || ":" })}
|
||||
/>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (kind === "oracle") {
|
||||
return (
|
||||
<>
|
||||
<Row label="名称">{text("name", "Oracle 连接")}</Row>
|
||||
<Row label="主机">{text("host", selected.hints?.host ?? "127.0.0.1")}</Row>
|
||||
<Row label="端口">{portInput}</Row>
|
||||
<Row label="服务名">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder={selected.hints?.database ?? "ORCL"}
|
||||
value={form.service || form.database || ""}
|
||||
onChange={(e) => patch({ service: e.target.value, database: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="用户">{text("user", selected.hints?.user ?? "system")}</Row>
|
||||
<Row label="密码">
|
||||
<PasswordInput value={form.password ?? ""} onChange={(v) => patch({ password: v })} />
|
||||
</Row>
|
||||
<Row label="保存密码">
|
||||
<label className="flex items-center gap-1.5 text-[12px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.savePassword !== false}
|
||||
onChange={(e) => patch({ savePassword: e.target.checked })}
|
||||
/>
|
||||
记住密码
|
||||
</label>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// standard (mysql-like) + PG / SQL Server extras
|
||||
const isPg = selected.id === "postgresql" || selected.id === "gaussdb" || selected.id === "kingbase";
|
||||
const isMssql = selected.id === "sqlserver";
|
||||
return (
|
||||
<>
|
||||
<Row label="名称">{text("name", "我的数据库")}</Row>
|
||||
<Row label="主机">{text("host", selected.hints?.host ?? "127.0.0.1")}</Row>
|
||||
<Row label="端口">{portInput}</Row>
|
||||
<Row label="用户">{text("user", selected.hints?.user ?? "root")}</Row>
|
||||
<Row label="密码">
|
||||
<PasswordInput value={form.password ?? ""} onChange={(v) => patch({ password: v })} />
|
||||
</Row>
|
||||
<Row label="保存密码">
|
||||
<label className="flex items-center gap-1.5 text-[12px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.savePassword !== false}
|
||||
onChange={(e) => patch({ savePassword: e.target.checked })}
|
||||
/>
|
||||
记住密码
|
||||
</label>
|
||||
</Row>
|
||||
{(isPg || isMssql || selected.id === "mysql" || selected.mysqlCompat) && (
|
||||
<Row label={isPg || isMssql ? "初始数据库" : "库名"}>
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder={selected.hints?.database ?? (isPg ? "postgres" : isMssql ? "master" : "")}
|
||||
value={form.database ?? ""}
|
||||
onChange={(e) => patch({ database: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const advanced = () => (
|
||||
<>
|
||||
<Row label="默认连接">
|
||||
<label className="flex items-center gap-1.5 text-[12px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.isDefault}
|
||||
onChange={(e) => patch({ isDefault: e.target.checked })}
|
||||
/>
|
||||
设为默认
|
||||
</label>
|
||||
</Row>
|
||||
<Row label="颜色">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className="h-4 w-4 rounded-full border"
|
||||
style={{
|
||||
background: c,
|
||||
borderColor: form.color === c ? "var(--ops-text)" : "transparent",
|
||||
}}
|
||||
onClick={() => patch({ color: c })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Row>
|
||||
{kind === "redis" && (
|
||||
<Row label="DB 索引">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder="0–15(database index)"
|
||||
inputMode="numeric"
|
||||
value={form.database ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value.trim();
|
||||
if (v === "") {
|
||||
patch({ database: "" });
|
||||
return;
|
||||
}
|
||||
if (!/^\d{1,2}$/.test(v)) return;
|
||||
const n = Number(v);
|
||||
if (n >= 0 && n <= 15) patch({ database: v });
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
)}
|
||||
{kind !== "file" && (
|
||||
<Row label="连接 URL">
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
className="ops-input h-7 flex-1 text-[12.5px]"
|
||||
placeholder="mysql://root:pass@127.0.0.1:3306/demo"
|
||||
value={dsn}
|
||||
onChange={(e) => setDsn(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") onApplyDsn();
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="ops-btn-secondary h-7 px-2 text-[12px]" onClick={onApplyDsn}>
|
||||
填入
|
||||
</button>
|
||||
</div>
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const sslOn = !!(form.ssl || (form.sslMode && form.sslMode !== "disable"));
|
||||
const sslMode = (form.sslMode && form.sslMode !== "disable" ? form.sslMode : "require") as
|
||||
| "require"
|
||||
| "skip-verify"
|
||||
| "verify-ca";
|
||||
|
||||
const ssl = () => (
|
||||
<>
|
||||
<Row label="SSL">
|
||||
<label className="flex items-center gap-1.5 text-[12px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={kind !== "file" && sslOn}
|
||||
disabled={kind === "file"}
|
||||
onChange={(e) => {
|
||||
const on = e.target.checked;
|
||||
patch(
|
||||
on
|
||||
? {
|
||||
ssl: true,
|
||||
sslMode: form.sslMode && form.sslMode !== "disable" ? form.sslMode : "require",
|
||||
}
|
||||
: { ssl: false, sslMode: "disable" },
|
||||
);
|
||||
}}
|
||||
/>
|
||||
使用 SSL
|
||||
</label>
|
||||
</Row>
|
||||
{kind === "file" ? (
|
||||
<p className="pl-[7.5rem] text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
文件库(SQLite)不支持 SSL。
|
||||
</p>
|
||||
) : sslOn ? (
|
||||
<>
|
||||
<Row label="模式">
|
||||
<select
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
value={sslMode}
|
||||
onChange={(e) =>
|
||||
patch({
|
||||
ssl: true,
|
||||
sslMode: e.target.value as "require" | "skip-verify" | "verify-ca",
|
||||
})
|
||||
}
|
||||
>
|
||||
<option value="require">require(加密)</option>
|
||||
<option value="skip-verify">skip-verify(跳过证书校验)</option>
|
||||
<option value="verify-ca">verify-ca(校验 CA)</option>
|
||||
</select>
|
||||
</Row>
|
||||
<Row label="CA 证书">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder="可选,本机 PEM 路径 /path/to/ca.pem"
|
||||
value={form.sslCA ?? ""}
|
||||
onChange={(e) => patch({ sslCA: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="客户端证书">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder="可选,客户端证书 PEM 路径"
|
||||
value={form.sslCert ?? ""}
|
||||
onChange={(e) => patch({ sslCert: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="客户端密钥">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder="可选,客户端私钥 PEM 路径"
|
||||
value={form.sslKey ?? ""}
|
||||
onChange={(e) => patch({ sslKey: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
<p className="pl-[7.5rem] text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
{sslMode === "require"
|
||||
? "仅加密传输;路径可留空。verify-ca 需填 CA。"
|
||||
: sslMode === "skip-verify"
|
||||
? "加密且不校验服务端证书(自签常用);路径可留空。"
|
||||
: "校验服务端证书链;请填写 CA 证书路径。客户端证书/密钥可选。"}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="pl-[7.5rem] text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
开启后可选择 require / skip-verify / verify-ca,并可选填证书路径。
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const ssh = () => (
|
||||
<>
|
||||
{kind === "file" ? (
|
||||
<p className="pl-[7.5rem] text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
文件库(SQLite)为本机文件,无网络端点,无需 SSH 隧道。
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<Row label="SSH 隧道">
|
||||
<label className="flex items-center gap-1.5 text-[12px]">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.sshEnabled}
|
||||
onChange={(e) => patch({ sshEnabled: e.target.checked })}
|
||||
/>
|
||||
使用 SSH 隧道
|
||||
</label>
|
||||
</Row>
|
||||
{form.sshEnabled && (
|
||||
<>
|
||||
<Row label="SSH 主机">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder="跳板机地址"
|
||||
value={form.sshHost ?? ""}
|
||||
onChange={(e) => patch({ sshHost: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="SSH 端口">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder="22"
|
||||
value={form.sshPort === undefined || form.sshPort === null ? "22" : String(form.sshPort)}
|
||||
onChange={(e) => patch({ sshPort: Number(e.target.value) || 22 })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="SSH 用户">
|
||||
<input
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
placeholder="root"
|
||||
value={form.sshUser ?? ""}
|
||||
onChange={(e) => patch({ sshUser: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="认证方式">
|
||||
<select
|
||||
className="ops-input h-7 w-full text-[12.5px]"
|
||||
value={form.sshAuth ?? "password"}
|
||||
onChange={(e) => patch({ sshAuth: e.target.value as "password" | "privateKey" })}
|
||||
>
|
||||
<option value="password">密码</option>
|
||||
<option value="privateKey">私钥</option>
|
||||
</select>
|
||||
</Row>
|
||||
{(form.sshAuth ?? "password") === "password" ? (
|
||||
<Row label="SSH 密码">
|
||||
<PasswordInput
|
||||
value={form.sshPassword ?? ""}
|
||||
onChange={(v) => patch({ sshPassword: v })}
|
||||
/>
|
||||
</Row>
|
||||
) : (
|
||||
<>
|
||||
<Row label="私钥">
|
||||
<textarea
|
||||
className="ops-input min-h-[72px] w-full py-1 text-[11px] leading-snug"
|
||||
placeholder="PEM 内容或私钥路径 ~/.ssh/id_ed25519"
|
||||
value={form.sshPrivateKey ?? ""}
|
||||
onChange={(e) => patch({ sshPrivateKey: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="口令">
|
||||
<PasswordInput
|
||||
value={form.sshPassphrase ?? ""}
|
||||
placeholder="私钥口令(可选)"
|
||||
onChange={(v) => patch({ sshPassphrase: v })}
|
||||
/>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
<p className="pl-[7.5rem] text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
开启后,测试/连接先经本机端口转发再访问数据库。
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
className="flex shrink-0 gap-0 border-b px-1"
|
||||
style={{ borderColor: "var(--ops-border)" }}
|
||||
>
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={cx(
|
||||
"px-3 py-1.5 text-[12.5px] transition",
|
||||
tab === t.id ? "border-b-2 font-medium" : "opacity-70 hover:opacity-100",
|
||||
)}
|
||||
style={
|
||||
tab === t.id
|
||||
? { borderColor: "var(--ops-accent)", color: "var(--ops-text)" }
|
||||
: { borderColor: "transparent", color: "var(--ops-muted)" }
|
||||
}
|
||||
onClick={() => setTab(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 space-y-0.5 overflow-auto px-3 py-2">
|
||||
{tab === "general" && general()}
|
||||
{tab === "advanced" && advanced()}
|
||||
{tab === "ssl" && ssl()}
|
||||
{tab === "ssh" && ssh()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
import { useMemo, useRef, useState, type ChangeEvent, type ReactNode } from "react";
|
||||
import { driverById } from "./catalog";
|
||||
import { DriverIcon } from "./DriverIcon";
|
||||
import { DriverManager } from "./DriverManager";
|
||||
import { useWorkspaceStore, type ConnectionGroup, type SavedConnection, DEFAULT_CONN_GROUP } from "./store";
|
||||
|
||||
function hostSummary(c: SavedConnection) {
|
||||
const d = driverById(c.driver);
|
||||
if (c.driver === "sqlite") {
|
||||
return c.database || "本地文件";
|
||||
}
|
||||
const host = c.host || "127.0.0.1";
|
||||
const port = c.port ? `:${c.port}` : "";
|
||||
const db = c.database ? ` / ${c.database}` : "";
|
||||
return `${host}${port}${db}`;
|
||||
}
|
||||
|
||||
function matchesQuery(c: SavedConnection, needle: string): boolean {
|
||||
if (!needle) return true;
|
||||
const d = driverById(c.driver);
|
||||
const hay = [c.name, c.host || "", c.driver, d?.name ?? "", c.database || "", String(c.port || "")]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return hay.includes(needle);
|
||||
}
|
||||
|
||||
function ConnRow({
|
||||
c,
|
||||
onOpen,
|
||||
onDelete,
|
||||
setDefaultConnection,
|
||||
editConnection,
|
||||
notify,
|
||||
}: {
|
||||
c: SavedConnection;
|
||||
onOpen: (c: SavedConnection) => void;
|
||||
onDelete: (c: SavedConnection) => void;
|
||||
setDefaultConnection: (id: string) => void;
|
||||
editConnection: (c: SavedConnection) => void;
|
||||
notify: ReturnType<typeof useWorkspaceStore.getState>["notify"];
|
||||
}) {
|
||||
const d = driverById(c.driver);
|
||||
return (
|
||||
<div className="ops-conn-row group">
|
||||
<button type="button" className="flex min-w-0 flex-1 items-center gap-3 text-left" onClick={() => onOpen(c)}>
|
||||
<DriverIcon driver={c.driver} size={22} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 truncate text-[12.5px] font-medium">
|
||||
<span className="truncate">{c.name}</span>
|
||||
{c.isDefault && (
|
||||
<span
|
||||
className="shrink-0 rounded px-1 text-[10px] font-semibold"
|
||||
style={{
|
||||
color: "var(--ops-accent)",
|
||||
background: "color-mix(in srgb, var(--ops-accent) 14%, transparent)",
|
||||
}}
|
||||
>
|
||||
默认
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="truncate text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
{d?.name ?? c.driver} · {hostSummary(c)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-1 opacity-0 transition group-hover:opacity-100">
|
||||
{!c.isDefault && (
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost text-[11px]"
|
||||
onClick={() => {
|
||||
setDefaultConnection(c.id);
|
||||
notify({ kind: "success", title: "默认连接", message: `已设「${c.name}」为默认。`, mode: "toast" });
|
||||
}}
|
||||
>
|
||||
设默认
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="ops-btn-ghost text-[11px]" onClick={() => editConnection(c)}>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost text-[11px]"
|
||||
style={{ color: "var(--ops-danger)" }}
|
||||
onClick={() => void onDelete(c)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
className="sticky top-0 z-[1] px-3 py-1 text-[10px] font-semibold uppercase tracking-wide"
|
||||
style={{
|
||||
color: "var(--ops-muted)",
|
||||
background: "color-mix(in srgb, var(--ops-surface) 92%, transparent)",
|
||||
borderBottom: "1px solid var(--ops-border)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RECENT_N = 5;
|
||||
|
||||
export function ConnectionList() {
|
||||
const connections = useWorkspaceStore((s) => s.connections);
|
||||
const openPicker = useWorkspaceStore((s) => s.openPicker);
|
||||
const editConnection = useWorkspaceStore((s) => s.editConnection);
|
||||
const removeConnection = useWorkspaceStore((s) => s.removeConnection);
|
||||
const setDefaultConnection = useWorkspaceStore((s) => s.setDefaultConnection);
|
||||
const askConfirm = useWorkspaceStore((s) => s.askConfirm);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const notifyError = useWorkspaceStore((s) => s.notifyError);
|
||||
const [search, setSearch] = useState("");
|
||||
const [showDrivers, setShowDrivers] = useState(false);
|
||||
/** R2-F5:导入文件选择 */
|
||||
const importFileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const needle = search.trim().toLowerCase();
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
[...connections]
|
||||
.filter((c) => matchesQuery(c, needle))
|
||||
.sort((a, b) => {
|
||||
if (a.isDefault && !b.isDefault) return -1;
|
||||
if (!a.isDefault && b.isDefault) return 1;
|
||||
return (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0);
|
||||
}),
|
||||
[connections, needle],
|
||||
);
|
||||
|
||||
const recent = useMemo(() => {
|
||||
const ids = new Set(filtered.map((c) => c.id));
|
||||
return [...connections]
|
||||
.filter((c) => (c.lastUsedAt ?? 0) > 0 && ids.has(c.id))
|
||||
.sort((a, b) => (b.lastUsedAt ?? 0) - (a.lastUsedAt ?? 0))
|
||||
.slice(0, RECENT_N);
|
||||
}, [connections, filtered]);
|
||||
|
||||
/** 「全部」list: filtered, with recent still shown (pinned section above). */
|
||||
const allList = filtered;
|
||||
|
||||
const onDelete = async (c: SavedConnection) => {
|
||||
const ok = await askConfirm({
|
||||
title: "删除连接",
|
||||
message: `确认删除「${c.name}」?此操作不可撤销。`,
|
||||
okLabel: "删除",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
removeConnection(c.id);
|
||||
notify({ kind: "success", title: "已删除", message: `「${c.name}」已移除。`, mode: "toast" });
|
||||
};
|
||||
|
||||
const onOpen = (c: SavedConnection) => {
|
||||
set("activeConnectionId", c.id);
|
||||
set("view", "workbench");
|
||||
set("status", `已选择 ${c.name}`);
|
||||
};
|
||||
|
||||
/** R2-F5:导出全部连接 —— 原样导出存储形态(含分组结构与加密态 secrets),文件头带版本 */
|
||||
const exportAll = () => {
|
||||
const st = useWorkspaceStore.getState();
|
||||
const payload = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
version: 1,
|
||||
connections: st.connections,
|
||||
connGroups: st.connGroups,
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json;charset=utf-8" });
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `opsxdb-connections-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
notify({ kind: "success", title: "已导出", message: `${st.connections.length} 条连接`, mode: "toast", durationMs: 1600 });
|
||||
};
|
||||
|
||||
/** R2-F5:导入连接 —— 校验数组结构;id 冲突重新生成后追加;持久化走 store 现有 action */
|
||||
const onImportFile = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
try {
|
||||
const text = await file.text();
|
||||
const parsed = JSON.parse(text) as { connections?: unknown; connGroups?: unknown } | unknown[];
|
||||
const arr = Array.isArray(parsed) ? parsed : (parsed as { connections?: unknown }).connections;
|
||||
if (!Array.isArray(arr)) throw new Error("不是有效的连接导出文件(缺少 connections 数组)");
|
||||
const st = useWorkspaceStore.getState();
|
||||
const existingIds = new Set(st.connections.map((c) => c.id));
|
||||
const groupIds = new Set(st.connGroups.map((g) => g.id));
|
||||
let n = 0;
|
||||
for (const raw of arr) {
|
||||
if (!raw || typeof raw !== "object") continue;
|
||||
const item = { ...(raw as SavedConnection) };
|
||||
if (!item.name || !item.driver) continue; // 跳过非法条目
|
||||
if (!item.id || existingIds.has(item.id)) item.id = crypto.randomUUID();
|
||||
existingIds.add(item.id);
|
||||
if (!item.groupId || !groupIds.has(item.groupId)) item.groupId = DEFAULT_CONN_GROUP;
|
||||
st.upsertConnection(item);
|
||||
n++;
|
||||
}
|
||||
if (!n) throw new Error("文件中没有可导入的连接");
|
||||
if (!Array.isArray(parsed) && Array.isArray((parsed as { connGroups?: unknown }).connGroups)) {
|
||||
st.importConnGroups((parsed as { connGroups: ConnectionGroup[] }).connGroups);
|
||||
}
|
||||
notify({ kind: "success", title: "导入完成", message: `已导入 ${n} 条连接`, mode: "toast", durationMs: 2000 });
|
||||
} catch (err) {
|
||||
notifyError(err, "modal");
|
||||
}
|
||||
};
|
||||
|
||||
const rowProps = { onOpen, onDelete, setDefaultConnection, editConnection, notify };
|
||||
|
||||
return (
|
||||
<div className="ops-shell flex h-screen flex-col">
|
||||
<header className="ops-chrome-header px-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[14px] font-semibold leading-tight">连接</div>
|
||||
<div className="text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
选择或新建数据库连接
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto flex flex-wrap items-center justify-end gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost grid h-6 w-6 place-items-center p-0 text-[13px]"
|
||||
title="驱动管理(agent 驱动:MongoDB / ZooKeeper / 达梦)"
|
||||
aria-label="驱动管理"
|
||||
onClick={() => setShowDrivers(true)}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
{/* R2-F5:连接配置导入/导出 */}
|
||||
{connections.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost grid h-6 w-6 place-items-center p-0 text-[13px]"
|
||||
title="导出全部连接(JSON,含分组与密码存储形态)"
|
||||
aria-label="导出全部连接"
|
||||
onClick={exportAll}
|
||||
>
|
||||
⤓
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost grid h-6 w-6 place-items-center p-0 text-[13px]"
|
||||
title="导入连接(JSON)"
|
||||
aria-label="导入连接"
|
||||
onClick={() => importFileRef.current?.click()}
|
||||
>
|
||||
⤒
|
||||
</button>
|
||||
<input
|
||||
ref={importFileRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
className="hidden"
|
||||
aria-label="选择连接导出文件"
|
||||
onChange={(e) => void onImportFile(e)}
|
||||
/>
|
||||
<button type="button" className="ops-btn-primary px-3" onClick={() => openPicker()} title="新建数据库连接">
|
||||
新建
|
||||
</button>
|
||||
{connections.length > 0 && (
|
||||
<button type="button" className="ops-btn-secondary px-3" onClick={() => set("view", "workbench")} title="返回工作台">
|
||||
工作台
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{connections.length > 0 && (
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-2 border-b px-3"
|
||||
style={{ height: 36, borderColor: "var(--ops-border)", background: "var(--ops-surface)" }}
|
||||
>
|
||||
<input
|
||||
type="search"
|
||||
className="h-7 w-full max-w-md rounded border px-2 text-[12px] outline-none"
|
||||
style={{
|
||||
borderColor: "var(--ops-border)",
|
||||
background: "var(--ops-bg)",
|
||||
color: "var(--ops-text)",
|
||||
}}
|
||||
placeholder="搜索名称 / 主机 / 驱动…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
aria-label="搜索连接"
|
||||
/>
|
||||
{search && (
|
||||
<button type="button" className="ops-btn-ghost shrink-0 text-[11px]" onClick={() => setSearch("")}>
|
||||
清除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{connections.length === 0 ? (
|
||||
<div className="ops-empty">
|
||||
<h2>还没有连接</h2>
|
||||
<p>新建一个连接,保存后会出现在这里。</p>
|
||||
<button type="button" className="ops-btn-primary px-3" onClick={() => openPicker()} title="新建数据库连接">
|
||||
新建
|
||||
</button>
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="ops-empty">
|
||||
<h2>没有匹配的连接</h2>
|
||||
<p>换个关键词试试,或清除搜索查看全部。</p>
|
||||
<button type="button" className="ops-btn-secondary" onClick={() => setSearch("")}>
|
||||
清除搜索
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{recent.length > 0 && (
|
||||
<>
|
||||
<SectionLabel>最近</SectionLabel>
|
||||
{recent.map((c) => (
|
||||
<ConnRow key={`recent-${c.id}`} c={c} {...rowProps} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
<SectionLabel>全部{needle ? ` · ${allList.length}` : ""}</SectionLabel>
|
||||
{allList.map((c) => (
|
||||
<ConnRow key={c.id} c={c} {...rowProps} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{showDrivers && <DriverManager onClose={() => setShowDrivers(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,337 +0,0 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { CATEGORIES, DRIVERS, driverById, isDriverReady, type DriverDef, type DriverId } from "./catalog";
|
||||
import { ConnectionForm } from "./ConnectionForm";
|
||||
import { ConnectionList } from "./ConnectionList";
|
||||
import { parseDSN } from "./dsn";
|
||||
import { sanitizeSecret } from "./secrets";
|
||||
import { useWorkspaceStore, type SavedConnection } from "./store";
|
||||
import { openSession, testConnection } from "./api";
|
||||
import { DriverIcon } from "./DriverIcon";
|
||||
|
||||
function cx(...xs: Array<string | false | null | undefined>) {
|
||||
return xs.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
|
||||
export function ConnectionPicker() {
|
||||
const search = useWorkspaceStore((s) => s.pickerSearch);
|
||||
const category = useWorkspaceStore((s) => s.pickerCategory);
|
||||
const driver = useWorkspaceStore((s) => s.pickerDriver);
|
||||
const pickerMode = useWorkspaceStore((s) => s.pickerMode);
|
||||
const form = useWorkspaceStore((s) => s.form);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const openPicker = useWorkspaceStore((s) => s.openPicker);
|
||||
const openConnectionList = useWorkspaceStore((s) => s.openConnectionList);
|
||||
const upsert = useWorkspaceStore((s) => s.upsertConnection);
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const notifyError = useWorkspaceStore((s) => s.notifyError);
|
||||
const [dsn, setDsn] = useState("");
|
||||
const [testMsg, setTestMsg] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return DRIVERS.filter((d) => {
|
||||
if (category !== "全部" && d.category !== category) return false;
|
||||
if (!q) return true;
|
||||
return d.name.toLowerCase().includes(q) || d.id.includes(q);
|
||||
});
|
||||
}, [search, category]);
|
||||
|
||||
const selected = driver ? driverById(driver) : null;
|
||||
|
||||
const buildConnection = (): SavedConnection | null => {
|
||||
if (!form.driver || !form.name?.trim()) {
|
||||
notify({ kind: "warning", title: "保存", message: "请填写连接名称。", mode: "toast" });
|
||||
return null;
|
||||
}
|
||||
const savePassword = form.savePassword !== false;
|
||||
const sslOn = !!(form.ssl || (form.sslMode && form.sslMode !== "disable"));
|
||||
// Keep live secrets for test/connect; upsertConnection clears them when savePassword=false.
|
||||
return {
|
||||
id: form.id || crypto.randomUUID(),
|
||||
name: form.name.trim(),
|
||||
driver: form.driver as DriverId,
|
||||
host: form.host || "127.0.0.1",
|
||||
port: Number(form.port ?? selected?.defaultPort ?? 3306),
|
||||
user: form.user || "",
|
||||
password: sanitizeSecret(form.password || ""),
|
||||
database: form.database || "",
|
||||
ssl: sslOn,
|
||||
sslMode: (sslOn
|
||||
? form.sslMode && form.sslMode !== "disable"
|
||||
? form.sslMode
|
||||
: "require"
|
||||
: "disable") as SavedConnection["sslMode"],
|
||||
sslCA: form.sslCA || "",
|
||||
sslCert: form.sslCert || "",
|
||||
sslKey: form.sslKey || "",
|
||||
service: form.service || "",
|
||||
color: form.color,
|
||||
lastUsedAt: Date.now(),
|
||||
groupId: form.groupId,
|
||||
isDefault: !!form.isDefault,
|
||||
savePassword,
|
||||
keySeparator: form.keySeparator,
|
||||
redisMode: form.redisMode,
|
||||
redisNodes: form.redisNodes,
|
||||
masterName: form.masterName,
|
||||
sentinelPassword: sanitizeSecret(form.sentinelPassword || ""),
|
||||
oracleConnType: form.oracleConnType,
|
||||
oracleIdKind: form.oracleIdKind,
|
||||
oracleRole: form.oracleRole,
|
||||
authMode: form.authMode,
|
||||
sshEnabled: !!form.sshEnabled,
|
||||
sshHost: form.sshHost || "",
|
||||
sshPort: Number(form.sshPort || 22),
|
||||
sshUser: form.sshUser || "",
|
||||
sshAuth: form.sshAuth || "password",
|
||||
sshPassword: sanitizeSecret(form.sshPassword || ""),
|
||||
sshPrivateKey: form.sshPrivateKey || "",
|
||||
sshPassphrase: sanitizeSecret(form.sshPassphrase || ""),
|
||||
};
|
||||
};
|
||||
|
||||
const backToList = () => {
|
||||
setTestMsg(null);
|
||||
openConnectionList();
|
||||
};
|
||||
|
||||
const onSave = () => {
|
||||
const c = buildConnection();
|
||||
if (!c) return;
|
||||
upsert(c);
|
||||
set("form", { ...form, id: c.id });
|
||||
set("status", `已保存 ${c.name}`);
|
||||
notify({ kind: "success", title: "保存", message: `已保存「${c.name}」。`, mode: "toast" });
|
||||
backToList();
|
||||
};
|
||||
|
||||
const onTest = async () => {
|
||||
const c = buildConnection();
|
||||
if (!c) return;
|
||||
if (!isDriverReady(c.driver)) {
|
||||
setTestMsg({ ok: false, text: `${driverById(c.driver)?.name ?? c.driver} 驱动未就绪。` });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await testConnection(c);
|
||||
setTestMsg({ ok: true, text: "连接成功。" });
|
||||
notify({ kind: "success", title: "测试", message: "连接成功。", mode: "toast" });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setTestMsg({ ok: false, text: msg });
|
||||
notifyError(e, "modal");
|
||||
}
|
||||
};
|
||||
|
||||
const onConnect = async () => {
|
||||
const c = buildConnection();
|
||||
if (!c) return;
|
||||
if (!isDriverReady(c.driver)) {
|
||||
notify({ kind: "warning", title: "连接", message: `${driverById(c.driver)?.name ?? c.driver} 驱动未就绪,可先保存。`, mode: "toast" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { sessionId } = await openSession(c);
|
||||
useWorkspaceStore.getState().setSession(c.id, sessionId);
|
||||
} catch (e) {
|
||||
notifyError(e, "modal");
|
||||
return;
|
||||
}
|
||||
upsert(c);
|
||||
set("activeConnectionId", c.id);
|
||||
set("view", "workbench");
|
||||
set("status", `已连接 ${c.name}`);
|
||||
};
|
||||
|
||||
if (!selected) {
|
||||
if (pickerMode === "list") {
|
||||
return <ConnectionList />;
|
||||
}
|
||||
return (
|
||||
<div className="ops-shell flex h-screen">
|
||||
<aside className="ops-panel flex w-44 shrink-0 flex-col border-r">
|
||||
<div className="border-b px-2.5 py-2" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<div className="text-[14px] font-semibold">新建</div>
|
||||
<div className="text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
选择引擎
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-px p-1.5">
|
||||
{CATEGORIES.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => set("pickerCategory", c)}
|
||||
className={cx(
|
||||
"block w-full rounded px-2 py-1 text-left text-[12.5px] transition",
|
||||
category === c ? "ops-tree-item-active" : "ops-tree-item",
|
||||
)}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<button type="button" className="m-1.5 ops-btn-secondary" onClick={backToList}>
|
||||
返回列表
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col">
|
||||
<div className="ops-chrome-header px-3">
|
||||
<input
|
||||
className="ops-input w-full max-w-sm"
|
||||
placeholder="搜索类型…"
|
||||
value={search}
|
||||
onChange={(e) => set("pickerSearch", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<section className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div className="ops-section-label border-b">{category === "全部" ? "全部引擎" : category}</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{filtered.map((d) => (
|
||||
<button key={d.id} type="button" className={cx("ops-driver-row", !(d.ready || d.mysqlCompat) && "opacity-60")} onClick={() => openPicker(d.id)} title={!(d.ready || d.mysqlCompat) ? "驱动未接入" : d.verified === false ? "已接入,待验收" : undefined}>
|
||||
<DriverIcon driver={d.id} size={20} />
|
||||
<span className="min-w-0 truncate font-medium">{d.name}</span>
|
||||
{d.verified === false && (
|
||||
<span
|
||||
className="shrink-0 rounded px-1 text-[10px]"
|
||||
style={{
|
||||
color: "var(--ops-warning)",
|
||||
background: "color-mix(in srgb, var(--ops-warning) 12%, transparent)",
|
||||
}}
|
||||
title={d.notes?.trim() || "已接入,待验收"}
|
||||
>
|
||||
未验收
|
||||
</span>
|
||||
)}
|
||||
{d.ready || d.mysqlCompat ? (
|
||||
<span className="ops-driver-meta">可用</span>
|
||||
) : (
|
||||
<span
|
||||
className="ops-driver-meta"
|
||||
style={{ color: "var(--ops-muted)", opacity: 0.85 }}
|
||||
title="驱动未接入"
|
||||
>
|
||||
未就绪
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-3 py-4 text-[12.5px]" style={{ color: "var(--ops-muted)" }}>
|
||||
没有匹配的引擎
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ready = isDriverReady(selected.id);
|
||||
const applyDsn = () => {
|
||||
const parsed = parseDSN(dsn);
|
||||
if (!parsed) {
|
||||
notify({ kind: "warning", title: "URL", message: "无法解析。试试 mysql://user:pass@host:3306/db", mode: "toast" });
|
||||
return;
|
||||
}
|
||||
set("form", {
|
||||
...form,
|
||||
driver: parsed.driver,
|
||||
host: parsed.host ?? form.host,
|
||||
port: parsed.port ?? form.port,
|
||||
user: parsed.user ?? form.user,
|
||||
password: parsed.password ?? form.password,
|
||||
database: parsed.database ?? form.database,
|
||||
ssl: parsed.ssl ?? form.ssl,
|
||||
name: form.name || `${driverById(parsed.driver)?.name ?? parsed.driver} 连接`,
|
||||
});
|
||||
if (parsed.driver !== selected.id) {
|
||||
set("pickerDriver", parsed.driver);
|
||||
}
|
||||
notify({ kind: "success", title: "已填入", message: "从 URL 拆出字段。", mode: "toast" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ops-shell flex h-screen flex-col">
|
||||
<header className="ops-chrome-header gap-2 px-3">
|
||||
<button type="button" className="ops-btn-secondary" onClick={() => { setTestMsg(null); set("pickerDriver", null); set("pickerMode", "drivers"); }}>
|
||||
返回
|
||||
</button>
|
||||
<DriverIcon driver={selected.id} size={20} />
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[14px] font-semibold leading-tight">{selected.name}</div>
|
||||
<div className="text-[11px]" style={{ color: ready ? "var(--ops-accent)" : "var(--ops-muted)" }}>
|
||||
{ready ? "可连接" : "驱动未就绪"}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="ops-btn-ghost ml-auto" onClick={backToList}>
|
||||
返回
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="mx-auto flex w-full max-w-xl min-h-0 flex-1 flex-col">
|
||||
<ConnectionForm
|
||||
selected={selected}
|
||||
form={form}
|
||||
setForm={(next) => set("form", next)}
|
||||
dsn={dsn}
|
||||
setDsn={setDsn}
|
||||
onApplyDsn={applyDsn}
|
||||
/>
|
||||
|
||||
{testMsg && (
|
||||
<div
|
||||
className="mx-3 mb-2 rounded border px-2.5 py-1.5 text-[12.5px]"
|
||||
style={{
|
||||
borderColor: testMsg.ok
|
||||
? "color-mix(in srgb, var(--ops-accent) 40%, var(--ops-border))"
|
||||
: "color-mix(in srgb, var(--ops-danger) 40%, var(--ops-border))",
|
||||
color: testMsg.ok ? "var(--ops-accent)" : "var(--ops-danger)",
|
||||
background: testMsg.ok
|
||||
? "color-mix(in srgb, var(--ops-accent) 8%, transparent)"
|
||||
: "color-mix(in srgb, var(--ops-danger) 8%, transparent)",
|
||||
}}
|
||||
>
|
||||
{testMsg.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!ready && (
|
||||
<div
|
||||
className="mx-3 mb-2 rounded border px-2.5 py-1.5 text-[12.5px]"
|
||||
style={{
|
||||
borderColor: "color-mix(in srgb, var(--ops-warning) 40%, var(--ops-border))",
|
||||
background: "color-mix(in srgb, var(--ops-warning) 8%, transparent)",
|
||||
color: "var(--ops-warning)",
|
||||
}}
|
||||
>
|
||||
{selected.name} 驱动未就绪,可先保存配置。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="flex shrink-0 items-center gap-1.5 border-t px-3 py-2"
|
||||
style={{ borderColor: "var(--ops-border)" }}
|
||||
>
|
||||
<button type="button" className="ops-btn-secondary" onClick={() => void onTest()}>
|
||||
测试连接
|
||||
</button>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<button type="button" className="ops-btn-ghost" onClick={backToList}>
|
||||
取消
|
||||
</button>
|
||||
<button type="button" className="ops-btn-secondary" onClick={() => onSave()}>
|
||||
保存
|
||||
</button>
|
||||
<button type="button" className="ops-btn-primary" onClick={() => void onConnect()}>
|
||||
保存并连接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { closeSession } from "./api";
|
||||
import { driverById } from "./catalog";
|
||||
import { askInput } from "./dialogs";
|
||||
import { DriverIcon } from "./DriverIcon";
|
||||
import { ObjectTree } from "./ObjectTree";
|
||||
import { TreeChevron } from "./TreeChevron";
|
||||
import {
|
||||
DEFAULT_CONN_GROUP,
|
||||
useWorkspaceStore,
|
||||
type SavedConnection,
|
||||
} from "./store";
|
||||
|
||||
function cx(...xs: Array<string | false | null | undefined>) {
|
||||
return xs.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
type DropHint = { groupId: string; index: number } | null;
|
||||
|
||||
export function ConnectionSidebar({
|
||||
onSelect,
|
||||
onDelete,
|
||||
}: {
|
||||
onSelect: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const connections = useWorkspaceStore((s) => s.connections);
|
||||
const groups = useWorkspaceStore((s) => s.connGroups);
|
||||
const expanded = useWorkspaceStore((s) => s.expandedConnGroups);
|
||||
const activeId = useWorkspaceStore((s) => s.activeConnectionId);
|
||||
const addConnGroup = useWorkspaceStore((s) => s.addConnGroup);
|
||||
const renameConnGroup = useWorkspaceStore((s) => s.renameConnGroup);
|
||||
const removeConnGroup = useWorkspaceStore((s) => s.removeConnGroup);
|
||||
const moveConnection = useWorkspaceStore((s) => s.moveConnection);
|
||||
const toggleConnGroup = useWorkspaceStore((s) => s.toggleConnGroup);
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const editConnection = useWorkspaceStore((s) => s.editConnection);
|
||||
|
||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; conn: SavedConnection } | null>(null);
|
||||
/** 断开连接:关会话 + 清树 + 清激活 */
|
||||
const disconnect = (c: SavedConnection) => {
|
||||
const st = useWorkspaceStore.getState();
|
||||
const sid = st.sessions[c.id];
|
||||
if (sid) {
|
||||
void closeSession(sid).catch(() => {});
|
||||
}
|
||||
st.setSession(c.id, null);
|
||||
if (st.activeConnectionId === c.id) {
|
||||
st.set("activeConnectionId", null);
|
||||
st.set("tree", []);
|
||||
st.set("connState", "down");
|
||||
st.set("status", `已断开 ${c.name}`);
|
||||
}
|
||||
};
|
||||
|
||||
const [dragId, setDragId] = useState<string | null>(null);
|
||||
const [dropHint, setDropHint] = useState<DropHint>(null);
|
||||
const dragIdRef = useRef<string | null>(null);
|
||||
|
||||
const byGroup = useMemo(() => {
|
||||
const map = new Map<string, SavedConnection[]>();
|
||||
for (const g of groups) map.set(g.id, []);
|
||||
for (const c of connections) {
|
||||
const gid = c.groupId || DEFAULT_CONN_GROUP;
|
||||
if (!map.has(gid)) map.set(gid, []);
|
||||
map.get(gid)!.push(c);
|
||||
}
|
||||
for (const list of map.values()) {
|
||||
list.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
}
|
||||
return map;
|
||||
}, [connections, groups]);
|
||||
|
||||
const onAddGroup = () => {
|
||||
void (async () => {
|
||||
// C6:askInput 替代 window.prompt(WKWebView 下 prompt 返回 null)
|
||||
const name = await askInput({ title: "分组名称", defaultValue: "新分组" });
|
||||
if (name == null) return;
|
||||
const id = addConnGroup(name);
|
||||
notify({ kind: "info", title: "分组", message: `已创建「${name.trim() || "新分组"}」`, mode: "toast" });
|
||||
void id;
|
||||
})();
|
||||
};
|
||||
|
||||
const onRenameGroup = (id: string, cur: string) => {
|
||||
if (id === DEFAULT_CONN_GROUP) return;
|
||||
void (async () => {
|
||||
const name = await askInput({ title: "重命名分组", defaultValue: cur });
|
||||
if (name == null || !name.trim()) return;
|
||||
renameConnGroup(id, name);
|
||||
})();
|
||||
};
|
||||
|
||||
const onRemoveGroup = (id: string, name: string) => {
|
||||
if (id === DEFAULT_CONN_GROUP) return;
|
||||
void (async () => {
|
||||
const ok = await useWorkspaceStore.getState().askConfirm({
|
||||
title: "删除分组",
|
||||
message: `删除分组「${name}」?连接将移回默认。`,
|
||||
okLabel: "删除",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
removeConnGroup(id);
|
||||
})();
|
||||
};
|
||||
|
||||
const setDrop = (groupId: string, index: number) => {
|
||||
const id = dragIdRef.current;
|
||||
if (!id) return;
|
||||
setDropHint({ groupId, index });
|
||||
};
|
||||
|
||||
const commitDrop = () => {
|
||||
const id = dragIdRef.current;
|
||||
const hint = dropHint;
|
||||
dragIdRef.current = null;
|
||||
setDragId(null);
|
||||
setDropHint(null);
|
||||
if (!id || !hint) return;
|
||||
moveConnection(id, hint.groupId, hint.index);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex shrink-0 flex-col">
|
||||
<div className="flex items-center justify-between px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
<span>连接</span>
|
||||
<button type="button" className="hover:underline" style={{ color: "var(--ops-accent)" }} onClick={onAddGroup} title="新建分组">
|
||||
+ 分组
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-1">
|
||||
{connections.length === 0 && (
|
||||
<div className="px-1.5 py-1 text-[11px]" style={{ color: "var(--ops-muted)" }}>
|
||||
暂无连接
|
||||
</div>
|
||||
)}
|
||||
{groups.map((g) => {
|
||||
const list = byGroup.get(g.id) ?? [];
|
||||
const open = expanded.includes(g.id);
|
||||
return (
|
||||
<div
|
||||
key={g.id}
|
||||
className="mb-0.5"
|
||||
data-group-id={g.id}
|
||||
onDragOver={(e) => {
|
||||
if (!dragIdRef.current) return;
|
||||
e.preventDefault();
|
||||
setDrop(g.id, list.length);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
commitDrop();
|
||||
}}
|
||||
>
|
||||
<div className="ops-tree-item group/g cursor-pointer" onClick={() => toggleConnGroup(g.id)}>
|
||||
<span className="ops-tree-chevron w-3 shrink-0 text-center text-[10px] opacity-50">{open ? "▾" : "▸"}</span>
|
||||
<span
|
||||
className="mr-1 inline-block h-3 w-3 shrink-0 rounded-[2px] border"
|
||||
style={{ borderColor: "var(--ops-muted)", background: "color-mix(in srgb, var(--ops-muted) 25%, transparent)" }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">{g.name}</span>
|
||||
<span className="text-[10px] opacity-40">{list.length}</span>
|
||||
{g.id !== DEFAULT_CONN_GROUP && (
|
||||
<span className="ml-1 hidden gap-0.5 group-hover/g:inline-flex" onClick={(e) => e.stopPropagation()}>
|
||||
<button type="button" className="px-0.5 text-[10px] opacity-60 hover:opacity-100" title="重命名" onClick={() => onRenameGroup(g.id, g.name)}>
|
||||
✎
|
||||
</button>
|
||||
<button type="button" className="px-0.5 text-[10px] opacity-60 hover:opacity-100" style={{ color: "var(--ops-danger)" }} title="删除分组" onClick={() => onRemoveGroup(g.id, g.name)}>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="ml-1 border-l pl-0.5" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
{list.length === 0 && (
|
||||
<div
|
||||
className={cx("ops-drop-slot mx-0.5 my-0.5 rounded px-1.5 py-1 text-[10px]", dropHint?.groupId === g.id ? "ops-drop-slot-active" : "")}
|
||||
style={{ color: "var(--ops-muted)" }}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setDrop(g.id, 0);
|
||||
}}
|
||||
>
|
||||
拖到此处
|
||||
</div>
|
||||
)}
|
||||
{list.map((c, i) => (
|
||||
<div key={c.id}>
|
||||
{dropHint?.groupId === g.id && dropHint.index === i && <div className="ops-drop-line" />}
|
||||
<div
|
||||
className={cx(
|
||||
"group flex items-center gap-0.5 rounded",
|
||||
activeId === c.id ? "ops-tree-item-active" : "",
|
||||
dragId === c.id ? "opacity-45" : "",
|
||||
)}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
dragIdRef.current = c.id;
|
||||
setDragId(c.id);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", c.id);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
dragIdRef.current = null;
|
||||
setDragId(null);
|
||||
setDropHint(null);
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
if (!dragIdRef.current || dragIdRef.current === c.id) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const before = e.clientY < rect.top + rect.height / 2;
|
||||
setDrop(g.id, before ? i : i + 1);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
commitDrop();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-tree-item min-w-0 flex-1"
|
||||
onClick={() => onSelect(c.id)}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setCtxMenu({ x: e.clientX, y: e.clientY, conn: c });
|
||||
}}
|
||||
>
|
||||
{/* 实例级展开/收起箭头(行首树形 chevron,SVG 保证方向清晰) */}
|
||||
<span
|
||||
className="grid w-3.5 shrink-0 cursor-pointer place-items-center opacity-60 hover:opacity-100"
|
||||
title={activeId === c.id ? "点击收起对象树" : "点击展开对象树"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (activeId === c.id) {
|
||||
// 已激活 → 收起(清空树)
|
||||
useWorkspaceStore.getState().set("tree", []);
|
||||
useWorkspaceStore.getState().set("activeConnectionId", null);
|
||||
} else {
|
||||
// 未激活 → 展开(激活并加载树)
|
||||
onSelect(c.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TreeChevron open={activeId === c.id} size={10} />
|
||||
</span>
|
||||
{c.color && (
|
||||
<span className="h-3 w-1 shrink-0 rounded-sm" style={{ background: c.color }} />
|
||||
)}
|
||||
<DriverIcon driver={c.driver} size={14} />
|
||||
<span className="min-w-0 truncate" title={`${driverById(c.driver)?.name ?? c.driver} · ${c.name}`}>
|
||||
{c.name}
|
||||
</span>
|
||||
<span
|
||||
className="ml-1 h-1.5 w-1.5 shrink-0 rounded-full"
|
||||
style={{
|
||||
background: activeId === c.id ? '#22c55e' : 'var(--ops-muted)',
|
||||
opacity: activeId === c.id ? 1 : 0.4,
|
||||
}}
|
||||
title={activeId === c.id ? '已连接' : '未激活'}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="hidden px-0.5 text-[10px] opacity-50 group-hover:inline hover:opacity-100"
|
||||
title="编辑"
|
||||
onClick={() => editConnection(c)}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mr-0.5 hidden px-1 text-[11px] opacity-50 group-hover:inline hover:opacity-100"
|
||||
style={{ color: "var(--ops-danger)" }}
|
||||
onClick={() => onDelete(c.id)}
|
||||
title="删除连接"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{activeId === c.id && <ObjectTree />}
|
||||
{dropHint?.groupId === g.id && dropHint.index === list.length && i === list.length - 1 && (
|
||||
<div className="ops-drop-line" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{ctxMenu && (
|
||||
<div
|
||||
className="ops-ctx"
|
||||
style={{ position: "fixed", left: ctxMenu.x, top: ctxMenu.y, zIndex: 1000 }}
|
||||
>
|
||||
{activeId === ctxMenu.conn.id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
disconnect(ctxMenu.conn);
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
断开连接
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelect(ctxMenu.conn.id);
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
连接
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
editConnection(ctxMenu.conn);
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{ color: "var(--ops-danger)" }}
|
||||
onClick={() => {
|
||||
onDelete(ctxMenu.conn.id);
|
||||
setCtxMenu(null);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { getDdl } from "./api";
|
||||
import { ensureSession } from "./ObjectTree";
|
||||
import { useWorkspaceStore, type SavedConnection } from "./store";
|
||||
import { OpsModal } from "./OpsModal";
|
||||
|
||||
/** 超长 DDL 截断阈值(R2-F3):超过只展示前 5000 字符并提示 */
|
||||
const DDL_CAP = 5000;
|
||||
|
||||
type Props = {
|
||||
conn: SavedConnection;
|
||||
database: string;
|
||||
table: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* R2-F3:DDL 查看 —— OpsModal(info 变体)展示服务端返回的建表语句。
|
||||
* 等宽 pre + 加载态 + 错误重试;「复制」「插入编辑器」把 DDL 带到剪贴板/当前 Tab。
|
||||
*/
|
||||
export function DdlModal({ conn, database, table, onClose }: Props) {
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const [ddl, setDdl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const sid = await ensureSession(conn);
|
||||
const res = await getDdl(conn, table, { database, sessionId: sid });
|
||||
if (res.error) throw new Error(res.error);
|
||||
setDdl(res.ddl ?? "(空)");
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setDdl(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [conn, database, table]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const truncated = ddl != null && ddl.length > DDL_CAP;
|
||||
const text = truncated ? (ddl ?? "").slice(0, DDL_CAP) : (ddl ?? "");
|
||||
|
||||
const copy = async () => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
notify({ kind: "success", title: "已复制", message: `${database}.${table} DDL`, mode: "toast", durationMs: 1400 });
|
||||
};
|
||||
|
||||
/** 插入编辑器:DDL 追加到当前激活 Tab 的 SQL 末尾 */
|
||||
const insertToEditor = () => {
|
||||
const st = useWorkspaceStore.getState();
|
||||
const tab = st.tabs.find((t) => t.id === st.activeTabId);
|
||||
const cur = tab?.sql ?? "";
|
||||
st.updateActiveSql(cur.trim() ? `${cur.replace(/\s+$/, "")}\n\n${text}` : text);
|
||||
notify({ kind: "success", title: "已插入编辑器", message: `${database}.${table} DDL`, mode: "toast", durationMs: 1600 });
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<OpsModal variant="info" wide title={`DDL · ${database ? `${database}.` : ""}${table}`} okLabel="关闭" onOk={onClose}>
|
||||
{loading ? (
|
||||
<div style={{ color: "var(--ops-muted)" }}>加载 DDL…</div>
|
||||
) : error ? (
|
||||
<div>
|
||||
<div
|
||||
className="rounded border px-2.5 py-1.5"
|
||||
style={{
|
||||
borderColor: "color-mix(in srgb, var(--ops-danger) 40%, var(--ops-border))",
|
||||
background: "color-mix(in srgb, var(--ops-danger) 10%, transparent)",
|
||||
color: "var(--ops-danger)",
|
||||
fontSize: "var(--ops-text-sm)",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<button type="button" className="ops-btn-secondary h-6 px-2 text-[11px]" onClick={() => void load()}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-1.5 flex items-center justify-end gap-1.5">
|
||||
<button type="button" className="ops-btn-secondary h-6 px-2 text-[11px]" onClick={() => void copy()}>
|
||||
复制
|
||||
</button>
|
||||
<button type="button" className="ops-btn-secondary h-6 px-2 text-[11px]" onClick={insertToEditor}>
|
||||
插入编辑器
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
className="m-0 max-h-[50vh] overflow-auto whitespace-pre-wrap break-all rounded border px-2.5 py-2 font-mono leading-relaxed"
|
||||
style={{
|
||||
fontSize: "var(--ops-text-xs)",
|
||||
borderColor: "var(--ops-border-soft)",
|
||||
background: "var(--ops-editor-bg)",
|
||||
color: "var(--ops-text)",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</pre>
|
||||
{truncated && (
|
||||
<div className="mt-1.5" style={{ fontSize: "var(--ops-text-xs)", color: "var(--ops-warning)" }}>
|
||||
内容过长,仅显示前 {DDL_CAP} 字符(共 {ddl?.length} 字符)。
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</OpsModal>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { driverById, type DriverId } from "./catalog";
|
||||
import { resolveDriverIconUrl } from "./driverIconUrls";
|
||||
|
||||
function initials(name: string, id: string): string {
|
||||
const raw = (name || id).replace(/[^a-zA-Z0-9\u4e00-\u9fff]/g, "");
|
||||
if (!raw) return "?";
|
||||
// Prefer 2-letter latin abbreviation from id
|
||||
if (/^[a-z]/i.test(id)) return id.slice(0, 2).toUpperCase();
|
||||
return raw.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function LetterFallback({
|
||||
label,
|
||||
size,
|
||||
className,
|
||||
title,
|
||||
}: {
|
||||
label: string;
|
||||
size: number;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-grid shrink-0 place-items-center rounded-[3px] text-[9px] font-bold ${className ?? ""}`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
background: "color-mix(in srgb, var(--ops-muted) 22%, transparent)",
|
||||
color: "var(--ops-muted)",
|
||||
}}
|
||||
title={title}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function DriverIcon({
|
||||
driver,
|
||||
size = 16,
|
||||
className = "",
|
||||
}: {
|
||||
driver: DriverId | string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const d = driverById(driver);
|
||||
const label = initials(d?.name ?? String(driver), String(driver));
|
||||
const src = resolveDriverIconUrl(String(driver), d?.icon);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (src && !failed) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
width={size}
|
||||
height={size}
|
||||
className={`shrink-0 object-contain ${className}`}
|
||||
draggable={false}
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <LetterFallback label={label} size={size} className={className} title={d?.name ?? String(driver)} />;
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { driverImportOffline, driverInstall, driverStatus, type DriverStatusItem } from "./api";
|
||||
import { driverById } from "./catalog";
|
||||
import { DriverIcon } from "./DriverIcon";
|
||||
import { OpsModal } from "./OpsModal";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
mongodb: "MongoDB",
|
||||
zookeeper: "ZooKeeper",
|
||||
dameng: "达梦 DM8",
|
||||
};
|
||||
|
||||
function fmtSize(n: number): string {
|
||||
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MB`;
|
||||
if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(0)} KB`;
|
||||
return `${n} B`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 驱动管理弹框:agent 驱动(MongoDB / ZooKeeper / 达梦)的运行时状态与维护入口。
|
||||
* 在线环境首次连接会自动下载;这里提供预装、离线包导入与商用 jar 指引。
|
||||
*/
|
||||
export function DriverManager({ onClose }: { onClose: () => void }) {
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const notifyError = useWorkspaceStore((s) => s.notifyError);
|
||||
const [items, setItems] = useState<DriverStatusItem[] | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const reload = async () => {
|
||||
try {
|
||||
setItems(await driverStatus());
|
||||
} catch (e) {
|
||||
notifyError(e, "toast");
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const install = async (id: string) => {
|
||||
setBusy(id);
|
||||
try {
|
||||
const r = await driverInstall(id);
|
||||
if (r.drivers) setItems(r.drivers);
|
||||
if (r.ok) {
|
||||
notify({ kind: "success", title: "安装完成", message: `${LABELS[id] ?? id} 驱动已就绪`, mode: "toast" });
|
||||
} else {
|
||||
notify({ kind: "error", title: "安装失败", message: r.error ?? "未知错误", mode: "modal" });
|
||||
}
|
||||
} catch (e) {
|
||||
notifyError(e, "modal");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const importZip = async (f: File) => {
|
||||
setBusy("import");
|
||||
try {
|
||||
const r = await driverImportOffline(f);
|
||||
if (r.drivers) setItems(r.drivers);
|
||||
if (r.ok) {
|
||||
notify({ kind: "success", title: "导入完成", message: `已导入 ${r.imported} 个文件`, mode: "toast" });
|
||||
} else {
|
||||
notify({ kind: "error", title: "导入失败", message: r.error ?? "包校验失败", mode: "modal" });
|
||||
}
|
||||
} catch (e) {
|
||||
notifyError(e, "modal");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const jdk = items?.find((x) => x.jdkRequired);
|
||||
|
||||
return (
|
||||
<OpsModal variant="info" title="驱动管理" onOk={onClose}>
|
||||
<div className="space-y-2" style={{ minWidth: 480 }}>
|
||||
{/* 环境状态行 */}
|
||||
<div
|
||||
className="flex items-center gap-3 rounded border px-2.5 py-1.5 text-[11.5px]"
|
||||
style={{ borderColor: "var(--ops-border)", background: "var(--ops-surface-2)" }}
|
||||
>
|
||||
<span style={{ color: "var(--ops-muted)" }}>JVM:</span>
|
||||
{jdk?.jdkOk ? (
|
||||
<span style={{ color: "var(--ops-accent)" }}>已检测到 Java {jdk.javaVersion}</span>
|
||||
) : (
|
||||
<span style={{ color: "var(--ops-danger)" }}>未找到 Java(agent 驱动需 JDK 17+)</span>
|
||||
)}
|
||||
<span style={{ color: "var(--ops-muted)" }}>·</span>
|
||||
<span style={{ color: "var(--ops-muted)" }}>agent:</span>
|
||||
<span style={{ color: items?.[0]?.agentInstalled ? "var(--ops-accent)" : "var(--ops-warning)" }}>
|
||||
{items?.[0]?.agentInstalled ? "已安装" : "缺失(连接时自动下载)"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 驱动行 */}
|
||||
{items === null && <div className="py-3 text-[12px]" style={{ color: "var(--ops-muted)" }}>加载中…</div>}
|
||||
{items?.map((d) => {
|
||||
const done = d.allInstalled;
|
||||
const badge = !d.jdkOk
|
||||
? { t: "缺 JDK", c: "var(--ops-danger)" }
|
||||
: done
|
||||
? { t: "已安装", c: "var(--ops-accent)" }
|
||||
: d.license === "proprietary"
|
||||
? { t: "需自备 jar", c: "var(--ops-warning)" }
|
||||
: { t: "未安装", c: "var(--ops-muted)" };
|
||||
const total = d.jars.reduce((s, j) => s + (j.size || 0), 0);
|
||||
const got = d.jars.filter((j) => j.installed).length;
|
||||
return (
|
||||
<div
|
||||
key={d.id}
|
||||
className="flex items-center gap-2.5 rounded border px-2.5 py-2"
|
||||
style={{ borderColor: "var(--ops-border-soft)" }}
|
||||
>
|
||||
<DriverIcon driver={d.id} size={20} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[12.5px] font-medium">{LABELS[d.id] ?? d.label}</span>
|
||||
<span
|
||||
className="rounded px-1 text-[10px]"
|
||||
style={{ color: badge.c, background: `color-mix(in srgb, ${badge.c} 12%, transparent)` }}
|
||||
>
|
||||
{badge.t}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10.5px]" style={{ color: "var(--ops-muted)" }}>
|
||||
{d.license === "proprietary"
|
||||
? "商用许可不代下载:把官方 DmJdbcDriver jar 放到 ~/.opsxdb/drivers/dameng/"
|
||||
: d.jars.length > 0
|
||||
? `驱动 jar ${got}/${d.jars.length}${total > 0 ? ` · ${fmtSize(total)}` : ""}${d.autoFetch ? " · 连接时自动下载" : ""}`
|
||||
: "—"}
|
||||
</div>
|
||||
</div>
|
||||
{d.autoFetch && !done && d.jdkOk && (
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-secondary h-6 px-2 text-[11px]"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void install(d.id)}
|
||||
>
|
||||
{busy === d.id ? "安装中…" : "安装"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 离线包导入 */}
|
||||
<div
|
||||
className="flex items-center gap-2 border-t pt-2 text-[11px]"
|
||||
style={{ borderColor: "var(--ops-border-soft)", color: "var(--ops-muted)" }}
|
||||
>
|
||||
<span>内网/离线:</span>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-secondary h-6 px-2 text-[11px]"
|
||||
disabled={busy !== null}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
{busy === "import" ? "导入中…" : "导入离线驱动包…"}
|
||||
</button>
|
||||
<span className="truncate">(opsxdb-driver 仓库 pack.sh 产物 .zip)</span>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void importZip(f);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</OpsModal>
|
||||
);
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
import type { ConfirmRequest } from "./store";
|
||||
import type { NotifyKind, NotifyPayload } from "./notify";
|
||||
import { OpsModal } from "./OpsModal";
|
||||
import { settleInputRequest, useInputRequest } from "./dialogs";
|
||||
|
||||
// C6:items 字段由 WS-C 在 store.ts 正式补充,此处本地类型扩展先行渲染
|
||||
type ConfirmRequestWithItems = ConfirmRequest & { items?: string[] };
|
||||
|
||||
/**
|
||||
* 分级语义色:统一走 --ops-* 令牌(修 V2,去除 slate 硬编码),亮暗主题随行
|
||||
* error→danger / warning→warning / info→accent / success→success
|
||||
*/
|
||||
const KIND_META: Record<NotifyKind, { color: string; label: string }> = {
|
||||
error: { color: "var(--ops-danger)", label: "错误" },
|
||||
warning: { color: "var(--ops-warning)", label: "提示" },
|
||||
info: { color: "var(--ops-accent)", label: "信息" },
|
||||
success: { color: "var(--ops-success)", label: "成功" },
|
||||
};
|
||||
|
||||
function KindIcon({ kind }: { kind: NotifyKind }) {
|
||||
const meta = KIND_META[kind];
|
||||
const glyph =
|
||||
kind === "error" ? "!" : kind === "warning" ? "!" : kind === "success" ? "✓" : "i";
|
||||
return (
|
||||
<div
|
||||
className="grid h-9 w-9 shrink-0 place-items-center rounded-full text-sm font-bold"
|
||||
style={{
|
||||
background: `color-mix(in srgb, ${meta.color} 15%, transparent)`,
|
||||
color: meta.color,
|
||||
}}
|
||||
>
|
||||
{glyph}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 通知模态:走 OpsModal info 变体(Esc/遮罩关闭、焦点管理由 OpsModal 负责) */
|
||||
function ModalCard({ n, onClose }: { n: NotifyPayload; onClose: () => void }) {
|
||||
const meta = KIND_META[n.kind];
|
||||
return (
|
||||
<OpsModal
|
||||
variant="info"
|
||||
title={
|
||||
<span className="flex items-center gap-2.5">
|
||||
<KindIcon kind={n.kind} />
|
||||
<span>
|
||||
<span
|
||||
className="block font-semibold uppercase"
|
||||
style={{ fontSize: "var(--ops-text-xs)", letterSpacing: "0.05em", color: meta.color }}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
<span className="block" style={{ fontSize: "var(--ops-text-lg)" }}>
|
||||
{n.title}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
onOk={onClose}
|
||||
>
|
||||
<p className="m-0">{n.message}</p>
|
||||
|
||||
{n.tips && n.tips.length > 0 && (
|
||||
<ul
|
||||
className="m-0 mt-2 space-y-1.5 rounded border px-3 py-2.5"
|
||||
style={{
|
||||
fontSize: "var(--ops-text-sm)",
|
||||
borderColor: "var(--ops-border-soft)",
|
||||
background: "var(--ops-surface-2)",
|
||||
color: "var(--ops-muted)",
|
||||
listStyle: "none",
|
||||
}}
|
||||
>
|
||||
{n.tips.map((t) => (
|
||||
<li key={t} className="flex gap-2">
|
||||
<span className="mt-0.5" style={{ color: "var(--ops-accent)" }}>
|
||||
•
|
||||
</span>
|
||||
<span>{t}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{n.detail && (
|
||||
<details
|
||||
className="group mt-2 rounded border"
|
||||
style={{ borderColor: "var(--ops-border-soft)", background: "var(--ops-editor-bg)" }}
|
||||
>
|
||||
<summary
|
||||
className="cursor-pointer select-none px-3 py-2 hover:underline"
|
||||
style={{ fontSize: "var(--ops-text-sm)", color: "var(--ops-muted)" }}
|
||||
>
|
||||
技术详情
|
||||
</summary>
|
||||
<pre
|
||||
className="max-h-40 overflow-auto border-t px-3 py-2 font-mono leading-5 whitespace-pre-wrap break-all"
|
||||
style={{
|
||||
fontSize: "var(--ops-text-xs)",
|
||||
borderColor: "var(--ops-border-soft)",
|
||||
color: "var(--ops-muted)",
|
||||
}}
|
||||
>
|
||||
{n.detail}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</OpsModal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Toast:保留分级图标 + 自动关闭,样式走令牌 */
|
||||
function ToastCard({ n, onClose }: { n: NotifyPayload; onClose: () => void }) {
|
||||
const meta = KIND_META[n.kind];
|
||||
useEffect(() => {
|
||||
const ms = n.durationMs ?? 3200;
|
||||
const t = window.setTimeout(onClose, ms);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [n.durationMs, onClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="ops-toast"
|
||||
style={{ borderColor: `color-mix(in srgb, ${meta.color} 45%, var(--ops-border))` }}
|
||||
role="status"
|
||||
>
|
||||
<KindIcon kind={n.kind} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold" style={{ fontSize: "var(--ops-text-md)", color: "var(--ops-text)" }}>
|
||||
{n.title}
|
||||
</div>
|
||||
<div className="mt-0.5 leading-relaxed" style={{ fontSize: "var(--ops-text-sm)", color: "var(--ops-muted)" }}>
|
||||
{n.message}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost"
|
||||
style={{ height: "auto", padding: "2px 6px" }}
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 危险/普通确认框:走 OpsModal confirm 变体;items 语句清单前 3 条 + 折叠其余(修 C2) */
|
||||
function ConfirmCard() {
|
||||
const confirm = useWorkspaceStore((s) => s.confirm) as ConfirmRequestWithItems | null;
|
||||
const resolveConfirm = useWorkspaceStore((s) => s.resolveConfirm);
|
||||
if (!confirm) return null;
|
||||
const items = confirm.items;
|
||||
return (
|
||||
<OpsModal
|
||||
variant="confirm"
|
||||
title={confirm.title}
|
||||
danger={confirm.danger}
|
||||
okLabel={confirm.okLabel || "确定"}
|
||||
zIndex="var(--ops-z-confirm)"
|
||||
onOk={() => resolveConfirm(true)}
|
||||
onCancel={() => resolveConfirm(false)}
|
||||
>
|
||||
<p className="m-0">{confirm.message}</p>
|
||||
{items && items.length > 0 && (
|
||||
<div className="ops-modal-items">
|
||||
{items.slice(0, 3).map((s, i) => (
|
||||
<div key={i}>{s}</div>
|
||||
))}
|
||||
{items.length > 3 && (
|
||||
<details className="mt-1">
|
||||
<summary className="cursor-pointer select-none" style={{ color: "var(--ops-accent)" }}>
|
||||
还有 {items.length - 3} 条,展开查看
|
||||
</summary>
|
||||
{items.slice(3).map((s, i) => (
|
||||
<div key={i + 3} className="mt-1">
|
||||
{s}
|
||||
</div>
|
||||
))}
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</OpsModal>
|
||||
);
|
||||
}
|
||||
|
||||
/** askInput(C6)渲染宿主:队列中的输入请求逐个以 OpsModal input 变体展示 */
|
||||
function DialogHost() {
|
||||
const req = useInputRequest();
|
||||
if (!req) return null;
|
||||
return (
|
||||
<OpsModal
|
||||
key={req.id}
|
||||
variant="input"
|
||||
title={req.title}
|
||||
label={req.label}
|
||||
defaultValue={req.defaultValue}
|
||||
placeholder={req.placeholder}
|
||||
onOk={(v) => settleInputRequest(v ?? null)}
|
||||
onCancel={() => settleInputRequest(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotifyHost() {
|
||||
const modal = useWorkspaceStore((s) => s.notifyModal);
|
||||
const toasts = useWorkspaceStore((s) => s.notifyToasts);
|
||||
const dismissNotify = useWorkspaceStore((s) => s.dismissNotify);
|
||||
|
||||
return (
|
||||
<>
|
||||
{modal && <ModalCard n={modal} onClose={() => dismissNotify(modal.id)} />}
|
||||
<ConfirmCard />
|
||||
<DialogHost />
|
||||
<div
|
||||
className="pointer-events-none fixed bottom-4 right-4 flex flex-col-reverse gap-2"
|
||||
style={{ zIndex: "var(--ops-z-toast)" }}
|
||||
>
|
||||
{toasts.map((n) => (
|
||||
<ToastCard key={n.id} n={n} onClose={() => dismissNotify(n.id)} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* OpsModal — 统一模态基件(设计规范 v2 §2.2)
|
||||
*
|
||||
* - variant: info(仅"确定")/ confirm(取消+确定)/ input(带输入框,返回字符串)
|
||||
* - danger: 主按钮渲染为红色(危险操作确认)
|
||||
* - 可访问性:焦点陷阱(Tab 在模态内循环)、关闭后焦点归还触发元素、
|
||||
* Esc 关闭、遮罩点击关闭
|
||||
* - 样式全部走 styles.css 的 --ops-* 令牌,亮暗主题随行
|
||||
*/
|
||||
export type OpsModalVariant = "info" | "confirm" | "input";
|
||||
|
||||
export type OpsModalProps = {
|
||||
title: ReactNode;
|
||||
variant?: OpsModalVariant;
|
||||
/** 主按钮红色(删除/截断等危险确认) */
|
||||
danger?: boolean;
|
||||
okLabel?: string;
|
||||
cancelLabel?: string;
|
||||
/** input 变体:输入框标签 */
|
||||
label?: string;
|
||||
/** input 变体:默认值 */
|
||||
defaultValue?: string;
|
||||
/** input 变体:占位符 */
|
||||
placeholder?: string;
|
||||
/** 层级,默认 var(--ops-z-modal);确认框传 var(--ops-z-confirm) */
|
||||
zIndex?: string | number;
|
||||
/** 宽版(max-w-2xl),DDL 等长文本展示用 */
|
||||
wide?: boolean;
|
||||
/** 确认回调;input 变体携带输入值 */
|
||||
onOk: (value?: string) => void;
|
||||
/** 取消回调(Esc / 遮罩点击 / 取消按钮),info 变体缺省时回落到 onOk */
|
||||
onCancel?: () => void;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const FOCUSABLE =
|
||||
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
export function OpsModal(props: OpsModalProps) {
|
||||
const {
|
||||
title,
|
||||
variant = "info",
|
||||
danger = false,
|
||||
okLabel = "确定",
|
||||
cancelLabel = "取消",
|
||||
label,
|
||||
defaultValue = "",
|
||||
placeholder,
|
||||
zIndex = "var(--ops-z-modal)",
|
||||
wide = false,
|
||||
onOk,
|
||||
onCancel,
|
||||
children,
|
||||
} = props;
|
||||
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
// 回调放 ref,避免键盘监听因回调变化反复挂载
|
||||
const cbRef = useRef({ onOk, onCancel });
|
||||
cbRef.current = { onOk, onCancel };
|
||||
|
||||
const cancel = () => (cbRef.current.onCancel ?? cbRef.current.onOk)();
|
||||
const submit = () => cbRef.current.onOk(variant === "input" ? value : undefined);
|
||||
|
||||
// 焦点管理:打开时聚焦输入框/主按钮,Tab 在模态内循环,Esc 关闭,卸载时焦点归还触发元素
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current;
|
||||
if (!dialog) return;
|
||||
const prev = document.activeElement as HTMLElement | null;
|
||||
const focusables = () =>
|
||||
Array.from(dialog.querySelectorAll<HTMLElement>(FOCUSABLE)).filter(
|
||||
(el) => el.offsetParent !== null || el === document.activeElement,
|
||||
);
|
||||
const initial =
|
||||
variant === "input" && inputRef.current ? inputRef.current : focusables().pop();
|
||||
initial?.focus();
|
||||
if (variant === "input") inputRef.current?.select();
|
||||
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
// 模态可叠加(如确认框盖在通知模态上):仅最上层响应键盘
|
||||
const overlays = document.querySelectorAll(".ops-modal-overlay");
|
||||
if (overlays.length && overlays[overlays.length - 1] !== overlayRef.current) return;
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
cancel();
|
||||
return;
|
||||
}
|
||||
if (e.key !== "Tab") return;
|
||||
const items = focusables();
|
||||
if (!items.length) return;
|
||||
const first = items[0];
|
||||
const last = items[items.length - 1];
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
if (e.shiftKey) {
|
||||
if (!active || !dialog.contains(active) || active === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
}
|
||||
} else if (!active || !dialog.contains(active) || active === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey, true);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey, true);
|
||||
// 焦点归还触发元素
|
||||
prev?.focus?.();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
className="ops-modal-overlay"
|
||||
style={{ zIndex }}
|
||||
onMouseDown={(e) => {
|
||||
// 仅点击遮罩本体(非对话框内容)才关闭
|
||||
if (e.target === overlayRef.current) cancel();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={`ops-modal w-full ${wide ? "max-w-2xl" : "max-w-md"}`}
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-label={typeof title === "string" ? title : undefined}
|
||||
>
|
||||
<div className="ops-modal-header">{title}</div>
|
||||
<div className="ops-modal-body">
|
||||
{children}
|
||||
{variant === "input" && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
{label && (
|
||||
<label
|
||||
htmlFor="ops-modal-input"
|
||||
className="mb-1 block"
|
||||
style={{ fontSize: "var(--ops-text-sm)", color: "var(--ops-muted)" }}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
id="ops-modal-input"
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="ops-input w-full"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<div className="ops-modal-footer">
|
||||
{variant !== "info" && (
|
||||
<button type="button" className="ops-btn-secondary min-w-[72px]" onClick={cancel}>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`${danger ? "ops-btn-danger" : "ops-btn-primary"} min-w-[72px]`}
|
||||
onClick={submit}
|
||||
>
|
||||
{okLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,879 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { execSql, getRedisKey } from "./api";
|
||||
import { ensureSession, withSessionRetry } from "./ObjectTree";
|
||||
import { OpsModal } from "./OpsModal";
|
||||
import {
|
||||
displayRedisValue,
|
||||
formatRedisTtl,
|
||||
redisCmd,
|
||||
redisTypeColor,
|
||||
tryPrettyJson,
|
||||
tsLabel,
|
||||
} from "./redis";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
|
||||
type ValueMode = "table" | "json";
|
||||
/** 表格首屏渲染行数,超出走「加载更多」 */
|
||||
const INITIAL_VISIBLE = 100;
|
||||
const PAGE_STEP = 200;
|
||||
|
||||
/** 单元格单行摘要:JSON 压回单行、空白折叠、超长截断 —— 表格保持紧凑,完整 pretty 留给查看弹层 */
|
||||
function cellSummary(raw: string, pretty: string | null): { text: string; more: boolean } {
|
||||
const s = pretty ? JSON.stringify(JSON.parse(pretty)) : raw;
|
||||
const one = s.replace(/\s+/g, " ").trim();
|
||||
if (one.length > 160) return { text: `${one.slice(0, 160)}…`, more: true };
|
||||
return { text: one, more: false };
|
||||
}
|
||||
type LineEdit = {
|
||||
field: string;
|
||||
value: string;
|
||||
score: string;
|
||||
isNew: boolean;
|
||||
oldField?: string;
|
||||
index?: number;
|
||||
};
|
||||
|
||||
async function copyText(text: string) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
function cellString(v: unknown): string {
|
||||
if (v == null) return "";
|
||||
return String(v);
|
||||
}
|
||||
|
||||
export function RedisKeyPane() {
|
||||
const result = useWorkspaceStore((s) => s.result);
|
||||
const resultMeta = useWorkspaceStore((s) => s.resultMeta);
|
||||
const connections = useWorkspaceStore((s) => s.connections);
|
||||
const activeConnectionId = useWorkspaceStore((s) => s.activeConnectionId);
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const notifyError = useWorkspaceStore((s) => s.notifyError);
|
||||
const askConfirm = useWorkspaceStore((s) => s.askConfirm);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const conn = connections.find((c) => c.id === activeConnectionId);
|
||||
|
||||
const keyName = resultMeta?.table ?? "";
|
||||
const dbName = resultMeta?.database ?? conn?.database ?? "0";
|
||||
const type = resultMeta?.redisType || "string";
|
||||
const [ttlDraft, setTtlDraft] = useState(String(resultMeta?.redisTtl ?? -1));
|
||||
const [keyDraft, setKeyDraft] = useState(keyName);
|
||||
const [mode, setMode] = useState<ValueMode>(type === "string" ? "json" : "table");
|
||||
const [filter, setFilter] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [lineEdit, setLineEdit] = useState<LineEdit | null>(null);
|
||||
/** 行内 JSON 放大查看(pretty 全文) */
|
||||
const [cellViewer, setCellViewer] = useState<string | null>(null);
|
||||
/** string 的 JSON 视图可编辑草稿(pretty);保存时校验并压缩回写 */
|
||||
const [prettyDraft, setPrettyDraft] = useState<string | null>(null);
|
||||
/** 列排序:null=默认序;dir 1 升 / -1 降 */
|
||||
const [sort, setSort] = useState<{ col: string; dir: 1 | -1 } | null>(null);
|
||||
/** 双击单元格就地编辑:i=行号(filtered 内)、col=列 */
|
||||
const [inline, setInline] = useState<{ i: number; col: string; draft: string } | null>(null);
|
||||
/** 表格分批渲染:首屏条数与当前已显示条数(宽/大键不全量铺 DOM) */
|
||||
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE);
|
||||
const [stringDraft, setStringDraft] = useState(() => cellString(result?.rows?.[0]?.value));
|
||||
|
||||
useEffect(() => {
|
||||
setKeyDraft(keyName);
|
||||
setTtlDraft(String(resultMeta?.redisTtl ?? -1));
|
||||
setStringDraft(cellString(result?.rows?.[0]?.value));
|
||||
setFilter("");
|
||||
setMode(type === "string" ? "json" : "table");
|
||||
setVisibleCount(INITIAL_VISIBLE);
|
||||
setSort(null);
|
||||
setInline(null);
|
||||
}, [keyName, type, resultMeta?.redisTtl, result]);
|
||||
|
||||
const rows = result?.rows ?? [];
|
||||
const filtered = useMemo(() => {
|
||||
const q = filter.trim().toLowerCase();
|
||||
if (!q) return rows;
|
||||
return rows.filter((r) => Object.values(r).some((v) => String(v ?? "").toLowerCase().includes(q)));
|
||||
}, [rows, filter]);
|
||||
const sorted = useMemo(() => {
|
||||
if (!sort) return filtered;
|
||||
const { col, dir } = sort;
|
||||
return [...filtered].sort((a, b) => {
|
||||
const av = a[col];
|
||||
const bv = b[col];
|
||||
if (col === "score") return ((Number(av) || 0) - (Number(bv) || 0)) * dir;
|
||||
return String(av ?? "").localeCompare(String(bv ?? ""), undefined, { numeric: true }) * dir;
|
||||
});
|
||||
}, [filtered, sort]);
|
||||
|
||||
const jsonText = useMemo(() => {
|
||||
if (type === "string") {
|
||||
const raw = cellString(rows[0]?.value);
|
||||
return tryPrettyJson(raw) || raw;
|
||||
}
|
||||
if (type === "hash") {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const r of rows) {
|
||||
const f = cellString(r.field);
|
||||
const v = r.value;
|
||||
const parsed = tryPrettyJson(v);
|
||||
obj[f] = parsed ? JSON.parse(parsed) : v;
|
||||
}
|
||||
return JSON.stringify(obj, null, 2);
|
||||
}
|
||||
return JSON.stringify(rows, null, 2);
|
||||
}, [rows, type]);
|
||||
|
||||
// string 的 JSON 草稿随数据刷新同步(保存/重载后回到干净态)
|
||||
useEffect(() => {
|
||||
if (type === "string") setPrettyDraft(jsonText);
|
||||
}, [jsonText, type]);
|
||||
|
||||
const reload = async () => {
|
||||
if (!conn || !keyName) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await withSessionRetry(conn, (sid) => getRedisKey(conn, dbName, keyName, { sessionId: sid, limit: 500 }));
|
||||
const qr = {
|
||||
columns: res.columns ?? [],
|
||||
rows: res.rows ?? [],
|
||||
rowCount: res.rowCount ?? 0,
|
||||
timeMs: res.timeMs ?? 0,
|
||||
error: res.error,
|
||||
truncated: res.truncated,
|
||||
limit: res.limit,
|
||||
};
|
||||
set("result", qr);
|
||||
set("resultMeta", {
|
||||
database: dbName,
|
||||
table: keyName,
|
||||
pkCols: [],
|
||||
redisType: res.type,
|
||||
redisTtl: res.ttl,
|
||||
});
|
||||
setTtlDraft(String(res.ttl ?? -1));
|
||||
setKeyDraft(keyName);
|
||||
if (res.type === "string") setStringDraft(cellString(res.rows?.[0]?.value));
|
||||
set("status", `${keyName} · ${res.type} · TTL ${formatRedisTtl(res.ttl)}`);
|
||||
} catch (e) {
|
||||
notifyError(e, "toast");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runCmd = async (sql: string, okMsg?: string, opts?: { reload?: boolean }) => {
|
||||
if (!conn) return false;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await withSessionRetry(conn, (sid) => execSql(conn, sql, { sessionId: sid, database: dbName }));
|
||||
if (res.error) throw new Error(res.error);
|
||||
if (okMsg) notify({ kind: "success", title: okMsg, message: keyName, mode: "toast", durationMs: 1400 });
|
||||
if (opts?.reload !== false) await reload();
|
||||
return true;
|
||||
} catch (e) {
|
||||
notifyError(e, "modal");
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveTtl = async () => {
|
||||
const n = Number(ttlDraft);
|
||||
if (!Number.isFinite(n)) {
|
||||
notify({ kind: "warning", title: "TTL", message: "请输入秒数,-1 表示永久", mode: "toast" });
|
||||
return;
|
||||
}
|
||||
if (n < 0) await runCmd(redisCmd("PERSIST", keyName), "已设为永久");
|
||||
else await runCmd(redisCmd("EXPIRE", keyName, String(Math.floor(n))), "已更新 TTL");
|
||||
};
|
||||
|
||||
const rename = async () => {
|
||||
const next = keyDraft.trim();
|
||||
if (!next || next === keyName) return;
|
||||
const ok = await runCmd(redisCmd("RENAME", keyName, next), "已重命名");
|
||||
if (ok) {
|
||||
set("resultMeta", {
|
||||
database: dbName,
|
||||
table: next,
|
||||
pkCols: [],
|
||||
redisType: type,
|
||||
redisTtl: resultMeta?.redisTtl,
|
||||
});
|
||||
setKeyDraft(next);
|
||||
}
|
||||
};
|
||||
|
||||
/** 服务端游标续载:追加行而不是整键替换(hash/set=SCAN 游标;list/zset=偏移) */
|
||||
const loadMore = async () => {
|
||||
if (!conn || !keyName) return;
|
||||
const cur = result?.nextCursor ?? 0;
|
||||
if (!cur) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await withSessionRetry(conn, (sid) => getRedisKey(conn, dbName, keyName, { sessionId: sid, limit: result?.limit || 500, cursor: cur }));
|
||||
if (res.error) throw new Error(res.error);
|
||||
const merged = [...(result?.rows ?? []), ...(res.rows ?? [])];
|
||||
set("result", {
|
||||
columns: result?.columns ?? res.columns ?? [],
|
||||
rows: merged,
|
||||
rowCount: merged.length,
|
||||
timeMs: result?.timeMs ?? res.timeMs ?? 0,
|
||||
truncated: res.truncated,
|
||||
nextCursor: res.nextCursor,
|
||||
limit: result?.limit ?? res.limit,
|
||||
});
|
||||
setVisibleCount((v) => v + PAGE_STEP);
|
||||
} catch (e) {
|
||||
notifyError(e, "toast");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 双击单元格就地编辑提交:按类型/列生成与「编辑」弹窗一致的命令 */
|
||||
const commitInline = async (row: Record<string, unknown>, col: string, draft: string) => {
|
||||
let sql = "";
|
||||
if (type === "hash") {
|
||||
if (col === "field") {
|
||||
const old = cellString(row.field);
|
||||
if (!draft.trim()) {
|
||||
notify({ kind: "warning", title: "Hash", message: "Field 必填", mode: "toast" });
|
||||
return;
|
||||
}
|
||||
if (draft !== old) sql = `${redisCmd("HDEL", keyName, old)}\n${redisCmd("HSET", keyName, draft, cellString(row.value))}`;
|
||||
else return;
|
||||
} else {
|
||||
if (draft === cellString(row.value)) return;
|
||||
sql = redisCmd("HSET", keyName, cellString(row.field), draft);
|
||||
}
|
||||
} else if (type === "zset") {
|
||||
if (col === "member") {
|
||||
const old = cellString(row.member);
|
||||
if (draft === old) return;
|
||||
sql = `${redisCmd("ZREM", keyName, old)}\n${redisCmd("ZADD", keyName, cellString(row.score), draft)}`;
|
||||
} else {
|
||||
if (String(draft) === cellString(row.score)) return;
|
||||
if (Number.isNaN(Number(draft))) {
|
||||
notify({ kind: "warning", title: "Score", message: "必须是数字", mode: "toast" });
|
||||
return;
|
||||
}
|
||||
sql = redisCmd("ZADD", keyName, String(draft), cellString(row.member));
|
||||
}
|
||||
} else if (type === "set") {
|
||||
const old = cellString(row.member ?? row.value);
|
||||
if (draft === old) return;
|
||||
sql = `${redisCmd("SREM", keyName, old)}\n${redisCmd("SADD", keyName, draft)}`;
|
||||
} else if (type === "list") {
|
||||
if (draft === cellString(row.value)) return;
|
||||
sql = redisCmd("LSET", keyName, cellString(row.index ?? 0), draft);
|
||||
}
|
||||
if (!sql) return;
|
||||
setInline(null);
|
||||
for (const p of sql.split("\n")) {
|
||||
const ok = await runCmd(p, "已更新");
|
||||
if (!ok) break;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSort = (col: string) => {
|
||||
setSort((s0) => (!s0 || s0.col !== col ? { col, dir: 1 } : s0.dir === 1 ? { col, dir: -1 } : null));
|
||||
};
|
||||
|
||||
const sortTh = (col: string, label: string) => (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-0.5 hover:underline"
|
||||
onClick={() => toggleSort(col)}
|
||||
title="点击排序:升 → 降 → 默认"
|
||||
>
|
||||
{label}
|
||||
{sort?.col === col ? <span className="text-[9px]">{sort.dir === 1 ? "▲" : "▼"}</span> : null}
|
||||
</button>
|
||||
);
|
||||
|
||||
/** 就地编辑输入框:Enter 提交 / Esc 取消 */
|
||||
const InlineInput = ({ row, i, col, value }: { row: Record<string, unknown>; i: number; col: string; value: string }) => (
|
||||
<input
|
||||
autoFocus
|
||||
className="ops-input h-6 w-full px-1 font-mono text-[11px]"
|
||||
value={inline?.i === i && inline.col === col ? inline.draft : value}
|
||||
onChange={(e) => setInline({ i, col, draft: e.target.value })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
void commitInline(row, col, inline?.draft ?? value);
|
||||
} else if (e.key === "Escape") setInline(null);
|
||||
}}
|
||||
onBlur={() => setInline(null)}
|
||||
/>
|
||||
);
|
||||
|
||||
const saveString = async () => {
|
||||
let val = stringDraft;
|
||||
if (type === "string" && mode === "json") {
|
||||
// JSON 视图直接编辑:保存前校验并压回单行,避免把 pretty 缩进写进 Redis
|
||||
const draft = prettyDraft ?? jsonText;
|
||||
try {
|
||||
val = JSON.stringify(JSON.parse(draft));
|
||||
} catch (e) {
|
||||
notify({
|
||||
kind: "error",
|
||||
title: "JSON 解析失败",
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
mode: "toast",
|
||||
durationMs: 2600,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
await runCmd(redisCmd("SET", keyName, val), "已保存");
|
||||
};
|
||||
|
||||
const deleteKey = async () => {
|
||||
const ok = await askConfirm({
|
||||
title: "删除键",
|
||||
message: `确认删除 Redis 键「${keyName}」?此操作不可恢复。`,
|
||||
okLabel: "删除",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
const done = await runCmd(redisCmd("DEL", keyName), "已删除", { reload: false });
|
||||
if (done) {
|
||||
set("result", null);
|
||||
set("resultMeta", { database: dbName, pkCols: [] });
|
||||
}
|
||||
};
|
||||
|
||||
const openAdd = () => {
|
||||
setLineEdit({
|
||||
field: type === "hash" ? "" : type === "zset" ? "" : "",
|
||||
value: "",
|
||||
score: "0",
|
||||
isNew: true,
|
||||
});
|
||||
};
|
||||
|
||||
const openEditRow = (row: Record<string, unknown>, index: number) => {
|
||||
if (type === "hash") {
|
||||
setLineEdit({
|
||||
field: cellString(row.field),
|
||||
value: cellString(row.value),
|
||||
score: "0",
|
||||
isNew: false,
|
||||
oldField: cellString(row.field),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (type === "list") {
|
||||
setLineEdit({ field: "", value: cellString(row.value), score: "0", isNew: false, index });
|
||||
return;
|
||||
}
|
||||
if (type === "set") {
|
||||
setLineEdit({
|
||||
field: "",
|
||||
value: cellString(row.member ?? row.value),
|
||||
score: "0",
|
||||
isNew: false,
|
||||
oldField: cellString(row.member ?? row.value),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (type === "zset") {
|
||||
setLineEdit({
|
||||
field: cellString(row.member),
|
||||
value: cellString(row.member),
|
||||
score: cellString(row.score),
|
||||
isNew: false,
|
||||
oldField: cellString(row.member),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const commitLine = async () => {
|
||||
if (!lineEdit) return;
|
||||
let sql = "";
|
||||
if (type === "hash") {
|
||||
if (!lineEdit.field.trim()) {
|
||||
notify({ kind: "warning", title: "Hash", message: "Field 必填", mode: "toast" });
|
||||
return;
|
||||
}
|
||||
if (!lineEdit.isNew && lineEdit.oldField && lineEdit.oldField !== lineEdit.field) {
|
||||
sql = `${redisCmd("HDEL", keyName, lineEdit.oldField)}\n${redisCmd("HSET", keyName, lineEdit.field, lineEdit.value)}`;
|
||||
} else {
|
||||
sql = redisCmd("HSET", keyName, lineEdit.field, lineEdit.value);
|
||||
}
|
||||
} else if (type === "list") {
|
||||
if (lineEdit.isNew) sql = redisCmd("RPUSH", keyName, lineEdit.value);
|
||||
else sql = redisCmd("LSET", keyName, String(lineEdit.index ?? 0), lineEdit.value);
|
||||
} else if (type === "set") {
|
||||
if (lineEdit.isNew) sql = redisCmd("SADD", keyName, lineEdit.value);
|
||||
else if (lineEdit.oldField && lineEdit.oldField !== lineEdit.value) {
|
||||
sql = `${redisCmd("SREM", keyName, lineEdit.oldField)}\n${redisCmd("SADD", keyName, lineEdit.value)}`;
|
||||
} else sql = redisCmd("SADD", keyName, lineEdit.value);
|
||||
} else if (type === "zset") {
|
||||
const member = lineEdit.value || lineEdit.field;
|
||||
if (lineEdit.isNew) sql = redisCmd("ZADD", keyName, lineEdit.score || "0", member);
|
||||
else if (lineEdit.oldField && lineEdit.oldField !== member) {
|
||||
sql = `${redisCmd("ZREM", keyName, lineEdit.oldField)}\n${redisCmd("ZADD", keyName, lineEdit.score || "0", member)}`;
|
||||
} else sql = redisCmd("ZADD", keyName, lineEdit.score || "0", member);
|
||||
}
|
||||
if (!sql) return;
|
||||
const parts = sql.split("\n");
|
||||
setLineEdit(null);
|
||||
for (const p of parts) {
|
||||
const ok = await runCmd(p, lineEdit.isNew ? "已新增" : "已更新");
|
||||
if (!ok) break;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteLine = async (row: Record<string, unknown>, index: number) => {
|
||||
const ok = await askConfirm({
|
||||
title: "删除元素",
|
||||
message: "确认删除这一行?",
|
||||
okLabel: "删除",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
if (type === "hash") await runCmd(redisCmd("HDEL", keyName, cellString(row.field)), "已删除字段");
|
||||
else if (type === "set") await runCmd(redisCmd("SREM", keyName, cellString(row.member ?? row.value)), "已删除成员");
|
||||
else if (type === "zset") await runCmd(redisCmd("ZREM", keyName, cellString(row.member)), "已删除成员");
|
||||
else if (type === "list") {
|
||||
const val = cellString(row.value);
|
||||
await runCmd(redisCmd("LREM", keyName, "1", val), "已删除元素");
|
||||
void index;
|
||||
}
|
||||
};
|
||||
|
||||
/** 单行重建命令:复制后可直接在别处回放(zset→ZADD score member 等) */
|
||||
const rowCommand = (row: Record<string, unknown>): string => {
|
||||
switch (type) {
|
||||
case "hash":
|
||||
return redisCmd("HSET", keyName, cellString(row.field), cellString(row.value));
|
||||
case "zset":
|
||||
return redisCmd("ZADD", keyName, cellString(row.score), cellString(row.member));
|
||||
case "set":
|
||||
return redisCmd("SADD", keyName, cellString(row.member ?? row.value));
|
||||
case "list":
|
||||
return redisCmd("RPUSH", keyName, cellString(row.value));
|
||||
default:
|
||||
return cellString(row.value ?? row.member ?? "");
|
||||
}
|
||||
};
|
||||
|
||||
const dumpClipboard = async () => {
|
||||
const lines: string[] = [];
|
||||
if (type === "string") lines.push(redisCmd("SET", keyName, cellString(rows[0]?.value)));
|
||||
else if (type === "hash") {
|
||||
for (const r of rows) lines.push(redisCmd("HSET", keyName, cellString(r.field), cellString(r.value)));
|
||||
} else if (type === "list") {
|
||||
for (const r of rows) lines.push(redisCmd("RPUSH", keyName, cellString(r.value)));
|
||||
} else if (type === "set") {
|
||||
for (const r of rows) lines.push(redisCmd("SADD", keyName, cellString(r.member ?? r.value)));
|
||||
} else if (type === "zset") {
|
||||
for (const r of rows) lines.push(redisCmd("ZADD", keyName, cellString(r.score), cellString(r.member)));
|
||||
}
|
||||
const ttl = resultMeta?.redisTtl ?? -1;
|
||||
if (ttl >= 0) lines.push(redisCmd("EXPIRE", keyName, String(ttl)));
|
||||
await copyText(lines.join("\n") || redisCmd("GET", keyName));
|
||||
notify({ kind: "success", title: "已复制命令", message: `${lines.length} 条`, mode: "toast", durationMs: 1400 });
|
||||
};
|
||||
|
||||
const addLabel =
|
||||
type === "hash" ? "新增字段" : type === "list" ? "追加元素" : type === "zset" ? "新增成员" : type === "set" ? "新增成员" : null;
|
||||
|
||||
const colTitle =
|
||||
type === "hash" ? `ID(共 ${filtered.length})` : type === "list" ? "Index" : type === "zset" ? "#" : "#";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b px-2 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<span
|
||||
className="rounded px-2 py-0.5 text-[11px] font-semibold uppercase"
|
||||
style={{ background: `${redisTypeColor(type)}22`, color: redisTypeColor(type) }}
|
||||
>
|
||||
{type}
|
||||
</span>
|
||||
<input
|
||||
className="ops-input h-7 min-w-0 flex-1 font-mono text-[12px]"
|
||||
value={keyDraft}
|
||||
title="回车重命名"
|
||||
onChange={(e) => setKeyDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void rename();
|
||||
}}
|
||||
/>
|
||||
<div className="flex h-7 overflow-hidden rounded border" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<span className="flex items-center px-2 text-[10px]" style={{ background: "var(--ops-surface-2)", color: "var(--ops-muted)" }}>
|
||||
TTL
|
||||
</span>
|
||||
<input
|
||||
className="w-20 bg-transparent px-2 text-[12px] outline-none"
|
||||
value={ttlDraft}
|
||||
title="秒;-1 永久。回车保存"
|
||||
onChange={(e) => setTtlDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void saveTtl();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button type="button" className="ops-btn-secondary h-7 px-2 text-[11px]" disabled={busy} onClick={() => void saveTtl()}>
|
||||
保存 TTL
|
||||
</button>
|
||||
<button type="button" className="ops-btn-ghost h-7 px-2 text-[11px]" disabled={busy} title="刷新" onClick={() => void reload()}>
|
||||
刷新
|
||||
</button>
|
||||
<button type="button" className="ops-btn-ghost h-7 px-2 text-[11px]" title="复制还原命令" onClick={() => void dumpClipboard()}>
|
||||
导出命令
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost h-7 px-2 text-[11px]"
|
||||
style={{ color: "var(--ops-danger)" }}
|
||||
disabled={busy}
|
||||
onClick={() => void deleteKey()}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-b px-2 py-1" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<div className="flex items-center gap-2">
|
||||
{addLabel && (
|
||||
<button type="button" className="ops-btn-primary h-7 px-2 text-[11px]" disabled={busy} onClick={openAdd}>
|
||||
{addLabel}
|
||||
</button>
|
||||
)}
|
||||
{type === "string" && (
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-primary h-7 px-2 text-[11px]"
|
||||
disabled={
|
||||
busy ||
|
||||
(mode === "json"
|
||||
? (prettyDraft ?? jsonText) === jsonText
|
||||
: stringDraft === cellString(rows[0]?.value))
|
||||
}
|
||||
onClick={() => void saveString()}
|
||||
>
|
||||
保存值
|
||||
</button>
|
||||
)}
|
||||
<div className="ops-seg">
|
||||
<button type="button" className={mode === "table" ? "ops-seg-active" : undefined} onClick={() => setMode("table")}>
|
||||
{type === "string" ? "原文" : "结构"}
|
||||
</button>
|
||||
<button type="button" className={mode === "json" ? "ops-seg-active" : undefined} onClick={() => setMode("json")}>
|
||||
JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{type !== "string" && (
|
||||
<input
|
||||
className="ops-input h-7 w-48 text-[11px]"
|
||||
placeholder="搜索字段或值"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result?.truncated && (
|
||||
<div
|
||||
className="shrink-0 px-2 py-1 text-[11px]"
|
||||
style={{ color: "var(--ops-warning)", background: "color-mix(in srgb, var(--ops-warning) 10%, transparent)" }}
|
||||
>
|
||||
{(result?.nextCursor ?? 0) > 0
|
||||
? `已分页加载前 ${rows.length} 行,底部可继续加载。`
|
||||
: `已截断,最多 ${result.limit || 500} 行。用过滤或命令缩小范围。`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{type === "string" || mode === "json" ? (
|
||||
type === "string" && mode === "table" ? (
|
||||
<textarea
|
||||
className="ops-input min-h-full w-full resize-none rounded-none border-0 p-3 font-mono text-[12px] leading-relaxed"
|
||||
value={stringDraft}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setStringDraft(e.target.value)}
|
||||
/>
|
||||
) : type === "string" ? (
|
||||
<textarea
|
||||
className="ops-input min-h-full w-full resize-none rounded-none border-0 p-3 font-mono text-[12px] leading-relaxed"
|
||||
value={mode === "json" ? prettyDraft ?? jsonText : stringDraft}
|
||||
spellCheck={false}
|
||||
onChange={(e) => {
|
||||
if (mode === "json") setPrettyDraft(e.target.value);
|
||||
else setStringDraft(e.target.value);
|
||||
}}
|
||||
placeholder="JSON 视图可直接编辑;保存时自动校验并压缩回写"
|
||||
/>
|
||||
) : (
|
||||
<pre className="m-0 whitespace-pre-wrap break-all p-3 font-mono text-[12px] leading-relaxed">{jsonText}</pre>
|
||||
)
|
||||
) : (
|
||||
<table className="ops-grid" style={{ tableLayout: "fixed", width: "100%" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 72 }}>{colTitle}</th>
|
||||
{type === "hash" && <th style={{ width: "28%" }}>{sortTh("field", "Key")}</th>}
|
||||
{type === "zset" && <th style={{ width: "56%" }}>{sortTh("member", "Member")}</th>}
|
||||
{type === "zset" && <th style={{ width: 120 }}>{sortTh("score", "Score")}</th>}
|
||||
{type !== "zset" && <th>{type === "list" ? "Value" : sortTh("value", "Value")}</th>}
|
||||
<th style={{ width: 108 }} />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.slice(0, visibleCount).map((row, i) => {
|
||||
const valueRaw = type === "hash" ? row.value : type === "zset" ? row.member : row.member ?? row.value;
|
||||
const shown = displayRedisValue(valueRaw);
|
||||
const valuePretty = tryPrettyJson(cellString(valueRaw));
|
||||
const summary = cellSummary(cellString(valueRaw), valuePretty);
|
||||
const scoreTs = type === "zset" ? tsLabel(Number(row.score)) : null;
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td className="ops-rownum">{type === "list" ? cellString(row.index ?? i) : i + 1}</td>
|
||||
{type === "hash" && (
|
||||
<td className="font-mono" title={cellString(row.field)}>
|
||||
{inline?.i === i && inline.col === "field" ? (
|
||||
<InlineInput row={row} i={i} col="field" value={cellString(row.field)} />
|
||||
) : (
|
||||
<span
|
||||
className="cursor-text"
|
||||
title="双击重命名"
|
||||
onDoubleClick={() => setInline({ i, col: "field", draft: cellString(row.field) })}
|
||||
>
|
||||
{cellString(row.field)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{type === "zset" && (
|
||||
<td className="font-mono">
|
||||
{valuePretty ? (
|
||||
<button
|
||||
type="button"
|
||||
className="m-0 flex w-full items-baseline gap-1 p-0 text-left"
|
||||
style={{ cursor: "zoom-in" }}
|
||||
title="点击查看完整 JSON"
|
||||
onClick={() => setCellViewer(valuePretty)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] leading-snug">{summary.text}</span>
|
||||
{summary.more && (
|
||||
<span className="shrink-0 text-[10px]" style={{ color: "var(--ops-accent)" }}>
|
||||
更多
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
) : inline?.i === i && inline.col === "member" ? (
|
||||
<InlineInput row={row} i={i} col="member" value={cellString(row.member)} />
|
||||
) : (
|
||||
<span
|
||||
className="cursor-text"
|
||||
title="双击编辑"
|
||||
onDoubleClick={() => setInline({ i, col: "member", draft: cellString(row.member) })}
|
||||
>
|
||||
{cellString(row.member)}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{type === "zset" && (
|
||||
<td className="font-mono" title={scoreTs ?? "双击改分"}>
|
||||
{inline?.i === i && inline.col === "score" ? (
|
||||
<InlineInput row={row} i={i} col="score" value={cellString(row.score)} />
|
||||
) : (
|
||||
<div
|
||||
className="cursor-text"
|
||||
onDoubleClick={() => setInline({ i, col: "score", draft: cellString(row.score) })}
|
||||
>
|
||||
{cellString(row.score)}
|
||||
{scoreTs && <div className="text-[10px] leading-3 opacity-60">{scoreTs}</div>}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
{type !== "zset" && (
|
||||
<td title={valuePretty ? "点击查看完整 JSON" : shown.pretty ?? cellString(valueRaw)}>
|
||||
{valuePretty || cellString(valueRaw).length > 180 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="m-0 flex w-full items-baseline gap-1 p-0 text-left"
|
||||
style={{ cursor: "zoom-in" }}
|
||||
onClick={() => setCellViewer(valuePretty ?? cellString(valueRaw))}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-[11px] leading-snug">{summary.text}</span>
|
||||
{summary.more && (
|
||||
<span className="shrink-0 text-[10px]" style={{ color: "var(--ops-accent)" }}>
|
||||
更多
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
) : inline?.i === i && inline.col === "value" ? (
|
||||
<InlineInput row={row} i={i} col="value" value={cellString(valueRaw)} />
|
||||
) : (
|
||||
<pre
|
||||
className="m-0 max-h-24 cursor-text overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-snug"
|
||||
title="双击编辑"
|
||||
onDoubleClick={() => setInline({ i, col: "value", draft: cellString(valueRaw) })}
|
||||
>
|
||||
{shown.text}
|
||||
</pre>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[10px]"
|
||||
title="复制值(纯文本)"
|
||||
onClick={() => {
|
||||
const val = cellString(valueRaw);
|
||||
void copyText(val).then(() =>
|
||||
notify({ kind: "success", title: "已复制值", message: "", mode: "toast", durationMs: 1200 }),
|
||||
);
|
||||
}}
|
||||
>
|
||||
⧉
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[10px]"
|
||||
title={`复制重建命令(${type === "zset" ? "ZADD key score member" : type === "hash" ? "HSET key field value" : type === "set" ? "SADD key member" : "RPUSH key value"})`}
|
||||
onClick={() => {
|
||||
const cmd = rowCommand(row);
|
||||
void copyText(cmd).then(() =>
|
||||
notify({ kind: "success", title: "已复制命令", message: cmd, mode: "toast", durationMs: 1600 }),
|
||||
);
|
||||
}}
|
||||
>
|
||||
⌘
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[10px]"
|
||||
title={valuePretty ? "查看 JSON" : "查看文本"}
|
||||
onClick={() => setCellViewer(valuePretty ?? cellString(valueRaw))}
|
||||
>
|
||||
{valuePretty ? "{}" : "☰"}
|
||||
</button>
|
||||
<button type="button" className="ops-btn-ghost px-1 text-[11px]" title="编辑" onClick={() => openEditRow(row, i)}>
|
||||
✎
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost grid h-5 w-5 place-items-center p-0 text-[11px]"
|
||||
style={{ color: "var(--ops-danger)" }}
|
||||
title="删除此行"
|
||||
onClick={() => void deleteLine(row, i)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-6 text-center" style={{ color: "var(--ops-muted)" }}>
|
||||
{rows.length === 0 ? "空值" : "无匹配"}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{(result?.nextCursor ?? 0) > 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost text-[11px]"
|
||||
disabled={busy}
|
||||
onClick={() => void loadMore()}
|
||||
>
|
||||
加载更多(服务端续载 · 已加载 {rows.length} 行)
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
) : filtered.length > visibleCount ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-ghost text-[11px]"
|
||||
onClick={() => setVisibleCount((v) => v + PAGE_STEP)}
|
||||
>
|
||||
加载更多(已显示 {visibleCount} / 共 {filtered.length} 行)
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{lineEdit && (
|
||||
<OpsModal
|
||||
title={lineEdit.isNew ? addLabel || "新增" : "编辑"}
|
||||
variant="confirm"
|
||||
okLabel="保存"
|
||||
onOk={() => void commitLine()}
|
||||
onCancel={() => setLineEdit(null)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{type === "hash" && (
|
||||
<label className="block text-[12px]">
|
||||
<span style={{ color: "var(--ops-muted)" }}>Field</span>
|
||||
<input
|
||||
className="ops-input mt-1 w-full font-mono"
|
||||
value={lineEdit.field}
|
||||
onChange={(e) => setLineEdit({ ...lineEdit, field: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
{type === "zset" && (
|
||||
<label className="block text-[12px]">
|
||||
<span style={{ color: "var(--ops-muted)" }}>Score</span>
|
||||
<input
|
||||
className="ops-input mt-1 w-full font-mono"
|
||||
value={lineEdit.score}
|
||||
onChange={(e) => setLineEdit({ ...lineEdit, score: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="block text-[12px]">
|
||||
<span style={{ color: "var(--ops-muted)" }}>{type === "zset" ? "Member" : "Value"}</span>
|
||||
<textarea
|
||||
className="ops-input mt-1 min-h-[120px] w-full font-mono text-[12px]"
|
||||
value={lineEdit.value}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setLineEdit({ ...lineEdit, value: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</OpsModal>
|
||||
)}
|
||||
|
||||
{cellViewer != null && (
|
||||
<OpsModal
|
||||
title="JSON 查看"
|
||||
variant="confirm"
|
||||
okLabel="复制 JSON"
|
||||
onOk={() => {
|
||||
void copyText(cellViewer).then(() =>
|
||||
notify({ kind: "success", title: "已复制 JSON", message: "", mode: "toast", durationMs: 1200 }),
|
||||
);
|
||||
setCellViewer(null);
|
||||
}}
|
||||
onCancel={() => setCellViewer(null)}
|
||||
>
|
||||
<pre
|
||||
className="m-0 max-h-[56vh] overflow-auto whitespace-pre-wrap break-all rounded border p-2 font-mono text-[11px] leading-snug"
|
||||
style={{ borderColor: "var(--ops-border)", background: "var(--ops-bg-soft, transparent)" }}
|
||||
>
|
||||
{cellViewer}
|
||||
</pre>
|
||||
</OpsModal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { execSql, listColumns } from "./api";
|
||||
import { DdlModal } from "./DdlModal";
|
||||
import { ensureSession } from "./ObjectTree";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
|
||||
type MetaRow = Record<string, unknown>;
|
||||
|
||||
type TableMeta = {
|
||||
indexes: MetaRow[];
|
||||
fks: MetaRow[];
|
||||
triggers: MetaRow[];
|
||||
};
|
||||
|
||||
const EMPTY_META: TableMeta = { indexes: [], fks: [], triggers: [] };
|
||||
|
||||
/** MySQL 协议族才走 information_schema 元数据(PG/Oracle 的系统目录另行适配) */
|
||||
function isMysqlFamily(driver?: string) {
|
||||
return ["mysql", "mariadb", "tidb", "oceanbase", "polardb", "tdsql"].includes((driver || "").toLowerCase());
|
||||
}
|
||||
|
||||
/** 标识符转义进 information_schema 字符串比较 */
|
||||
const esc = (s: string) => s.replace(/'/g, "''");
|
||||
|
||||
export function SchemaPane() {
|
||||
const schema = useWorkspaceStore((s) => s.schemaView);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const conn = useWorkspaceStore((s) => s.connections.find((c) => c.id === s.activeConnectionId));
|
||||
const [meta, setMeta] = useState<TableMeta>(EMPTY_META);
|
||||
/** R2-F3:DDL 查看弹窗 */
|
||||
const [showDdl, setShowDdl] = useState(false);
|
||||
const mysql = isMysqlFamily(conn?.driver);
|
||||
/** Redis 键的结构页不展示 DDL(服务端仅支持表/视图) */
|
||||
const ddlEnabled = !!conn && (conn.driver || "").toLowerCase() !== "redis";
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setMeta(EMPTY_META);
|
||||
if (!conn || !mysql || !schema || schema.loading || schema.error || !schema.table) return;
|
||||
(async () => {
|
||||
try {
|
||||
const sid = await ensureSession(conn);
|
||||
const db = esc(schema.database);
|
||||
const t = esc(schema.table);
|
||||
const run = (sqlText: string) => execSql(conn, sqlText, { database: schema.database, sessionId: sid, limit: 200 });
|
||||
const [ix, fk, tg] = await Promise.all([
|
||||
run(
|
||||
`SELECT INDEX_NAME AS name, IF(NON_UNIQUE=0,'UNIQUE','') AS uniq, GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ', ') AS cols FROM information_schema.STATISTICS WHERE TABLE_SCHEMA='${db}' AND TABLE_NAME='${t}' GROUP BY INDEX_NAME, NON_UNIQUE ORDER BY INDEX_NAME`,
|
||||
),
|
||||
run(
|
||||
`SELECT CONSTRAINT_NAME AS name, COLUMN_NAME AS col, REFERENCED_TABLE_NAME AS refTable, REFERENCED_COLUMN_NAME AS refCol FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA='${db}' AND TABLE_NAME='${t}' AND REFERENCED_TABLE_NAME IS NOT NULL`,
|
||||
),
|
||||
run(
|
||||
`SELECT TRIGGER_NAME AS name, ACTION_TIMING AS timing, EVENT_MANIPULATION AS event, EVENT_OBJECT_TABLE AS onTable FROM information_schema.TRIGGERS WHERE TRIGGER_SCHEMA='${db}' AND EVENT_OBJECT_TABLE='${t}'`,
|
||||
),
|
||||
]);
|
||||
if (alive) setMeta({ indexes: ix.rows, fks: fk.rows, triggers: tg.rows });
|
||||
} catch {
|
||||
/* 元数据不可用时静默降级:结构页至少保底显示列 */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [conn, mysql, schema?.database, schema?.table, schema?.loading, schema?.error]);
|
||||
|
||||
if (!schema) {
|
||||
return (
|
||||
<div className="ops-empty" style={{ minHeight: 120 }}>
|
||||
<h2 style={{ fontSize: "var(--ops-text-lg)" }}>表结构</h2>
|
||||
<p>在对象树点表,或右键「查看结构」。</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** E1:结构加载失败重试 —— 重新拉列元数据 */
|
||||
const retryLoad = async () => {
|
||||
if (!conn || !schema) return;
|
||||
set("schemaView", { ...schema, loading: true, error: undefined });
|
||||
try {
|
||||
const sid = await ensureSession(conn);
|
||||
const { columns } = await listColumns(conn, schema.database, schema.table, sid);
|
||||
set("schemaView", { database: schema.database, table: schema.table, columns, loading: false });
|
||||
const pkCols = columns.filter((c) => c.key === "PRI").map((c) => c.name);
|
||||
set("resultMeta", { database: schema.database, table: schema.table, pkCols });
|
||||
set("connState", "ok");
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
set("schemaView", { database: schema.database, table: schema.table, columns: [], loading: false, error: msg });
|
||||
if (msg.includes("会话") || msg.toLowerCase().includes("session")) set("connState", "down");
|
||||
}
|
||||
};
|
||||
|
||||
if (schema.loading) {
|
||||
return (
|
||||
<div className="px-3 py-2 text-[12.5px]" style={{ color: "var(--ops-muted)" }}>
|
||||
加载 {schema.table} 结构…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (schema.error) {
|
||||
return (
|
||||
<div
|
||||
className="m-1.5 rounded border px-2.5 py-1.5 text-[12.5px]"
|
||||
style={{
|
||||
borderColor: "color-mix(in srgb, var(--ops-danger) 40%, var(--ops-border))",
|
||||
background: "color-mix(in srgb, var(--ops-danger) 10%, transparent)",
|
||||
color: "var(--ops-danger)",
|
||||
}}
|
||||
>
|
||||
<div>{schema.error}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 hover:underline"
|
||||
style={{ fontSize: "var(--ops-text-xs)" }}
|
||||
onClick={() => void retryLoad()}
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const str = (v: unknown) => (v == null ? "" : String(v));
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="ops-section-label justify-between border-b">
|
||||
<span>
|
||||
结构 · {schema.database ? `${schema.database}.` : ""}
|
||||
{schema.table}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2 normal-case tracking-normal">
|
||||
{ddlEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--ops-accent)" }}
|
||||
title="查看建表语句(DDL)"
|
||||
onClick={() => setShowDdl(true)}
|
||||
>
|
||||
DDL
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--ops-accent)" }}
|
||||
onClick={() => set("resultTab", "data")}
|
||||
>
|
||||
回结果
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto">
|
||||
{schema.columns.length === 0 ? (
|
||||
<div className="px-3 py-2 text-[12.5px]" style={{ color: "var(--ops-muted)" }}>
|
||||
无列信息
|
||||
</div>
|
||||
) : (
|
||||
<table className="ops-grid" style={{ width: "100%" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 36 }}>#</th>
|
||||
<th>列名</th>
|
||||
<th>类型</th>
|
||||
<th style={{ width: 52 }}>键</th>
|
||||
<th style={{ width: 64 }}>可空</th>
|
||||
<th>默认值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{schema.columns.map((c, i) => (
|
||||
<tr key={c.name}>
|
||||
<td className="ops-rownum">{i + 1}</td>
|
||||
<td>
|
||||
{c.key === "PRI" ? <span title="主键">🔑 </span> : null}
|
||||
{c.name}
|
||||
</td>
|
||||
<td className="font-mono text-[12px]">{c.type}</td>
|
||||
<td>
|
||||
{c.key === "PRI" ? (
|
||||
<span
|
||||
className="rounded px-1 font-semibold"
|
||||
style={{
|
||||
fontSize: "var(--ops-text-xs)",
|
||||
color: "var(--ops-warning)",
|
||||
background: "color-mix(in srgb, var(--ops-warning) 14%, transparent)",
|
||||
}}
|
||||
>
|
||||
PRI
|
||||
</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</td>
|
||||
<td>{c.nullable ? "YES" : "NO"}</td>
|
||||
<td className="font-mono text-[12px]" style={{ color: "var(--ops-muted)" }}>
|
||||
{c.default == null ? <span className="ops-null">NULL</span> : String(c.default)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{mysql && (
|
||||
<div className="border-t" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<details open className="px-3 py-1.5"> <summary className="cursor-pointer text-[12.5px] font-medium" style={{ listStyle: "none" }}>
|
||||
索引({meta.indexes.length})
|
||||
</summary>
|
||||
{meta.indexes.length === 0 ? (
|
||||
<div className="px-1 py-1 text-[11.5px]" style={{ color: "var(--ops-muted)" }}>无索引</div>
|
||||
) : (
|
||||
<table className="ops-grid" style={{ width: "100%" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>索引名</th>
|
||||
<th style={{ width: 70 }}>唯一</th>
|
||||
<th>列</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{meta.indexes.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td className="font-mono text-[12px]">{str(r.name)}</td>
|
||||
<td>{r.uniq ? "✓" : ""}</td>
|
||||
<td className="font-mono text-[12px]" style={{ color: "var(--ops-muted)" }}>{str(r.cols)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</details>
|
||||
|
||||
<details open className="border-t px-3 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<summary className="cursor-pointer text-[12.5px] font-medium" style={{ listStyle: "none" }}>
|
||||
外键({meta.fks.length})
|
||||
</summary>
|
||||
{meta.fks.length === 0 ? (
|
||||
<div className="px-1 py-1 text-[11.5px]" style={{ color: "var(--ops-muted)" }}>无外键</div>
|
||||
) : (
|
||||
<table className="ops-grid" style={{ width: "100%" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>约束名</th>
|
||||
<th>列</th>
|
||||
<th>引用</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{meta.fks.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td className="font-mono text-[12px]">{str(r.name)}</td>
|
||||
<td className="font-mono text-[12px]">{str(r.col)}</td>
|
||||
<td className="font-mono text-[12px]" style={{ color: "var(--ops-muted)" }}>
|
||||
{str(r.refTable)}.{str(r.refCol)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</details>
|
||||
|
||||
<details open className="border-t px-3 py-1.5" style={{ borderColor: "var(--ops-border)" }}>
|
||||
<summary className="cursor-pointer text-[12.5px] font-medium" style={{ listStyle: "none" }}>
|
||||
触发器({meta.triggers.length})
|
||||
</summary>
|
||||
{meta.triggers.length === 0 ? (
|
||||
<div className="px-1 py-1 text-[11.5px]" style={{ color: "var(--ops-muted)" }}>无触发器</div>
|
||||
) : (
|
||||
<table className="ops-grid" style={{ width: "100%" }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>触发器</th>
|
||||
<th style={{ width: 80 }}>时机</th>
|
||||
<th style={{ width: 80 }}>事件</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{meta.triggers.map((r, i) => (
|
||||
<tr key={i}>
|
||||
<td className="font-mono text-[12px]">{str(r.name)}</td>
|
||||
<td>{str(r.timing)}</td>
|
||||
<td>{str(r.event)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showDdl && conn && (
|
||||
<DdlModal conn={conn} database={schema.database} table={schema.table} onClose={() => setShowDdl(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,260 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { driverStatus, health, type DriverStatusItem } from "./api";
|
||||
import { OpsModal } from "./OpsModal";
|
||||
import { useWorkspaceStore } from "./store";
|
||||
|
||||
const IS_MAC = typeof navigator !== "undefined" && /Mac/i.test(navigator.platform);
|
||||
const MOD = IS_MAC ? "⌘" : "Ctrl";
|
||||
|
||||
type TabId = "general" | "execution" | "results" | "drivers" | "shortcuts" | "about";
|
||||
|
||||
const TABS: Array<{ id: TabId; icon: string; label: string; desc: string }> = [
|
||||
{ id: "general", icon: "🎨", label: "常规", desc: "主题与界面" },
|
||||
{ id: "execution", icon: "⚡", label: "执行", desc: "超时与多语句" },
|
||||
{ id: "results", icon: "📊", label: "结果", desc: "面板与分页" },
|
||||
{ id: "drivers", icon: "🧩", label: "驱动", desc: "agent 驱动状态" },
|
||||
{ id: "shortcuts", icon: "⌨️", label: "快捷键", desc: "键盘操作一览" },
|
||||
{ id: "about", icon: "ℹ️", label: "关于", desc: "版本与数据目录" },
|
||||
];
|
||||
|
||||
function Row({ label, children, hint }: { label: string; children: React.ReactNode; hint?: string }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-[12.5px]" style={{ color: "var(--ops-text)" }}>{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
{hint && <div className="text-[11px]" style={{ color: "var(--ops-muted)" }}>{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
className="h-4.5 w-9 rounded-full border transition"
|
||||
style={{
|
||||
height: 18,
|
||||
borderColor: "var(--ops-border)",
|
||||
background: checked ? "var(--ops-accent)" : "var(--ops-surface-2)",
|
||||
}}
|
||||
onClick={() => onChange(!checked)}
|
||||
>
|
||||
<span
|
||||
className="block rounded-full bg-white transition-all"
|
||||
style={{ width: 12, height: 12, marginLeft: checked ? 22 : 3, boxShadow: "0 1px 2px rgba(0,0,0,.3)" }}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function Seg<T extends string | number>({ value, options, onChange }: { value: T; options: Array<{ v: T; label: string }>; onChange: (v: T) => void }) {
|
||||
return (
|
||||
<div className="ops-seg flex">
|
||||
{options.map((o) => (
|
||||
<button
|
||||
key={String(o.v)}
|
||||
type="button"
|
||||
className={value === o.v ? "ops-tree-item-active" : ""}
|
||||
style={{ fontSize: "var(--ops-text-xs)" }}
|
||||
onClick={() => onChange(o.v)}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 设置弹框:左分类导航 + 右面板(对标 opsxshell / DBeaver Preferences) */
|
||||
export function SettingsModal({ onClose }: { onClose: () => void }) {
|
||||
const [tab, setTab] = useState<TabId>("general");
|
||||
const theme = useWorkspaceStore((s) => s.theme);
|
||||
const showAi = useWorkspaceStore((s) => s.showAi);
|
||||
const stopOnError = useWorkspaceStore((s) => s.stopOnError);
|
||||
const execTimeoutMs = useWorkspaceStore((s) => s.execTimeoutMs);
|
||||
const resultRatio = useWorkspaceStore((s) => s.resultRatio);
|
||||
const set = useWorkspaceStore((s) => s.set);
|
||||
const notify = useWorkspaceStore((s) => s.notify);
|
||||
const openConnectionList = useWorkspaceStore((s) => s.openConnectionList);
|
||||
|
||||
const [drivers, setDrivers] = useState<DriverStatusItem[] | null>(null);
|
||||
const [version, setVersion] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
void driverStatus().then(setDrivers).catch(() => setDrivers([]));
|
||||
void health().then((h) => setVersion(String((h as { version?: string }).version ?? ""))).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const resetLayout = () => {
|
||||
set("leftWidth", 260);
|
||||
set("aiWidth", 280);
|
||||
set("resultRatio", 0.38);
|
||||
notify({ kind: "success", title: "已重置", message: "面板布局恢复默认", mode: "toast", durationMs: 1400 });
|
||||
};
|
||||
|
||||
return (
|
||||
<OpsModal variant="info" title="设置" onOk={onClose}>
|
||||
<div className="flex" style={{ minWidth: 560, minHeight: 340 }}>
|
||||
{/* 左导航 */}
|
||||
<nav className="w-36 shrink-0 space-y-px border-r pr-1" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
className={`flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12px] transition ${
|
||||
tab === t.id ? "ops-tree-item-active font-medium" : "opacity-70 hover:opacity-100"
|
||||
}`}
|
||||
onClick={() => setTab(t.id)}
|
||||
>
|
||||
<span className="w-4 text-center">{t.icon}</span>
|
||||
<span>{t.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* 右面板 */}
|
||||
<div className="min-w-0 flex-1 space-y-4 pl-4">
|
||||
{tab === "general" && (
|
||||
<>
|
||||
<Row label="主题">
|
||||
<Seg
|
||||
value={theme}
|
||||
options={[{ v: "dark" as const, label: "深色" }, { v: "light" as const, label: "浅色" }]}
|
||||
onChange={(v) => set("theme", v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="显示 AI 面板" hint="工作台右侧的 AI 助手面板">
|
||||
<Toggle checked={showAi} onChange={(v) => set("showAi", v)} />
|
||||
</Row>
|
||||
<Row label="面板布局" hint="侧栏宽度、AI 面板宽度、结果面板占比">
|
||||
<button type="button" className="ops-btn-secondary h-6 px-2 text-[11px]" onClick={resetLayout}>
|
||||
恢复默认
|
||||
</button>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "execution" && (
|
||||
<>
|
||||
<Row label="默认执行超时" hint="单语句执行上限,超时由服务端强制中断">
|
||||
<Seg
|
||||
value={execTimeoutMs}
|
||||
options={[
|
||||
{ v: 0, label: "无" },
|
||||
{ v: 10000, label: "10s" },
|
||||
{ v: 30000, label: "30s" },
|
||||
{ v: 60000, label: "60s" },
|
||||
{ v: 300000, label: "5min" },
|
||||
]}
|
||||
onChange={(v) => set("execTimeoutMs", v)}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="多语句遇错即停" hint="一次执行多条时,某条失败即停止后续">
|
||||
<Toggle checked={stopOnError} onChange={(v) => set("stopOnError", v)} />
|
||||
</Row>
|
||||
<Row label="危险语句确认" hint="DROP/TRUNCATE/无 WHERE 删除等由服务端权威拦截,执行前需二次确认(不可关闭)">
|
||||
<span className="text-[11px]" style={{ color: "var(--ops-accent)" }}>始终开启</span>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "results" && (
|
||||
<>
|
||||
<Row label={`结果面板占比 ${Math.round(resultRatio * 100)}%`} hint="编辑器与结果面板的高度分配">
|
||||
<input
|
||||
type="range"
|
||||
min={25}
|
||||
max={60}
|
||||
value={Math.round(resultRatio * 100)}
|
||||
onChange={(e) => set("resultRatio", Number(e.target.value) / 100)}
|
||||
style={{ accentColor: "var(--ops-accent)", width: 120 }}
|
||||
/>
|
||||
</Row>
|
||||
<Row label="行数上限" hint="查询默认 1000 行、硬上限 10000 行(超出截断提示),由服务端强制执行">
|
||||
<span className="text-[11px]" style={{ color: "var(--ops-muted)" }}>1000 / 10000</span>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "drivers" && (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
{drivers === null && <div className="text-[11px]" style={{ color: "var(--ops-muted)" }}>加载中…</div>}
|
||||
{drivers?.map((d) => (
|
||||
<div key={d.id} className="flex items-center justify-between rounded border px-2 py-1.5 text-[11.5px]" style={{ borderColor: "var(--ops-border-soft)" }}>
|
||||
<span>{d.id}</span>
|
||||
<span style={{ color: d.allInstalled ? "var(--ops-accent)" : "var(--ops-warning)" }}>
|
||||
{d.allInstalled ? "已安装" : d.license === "proprietary" ? "需自备 jar" : "未安装"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Row label="驱动管理" hint="预装下载、离线包导入、JDK 检测">
|
||||
<button
|
||||
type="button"
|
||||
className="ops-btn-secondary h-6 px-2 text-[11px]"
|
||||
onClick={() => {
|
||||
onClose();
|
||||
openConnectionList();
|
||||
notify({ kind: "info", title: "驱动管理", message: "连接列表页右上角 ⚙ 打开驱动管理", mode: "toast", durationMs: 2600 });
|
||||
}}
|
||||
>
|
||||
去管理
|
||||
</button>
|
||||
</Row>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "shortcuts" && (
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
[`${MOD} + Enter`, "执行当前语句或选区"],
|
||||
[`${MOD} + T`, "新建查询"],
|
||||
[`${MOD} + W`, "关闭查询"],
|
||||
[`${MOD} + 1-9`, "切换到第 N 个查询"],
|
||||
["Ctrl + Tab", "循环切换查询"],
|
||||
[`${MOD} + ⇧ + F`, "聚焦对象树过滤框"],
|
||||
[`${MOD} + B`, "显示/隐藏 AI"],
|
||||
[`${MOD} + /`, "快捷键帮助"],
|
||||
["双击表名", "打开前 100 行"],
|
||||
["双击单元格", "进入行编辑"],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between gap-3 text-[12px]">
|
||||
<span className="font-mono" style={{ color: "var(--ops-accent)" }}>{k}</span>
|
||||
<span style={{ color: "var(--ops-muted)" }}>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "about" && (
|
||||
<div className="space-y-2 text-[12px]" style={{ color: "var(--ops-text)" }}>
|
||||
<div className="flex justify-between"><span style={{ color: "var(--ops-muted)" }}>版本</span><span className="font-mono">{version || "—"}</span></div>
|
||||
<div className="flex justify-between"><span style={{ color: "var(--ops-muted)" }}>形态</span><span>桌面客户端(Tauri 2)</span></div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span style={{ color: "var(--ops-muted)" }}>驱动目录</span>
|
||||
<span className="truncate font-mono" title="~/.opsxdb/drivers">~/.opsxdb/drivers</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<span style={{ color: "var(--ops-muted)" }}>驱动商店</span>
|
||||
<a
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--ops-accent)" }}
|
||||
href="https://gitee.com/attacker/opsxdb-driver"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
gitee.com/attacker/opsxdb-driver
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</OpsModal>
|
||||
);
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
||||
import { EditorSelection, EditorState } from "@codemirror/state";
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, placeholder } from "@codemirror/view";
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
import { sql } from "@codemirror/lang-sql";
|
||||
import { autocompletion, type Completion, type CompletionContext, type CompletionResult } from "@codemirror/autocomplete";
|
||||
import { tags as t } from "@lezer/highlight";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { getCachedSchema } from "./api";
|
||||
import { REDIS_COMMANDS } from "./redis";
|
||||
import { formatSqlText, statementAt } from "./sql";
|
||||
|
||||
/** §3.10:SQL 关键字静态补全表(通用 + MySQL/PG 常用) */
|
||||
const SQL_KEYWORDS = [
|
||||
"SELECT", "FROM", "WHERE", "AND", "OR", "NOT", "NULL", "INSERT", "INTO", "VALUES",
|
||||
"UPDATE", "SET", "DELETE", "CREATE", "TABLE", "ALTER", "DROP", "INDEX", "VIEW",
|
||||
"JOIN", "LEFT", "RIGHT", "INNER", "OUTER", "FULL", "CROSS", "ON", "AS", "DISTINCT",
|
||||
"GROUP", "BY", "ORDER", "ASC", "DESC", "LIMIT", "OFFSET", "HAVING", "UNION", "ALL",
|
||||
"CASE", "WHEN", "THEN", "ELSE", "END", "IN", "EXISTS", "BETWEEN", "LIKE", "IS",
|
||||
"COUNT", "SUM", "AVG", "MIN", "MAX", "COALESCE", "CAST", "WITH", "EXPLAIN",
|
||||
"PRIMARY", "KEY", "FOREIGN", "REFERENCES", "DEFAULT", "UNIQUE", "CHECK",
|
||||
"BEGIN", "COMMIT", "ROLLBACK", "GRANT", "REVOKE", "TRUNCATE", "RETURNING",
|
||||
].map((kw) => ({ label: kw, type: "keyword" }) satisfies Completion);
|
||||
|
||||
/**
|
||||
* §3.10/C8:补全数据源 = 关键字 + schemaCache(对象树懒加载时写入的库/表/列)。
|
||||
* 列名携带类型信息(detail),表/库/列分别标注类型。
|
||||
*/
|
||||
function schemaOptions(connId?: string): Completion[] {
|
||||
const cache = getCachedSchema(connId);
|
||||
if (!cache) return [];
|
||||
const out: Completion[] = [];
|
||||
for (const [db, tables] of cache) {
|
||||
out.push({ label: db, type: "namespace", detail: "库" });
|
||||
for (const [table, cols] of tables) {
|
||||
out.push({ label: table, type: "table", detail: db });
|
||||
for (const col of cols) {
|
||||
out.push({ label: col.name, type: "property", detail: `${table} · ${col.type}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function sqlComplete(connIdRef: React.RefObject<string | undefined>) {
|
||||
return (ctx: CompletionContext): CompletionResult | null => {
|
||||
const word = ctx.matchBefore(/[\w$一-龥]*$/);
|
||||
if (!word || (word.from === word.to && !ctx.explicit)) return null;
|
||||
return { from: word.from, options: [...SQL_KEYWORDS, ...schemaOptions(connIdRef.current)] };
|
||||
};
|
||||
}
|
||||
|
||||
function redisComplete(ctx: CompletionContext): CompletionResult | null {
|
||||
const word = ctx.matchBefore(/[\w$]*$/);
|
||||
if (!word || (word.from === word.to && !ctx.explicit)) return null;
|
||||
return { from: word.from, options: REDIS_COMMANDS };
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL 高亮走克制的四色方案(VS Code Dark+ 观感):
|
||||
* 关键字+数字 = 蓝,字符串 = 橙,注释 = 绿,标识符/运算符 = 前景浅白。
|
||||
* 注册在 oneDark 之后,覆盖其默认的五彩配色。
|
||||
*/
|
||||
const sqlDarkHighlight = HighlightStyle.define([
|
||||
{ tag: [t.keyword, t.controlKeyword, t.moduleKeyword, t.operatorKeyword, t.definitionKeyword], color: "#569CD6" },
|
||||
{ tag: [t.number, t.integer, t.bool, t.null, t.atom], color: "#569CD6" },
|
||||
{ tag: [t.string, t.special(t.string)], color: "#CE9178" },
|
||||
{ tag: [t.lineComment, t.blockComment, t.docComment], color: "#6A9955" },
|
||||
{ tag: [t.variableName, t.typeName, t.propertyName, t.operator, t.punctuation, t.definition(t.variableName)], color: "#D4D4D4" },
|
||||
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: "#D4D4D4" },
|
||||
]);
|
||||
|
||||
/** 浅色主题四色方案:关键字蓝、字符串枣红、注释绿、标识符近黑 */
|
||||
const sqlLightHighlight = HighlightStyle.define([
|
||||
{ tag: [t.keyword, t.controlKeyword, t.moduleKeyword, t.operatorKeyword, t.definitionKeyword], color: "#0000FF" },
|
||||
{ tag: [t.number, t.integer, t.bool, t.null, t.atom], color: "#0000FF" },
|
||||
{ tag: [t.string, t.special(t.string)], color: "#A31515" },
|
||||
{ tag: [t.lineComment, t.blockComment, t.docComment], color: "#008000" },
|
||||
{ tag: [t.variableName, t.typeName, t.propertyName, t.operator, t.punctuation, t.definition(t.variableName)], color: "#24292F" },
|
||||
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: "#24292F" },
|
||||
]);
|
||||
|
||||
export type SqlEditorHandle = {
|
||||
/** Selection trim if nonempty; else statement at cursor (statementAt). */
|
||||
getRunSql: () => string;
|
||||
/** Format selection (if prefer + nonempty) or whole doc; single history-friendly change. */
|
||||
format: (selectionPrefer?: boolean) => void;
|
||||
focus: () => void;
|
||||
hasSelection: () => boolean;
|
||||
selectedText: () => string;
|
||||
selectAll: () => void;
|
||||
cutSelection: () => Promise<void>;
|
||||
copySelection: () => Promise<void>;
|
||||
pasteClipboard: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type SqlEditorContextInfo = {
|
||||
x: number;
|
||||
y: number;
|
||||
hasSelection: boolean;
|
||||
};
|
||||
|
||||
export const SqlEditor = forwardRef<
|
||||
SqlEditorHandle,
|
||||
{
|
||||
value: string;
|
||||
theme: "dark" | "light";
|
||||
onChange: (sql: string) => void;
|
||||
onRun: (sql: string) => void;
|
||||
/** Optional driver for dialect-aware format. */
|
||||
driver?: string;
|
||||
/** §3.10:schema 补全按连接隔离(C8 schemaCache) */
|
||||
connectionId?: string;
|
||||
/** 自绘右键:拦住系统菜单后回调 */
|
||||
onContextMenu?: (info: SqlEditorContextInfo) => void;
|
||||
}
|
||||
>(function SqlEditor({ value, theme, onChange, onRun, driver, connectionId, onContextMenu }, ref) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const onChangeRef = useRef(onChange);
|
||||
const onRunRef = useRef(onRun);
|
||||
const valueRef = useRef(value);
|
||||
const driverRef = useRef(driver);
|
||||
const connIdRef = useRef(connectionId);
|
||||
const onContextMenuRef = useRef(onContextMenu);
|
||||
onChangeRef.current = onChange;
|
||||
onRunRef.current = onRun;
|
||||
valueRef.current = value;
|
||||
driverRef.current = driver;
|
||||
connIdRef.current = connectionId;
|
||||
onContextMenuRef.current = onContextMenu;
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getRunSql: () => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return valueRef.current.trim();
|
||||
const sel = view.state.selection.main;
|
||||
const selected = sel.empty ? "" : view.state.sliceDoc(sel.from, sel.to);
|
||||
return statementAt(view.state.doc.toString(), sel.head, selected, driverRef.current);
|
||||
},
|
||||
format: (selectionPrefer = true) => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
const sel = view.state.selection.main;
|
||||
const useSel = selectionPrefer && !sel.empty;
|
||||
const from = useSel ? sel.from : 0;
|
||||
const to = useSel ? sel.to : view.state.doc.length;
|
||||
const raw = view.state.sliceDoc(from, to);
|
||||
if (!raw.trim()) return;
|
||||
const formatted = formatSqlText(raw, driverRef.current);
|
||||
if (formatted === raw) return;
|
||||
view.dispatch({
|
||||
changes: { from, to, insert: formatted },
|
||||
selection: useSel
|
||||
? EditorSelection.range(from, from + formatted.length)
|
||||
: EditorSelection.cursor(Math.min(from + formatted.length, from + formatted.length)),
|
||||
});
|
||||
view.focus();
|
||||
},
|
||||
focus: () => viewRef.current?.focus(),
|
||||
hasSelection: () => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return false;
|
||||
return !view.state.selection.main.empty;
|
||||
},
|
||||
selectedText: () => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return "";
|
||||
const sel = view.state.selection.main;
|
||||
if (sel.empty) return "";
|
||||
return view.state.sliceDoc(sel.from, sel.to);
|
||||
},
|
||||
selectAll: () => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
view.dispatch({ selection: EditorSelection.range(0, view.state.doc.length) });
|
||||
view.focus();
|
||||
},
|
||||
cutSelection: async () => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
const sel = view.state.selection.main;
|
||||
if (sel.empty) return;
|
||||
const text = view.state.sliceDoc(sel.from, sel.to);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
view.dispatch({ changes: { from: sel.from, to: sel.to, insert: "" } });
|
||||
view.focus();
|
||||
},
|
||||
copySelection: async () => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
const sel = view.state.selection.main;
|
||||
if (sel.empty) return;
|
||||
const text = view.state.sliceDoc(sel.from, sel.to);
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
view.focus();
|
||||
},
|
||||
pasteClipboard: async () => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
let text = "";
|
||||
try {
|
||||
text = await navigator.clipboard.readText();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const sel = view.state.selection.main;
|
||||
view.dispatch({
|
||||
changes: { from: sel.from, to: sel.to, insert: text },
|
||||
selection: EditorSelection.cursor(sel.from + text.length),
|
||||
});
|
||||
view.focus();
|
||||
},
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (!host.current) return;
|
||||
const runKey = {
|
||||
key: "Mod-Enter",
|
||||
run: (view: EditorView) => {
|
||||
const sel = view.state.selection.main;
|
||||
const selected = sel.empty ? "" : view.state.sliceDoc(sel.from, sel.to);
|
||||
onRunRef.current(statementAt(view.state.doc.toString(), sel.head, selected, driverRef.current));
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const redis = (driverRef.current || "").toLowerCase() === "redis";
|
||||
const state = EditorState.create({
|
||||
doc: valueRef.current,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
highlightActiveLine(),
|
||||
history(),
|
||||
sql(),
|
||||
// §3.10:关键字 + schemaCache 补全(override 覆盖 lang-sql 默认源)
|
||||
autocompletion({ override: [redis ? redisComplete : sqlComplete(connIdRef)] }),
|
||||
placeholder(redis ? "写 Redis 命令,⌘/Ctrl+Enter 执行当前行或选区" : "写 SQL,⌘/Ctrl+Enter 执行当前语句或选区"),
|
||||
keymap.of([runKey, indentWithTab, ...historyKeymap, ...defaultKeymap]),
|
||||
EditorView.updateListener.of((u) => {
|
||||
if (u.docChanged) onChangeRef.current(u.state.doc.toString());
|
||||
}),
|
||||
EditorView.domEventHandlers({
|
||||
contextmenu: (e, view) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const sel = view.state.selection.main;
|
||||
onContextMenuRef.current?.({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
hasSelection: !sel.empty,
|
||||
});
|
||||
return true;
|
||||
},
|
||||
}),
|
||||
EditorView.theme({
|
||||
"&": { height: "100%", fontSize: "12.5px" },
|
||||
// 字体栈走外层 .ops-code 的 tailwind font-mono 令牌(§2.1 修 V6),不再内联硬编码
|
||||
".cm-scroller": { lineHeight: "1.45" },
|
||||
".cm-content": { padding: "8px 0" },
|
||||
".cm-gutters": { border: "none" },
|
||||
}),
|
||||
...(theme === "dark" ? [oneDark] : []),
|
||||
syntaxHighlighting(theme === "dark" ? sqlDarkHighlight : sqlLightHighlight),
|
||||
],
|
||||
});
|
||||
const view = new EditorView({ state, parent: host.current });
|
||||
viewRef.current = view;
|
||||
return () => {
|
||||
view.destroy();
|
||||
viewRef.current = null;
|
||||
};
|
||||
// recreate on theme / Redis↔SQL 方言切换(占位与补全不同)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [theme, driver === "redis"]);
|
||||
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
if (view.state.doc.toString() !== value) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: value },
|
||||
});
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return <div ref={host} className="ops-code h-full min-h-[140px] w-full overflow-hidden" />;
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
/** 树形方向箭头(SVG 描边 chevron,替代 ▸/▾ 字符 —— Unicode 小三角在部分字体下糊成圆点,
|
||||
* 填充三角又显臃肿)。细长 ">" 造型,open 时旋转 90° 朝下。 */
|
||||
export function TreeChevron({
|
||||
open,
|
||||
size = 10,
|
||||
className = "",
|
||||
}: {
|
||||
/** undefined = 静态朝右(details 折叠组内由浏览器管开合) */
|
||||
open?: boolean;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 12 12"
|
||||
width={size}
|
||||
height={size}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
className={`shrink-0 ${open === undefined ? "" : `transition-transform duration-150 ${open ? "rotate-90" : ""}`} ${className}`}
|
||||
>
|
||||
<path d="M4.4 2.6 L8.4 6 L4.4 9.4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,592 +0,0 @@
|
||||
import type { SavedConnection, QueryResult, TreeNode, ColumnInfo } from "./store";
|
||||
import { decodeSecret } from "./secrets";
|
||||
|
||||
/** C2:桌面 sidecar 注入的运行时配置(缺省时行为与现状一致) */
|
||||
declare global {
|
||||
interface Window {
|
||||
__OPSXDB_API__?: string;
|
||||
__OPSXDB_TOKEN__?: string;
|
||||
}
|
||||
}
|
||||
|
||||
function injectedApi(): string | undefined {
|
||||
if (typeof window === "undefined") return undefined;
|
||||
const v = window.__OPSXDB_API__;
|
||||
return v ? v.replace(/\/$/, "") : undefined;
|
||||
}
|
||||
|
||||
function injectedToken(): string {
|
||||
if (typeof window === "undefined") return "";
|
||||
return window.__OPSXDB_TOKEN__ ?? "";
|
||||
}
|
||||
|
||||
/** 开发态走 Vite 代理(空串);桌面生产包优先 sidecar 注入地址,回落默认 8788。 */
|
||||
function resolveApiBase(): string {
|
||||
const injected = injectedApi();
|
||||
if (injected) return injected;
|
||||
const fromEnv = (import.meta as { env?: Record<string, string> }).env?.VITE_OPSXDB_API;
|
||||
if (fromEnv) return fromEnv.replace(/\/$/, "");
|
||||
if (typeof window === "undefined") return "";
|
||||
const { protocol, hostname, port } = window.location;
|
||||
if (protocol === "tauri:" || hostname === "tauri.localhost" || hostname === "localhost" && port === "") {
|
||||
return "http://127.0.0.1:8788";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const API = resolveApiBase();
|
||||
|
||||
/** C2:token 存在时统一带 Authorization 头;token 为空(本机 dev)行为不变 */
|
||||
function authHeaders(): Record<string, string> {
|
||||
const token = injectedToken();
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
async function post<T>(path: string, body: unknown, signal?: AbortSignal): Promise<T> {
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error((data as { error?: string }).error || res.statusText || "request failed");
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
async function get<T>(path: string, signal?: AbortSignal): Promise<T> {
|
||||
const res = await fetch(`${API}${path}`, { headers: { ...authHeaders() }, signal });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
throw new Error((data as { error?: string }).error || res.statusText || "request failed");
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export type ConnBody = {
|
||||
driver?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
user?: string;
|
||||
password?: string;
|
||||
database?: string;
|
||||
service?: string;
|
||||
ssl?: boolean;
|
||||
sslMode?: string;
|
||||
sslCA?: string;
|
||||
sslCert?: string;
|
||||
sslKey?: string;
|
||||
sessionId?: string;
|
||||
sql?: string;
|
||||
table?: string;
|
||||
limit?: number;
|
||||
/** E1:执行请求标识,配合 POST /api/db/cancel 取消在途语句 */
|
||||
requestId?: string;
|
||||
/** E2:单语句执行超时(毫秒),0/缺省 = 服务端默认不额外限制 */
|
||||
timeoutMs?: number;
|
||||
/** E6:对象搜索关键字 */
|
||||
keyword?: string;
|
||||
/** E7:服务端导出格式 */
|
||||
format?: string;
|
||||
/** C5:结果分页,跳过前 offset 行 */
|
||||
offset?: number;
|
||||
/** C1:前端已完成危险语句确认后重发时置 true */
|
||||
confirmDanger?: boolean;
|
||||
key?: string;
|
||||
pattern?: string;
|
||||
cursor?: number;
|
||||
redisMode?: string;
|
||||
redisNodes?: string;
|
||||
masterName?: string;
|
||||
sentinelPassword?: string;
|
||||
sshEnabled?: boolean;
|
||||
sshHost?: string;
|
||||
sshPort?: number;
|
||||
sshUser?: string;
|
||||
sshAuth?: string;
|
||||
sshPassword?: string;
|
||||
sshPrivateKey?: string;
|
||||
sshPassphrase?: string;
|
||||
};
|
||||
|
||||
export function fullConnPayload(c: SavedConnection): ConnBody {
|
||||
const sslOn = !!(c.ssl || (c.sslMode && c.sslMode !== "disable"));
|
||||
return {
|
||||
driver: c.driver,
|
||||
host: c.host,
|
||||
port: c.port,
|
||||
user: c.user,
|
||||
password: decodeSecret(c.password),
|
||||
database: c.database,
|
||||
service: c.service,
|
||||
ssl: sslOn,
|
||||
sslMode: sslOn ? (c.sslMode && c.sslMode !== "disable" ? c.sslMode : "require") : "disable",
|
||||
sslCA: c.sslCA || "",
|
||||
sslCert: c.sslCert || "",
|
||||
sslKey: c.sslKey || "",
|
||||
redisMode: c.redisMode || "standalone",
|
||||
redisNodes: c.redisNodes || "",
|
||||
masterName: c.masterName || "",
|
||||
sentinelPassword: c.sentinelPassword ? decodeSecret(c.sentinelPassword) : "",
|
||||
sshEnabled: !!c.sshEnabled,
|
||||
sshHost: c.sshHost,
|
||||
sshPort: c.sshPort,
|
||||
sshUser: c.sshUser,
|
||||
sshAuth: c.sshAuth,
|
||||
sshPassword: c.sshPassword ? decodeSecret(c.sshPassword) : "",
|
||||
sshPrivateKey: c.sshPrivateKey,
|
||||
sshPassphrase: c.sshPassphrase ? decodeSecret(c.sshPassphrase) : "",
|
||||
};
|
||||
}
|
||||
|
||||
export function sessionPayload(c: SavedConnection, sessionId: string, extra?: Partial<ConnBody>): ConnBody {
|
||||
return {
|
||||
sessionId,
|
||||
database: extra?.database ?? c.database,
|
||||
sql: extra?.sql,
|
||||
table: extra?.table,
|
||||
limit: extra?.limit,
|
||||
offset: extra?.offset,
|
||||
confirmDanger: extra?.confirmDanger,
|
||||
key: extra?.key,
|
||||
pattern: extra?.pattern,
|
||||
service: extra?.service ?? c.service,
|
||||
requestId: extra?.requestId,
|
||||
timeoutMs: extra?.timeoutMs,
|
||||
keyword: extra?.keyword,
|
||||
format: extra?.format,
|
||||
};
|
||||
}
|
||||
|
||||
export async function testConnection(c: SavedConnection) {
|
||||
return post<{ ok: boolean; message?: string }>("/api/db/test-connection", fullConnPayload(c));
|
||||
}
|
||||
|
||||
export async function openSession(c: SavedConnection) {
|
||||
return post<{ ok: boolean; sessionId: string; message?: string }>("/api/db/open", fullConnPayload(c));
|
||||
}
|
||||
|
||||
export async function closeSession(sessionId: string) {
|
||||
return post<{ ok: boolean }>("/api/db/close", { sessionId });
|
||||
}
|
||||
|
||||
function body(c: SavedConnection, sessionId: string | undefined, extra?: Partial<ConnBody>): ConnBody {
|
||||
if (sessionId) return sessionPayload(c, sessionId, extra);
|
||||
return { ...fullConnPayload(c), ...extra };
|
||||
}
|
||||
|
||||
export async function listDatabases(c: SavedConnection, sessionId?: string) {
|
||||
return post<{ databases: string[] }>("/api/db/databases", body(c, sessionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* C8:schema 缓存 —— connId → database → table → columns。
|
||||
* listTables/listColumns 成功时写入,供 SqlEditor 补全与 AiPanel 上下文注入共用。
|
||||
*/
|
||||
const schemaCache = new Map<string, Map<string, Map<string, ColumnInfo[]>>>();
|
||||
|
||||
function cacheTables(connId: string, db: string, tables: string[]) {
|
||||
let dbMap = schemaCache.get(connId);
|
||||
if (!dbMap) {
|
||||
dbMap = new Map();
|
||||
schemaCache.set(connId, dbMap);
|
||||
}
|
||||
let tblMap = dbMap.get(db);
|
||||
if (!tblMap) {
|
||||
tblMap = new Map();
|
||||
dbMap.set(db, tblMap);
|
||||
}
|
||||
for (const t of tables) {
|
||||
if (!tblMap.has(t)) tblMap.set(t, []);
|
||||
}
|
||||
}
|
||||
|
||||
function cacheColumns(connId: string, db: string, table: string, columns: ColumnInfo[]) {
|
||||
let dbMap = schemaCache.get(connId);
|
||||
if (!dbMap) {
|
||||
dbMap = new Map();
|
||||
schemaCache.set(connId, dbMap);
|
||||
}
|
||||
let tblMap = dbMap.get(db);
|
||||
if (!tblMap) {
|
||||
tblMap = new Map();
|
||||
dbMap.set(db, tblMap);
|
||||
}
|
||||
tblMap.set(table, columns);
|
||||
}
|
||||
|
||||
/** 连接关闭/删除后丢弃其缓存,避免跨连接串数据 */
|
||||
export function dropSchemaCache(connId: string) {
|
||||
schemaCache.delete(connId);
|
||||
}
|
||||
|
||||
/** SqlEditor 补全数据源:已加载的表与列 */
|
||||
export function getCachedSchema(
|
||||
connId: string | null | undefined,
|
||||
): Map<string, Map<string, ColumnInfo[]>> | null {
|
||||
if (!connId) return null;
|
||||
return schemaCache.get(connId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* AiPanel 上下文注入:把已加载的库表列拼成文本,按 connId 隔离,截断 4000 字符。
|
||||
* 优先输出当前库,其余库只列库.表名。
|
||||
*/
|
||||
export function schemaContextFor(connId: string | null | undefined, currentDb?: string): string {
|
||||
const dbMap = connId ? schemaCache.get(connId) : undefined;
|
||||
if (!dbMap || dbMap.size === 0) return "";
|
||||
const MAX = 4000;
|
||||
const lines: string[] = [];
|
||||
const dbs = [...dbMap.keys()].sort((a, b) => {
|
||||
if (a === currentDb) return -1;
|
||||
if (b === currentDb) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
for (const db of dbs) {
|
||||
const tblMap = dbMap.get(db)!;
|
||||
for (const [table, cols] of tblMap) {
|
||||
const colText = cols.length
|
||||
? `(${cols.map((c) => `${c.name} ${c.type}`.trim()).join(", ")})`
|
||||
: "";
|
||||
lines.push(`${db}.${table}${colText}`);
|
||||
if (lines.join("\n").length > MAX) {
|
||||
return lines.join("\n").slice(0, MAX);
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.join("\n").slice(0, MAX);
|
||||
}
|
||||
|
||||
export async function listTables(c: SavedConnection, database: string, sessionId?: string) {
|
||||
const res = await post<{ tables: string[] }>("/api/db/tables", body(c, sessionId, { database }));
|
||||
if (Array.isArray(res.tables)) cacheTables(c.id, database, res.tables);
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function listColumns(c: SavedConnection, database: string, table: string, sessionId?: string) {
|
||||
const res = await post<{ columns: ColumnInfo[] }>("/api/db/columns", body(c, sessionId, { database, table }));
|
||||
if (Array.isArray(res.columns)) cacheColumns(c.id, database, table, res.columns);
|
||||
return res;
|
||||
}
|
||||
|
||||
/** E1:最近一次 execSql 的 requestId,供 cancelExec() 取消在途语句 */
|
||||
let lastExecRequestId: string | undefined;
|
||||
|
||||
export async function execSql(
|
||||
c: SavedConnection,
|
||||
sql: string,
|
||||
opts?: {
|
||||
database?: string;
|
||||
sessionId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
confirmDanger?: boolean;
|
||||
/** E2:单语句执行超时(毫秒),0/缺省 = 不额外限制 */
|
||||
timeoutMs?: number;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
) {
|
||||
// E1:每次执行生成独立 requestId(模块级记录最后一次),取消端点按 id 精准取消
|
||||
const requestId = crypto.randomUUID();
|
||||
lastExecRequestId = requestId;
|
||||
return post<QueryResult>(
|
||||
"/api/db/exec",
|
||||
body(c, opts?.sessionId, {
|
||||
sql,
|
||||
database: opts?.database || c.database,
|
||||
limit: opts?.limit,
|
||||
offset: opts?.offset,
|
||||
confirmDanger: opts?.confirmDanger,
|
||||
requestId,
|
||||
timeoutMs: opts?.timeoutMs,
|
||||
}),
|
||||
opts?.signal,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* E1:取消最近一次执行 —— fire-and-forget 调 POST /api/db/cancel。
|
||||
* 与前端 abort 互补:服务端收到取消后释放连接/杀查询,错误一律吞掉。
|
||||
*/
|
||||
export function cancelExec(): void {
|
||||
const rid = lastExecRequestId;
|
||||
if (!rid) return;
|
||||
void fetch(`${API}/api/db/cancel`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body: JSON.stringify({ requestId: rid }),
|
||||
}).catch(() => {
|
||||
/* 取消失败不影响本地中止流程 */
|
||||
});
|
||||
}
|
||||
|
||||
/** E5:查看表/视图 DDL(方言差异由服务端处理) */
|
||||
export async function getDdl(
|
||||
c: SavedConnection,
|
||||
table: string,
|
||||
opts?: { database?: string; sessionId?: string },
|
||||
) {
|
||||
return post<{ ddl?: string; error?: string }>(
|
||||
"/api/db/ddl",
|
||||
body(c, opts?.sessionId, { table, database: opts?.database || c.database }),
|
||||
);
|
||||
}
|
||||
|
||||
export type SearchObjectHit = { schema: string; name: string; type: "table" | "view" };
|
||||
|
||||
/** E6:跨库对象搜索(服务端 LIKE,大小写策略由驱动决定) */
|
||||
export async function searchObjects(
|
||||
c: SavedConnection,
|
||||
keyword: string,
|
||||
opts?: { database?: string; sessionId?: string },
|
||||
) {
|
||||
return post<{ results: SearchObjectHit[]; error?: string }>(
|
||||
"/api/db/search",
|
||||
body(c, opts?.sessionId, { keyword, database: opts?.database || c.database }),
|
||||
);
|
||||
}
|
||||
|
||||
export type ExportRequest = {
|
||||
format: "csv" | "json" | "insert";
|
||||
/** 自定义查询导出(单条 SELECT);命中危险/非 SELECT 时服务端返回 needConfirm */
|
||||
sql?: string;
|
||||
/** 整表导出 */
|
||||
table?: string;
|
||||
limit?: number;
|
||||
database?: string;
|
||||
sessionId?: string;
|
||||
/** C1:危险确认二段流重发时置 true */
|
||||
confirmDanger?: boolean;
|
||||
};
|
||||
|
||||
/** E7 服务端导出失败:Error 上挂 needConfirm,由调用侧弹二段确认后重发 */
|
||||
export type ExportError = Error & { needConfirm?: boolean };
|
||||
|
||||
/**
|
||||
* E7:服务端导出 —— 文件流下载(Content-Disposition attachment)。
|
||||
* 返回 blob 与解析出的文件名;失败时抛 ExportError(needConfirm=true 表示需二段确认)。
|
||||
*/
|
||||
export async function exportData(c: SavedConnection, opts: ExportRequest): Promise<{ blob: Blob; filename?: string }> {
|
||||
const res = await fetch(`${API}/api/db/export`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body: JSON.stringify(
|
||||
body(c, opts.sessionId, {
|
||||
format: opts.format,
|
||||
sql: opts.sql,
|
||||
table: opts.table,
|
||||
limit: opts.limit,
|
||||
database: opts.database || c.database,
|
||||
confirmDanger: opts.confirmDanger,
|
||||
}),
|
||||
),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const err = new Error((data as { error?: string }).error || res.statusText || "导出失败") as ExportError;
|
||||
if ((data as { needConfirm?: boolean }).needConfirm) err.needConfirm = true;
|
||||
throw err;
|
||||
}
|
||||
const cd = res.headers.get("Content-Disposition") || "";
|
||||
const m = /filename\*?=(?:UTF-8''|\")?([^";]+)/i.exec(cd);
|
||||
const filename = m ? decodeURIComponent(m[1].replace(/"$/, "").trim()) : undefined;
|
||||
const blob = await res.blob();
|
||||
return { blob, filename };
|
||||
}
|
||||
|
||||
/** C3:服务端专用执行计划端点(方言包装在服务端完成,前端不再拼 EXPLAIN) */
|
||||
export type ExplainResult = {
|
||||
columns?: string[];
|
||||
rows?: Record<string, unknown>[];
|
||||
/** MSSQL SHOWPLAN_XML / Oracle 文本计划 */
|
||||
text?: string;
|
||||
timeMs: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export async function explainSql(
|
||||
c: SavedConnection,
|
||||
sql: string,
|
||||
opts?: { database?: string; sessionId?: string; signal?: AbortSignal },
|
||||
) {
|
||||
return post<ExplainResult>(
|
||||
"/api/db/explain",
|
||||
body(c, opts?.sessionId, { sql, database: opts?.database || c.database }),
|
||||
opts?.signal,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export type RedisKeyInfo = { name: string; type: string; ttl: number };
|
||||
|
||||
export type RedisKeysResult = {
|
||||
keys: RedisKeyInfo[];
|
||||
truncated?: boolean;
|
||||
timeMs?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type RedisKeyValue = {
|
||||
name: string;
|
||||
type: string;
|
||||
ttl: number;
|
||||
columns: string[];
|
||||
rows: Record<string, unknown>[];
|
||||
rowCount: number;
|
||||
timeMs: number;
|
||||
truncated?: boolean;
|
||||
limit?: number;
|
||||
/** >0 表示还有更多;续载时作为 cursor 回传 */
|
||||
nextCursor?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export async function listRedisKeys(
|
||||
c: SavedConnection,
|
||||
database: string,
|
||||
opts?: { sessionId?: string; pattern?: string; limit?: number },
|
||||
) {
|
||||
return post<RedisKeysResult>(
|
||||
"/api/db/redis/keys",
|
||||
body(c, opts?.sessionId, { database, pattern: opts?.pattern, limit: opts?.limit, driver: c.driver }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getRedisKey(
|
||||
c: SavedConnection,
|
||||
database: string,
|
||||
key: string,
|
||||
opts?: { sessionId?: string; limit?: number; cursor?: number },
|
||||
) {
|
||||
return post<RedisKeyValue>(
|
||||
"/api/db/redis/key",
|
||||
body(c, opts?.sessionId, { database, key, table: key, limit: opts?.limit, cursor: opts?.cursor, driver: c.driver }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteRedisKey(
|
||||
c: SavedConnection,
|
||||
database: string,
|
||||
key: string,
|
||||
opts?: { sessionId?: string },
|
||||
) {
|
||||
return post<QueryResult>(
|
||||
"/api/db/redis/key/delete",
|
||||
body(c, opts?.sessionId, { database, key, table: key, driver: c.driver }),
|
||||
);
|
||||
}
|
||||
|
||||
/** 目录(前缀)批量删除:pattern 如 `agent:*`;后端 SCAN+UNLINK,拒全库与超上限 */
|
||||
export async function deleteRedisPattern(
|
||||
c: SavedConnection,
|
||||
database: string,
|
||||
pattern: string,
|
||||
opts?: { sessionId?: string },
|
||||
) {
|
||||
return post<{ deleted: number; scanned: number; truncated: boolean; error?: string; timeMs?: number }>(
|
||||
"/api/db/redis/del-pattern",
|
||||
body(c, opts?.sessionId, { database, pattern, driver: c.driver }),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export async function loadTree(c: SavedConnection, sessionId?: string): Promise<TreeNode[]> {
|
||||
const { databases } = await listDatabases(c, sessionId);
|
||||
return databases.map((name) => ({ name, kind: "database" as const }));
|
||||
}
|
||||
|
||||
export type AiAction =
|
||||
| "ask"
|
||||
| "generate"
|
||||
| "explain"
|
||||
| "optimize"
|
||||
| "fix"
|
||||
| "dialect"
|
||||
| "sample";
|
||||
|
||||
export async function callAi(payload: {
|
||||
action: AiAction;
|
||||
prompt?: string;
|
||||
sql?: string;
|
||||
driver?: string;
|
||||
database?: string;
|
||||
error?: string;
|
||||
/** C4/C8:前端注入的库表列上下文(截断 4000 字符) */
|
||||
schema?: string;
|
||||
model?: string;
|
||||
}) {
|
||||
return post<{ content: string; sql?: string }>("/api/ai/chat", payload);
|
||||
}
|
||||
|
||||
/** C4:AI 配置(key 脱敏,只回掩码与是否已配置) */
|
||||
export type AiConfig = {
|
||||
provider: string;
|
||||
baseURL: string;
|
||||
model: string;
|
||||
hasKey: boolean;
|
||||
apiKey?: string;
|
||||
};
|
||||
|
||||
export async function getAiConfig(signal?: AbortSignal) {
|
||||
return get<AiConfig>("/api/ai/config", signal);
|
||||
}
|
||||
|
||||
export async function saveAiConfig(cfg: { provider: string; baseURL: string; apiKey?: string; model: string }) {
|
||||
return post<{ ok: boolean }>("/api/ai/config", cfg);
|
||||
}
|
||||
|
||||
export async function health() {
|
||||
const res = await fetch(`${API}/api/health`, { headers: { ...authHeaders() } });
|
||||
if (!res.ok) throw new Error(`health ${res.status}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** fetch AbortController / DOMException AbortError */
|
||||
export function isAbortError(e: unknown): boolean {
|
||||
if (!e || typeof e !== "object") return false;
|
||||
const name = (e as { name?: string }).name;
|
||||
if (name === "AbortError") return true;
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
return /aborted|abort/i.test(msg) && !/session/i.test(msg);
|
||||
}
|
||||
|
||||
// ---------- 驱动管理(agent 驱动) ----------
|
||||
|
||||
export type DriverJarStatus = { file: string; size: number; installed: boolean };
|
||||
|
||||
export type DriverStatusItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
runtime: string;
|
||||
license: string;
|
||||
autoFetch: boolean;
|
||||
jars: DriverJarStatus[];
|
||||
allInstalled: boolean;
|
||||
jdkRequired: boolean;
|
||||
jdkOk: boolean;
|
||||
javaVersion: string;
|
||||
agentInstalled: boolean;
|
||||
offlinePack: boolean;
|
||||
};
|
||||
|
||||
export async function driverStatus() {
|
||||
const res = await fetch(`${API}/api/drivers/status`);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error((data as { error?: string }).error || res.statusText);
|
||||
return (data as { drivers: DriverStatusItem[] }).drivers;
|
||||
}
|
||||
|
||||
export async function driverInstall(driver: string) {
|
||||
return post<{ ok: boolean; error?: string; drivers?: DriverStatusItem[] }>("/api/drivers/install", { driver });
|
||||
}
|
||||
|
||||
export async function driverImportOffline(file: File) {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await fetch(`${API}/api/drivers/import`, { method: "POST", body: form });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error((data as { error?: string }).error || res.statusText);
|
||||
return data as { ok: boolean; error?: string; imported?: number; drivers?: DriverStatusItem[] };
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
import registry from "../../../connection-types/connection-types.json";
|
||||
|
||||
/**
|
||||
* 驱动目录 §3.11:单一事实来源是 connection-types/connection-types.json(运行时 import),
|
||||
* 本文件只做类型收敛与查询辅助。新增驱动请先改 JSON(Go 侧 open.go 分发表仍需手工同步)。
|
||||
*/
|
||||
export type DriverId =
|
||||
| "mysql"
|
||||
| "mariadb"
|
||||
| "tidb"
|
||||
| "postgresql"
|
||||
| "sqlserver"
|
||||
| "oracle"
|
||||
| "sqlite"
|
||||
| "mongodb"
|
||||
| "redis"
|
||||
| "elasticsearch"
|
||||
| "zookeeper"
|
||||
| "influxdb"
|
||||
| "dameng"
|
||||
| "oceanbase"
|
||||
| "gaussdb"
|
||||
| "opengauss" // 服务端族接受此 id(老数据/URL 粘贴兼容),选择器与 gaussdb 合并展示
|
||||
| "polardb"
|
||||
| "tdsql"
|
||||
| "kingbase";
|
||||
|
||||
export type DriverCategory = "关系型" | "NoSQL" | "国产库" | "时序/搜索" | "全部";
|
||||
|
||||
export type FormKind = "standard" | "file" | "redis" | "oracle";
|
||||
|
||||
export type DriverDef = {
|
||||
id: DriverId;
|
||||
name: string;
|
||||
category: Exclude<DriverCategory, "全部">;
|
||||
defaultPort: number;
|
||||
color: string;
|
||||
icon?: string;
|
||||
mysqlCompat?: boolean;
|
||||
ready?: boolean;
|
||||
/** 是否已在 docker 验收矩阵验收;false 时引擎列表显示「未验收」徽标 */
|
||||
verified?: boolean;
|
||||
/** 未验收/未就绪说明 */
|
||||
notes?: string;
|
||||
formKind?: FormKind;
|
||||
hints?: { host?: string; port?: string; user?: string; database?: string };
|
||||
};
|
||||
|
||||
type RegistryEntry = {
|
||||
id: string;
|
||||
label: string;
|
||||
category: Exclude<DriverCategory, "全部">;
|
||||
form?: FormKind;
|
||||
ready?: boolean;
|
||||
verified?: boolean;
|
||||
notes?: string;
|
||||
defaultPort?: number;
|
||||
mysqlCompat?: boolean;
|
||||
color?: string;
|
||||
icon?: string;
|
||||
hints?: { host?: string; port?: string; user?: string; database?: string };
|
||||
};
|
||||
|
||||
export const DRIVERS: DriverDef[] = (registry.types as RegistryEntry[]).map((t) => ({
|
||||
id: t.id as DriverId,
|
||||
name: t.label,
|
||||
category: t.category,
|
||||
defaultPort: t.defaultPort ?? 0,
|
||||
color: t.color ?? "#64748b",
|
||||
icon: t.icon,
|
||||
mysqlCompat: t.mysqlCompat,
|
||||
ready: t.ready,
|
||||
verified: t.verified,
|
||||
notes: t.notes,
|
||||
formKind: t.form,
|
||||
hints: t.hints,
|
||||
}));
|
||||
|
||||
export const CATEGORIES: DriverCategory[] = ["全部", "关系型", "NoSQL", "国产库", "时序/搜索"];
|
||||
|
||||
export function driverById(id: string) {
|
||||
return DRIVERS.find((d) => d.id === id);
|
||||
}
|
||||
|
||||
export function isDriverReady(id: string) {
|
||||
const d = driverById(id);
|
||||
return !!(d?.ready || d?.mysqlCompat);
|
||||
}
|
||||
|
||||
export function formKindOf(id: string): FormKind {
|
||||
return driverById(id)?.formKind ?? "standard";
|
||||
}
|
||||
|
||||
// ---------- 驱动能力模型 ----------
|
||||
// 对象树分组 / 右键菜单 / 行编辑等按驱动开关,禁止名不副实的功能展示
|
||||
// (如 ES 挂「存储过程」、ZK 节点提供「删除表」)。
|
||||
|
||||
export type TreeGroupKind = "tables" | "views" | "procedures" | "functions";
|
||||
|
||||
export type DriverCaps = {
|
||||
/** 库节点下展示哪些分组;空数组 = 该驱动无分组概念(走特殊树) */
|
||||
treeGroups: TreeGroupKind[];
|
||||
/** 分组显示别名(如 ES 的 tables →「索引」) */
|
||||
groupLabel: Partial<Record<TreeGroupKind, string>>;
|
||||
/** 对象节点可展开查看字段(列) */
|
||||
columns: boolean;
|
||||
/** 右键「查看 DDL」 */
|
||||
ddl: boolean;
|
||||
/** 右键「清空/截断」 */
|
||||
truncate: boolean;
|
||||
/** 右键「删除对象」(表/集合/索引…) */
|
||||
dropObject: boolean;
|
||||
/** 库节点「设为默认库」 */
|
||||
setDefaultDb: boolean;
|
||||
};
|
||||
|
||||
const ALL_GROUPS: TreeGroupKind[] = ["tables", "views", "procedures", "functions"];
|
||||
|
||||
/** 全能力 SQL 库(含达梦:JDBC 元数据全支持) */
|
||||
const SQL_CAPS: DriverCaps = {
|
||||
treeGroups: ALL_GROUPS,
|
||||
groupLabel: {},
|
||||
columns: true,
|
||||
ddl: true,
|
||||
truncate: true,
|
||||
dropObject: true,
|
||||
setDefaultDb: true,
|
||||
};
|
||||
|
||||
/** 只读 flat 对象树(无分组包装,对象直挂库节点)的 NoSQL 驱动 */
|
||||
function readonlyCaps(columns: boolean): DriverCaps {
|
||||
return {
|
||||
treeGroups: [], // flat:ES 索引/Mongo 集合/Influx measurement/ZK 子节点 直挂库节点
|
||||
groupLabel: {},
|
||||
columns,
|
||||
ddl: false,
|
||||
truncate: false,
|
||||
dropObject: false,
|
||||
setDefaultDb: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function driverCaps(driver: string): DriverCaps {
|
||||
const d = (driver || "").toLowerCase();
|
||||
switch (d) {
|
||||
case "mysql":
|
||||
case "mariadb":
|
||||
case "tidb":
|
||||
case "oceanbase":
|
||||
case "polardb":
|
||||
case "tdsql":
|
||||
case "postgresql":
|
||||
case "postgres":
|
||||
case "gaussdb":
|
||||
case "opengauss":
|
||||
case "kingbase":
|
||||
case "sqlserver":
|
||||
case "mssql":
|
||||
case "oracle":
|
||||
case "dameng":
|
||||
return SQL_CAPS;
|
||||
case "sqlite":
|
||||
// SQLite 无存储过程/函数/TRUNCATE;有视图与 DROP TABLE
|
||||
return { ...SQL_CAPS, treeGroups: ["tables", "views"], truncate: false };
|
||||
case "elasticsearch":
|
||||
// ES SQL 只读:索引≈表;无 DDL/写操作/默认库
|
||||
return readonlyCaps(true);
|
||||
case "influxdb":
|
||||
// bucket→measurement;字段可探(Flux fieldKeys),只读
|
||||
return readonlyCaps(true);
|
||||
case "mongodb":
|
||||
// 库→集合;抽样字段;写操作走 DSL 编辑器而非树右键
|
||||
return readonlyCaps(true);
|
||||
case "zookeeper":
|
||||
// 路径→子节点;节点数据靠双击 GET;无列概念
|
||||
return readonlyCaps(false);
|
||||
case "redis":
|
||||
// Redis 走专属键树(buildRedisKeyTree),这里全关兜底
|
||||
return { treeGroups: [], groupLabel: {}, columns: false, ddl: false, truncate: false, dropObject: false, setDefaultDb: false };
|
||||
default:
|
||||
return readonlyCaps(false);
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* 模块级输入对话框(C6 契约)
|
||||
*
|
||||
* 独立于 store.ts(避免与 WS-C 文件冲突):内部维护一个微型订阅队列,
|
||||
* 由 NotifyHost 内嵌的 DialogHost 订阅并以 OpsModal input 变体渲染。
|
||||
* 业务侧只需 `const name = await askInput({ title: "分组名称" })`,
|
||||
* 确认返回输入字符串,Esc/取消返回 null(替代 WKWebView 下返回 null 的 window.prompt)。
|
||||
*/
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
export type AskInputOptions = {
|
||||
title: string;
|
||||
label?: string;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
type InputRequest = AskInputOptions & {
|
||||
/** 递增 id,供渲染侧作 React key(连续请求时强制重挂,重置输入框状态) */
|
||||
id: number;
|
||||
resolve: (value: string | null) => void;
|
||||
};
|
||||
|
||||
// 请求队列:允许连续发起多个 askInput,逐个展示
|
||||
let queue: InputRequest[] = [];
|
||||
let nextId = 1;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit() {
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
/** C6 契约:弹出令牌化输入框,确认返回值 / Esc 或取消返回 null */
|
||||
export function askInput(opts: AskInputOptions): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
queue = [...queue, { ...opts, id: nextId++, resolve }];
|
||||
emit();
|
||||
});
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function getSnapshot(): InputRequest | null {
|
||||
return queue[0] ?? null;
|
||||
}
|
||||
|
||||
/** 内部:NotifyHost 的 DialogHost 订阅当前待处理的输入请求 */
|
||||
export function useInputRequest(): InputRequest | null {
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
}
|
||||
|
||||
/** 内部:消费当前请求(确认传值,取消传 null),并出队下一个 */
|
||||
export function settleInputRequest(value: string | null) {
|
||||
const [cur, ...rest] = queue;
|
||||
queue = rest;
|
||||
cur?.resolve(value);
|
||||
emit();
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/** Vite-bundled driver icons — works in Tauri/web without depending on /public path. */
|
||||
const modules = import.meta.glob("../assets/databases/*.{svg,png}", {
|
||||
eager: true,
|
||||
query: "?url",
|
||||
import: "default",
|
||||
}) as Record<string, string>;
|
||||
|
||||
function fileKey(path: string): string {
|
||||
const base = path.split("/").pop() ?? path;
|
||||
return base.replace(/\.(svg|png)$/i, "").toLowerCase();
|
||||
}
|
||||
|
||||
/** id → bundled asset URL */
|
||||
export const DRIVER_ICON_URLS: Record<string, string> = {};
|
||||
for (const [path, url] of Object.entries(modules)) {
|
||||
DRIVER_ICON_URLS[fileKey(path)] = url;
|
||||
}
|
||||
|
||||
/** Resolve catalog icon path or id to a loadable URL. */
|
||||
export function resolveDriverIconUrl(id: string, icon?: string): string | undefined {
|
||||
const byId = DRIVER_ICON_URLS[id.toLowerCase()];
|
||||
if (byId) return byId;
|
||||
if (!icon) return undefined;
|
||||
const m = icon.match(/\/([^/]+)\.(svg|png)$/i);
|
||||
if (m) {
|
||||
const byFile = DRIVER_ICON_URLS[m[1].toLowerCase()];
|
||||
if (byFile) return byFile;
|
||||
}
|
||||
// last resort: absolute/public path (web dev)
|
||||
return icon;
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import type { DriverId } from "./catalog";
|
||||
import { driverById } from "./catalog";
|
||||
|
||||
const PROTO: Record<string, DriverId> = {
|
||||
mysql: "mysql",
|
||||
mariadb: "mariadb",
|
||||
postgres: "postgresql",
|
||||
postgresql: "postgresql",
|
||||
redis: "redis",
|
||||
sqlite: "sqlite",
|
||||
file: "sqlite",
|
||||
sqlserver: "sqlserver",
|
||||
mssql: "sqlserver",
|
||||
oracle: "oracle",
|
||||
tidb: "tidb",
|
||||
mongodb: "mongodb",
|
||||
mongo: "mongodb",
|
||||
oceanbase: "oceanbase",
|
||||
gaussdb: "gaussdb",
|
||||
opengauss: "gaussdb",
|
||||
polardb: "polardb",
|
||||
tdsql: "tdsql",
|
||||
kingbase: "kingbase",
|
||||
dameng: "dameng",
|
||||
};
|
||||
|
||||
export type ParsedDSN = {
|
||||
driver: DriverId;
|
||||
host?: string;
|
||||
port?: number;
|
||||
user?: string;
|
||||
password?: string;
|
||||
database?: string;
|
||||
ssl?: boolean;
|
||||
};
|
||||
|
||||
/** 粘贴 mysql:// / postgres:// / redis:// / sqlite: 路径即可拆字段 */
|
||||
export function parseDSN(raw: string): ParsedDSN | null {
|
||||
const s = raw.trim();
|
||||
if (!s) return null;
|
||||
if (/^[a-zA-Z]:[\\/]/.test(s) || s.startsWith("/") || s.endsWith(".db") || s.endsWith(".sqlite")) {
|
||||
return { driver: "sqlite", database: s };
|
||||
}
|
||||
try {
|
||||
const u = new URL(s.includes("://") ? s : `mysql://${s}`);
|
||||
const proto = u.protocol.replace(":", "").toLowerCase();
|
||||
const driver = PROTO[proto];
|
||||
if (!driver) return null;
|
||||
const database = decodeURIComponent(u.pathname.replace(/^\/+/, ""));
|
||||
const port = u.port ? Number(u.port) : driverById(driver)?.defaultPort;
|
||||
const ssl = u.searchParams.get("sslmode") === "require" || u.searchParams.get("ssl") === "true";
|
||||
return {
|
||||
driver,
|
||||
host: u.hostname || undefined,
|
||||
port: port || undefined,
|
||||
user: decodeURIComponent(u.username || "") || undefined,
|
||||
password: decodeURIComponent(u.password || "") || undefined,
|
||||
database: database || undefined,
|
||||
ssl,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
export { OpsxWorkspace } from "./Workspace";
|
||||
export { useWorkspaceStore } from "./store";
|
||||
export { ConnectionPicker } from "./ConnectionPicker";
|
||||
export { ConnectionList } from "./ConnectionList";
|
||||
export { AiPanel } from "./AiPanel";
|
||||
export { DRIVERS, CATEGORIES, driverById, isDriverReady } from "./catalog";
|
||||
export type { DriverId, DriverDef, DriverCategory, FormKind } from "./catalog";
|
||||
export { NotifyHost } from "./NotifyHost";
|
||||
export { formatDbError } from "./notify";
|
||||
export { OpsModal } from "./OpsModal";
|
||||
export type { OpsModalProps, OpsModalVariant } from "./OpsModal";
|
||||
export { askInput } from "./dialogs";
|
||||
export type { AskInputOptions } from "./dialogs";
|
||||
export { DriverIcon } from "./DriverIcon";
|
||||
|
||||
export { SchemaPane } from "./SchemaPane";
|
||||
@@ -1,86 +0,0 @@
|
||||
/** Format raw DB / API errors into a brief in-app dialog payload. */
|
||||
|
||||
export type NotifyKind = "error" | "warning" | "info" | "success";
|
||||
|
||||
export type NotifyPayload = {
|
||||
id: string;
|
||||
kind: NotifyKind;
|
||||
title: string;
|
||||
message: string;
|
||||
detail?: string;
|
||||
tips?: string[];
|
||||
/** auto-dismiss ms for toast mode; modal stays until closed */
|
||||
mode: "modal" | "toast";
|
||||
durationMs?: number;
|
||||
};
|
||||
|
||||
type Parsed = {
|
||||
kind: NotifyKind;
|
||||
title: string;
|
||||
message: string;
|
||||
detail?: string;
|
||||
tips?: string[];
|
||||
};
|
||||
|
||||
export function formatDbError(raw: unknown): Parsed {
|
||||
const text = raw instanceof Error ? raw.message : String(raw ?? "未知错误");
|
||||
const lower = text.toLowerCase();
|
||||
|
||||
// MySQL / MariaDB Access denied
|
||||
if (/error\s*1045|access denied for user/i.test(text)) {
|
||||
const noPassword = /using password:\s*NO/i.test(text);
|
||||
return {
|
||||
kind: "error",
|
||||
title: "认证失败",
|
||||
message: noPassword ? "未填密码,服务器拒绝空密码登录。" : "用户名或密码错误,或主机无权访问。",
|
||||
detail: text,
|
||||
};
|
||||
}
|
||||
|
||||
if (/error\s*2003|can't connect|connection refused|econnrefused/i.test(text) || lower.includes("connect")) {
|
||||
if (/refused|2003|econnrefused|timed out|timeout|unreachable/i.test(text)) {
|
||||
return {
|
||||
kind: "error",
|
||||
title: "无法连接",
|
||||
message: "主机不可达,或端口未开 / 服务未启动。",
|
||||
detail: text,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (/error\s*1049|unknown database/i.test(text)) {
|
||||
return {
|
||||
kind: "error",
|
||||
title: "库不存在",
|
||||
message: "数据库名不正确;可留空连接后再选。",
|
||||
detail: text,
|
||||
};
|
||||
}
|
||||
|
||||
if (/驱动开发中/.test(text)) {
|
||||
return {
|
||||
kind: "warning",
|
||||
title: "驱动开发中",
|
||||
message: text,
|
||||
};
|
||||
}
|
||||
|
||||
if (/开发中/.test(text)) {
|
||||
return {
|
||||
kind: "info",
|
||||
title: "即将推出",
|
||||
message: text.replace(/:开发中$/, "功能开发中。"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "error",
|
||||
title: "操作失败",
|
||||
message: text.length > 120 ? "发生错误,详见详情。" : text,
|
||||
detail: text.length > 120 ? text : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function notifyId() {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildRedisKeyTree,
|
||||
createKeyCommand,
|
||||
isDangerousRedis,
|
||||
quoteRedisArg,
|
||||
redisCmd,
|
||||
redisLeafLabel,
|
||||
removeRedisKeyFromTree,
|
||||
splitRedisCommands,
|
||||
tryPrettyJson,
|
||||
tsLabel,
|
||||
} from "./redis";
|
||||
|
||||
describe("buildRedisKeyTree", () => {
|
||||
it("按 : 编文件夹,叶子保留全名", () => {
|
||||
const tree = buildRedisKeyTree(
|
||||
[
|
||||
{ name: "CEX:FOLLOW:FOLLOWERS:FUTURE:97865651", type: "hash", ttl: -1 },
|
||||
{ name: "CEX:FOLLOW:TRADERS:1", type: "hash", ttl: -1 },
|
||||
{ name: "LANG", type: "string", ttl: 12 },
|
||||
],
|
||||
":",
|
||||
);
|
||||
expect(tree.map((n) => n.name)).toEqual(["CEX", "LANG"]);
|
||||
const cex = tree[0];
|
||||
expect(cex.kind).toBe("folder");
|
||||
expect(cex.count).toBe(2);
|
||||
expect(cex.fullName).toBe("CEX:");
|
||||
const follow = cex.children?.[0];
|
||||
expect(follow?.name).toBe("FOLLOW");
|
||||
const leaves = (follow?.children ?? []).flatMap((n) =>
|
||||
n.kind === "folder" ? n.children ?? [] : [n],
|
||||
);
|
||||
expect(leaves.some((n) => n.name === "CEX:FOLLOW:TRADERS:1")).toBe(true);
|
||||
});
|
||||
|
||||
it("无分隔符时保持扁平", () => {
|
||||
const tree = buildRedisKeyTree(
|
||||
[
|
||||
{ name: "alpha", type: "string", ttl: -1 },
|
||||
{ name: "beta", type: "hash", ttl: 3 },
|
||||
],
|
||||
":",
|
||||
);
|
||||
expect(tree.every((n) => n.kind === "key")).toBe(true);
|
||||
expect(tree.map((n) => n.name)).toEqual(["alpha", "beta"]);
|
||||
});
|
||||
|
||||
it("删除键后收起空文件夹", () => {
|
||||
const tree = buildRedisKeyTree(
|
||||
[
|
||||
{ name: "a:b:c", type: "string", ttl: -1 },
|
||||
{ name: "a:x", type: "string", ttl: -1 },
|
||||
],
|
||||
":",
|
||||
);
|
||||
const next = removeRedisKeyFromTree(tree, "a:b:c");
|
||||
expect(next).toHaveLength(1);
|
||||
expect(next[0].kind).toBe("folder");
|
||||
expect(next[0].count).toBe(1);
|
||||
expect(next[0].children?.[0].name).toBe("a:x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("quote / cmd", () => {
|
||||
it("空白与引号转义", () => {
|
||||
expect(quoteRedisArg("abc")).toBe("abc");
|
||||
expect(quoteRedisArg("a b")).toBe('"a b"');
|
||||
expect(quoteRedisArg('say "hi"')).toBe('"say \\"hi\\""');
|
||||
expect(redisCmd("SET", "k", "v w")).toBe('SET k "v w"');
|
||||
});
|
||||
it("新建键命令", () => {
|
||||
expect(createKeyCommand("string", "demo", "x")).toBe("SET demo x");
|
||||
expect(createKeyCommand("hash", "h", "v")).toBe("HSET h field v");
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitRedisCommands", () => {
|
||||
it("按行切,跳过空行和 # 注释", () => {
|
||||
const parts = splitRedisCommands("GET a\n\n# skip\nHGETALL b");
|
||||
expect(parts.map((p) => p.text)).toEqual(["GET a", "HGETALL b"]);
|
||||
});
|
||||
it("不把分号当分隔", () => {
|
||||
const parts = splitRedisCommands('SET k "a;b"');
|
||||
expect(parts).toHaveLength(1);
|
||||
expect(parts[0].text).toBe('SET k "a;b"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("misc", () => {
|
||||
it("危险命令", () => {
|
||||
expect(isDangerousRedis("FLUSHALL")).toBe(true);
|
||||
expect(isDangerousRedis("GET k")).toBe(false);
|
||||
expect(isDangerousRedis("keys *")).toBe(true);
|
||||
});
|
||||
it("JSON 美化", () => {
|
||||
expect(tryPrettyJson('{"a":1}')).toBe('{\n "a": 1\n}');
|
||||
expect(tryPrettyJson("not-json")).toBeNull();
|
||||
});
|
||||
it("leaf label", () => {
|
||||
expect(redisLeafLabel("a:b:c", ":")).toBe("c");
|
||||
expect(redisLeafLabel("plain", ":")).toBe("plain");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tsLabel", () => {
|
||||
it("毫秒时间戳给可读时间", () => {
|
||||
expect(tsLabel(1782230400000)).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/);
|
||||
});
|
||||
it("秒时间戳给可读时间", () => {
|
||||
expect(tsLabel(1782230400)).toMatch(/^\d{4}-/);
|
||||
});
|
||||
it("普通数字/非法值不误判", () => {
|
||||
expect(tsLabel(10)).toBeNull();
|
||||
expect(tsLabel(77777777777777)).toBeNull();
|
||||
expect(tsLabel(Number.NaN)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,280 +0,0 @@
|
||||
import type { TreeNode } from "./store";
|
||||
|
||||
export type RedisKeyMeta = { name: string; type: string; ttl: number };
|
||||
|
||||
/** 类型点颜色,对齐常见 Redis GUI */
|
||||
export function redisTypeColor(type?: string): string {
|
||||
switch ((type || "").toLowerCase()) {
|
||||
case "string":
|
||||
return "#22c55e";
|
||||
case "hash":
|
||||
return "#3b82f6";
|
||||
case "list":
|
||||
return "#f59e0b";
|
||||
case "set":
|
||||
return "#a855f7";
|
||||
case "zset":
|
||||
return "#06b6d4";
|
||||
case "stream":
|
||||
return "#ec4899";
|
||||
case "rejson":
|
||||
case "json":
|
||||
case "rejson-rl":
|
||||
return "#14b8a6";
|
||||
default:
|
||||
return "#ef4444";
|
||||
}
|
||||
}
|
||||
|
||||
/** TTL 可读:永久 / 不存在 / Ns */
|
||||
export function formatRedisTtl(ttl: number | undefined): string {
|
||||
if (ttl == null) return "";
|
||||
if (ttl === -1) return "永久";
|
||||
if (ttl === -2) return "不存在";
|
||||
if (ttl < 0) return String(ttl);
|
||||
if (ttl < 60) return `${ttl}s`;
|
||||
if (ttl < 3600) return `${Math.floor(ttl / 60)}m${ttl % 60 ? `${ttl % 60}s` : ""}`;
|
||||
if (ttl < 86400) {
|
||||
const h = Math.floor(ttl / 3600);
|
||||
const m = Math.floor((ttl % 3600) / 60);
|
||||
return m ? `${h}h${m}m` : `${h}h`;
|
||||
}
|
||||
const d = Math.floor(ttl / 86400);
|
||||
const h = Math.floor((ttl % 86400) / 3600);
|
||||
return h ? `${d}d${h}h` : `${d}d`;
|
||||
}
|
||||
|
||||
/** 数值若形如 Unix 时间戳(秒/毫秒)则给可读时间,否则 null —— zset Score 常存毫秒时间戳(如 K 线) */
|
||||
export function tsLabel(n: number): string | null {
|
||||
if (!Number.isFinite(n)) return null;
|
||||
let d: Date | null = null;
|
||||
if (n > 1e11 && n < 9e12) d = new Date(n); // 毫秒
|
||||
else if (n > 1e8 && n < 9e9) d = new Date(n * 1000); // 秒
|
||||
if (!d || Number.isNaN(d.getTime())) return null;
|
||||
const p = (x: number) => String(x).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
||||
}
|
||||
|
||||
export function redisLeafLabel(full: string, sep: string): string {
|
||||
if (!sep) return full;
|
||||
const i = full.lastIndexOf(sep);
|
||||
if (i < 0) return full;
|
||||
const leaf = full.slice(i + sep.length);
|
||||
return leaf || full;
|
||||
}
|
||||
|
||||
type Bucket = {
|
||||
folders: Map<string, Bucket>;
|
||||
keys: RedisKeyMeta[];
|
||||
};
|
||||
|
||||
function keyCount(n: TreeNode): number {
|
||||
if (n.kind === "key") return 1;
|
||||
return n.count ?? (n.children ?? []).reduce((a, c) => a + keyCount(c), 0);
|
||||
}
|
||||
|
||||
/** 按分隔符把 SCAN 结果编成文件夹树(默认 `:`,对标 Another Redis Desktop Manager)。 */
|
||||
export function buildRedisKeyTree(keys: RedisKeyMeta[], separator = ":"): TreeNode[] {
|
||||
const sep = separator || ":";
|
||||
const root: Bucket = { folders: new Map(), keys: [] };
|
||||
for (const k of keys) {
|
||||
const parts = k.name.split(sep);
|
||||
if (parts.length <= 1) {
|
||||
root.keys.push(k);
|
||||
continue;
|
||||
}
|
||||
let cur = root;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
const p = parts[i] ?? "";
|
||||
let next = cur.folders.get(p);
|
||||
if (!next) {
|
||||
next = { folders: new Map(), keys: [] };
|
||||
cur.folders.set(p, next);
|
||||
}
|
||||
cur = next;
|
||||
}
|
||||
cur.keys.push(k);
|
||||
}
|
||||
return bucketToNodes(root, "", sep);
|
||||
}
|
||||
|
||||
function bucketToNodes(b: Bucket, prefix: string, sep: string): TreeNode[] {
|
||||
const folderNames = [...b.folders.keys()].sort((a, c) => a.localeCompare(c, undefined, { numeric: true }));
|
||||
const folders: TreeNode[] = folderNames.map((name) => {
|
||||
const child = b.folders.get(name)!;
|
||||
const fullName = prefix + name + sep;
|
||||
const children = bucketToNodes(child, fullName, sep);
|
||||
return {
|
||||
name,
|
||||
kind: "folder" as const,
|
||||
groupId: "folder",
|
||||
fullName,
|
||||
loaded: true,
|
||||
count: children.reduce((a, n) => a + keyCount(n), 0),
|
||||
children,
|
||||
};
|
||||
});
|
||||
const keys: TreeNode[] = [...b.keys]
|
||||
.sort((a, c) => a.name.localeCompare(c.name, undefined, { numeric: true }))
|
||||
.map((k) => ({
|
||||
name: k.name,
|
||||
kind: "key" as const,
|
||||
type: k.type,
|
||||
ttl: k.ttl,
|
||||
}));
|
||||
return [...folders, ...keys];
|
||||
}
|
||||
|
||||
export function removeRedisKeyFromTree(nodes: TreeNode[], key: string): TreeNode[] {
|
||||
const out: TreeNode[] = [];
|
||||
for (const n of nodes) {
|
||||
if (n.kind === "key" && n.name === key) continue;
|
||||
if (n.kind === "folder") {
|
||||
const children = removeRedisKeyFromTree(n.children ?? [], key);
|
||||
if (!children.length) continue;
|
||||
out.push({
|
||||
...n,
|
||||
children,
|
||||
count: children.reduce((a, c) => a + keyCount(c), 0),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
out.push(n);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 前端过滤键树:子串匹配键名/路径 + 类型筛选;空文件夹剪枝,计数同步 */
|
||||
export function filterRedisTree(nodes: TreeNode[], q: string, type: string): TreeNode[] {
|
||||
const needle = q.trim().toLowerCase();
|
||||
const typeOn = type && type !== "all";
|
||||
if (!needle && !typeOn) return nodes;
|
||||
const keep = (n: TreeNode): boolean =>
|
||||
!needle || n.name.toLowerCase().includes(needle) || (n.fullName || "").toLowerCase().includes(needle);
|
||||
const typeOk = (n: TreeNode): boolean => !typeOn || (n.type || "").toLowerCase() === type;
|
||||
const walk = (list: TreeNode[]): TreeNode[] => {
|
||||
const out: TreeNode[] = [];
|
||||
for (const n of list) {
|
||||
if (n.kind === "folder") {
|
||||
const children = walk(n.children ?? []);
|
||||
if (!children.length) continue;
|
||||
out.push({ ...n, children, count: children.reduce((a, c) => a + keyCount(c), 0) });
|
||||
continue;
|
||||
}
|
||||
if (n.kind === "key") {
|
||||
if (keep(n) && typeOk(n)) out.push(n);
|
||||
continue;
|
||||
}
|
||||
out.push(n);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
return walk(nodes);
|
||||
}
|
||||
|
||||
export function quoteRedisArg(s: string): string {
|
||||
if (s === "") return '""';
|
||||
if (/^[\w\-.:/@+=]+$/.test(s)) return s;
|
||||
return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
export function redisCmd(...parts: string[]): string {
|
||||
return parts.map(quoteRedisArg).join(" ");
|
||||
}
|
||||
|
||||
export function tryPrettyJson(raw: unknown): string | null {
|
||||
if (raw == null) return null;
|
||||
if (typeof raw === "object") {
|
||||
try {
|
||||
return JSON.stringify(raw, null, 2);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const s = String(raw).trim();
|
||||
if (!s || (s[0] !== "{" && s[0] !== "[" && s[0] !== '"')) return null;
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(s), null, 2);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function displayRedisValue(raw: unknown, max = 180): { text: string; pretty: string | null } {
|
||||
const pretty = tryPrettyJson(raw);
|
||||
const full = pretty ?? (raw == null ? "" : String(raw));
|
||||
if (full.length > max) return { text: `${full.slice(0, max)}…`, pretty };
|
||||
return { text: full, pretty };
|
||||
}
|
||||
|
||||
export const REDIS_COMMANDS = [
|
||||
"PING", "GET", "SET", "DEL", "EXISTS", "TTL", "PTTL", "EXPIRE", "PERSIST", "TYPE", "RENAME",
|
||||
"KEYS", "SCAN", "INFO", "DBSIZE", "SELECT", "ECHO",
|
||||
"HGET", "HGETALL", "HSET", "HDEL", "HKEYS", "HVALS", "HLEN", "HEXISTS", "HSCAN",
|
||||
"LPUSH", "RPUSH", "LRANGE", "LLEN", "LSET", "LPOP", "RPOP", "LINDEX",
|
||||
"SADD", "SREM", "SMEMBERS", "SCARD", "SISMEMBER", "SSCAN",
|
||||
"ZADD", "ZREM", "ZRANGE", "ZREVRANGE", "ZCARD", "ZSCORE", "ZSCAN",
|
||||
"MGET", "MSET", "INCR", "DECR", "INCRBY", "DECRBY", "APPEND", "STRLEN",
|
||||
"FLUSHDB", "FLUSHALL", "CONFIG", "CLIENT", "SLOWLOG", "MEMORY",
|
||||
].map((label) => ({ label, type: "keyword" as const }));
|
||||
|
||||
/** Redis 按行切命令(不是 SQL 分号)。空行与 # 注释跳过。 */
|
||||
export function splitRedisCommands(sql: string): { start: number; end: number; text: string }[] {
|
||||
const out: { start: number; end: number; text: string }[] = [];
|
||||
let i = 0;
|
||||
while (i <= sql.length) {
|
||||
const nl = sql.indexOf("\n", i);
|
||||
const end = nl < 0 ? sql.length : nl;
|
||||
const line = sql.slice(i, end);
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith("#")) {
|
||||
const lead = line.search(/\S/);
|
||||
const from = i + (lead < 0 ? 0 : lead);
|
||||
out.push({ start: from, end: from + trimmed.length, text: trimmed });
|
||||
}
|
||||
if (nl < 0) break;
|
||||
i = end + 1;
|
||||
}
|
||||
return out.length ? out : [{ start: 0, end: sql.length, text: sql }];
|
||||
}
|
||||
|
||||
export function isDangerousRedis(sql: string): boolean {
|
||||
const cmd = sql.trim().split(/\s+/)[0]?.toUpperCase() ?? "";
|
||||
return ["FLUSHALL", "FLUSHDB", "KEYS", "CONFIG", "SHUTDOWN", "DEBUG", "REPLICAOF", "SLAVEOF"].includes(cmd);
|
||||
}
|
||||
|
||||
export function redisHintForType(key: string, type?: string): string {
|
||||
const k = quoteRedisArg(key);
|
||||
switch (type) {
|
||||
case "hash":
|
||||
return `HGETALL ${k}`;
|
||||
case "list":
|
||||
return `LRANGE ${k} 0 499`;
|
||||
case "set":
|
||||
return `SMEMBERS ${k}`;
|
||||
case "zset":
|
||||
return `ZRANGE ${k} 0 499 WITHSCORES`;
|
||||
default:
|
||||
return `GET ${k}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function createKeyCommand(type: string, name: string, value: string): string {
|
||||
switch (type) {
|
||||
case "hash":
|
||||
return redisCmd("HSET", name, "field", value || "");
|
||||
case "list":
|
||||
return redisCmd("RPUSH", name, value || "");
|
||||
case "set":
|
||||
return redisCmd("SADD", name, value || "");
|
||||
case "zset":
|
||||
return redisCmd("ZADD", name, "0", value || "");
|
||||
default:
|
||||
return redisCmd("SET", name, value || "");
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否 Redis 连接(按驱动名) */
|
||||
export function isRedisConn(driver?: string): boolean {
|
||||
return (driver || "").toLowerCase() === "redis";
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
const PREFIX = "ox1.";
|
||||
|
||||
/** 本机混淆,不是保险箱。专业客户端最终应走 OS Keychain。 */
|
||||
export function encodeSecret(plain: string): string {
|
||||
if (!plain || plain.startsWith(PREFIX)) return plain;
|
||||
try {
|
||||
return PREFIX + btoa(unescape(encodeURIComponent(plain)));
|
||||
} catch {
|
||||
return plain;
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeSecret(stored: string): string {
|
||||
if (!stored) return "";
|
||||
if (!stored.startsWith(PREFIX)) return stored;
|
||||
try {
|
||||
return decodeURIComponent(escape(atob(stored.slice(PREFIX.length))));
|
||||
} catch {
|
||||
return stored;
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeSecret(raw: string): string {
|
||||
return raw.replace(/[\u200B-\u200D\uFEFF]/g, "").trim();
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isDangerousSQL,
|
||||
parseClipboardGrid,
|
||||
quoteIdent,
|
||||
rowsToCSV,
|
||||
rowsToInsert,
|
||||
splitRunnable,
|
||||
splitStatements,
|
||||
statementAt,
|
||||
} from "./sql";
|
||||
|
||||
describe("rowsToCSV", () => {
|
||||
it("转义引号/逗号/换行", () => {
|
||||
const out = rowsToCSV(
|
||||
["a", "b"],
|
||||
[{ a: 'x"y', b: "l1\nl2" }, { a: "c,d", b: null }],
|
||||
);
|
||||
expect(out).toBe('a,b\n"x""y","l1\nl2"\n"c,d",');
|
||||
});
|
||||
|
||||
it("空值输出空串并保留表头", () => {
|
||||
expect(rowsToCSV(["a"], [{ a: null }])).toBe("a\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rowsToInsert(方言引号)", () => {
|
||||
const rows = [{ id: 1, name: "O'Neil" }, { id: 2, name: null }];
|
||||
|
||||
it("mysql 族反引号 + 单引号字面量翻倍", () => {
|
||||
expect(rowsToInsert("mysql", "users", ["id", "name"], rows)).toBe(
|
||||
"INSERT INTO `users` (`id`, `name`) VALUES (1, 'O''Neil');\n" +
|
||||
"INSERT INTO `users` (`id`, `name`) VALUES (2, NULL);",
|
||||
);
|
||||
});
|
||||
|
||||
it("postgresql/sqlite 双引号", () => {
|
||||
expect(rowsToInsert("postgresql", "users", ["id"], [{ id: 1 }])).toBe(
|
||||
'INSERT INTO "users" ("id") VALUES (1);',
|
||||
);
|
||||
expect(rowsToInsert("sqlite", "users", ["id"], [{ id: 1 }])).toBe(
|
||||
'INSERT INTO "users" ("id") VALUES (1);',
|
||||
);
|
||||
});
|
||||
|
||||
it("mssql 中括号", () => {
|
||||
expect(rowsToInsert("sqlserver", "users", ["id"], [{ id: 1 }])).toBe(
|
||||
"INSERT INTO [users] ([id]) VALUES (1);",
|
||||
);
|
||||
});
|
||||
|
||||
it("oracle 裸名(大写惯例不加引号)", () => {
|
||||
expect(rowsToInsert("oracle", "USERS", ["ID"], [{ ID: 1 }])).toBe(
|
||||
"INSERT INTO USERS (ID) VALUES (1);",
|
||||
);
|
||||
});
|
||||
|
||||
it("库.表 全限定逐段加引号;内部反引号/双引号/中括号转义", () => {
|
||||
expect(rowsToInsert("mysql", "app.users", ["id"], [{ id: 1 }])).toBe(
|
||||
"INSERT INTO `app`.`users` (`id`) VALUES (1);",
|
||||
);
|
||||
expect(quoteIdent("mysql", "a`b")).toBe("`a``b`");
|
||||
expect(quoteIdent("postgresql", 'a"b')).toBe('"a""b"');
|
||||
expect(quoteIdent("sqlserver", "a]b")).toBe("[a]]b]");
|
||||
});
|
||||
|
||||
it("缺省 driver 按 mysql 处理", () => {
|
||||
expect(rowsToInsert(undefined, "t", ["c"], [{ c: "v" }])).toBe("INSERT INTO `t` (`c`) VALUES ('v');");
|
||||
});
|
||||
});
|
||||
|
||||
describe("quoteIdent", () => {
|
||||
it("各 family 引号风格", () => {
|
||||
expect(quoteIdent("mysql", "t")).toBe("`t`");
|
||||
expect(quoteIdent("mariadb", "t")).toBe("`t`");
|
||||
expect(quoteIdent("postgresql", "t")).toBe('"t"');
|
||||
expect(quoteIdent("gaussdb", "t")).toBe('"t"');
|
||||
expect(quoteIdent("sqlserver", "t")).toBe("[t]");
|
||||
expect(quoteIdent("mssql", "t")).toBe("[t]");
|
||||
expect(quoteIdent("oracle", "EMP")).toBe("EMP");
|
||||
expect(quoteIdent("sqlite", "t")).toBe('"t"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitStatements", () => {
|
||||
it("引号内分号不截断", () => {
|
||||
const parts = splitStatements("SELECT ';' AS x; SELECT 2");
|
||||
expect(parts).toHaveLength(2);
|
||||
expect(parts[0].text).toBe("SELECT ';' AS x");
|
||||
expect(parts[1].text.trim()).toBe("SELECT 2");
|
||||
});
|
||||
|
||||
it("行注释内分号不截断", () => {
|
||||
const parts = splitStatements("SELECT 1 -- c;\n; SELECT 2");
|
||||
expect(parts).toHaveLength(2);
|
||||
expect(parts[1].text.trim()).toBe("SELECT 2");
|
||||
});
|
||||
|
||||
it("块注释内分号不截断", () => {
|
||||
const parts = splitStatements("SELECT /* ; */ 1; SELECT 2");
|
||||
expect(parts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("双引号字符串内分号不截断", () => {
|
||||
const parts = splitStatements('SELECT "a;b" FROM t; SELECT 2');
|
||||
expect(parts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("单语句无分号原样返回", () => {
|
||||
const parts = splitStatements("SELECT 1");
|
||||
expect(parts).toHaveLength(1);
|
||||
expect(parts[0].text).toBe("SELECT 1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseClipboardGrid", () => {
|
||||
it("Tab 分隔", () => {
|
||||
expect(parseClipboardGrid("a\tb\nc\td")).toEqual([
|
||||
["a", "b"],
|
||||
["c", "d"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("literal NULL(任意大小写)→ null", () => {
|
||||
expect(parseClipboardGrid("x,NULL,y")).toEqual([["x", null, "y"]]);
|
||||
expect(parseClipboardGrid("null")).toEqual([[null]]);
|
||||
});
|
||||
|
||||
it("无 Tab 的简单 CSV 按逗号拆", () => {
|
||||
expect(parseClipboardGrid('a,"b,c"')).toEqual([["a", "b,c"]]);
|
||||
});
|
||||
|
||||
it("CRLF 与末尾换行归一", () => {
|
||||
expect(parseClipboardGrid("a\r\nb\n")).toEqual([["a"], ["b"]]);
|
||||
});
|
||||
|
||||
it("空串返回空数组", () => {
|
||||
expect(parseClipboardGrid("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDangerousSQL", () => {
|
||||
it("基本危险动词", () => {
|
||||
expect(isDangerousSQL("DROP TABLE t")).toBe(true);
|
||||
expect(isDangerousSQL("TRUNCATE TABLE t")).toBe(true);
|
||||
expect(isDangerousSQL("ALTER TABLE t ADD c INT")).toBe(true);
|
||||
expect(isDangerousSQL("GRANT ALL ON a TO b")).toBe(true);
|
||||
expect(isDangerousSQL("REVOKE SELECT ON a FROM b")).toBe(true);
|
||||
});
|
||||
|
||||
it("DELETE/UPDATE 无 WHERE 才算危险", () => {
|
||||
expect(isDangerousSQL("DELETE FROM t")).toBe(true);
|
||||
expect(isDangerousSQL("DELETE FROM t WHERE id=1")).toBe(false);
|
||||
expect(isDangerousSQL("UPDATE t SET a=1")).toBe(true);
|
||||
expect(isDangerousSQL("UPDATE t SET a=1 WHERE id=2")).toBe(false);
|
||||
});
|
||||
|
||||
it("注释开头的语句仍判定", () => {
|
||||
expect(isDangerousSQL("-- hi\nDROP TABLE t")).toBe(true);
|
||||
expect(isDangerousSQL("/* c */ DROP TABLE t")).toBe(true);
|
||||
expect(isDangerousSQL(" -- hi\n /* c */ TRUNCATE TABLE t")).toBe(true);
|
||||
});
|
||||
|
||||
it("普通查询与纯注释不误伤", () => {
|
||||
expect(isDangerousSQL("SELECT 1")).toBe(false);
|
||||
expect(isDangerousSQL("WITH x AS (SELECT 1) SELECT * FROM x")).toBe(false);
|
||||
expect(isDangerousSQL("-- only comment")).toBe(false);
|
||||
expect(isDangerousSQL("")).toBe(false);
|
||||
});
|
||||
|
||||
it("Redis 危险命令按 driver 判定", () => {
|
||||
expect(isDangerousSQL("FLUSHALL", "redis")).toBe(true);
|
||||
expect(isDangerousSQL("GET k", "redis")).toBe(false);
|
||||
expect(isDangerousSQL("FLUSHALL")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitRunnable / statementAt redis", () => {
|
||||
it("Redis 按行而不是分号", () => {
|
||||
const parts = splitRunnable("GET a\nHGETALL b", "redis");
|
||||
expect(parts.map((p) => p.text)).toEqual(["GET a", "HGETALL b"]);
|
||||
expect(statementAt('SET k "a;b"\nGET k', 0, undefined, "redis")).toBe('SET k "a;b"');
|
||||
});
|
||||
});
|
||||
@@ -1,392 +0,0 @@
|
||||
import { format as sqlFormat } from "sql-formatter";
|
||||
import { isDangerousRedis, splitRedisCommands } from "./redis";
|
||||
|
||||
/**
|
||||
* 危险语句 UX 预检(免一次往返)。
|
||||
* 注意:服务端 CheckDangerousSQL 为权威判定(逐语句/词边界/注释处理),
|
||||
* 此处只做宽松预提示,判定冲突时以服务端为准(needConfirm 二段流)。
|
||||
*/
|
||||
export function isDangerousSQL(sql: string, driver?: string): boolean {
|
||||
if ((driver || "").toLowerCase() === "redis") return isDangerousRedis(sql);
|
||||
const s = stripLead(sql);
|
||||
if (!s) return false;
|
||||
const up = s.toUpperCase();
|
||||
if (/^(DROP|TRUNCATE|ALTER|GRANT|REVOKE)\b/.test(up)) return true;
|
||||
if (/^DELETE\b/.test(up) && !/\bWHERE\b/.test(up)) return true;
|
||||
if (/^UPDATE\b/.test(up) && !/\bWHERE\b/.test(up)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function stripLead(sql: string): string {
|
||||
let s = sql.trim();
|
||||
while (true) {
|
||||
if (s.startsWith("--")) {
|
||||
const i = s.indexOf("\n");
|
||||
if (i < 0) return "";
|
||||
s = s.slice(i + 1).trim();
|
||||
continue;
|
||||
}
|
||||
if (s.startsWith("/*")) {
|
||||
const i = s.indexOf("*/");
|
||||
if (i < 0) return "";
|
||||
s = s.slice(i + 2).trim();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 选区优先;否则取光标所在语句。Redis 按行,SQL 按分号。 */
|
||||
export function statementAt(sql: string, cursor: number, selection?: string, driver?: string): string {
|
||||
const sel = selection?.trim();
|
||||
if (sel) return sel;
|
||||
const ranges = splitRunnable(sql, driver);
|
||||
const pos = Math.max(0, Math.min(cursor, sql.length));
|
||||
const hit = ranges.find((r) => pos >= r.start && pos <= r.end) ?? ranges.find((r) => pos < r.start);
|
||||
return (hit?.text || sql).trim();
|
||||
}
|
||||
|
||||
/** Redis 按行;其它引擎按 SQL 分号。 */
|
||||
export function splitRunnable(sql: string, driver?: string): { start: number; end: number; text: string }[] {
|
||||
if ((driver || "").toLowerCase() === "redis") return splitRedisCommands(sql);
|
||||
return splitStatements(sql);
|
||||
}
|
||||
|
||||
export function splitStatements(sql: string): { start: number; end: number; text: string }[] {
|
||||
const out: { start: number; end: number; text: string }[] = [];
|
||||
let start = 0;
|
||||
let i = 0;
|
||||
let inS = false;
|
||||
let inD = false;
|
||||
let inLine = false;
|
||||
let inBlock = false;
|
||||
while (i < sql.length) {
|
||||
const ch = sql[i];
|
||||
const nxt = sql[i + 1];
|
||||
if (inLine) {
|
||||
if (ch === "\n") inLine = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (inBlock) {
|
||||
if (ch === "*" && nxt === "/") {
|
||||
inBlock = false;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (!inS && !inD && ch === "-" && nxt === "-") {
|
||||
inLine = true;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (!inS && !inD && ch === "/" && nxt === "*") {
|
||||
inBlock = true;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (ch === "'" && !inD) {
|
||||
inS = !inS;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' && !inS) {
|
||||
inD = !inD;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (!inS && !inD && ch === ";") {
|
||||
const text = sql.slice(start, i);
|
||||
if (text.trim()) out.push({ start, end: i, text });
|
||||
start = i + 1;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
const tail = sql.slice(start);
|
||||
if (tail.trim()) out.push({ start, end: sql.length, text: tail });
|
||||
return out.length ? out : [{ start: 0, end: sql.length, text: sql }];
|
||||
}
|
||||
|
||||
/** limit:false 时不拼行数上限(树预览用,翻页由请求层 offset/limit 承担) */
|
||||
function buildSelectSql(driver: string | undefined, db: string, table: string, limit: number | false) {
|
||||
const d = (driver || "mysql").toLowerCase();
|
||||
if (d === "redis") return `GET ${table}`;
|
||||
if (d === "postgresql" || d === "postgres" || d === "gaussdb" || d === "kingbase" || d === "opengauss") {
|
||||
const ident = table.includes(".") ? table : `"${table}"`;
|
||||
return `SELECT * FROM ${ident}${limit === false ? "" : ` LIMIT ${limit}`};`;
|
||||
}
|
||||
if (d === "sqlserver" || d === "mssql") {
|
||||
return `SELECT ${limit === false ? "" : `TOP ${limit} `}* FROM ${table};`;
|
||||
}
|
||||
if (d === "oracle") {
|
||||
return `SELECT * FROM ${table}${limit === false ? "" : ` FETCH FIRST ${limit} ROWS ONLY`}`;
|
||||
}
|
||||
if (d === "sqlite") {
|
||||
return `SELECT * FROM "${table}"${limit === false ? "" : ` LIMIT ${limit}`};`;
|
||||
}
|
||||
const tbl = table.includes(".") ? table : `\`${table}\``;
|
||||
const lim = limit === false ? "" : ` LIMIT ${limit}`;
|
||||
if (db) return `SELECT * FROM \`${db}\`.${tbl}${lim};`;
|
||||
return `SELECT * FROM ${tbl}${lim};`;
|
||||
}
|
||||
|
||||
export function sampleSelectSql(driver: string | undefined, db: string, table: string) {
|
||||
return buildSelectSql(driver, db, table, 100);
|
||||
}
|
||||
|
||||
/** 树表预览 SQL:不带行数上限,翻页由请求层 offset/limit 完成(否则第 2 页起恒为空) */
|
||||
export function previewSelectSql(driver: string | undefined, db: string, table: string) {
|
||||
return buildSelectSql(driver, db, table, false);
|
||||
}
|
||||
|
||||
export function rowsToTSV(columns: string[], rows: Record<string, unknown>[], headers = true): string {
|
||||
const esc = (v: unknown) => {
|
||||
if (v == null) return "";
|
||||
return String(v).replace(/\t/g, " ").replace(/\r?\n/g, " ");
|
||||
};
|
||||
const lines = rows.map((r) => columns.map((c) => esc(r[c])).join("\t"));
|
||||
if (headers) lines.unshift(columns.join("\t"));
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function rowsToCSV(columns: string[], rows: Record<string, unknown>[]): string {
|
||||
const esc = (v: unknown) => {
|
||||
if (v == null) return "";
|
||||
const s = String(v);
|
||||
if (/[",\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
|
||||
return s;
|
||||
};
|
||||
return [columns.map(esc).join(","), ...rows.map((r) => columns.map((c) => esc(r[c])).join(","))].join("\n");
|
||||
}
|
||||
|
||||
/** R2-F2:表名/列名按方言 quoteIdent(修 01-P1-1 恒反引号);库.表 形式逐段加引号 */
|
||||
export function rowsToInsert(
|
||||
driver: string | undefined,
|
||||
table: string,
|
||||
columns: string[],
|
||||
rows: Record<string, unknown>[],
|
||||
): string {
|
||||
const ident = table
|
||||
? table.split(".").map((p) => quoteIdent(driver, p)).join(".")
|
||||
: "t";
|
||||
const cols = columns.map((c) => quoteIdent(driver, c)).join(", ");
|
||||
const lit = (v: unknown) => {
|
||||
if (v == null) return "NULL";
|
||||
if (typeof v === "number" && Number.isFinite(v)) return String(v);
|
||||
return `'${String(v).replace(/'/g, "''")}'`;
|
||||
};
|
||||
return rows
|
||||
.map((r) => `INSERT INTO ${ident} (${cols}) VALUES (${columns.map((c) => lit(r[c])).join(", ")});`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
|
||||
export function isWritableDriver(driver: string | undefined): boolean {
|
||||
const d = (driver || "").toLowerCase();
|
||||
return [
|
||||
"mysql", "mariadb", "tidb", "postgresql", "postgres", "sqlite",
|
||||
"sqlserver", "mssql", "oracle", "oceanbase", "polardb", "tdsql",
|
||||
"gaussdb", "kingbase", "opengauss",
|
||||
].includes(d);
|
||||
}
|
||||
|
||||
export function quoteIdent(driver: string | undefined, name: string): string {
|
||||
const d = (driver || "mysql").toLowerCase();
|
||||
if (d === "postgresql" || d === "postgres" || d === "gaussdb" || d === "kingbase" || d === "opengauss" || d === "sqlite") {
|
||||
return `"${name.replace(/"/g, '""')}"`;
|
||||
}
|
||||
if (d === "sqlserver" || d === "mssql") return `[${name.replace(/]/g, "]]")}]`;
|
||||
if (d === "oracle") return name; // often unquoted uppercase
|
||||
return `\`${name.replace(/`/g, "``")}\``;
|
||||
}
|
||||
|
||||
export function sqlLiteral(v: unknown): string {
|
||||
if (v == null || v === "") return "NULL";
|
||||
if (typeof v === "number" && Number.isFinite(v)) return String(v);
|
||||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||
return `'${String(v).replace(/'/g, "''")}'`;
|
||||
}
|
||||
|
||||
/** Build UPDATE … SET col=? WHERE pk… from edited cell. */
|
||||
export function buildUpdateSql(
|
||||
driver: string | undefined,
|
||||
database: string | undefined,
|
||||
table: string,
|
||||
pkCols: string[],
|
||||
row: Record<string, unknown>,
|
||||
col: string,
|
||||
newVal: unknown,
|
||||
): string | null {
|
||||
if (!pkCols.length) return null;
|
||||
const d = (driver || "mysql").toLowerCase();
|
||||
const t = table.includes(".")
|
||||
? table
|
||||
: database && !["postgresql", "postgres", "sqlite", "gaussdb", "kingbase", "opengauss"].includes(d)
|
||||
? `${quoteIdent(driver, database)}.${quoteIdent(driver, table)}`
|
||||
: quoteIdent(driver, table);
|
||||
const set = `${quoteIdent(driver, col)} = ${sqlLiteral(newVal === "" ? null : newVal)}`;
|
||||
const where = pkCols
|
||||
.map((k) => {
|
||||
const v = row[k];
|
||||
if (v == null) return `${quoteIdent(driver, k)} IS NULL`;
|
||||
return `${quoteIdent(driver, k)} = ${sqlLiteral(v)}`;
|
||||
})
|
||||
.join(" AND ");
|
||||
return `UPDATE ${t} SET ${set} WHERE ${where}`;
|
||||
}
|
||||
|
||||
|
||||
export function supportsExplain(driver: string | undefined): boolean {
|
||||
const d = (driver || "").toLowerCase();
|
||||
return [
|
||||
"mysql", "mariadb", "tidb", "postgresql", "postgres", "sqlite",
|
||||
"sqlserver", "mssql", "oracle", "oceanbase", "polardb", "tdsql",
|
||||
"gaussdb", "kingbase", "opengauss",
|
||||
].includes(d);
|
||||
}
|
||||
|
||||
// C3:方言 EXPLAIN 包装已下线 —— 改由服务端 POST /api/db/explain 按驱动完成
|
||||
// (MSSQL SHOWPLAN_XML 需钉单连接,Oracle 需查 DBMS_XPLAN,前端拼 SQL 拿不到)。
|
||||
|
||||
/** Map OpsxDB driver id → sql-formatter language. */
|
||||
export function sqlFormatterLanguage(driver?: string): string {
|
||||
const d = (driver || "mysql").toLowerCase();
|
||||
if (d === "postgresql" || d === "postgres" || d === "gaussdb" || d === "kingbase" || d === "opengauss") {
|
||||
return "postgresql";
|
||||
}
|
||||
if (d === "sqlite") return "sqlite";
|
||||
if (d === "sqlserver" || d === "mssql") return "tsql";
|
||||
if (d === "oracle") return "plsql";
|
||||
if (d === "mariadb") return "mariadb";
|
||||
// mysql / tidb / oceanbase / polardb / tdsql / redis fallback
|
||||
return "mysql";
|
||||
}
|
||||
|
||||
/**
|
||||
* Pretty-print SQL via sql-formatter.
|
||||
* On formatter parse errors, falls back to a minimal indent printer.
|
||||
*/
|
||||
export function formatSqlText(sql: string, driver?: string): string {
|
||||
const input = sql.replace(/\s+$/, "");
|
||||
if (!input.trim()) return sql;
|
||||
try {
|
||||
return sqlFormat(input, {
|
||||
// dialect heuristic — cast: sql-formatter language union is wide
|
||||
language: sqlFormatterLanguage(driver) as "mysql",
|
||||
tabWidth: 2,
|
||||
keywordCase: "upper",
|
||||
linesBetweenQueries: 1,
|
||||
});
|
||||
} catch {
|
||||
return minimalPrettySql(input);
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal pretty-printer when sql-formatter is not installed yet. */
|
||||
function minimalPrettySql(sql: string): string {
|
||||
const s = sql.trim();
|
||||
// Insert newline before major keywords (best-effort, ignores strings — fine as fallback)
|
||||
return s
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(
|
||||
/\b(SELECT|FROM|WHERE|AND|OR|JOIN|LEFT JOIN|RIGHT JOIN|INNER JOIN|OUTER JOIN|GROUP BY|ORDER BY|LIMIT|OFFSET|HAVING|UNION|INSERT INTO|VALUES|UPDATE|SET|DELETE FROM)\b/gi,
|
||||
(m) => `\n${m.toUpperCase()}`,
|
||||
)
|
||||
.replace(/^\n/, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Build DELETE FROM … WHERE pk… for a row. */
|
||||
export function buildDeleteSql(
|
||||
driver: string | undefined,
|
||||
database: string | undefined,
|
||||
table: string,
|
||||
pkCols: string[],
|
||||
row: Record<string, unknown>,
|
||||
): string | null {
|
||||
if (!pkCols.length) return null;
|
||||
const d = (driver || "mysql").toLowerCase();
|
||||
const t = table.includes(".")
|
||||
? table
|
||||
: database && !["postgresql", "postgres", "sqlite", "gaussdb", "kingbase", "opengauss"].includes(d)
|
||||
? `${quoteIdent(driver, database)}.${quoteIdent(driver, table)}`
|
||||
: quoteIdent(driver, table);
|
||||
const where = pkCols
|
||||
.map((k) => {
|
||||
const v = row[k];
|
||||
if (v == null) return `${quoteIdent(driver, k)} IS NULL`;
|
||||
return `${quoteIdent(driver, k)} = ${sqlLiteral(v)}`;
|
||||
})
|
||||
.join(" AND ");
|
||||
return `DELETE FROM ${t} WHERE ${where}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse clipboard TSV/CSV into a grid.
|
||||
* - Split lines on newlines (handles CRLF); cells on tabs.
|
||||
* - If a line has no tabs but looks like simple CSV (has commas), split on commas.
|
||||
* - Empty cell → ""; literal "NULL" (any case) → null.
|
||||
*/
|
||||
export function parseClipboardGrid(text: string): (string | null)[][] {
|
||||
const raw = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
// Drop a single trailing newline so Excel paste does not invent an empty row
|
||||
const trimmed = raw.endsWith("\n") ? raw.slice(0, -1) : raw;
|
||||
if (!trimmed) return [];
|
||||
const lines = trimmed.split("\n");
|
||||
return lines.map((line) => {
|
||||
let cells: string[];
|
||||
if (line.includes("\t")) {
|
||||
cells = line.split("\t");
|
||||
} else if (line.includes(",") && looksLikeSimpleCsv(line)) {
|
||||
cells = splitSimpleCsv(line);
|
||||
} else {
|
||||
cells = [line];
|
||||
}
|
||||
return cells.map((c) => {
|
||||
if (c === "") return "";
|
||||
if (/^null$/i.test(c)) return null;
|
||||
return c;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function looksLikeSimpleCsv(line: string): boolean {
|
||||
// Prefer CSV when commas separate simple unquoted or simply-quoted fields
|
||||
if (!line.includes(",")) return false;
|
||||
// Reject if it looks like a single free-text phrase with incidental commas and spaces only mid-word — still allow
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Minimal CSV split: handles commas and ""-quoted fields; no escaped newlines. */
|
||||
function splitSimpleCsv(line: string): string[] {
|
||||
const out: string[] = [];
|
||||
let cur = "";
|
||||
let inQ = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i];
|
||||
if (inQ) {
|
||||
if (ch === '"') {
|
||||
if (line[i + 1] === '"') {
|
||||
cur += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQ = false;
|
||||
}
|
||||
} else {
|
||||
cur += ch;
|
||||
}
|
||||
} else if (ch === '"') {
|
||||
inQ = true;
|
||||
} else if (ch === ",") {
|
||||
out.push(cur);
|
||||
cur = "";
|
||||
} else {
|
||||
cur += ch;
|
||||
}
|
||||
}
|
||||
out.push(cur);
|
||||
return out;
|
||||
}
|
||||
@@ -1,950 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import type { DriverId } from "./catalog";
|
||||
import { driverById } from "./catalog";
|
||||
import { formatDbError, notifyId, type NotifyKind, type NotifyPayload } from "./notify";
|
||||
import { decodeSecret, encodeSecret } from "./secrets";
|
||||
import { dropSchemaCache } from "./api";
|
||||
|
||||
export type SavedConnection = {
|
||||
id: string;
|
||||
name: string;
|
||||
driver: DriverId;
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
password: string;
|
||||
database: string;
|
||||
lastUsedAt?: number;
|
||||
/** 侧栏分组 id,缺省 default */
|
||||
groupId?: string;
|
||||
/** 组内排序,越小越靠前 */
|
||||
sortOrder?: number;
|
||||
ssl?: boolean;
|
||||
/** disable|require|skip-verify|verify-ca;关 SSL 时视为 disable */
|
||||
sslMode?: "disable" | "require" | "skip-verify" | "verify-ca";
|
||||
sslCA?: string;
|
||||
sslCert?: string;
|
||||
sslKey?: string;
|
||||
service?: string;
|
||||
color?: string;
|
||||
/** 连库页默认连接 */
|
||||
isDefault?: boolean;
|
||||
/** 是否持久化密码,默认 true */
|
||||
savePassword?: boolean;
|
||||
/** Redis key 分隔符,默认 : */
|
||||
keySeparator?: string;
|
||||
redisMode?: "standalone" | "sentinel" | "cluster";
|
||||
/** Sentinel/Cluster 节点列表 host:port(多行) */
|
||||
redisNodes?: string;
|
||||
/** Sentinel 主节点名 */
|
||||
masterName?: string;
|
||||
/** Sentinel 密码(可空) */
|
||||
sentinelPassword?: string;
|
||||
/** Oracle */
|
||||
oracleConnType?: "basic";
|
||||
oracleIdKind?: "service" | "sid";
|
||||
oracleRole?: string;
|
||||
/** SQL Server 验证方式 */
|
||||
authMode?: "sql" | "windows";
|
||||
/** SSH 隧道 */
|
||||
sshEnabled?: boolean;
|
||||
sshHost?: string;
|
||||
sshPort?: number;
|
||||
sshUser?: string;
|
||||
sshAuth?: "password" | "privateKey";
|
||||
sshPassword?: string;
|
||||
sshPrivateKey?: string;
|
||||
sshPassphrase?: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_CONN_GROUP = "default";
|
||||
|
||||
export type ConnectionGroup = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type QueryTab = {
|
||||
id: string;
|
||||
title: string;
|
||||
sql: string;
|
||||
connectionId?: string;
|
||||
database?: string;
|
||||
/** 相对打开/保存基线有改动 */
|
||||
dirty?: boolean;
|
||||
savedSql?: string;
|
||||
};
|
||||
|
||||
export type QueryResult = {
|
||||
columns: string[];
|
||||
rows: Record<string, unknown>[];
|
||||
rowCount: number;
|
||||
timeMs: number;
|
||||
error?: string;
|
||||
truncated?: boolean;
|
||||
limit?: number;
|
||||
/** C5:服务端回传的分页偏移 */
|
||||
offset?: number;
|
||||
/** Redis 值游标分页:>0 还有更多(hash/set=SCAN 游标;list/zset=偏移) */
|
||||
nextCursor?: number;
|
||||
/** C1:命中危险语句,需确认后带 confirmDanger 重发 */
|
||||
needConfirm?: boolean;
|
||||
};
|
||||
|
||||
export type SchemaView = {
|
||||
database: string;
|
||||
table: string;
|
||||
columns: ColumnInfo[];
|
||||
loading?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ResultMeta = {
|
||||
database?: string;
|
||||
table?: string;
|
||||
pkCols?: string[];
|
||||
/** Redis 键详情:打开键时带类型/TTL,命令结果不带 */
|
||||
redisType?: string;
|
||||
redisTtl?: number;
|
||||
};
|
||||
|
||||
export type ConnState = "ok" | "down" | "reconnecting";
|
||||
|
||||
export type ResultSetItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
sql: string;
|
||||
result: QueryResult;
|
||||
/** C5:分页状态(仅单条 SELECT 启用);page 从 0 起 */
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
/** 执行时使用的库(翻页重发需要) */
|
||||
database?: string;
|
||||
/** 列宽/选中态等视图持久化(修 G3,resultSet 级) */
|
||||
viewState?: {
|
||||
colWidths?: Record<string, number>;
|
||||
selectedIdx?: number | null;
|
||||
selectedCol?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type Bookmark = {
|
||||
id: string;
|
||||
name: string;
|
||||
sql: string;
|
||||
at: number;
|
||||
};
|
||||
|
||||
export type ColumnInfo = {
|
||||
name: string;
|
||||
type: string;
|
||||
nullable: boolean;
|
||||
key?: string;
|
||||
default?: unknown;
|
||||
comment?: string;
|
||||
};
|
||||
|
||||
export type TreeNode = {
|
||||
name: string;
|
||||
kind: "database" | "group" | "table" | "view" | "procedure" | "function" | "column" | "key" | "folder";
|
||||
/** group id: tables|views|procedures|functions|keys|folder */
|
||||
groupId?: string;
|
||||
count?: number;
|
||||
children?: TreeNode[];
|
||||
loaded?: boolean;
|
||||
loading?: boolean;
|
||||
type?: string;
|
||||
/** Redis TTL seconds; -1 permanent; -2 missing */
|
||||
ttl?: number;
|
||||
/** Redis 文件夹前缀(含分隔符),如 CEX:FOLLOW: */
|
||||
fullName?: string;
|
||||
nullable?: boolean;
|
||||
key?: string;
|
||||
};
|
||||
|
||||
export type QueryHistoryItem = {
|
||||
id: string;
|
||||
sql: string;
|
||||
at: number;
|
||||
connectionId?: string;
|
||||
rowCount?: number;
|
||||
timeMs?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type ConfirmRequest = {
|
||||
title: string;
|
||||
message: string;
|
||||
okLabel?: string;
|
||||
danger?: boolean;
|
||||
/** C6:将执行的语句清单(危险确认二段流展示,前 3 条 + 折叠其余) */
|
||||
items?: string[];
|
||||
};
|
||||
|
||||
export type AiMessage = {
|
||||
id: string;
|
||||
role: "user" | "assistant" | "system";
|
||||
content: string;
|
||||
at: string;
|
||||
};
|
||||
|
||||
export type ViewMode = "workbench" | "picker";
|
||||
export type PickerMode = "list" | "drivers";
|
||||
|
||||
type State = {
|
||||
theme: "dark" | "light";
|
||||
view: ViewMode;
|
||||
connections: SavedConnection[];
|
||||
connGroups: ConnectionGroup[];
|
||||
expandedConnGroups: string[];
|
||||
activeConnectionId: string | null;
|
||||
tree: TreeNode[];
|
||||
treeLoading: boolean;
|
||||
tabs: QueryTab[];
|
||||
activeTabId: string;
|
||||
result: QueryResult | null;
|
||||
resultSets: ResultSetItem[];
|
||||
activeResultSetId: string | null;
|
||||
resultMeta: ResultMeta | null;
|
||||
schemaView: SchemaView | null;
|
||||
resultTab: "data" | "schema" | "explain";
|
||||
explainText: string | null;
|
||||
bookmarks: Bookmark[];
|
||||
showBookmarks: boolean;
|
||||
connState: ConnState;
|
||||
/** 左栏底部统一搜索:非 Redis=过滤库/表;Redis=过滤键(回车服务端模糊扫描) */
|
||||
sidebarFilter: string;
|
||||
/** Redis 类型筛选 */
|
||||
sidebarTypeFilter: string;
|
||||
running: boolean;
|
||||
pickerSearch: string;
|
||||
pickerCategory: string;
|
||||
pickerDriver: DriverId | null;
|
||||
pickerMode: PickerMode;
|
||||
form: Partial<SavedConnection>;
|
||||
aiMessages: AiMessage[];
|
||||
aiAsk: string;
|
||||
aiModel: string;
|
||||
aiBusy: boolean;
|
||||
status: string;
|
||||
notifyModal: NotifyPayload | null;
|
||||
notifyToasts: NotifyPayload[];
|
||||
sessions: Record<string, string>;
|
||||
queryHistory: QueryHistoryItem[];
|
||||
leftWidth: number;
|
||||
aiWidth: number;
|
||||
resultRatio: number;
|
||||
showAi: boolean;
|
||||
showHelp: boolean;
|
||||
showHistory: boolean;
|
||||
confirm: ConfirmRequest | null;
|
||||
/** R2-F1:多语句执行遇错即停(默认 true);false 时全部执行完再汇总 */
|
||||
stopOnError: boolean;
|
||||
/** R2-F8:语句执行超时(ms),0 = 不超时;持久化到 prefs */
|
||||
execTimeoutMs: number;
|
||||
/** 对象树最近聚焦的表/视图(编辑器右键「查看数据/DDL」用) */
|
||||
focusedRelation: { database: string; table: string } | null;
|
||||
/** 请求 ObjectTree 打开 DDL 弹层 */
|
||||
ddlRequest: { database: string; table: string; token: number } | null;
|
||||
/** 请求 ObjectTree 打开表数据预览 */
|
||||
viewDataRequest: { database: string; table: string; token: number } | null;
|
||||
|
||||
set: <K extends keyof State>(key: K, value: State[K]) => void;
|
||||
notify: (input: {
|
||||
kind?: NotifyKind;
|
||||
title?: string;
|
||||
message: string;
|
||||
detail?: string;
|
||||
tips?: string[];
|
||||
mode?: "modal" | "toast";
|
||||
durationMs?: number;
|
||||
raw?: unknown;
|
||||
}) => void;
|
||||
notifyError: (raw: unknown, mode?: "modal" | "toast") => void;
|
||||
dismissNotify: (id: string) => void;
|
||||
loadConnections: () => void;
|
||||
saveConnections: (list: SavedConnection[]) => void;
|
||||
upsertConnection: (c: SavedConnection) => void;
|
||||
removeConnection: (id: string) => void;
|
||||
setDefaultConnection: (id: string) => void;
|
||||
addConnGroup: (name: string) => string;
|
||||
/** R2-F5:导入连接时并入分组(冲突/缺失 id 重新生成);默认分组不重复创建 */
|
||||
importConnGroups: (groups: ConnectionGroup[]) => void;
|
||||
renameConnGroup: (id: string, name: string) => void;
|
||||
removeConnGroup: (id: string) => void;
|
||||
moveConnection: (id: string, groupId: string, index: number) => void;
|
||||
toggleConnGroup: (id: string) => void;
|
||||
openPicker: (driver?: DriverId | null) => void;
|
||||
openConnectionList: () => void;
|
||||
editConnection: (c: SavedConnection) => void;
|
||||
newQueryTab: () => void;
|
||||
/** 重命名当前激活的查询 Tab(如打开表后标题设为表名) */
|
||||
setActiveTabTitle: (title: string) => void;
|
||||
/** 设置当前 Tab 的库上下文(Redis 编辑器 SELECT/USE 切库用) */
|
||||
setTabDatabase: (database: string) => void;
|
||||
closeTab: (id: string) => void;
|
||||
/** R2-F6:Tab 右键菜单批量关闭(脏确认由调用侧负责) */
|
||||
closeOtherTabs: (id: string) => void;
|
||||
closeRightTabs: (id: string) => void;
|
||||
closeAllTabs: () => void;
|
||||
setStopOnError: (v: boolean) => void;
|
||||
setExecTimeoutMs: (v: number) => void;
|
||||
updateActiveSql: (sql: string) => void;
|
||||
pushAi: (role: AiMessage["role"], content: string) => void;
|
||||
setSession: (connectionId: string, sessionId: string | null) => void;
|
||||
pushHistory: (item: Omit<QueryHistoryItem, "id" | "at"> & { at?: number }) => void;
|
||||
clearHistory: () => void;
|
||||
setResultSets: (items: ResultSetItem[]) => void;
|
||||
selectResultSet: (id: string) => void;
|
||||
/** C5/G3:局部更新某个结果集(翻页结果、视图持久化);若为激活项同步 result */
|
||||
updateResultSet: (id: string, patch: Partial<Omit<ResultSetItem, "id">>) => void;
|
||||
loadBookmarks: () => void;
|
||||
saveBookmark: (name: string, sql: string) => void;
|
||||
renameBookmark: (id: string, name: string) => void;
|
||||
removeBookmark: (id: string) => void;
|
||||
askConfirm: (req: ConfirmRequest) => Promise<boolean>;
|
||||
resolveConfirm: (ok: boolean) => void;
|
||||
persistPrefs: () => void;
|
||||
};
|
||||
|
||||
const LS_KEY = "opsxdb.connections.v1";
|
||||
const LS_GROUPS = "opsxdb.conn-groups.v1";
|
||||
const LS_EXPANDED = "opsxdb.conn-groups-expanded.v1";
|
||||
const LS_PREFS = "opsxdb.prefs.v1";
|
||||
const LS_TABS = "opsxdb.tabs.v1";
|
||||
const LS_HISTORY = "opsxdb.history.v1";
|
||||
const LS_BOOKMARKS = "opsxdb.bookmarks.v1";
|
||||
|
||||
let confirmResolver: ((ok: boolean) => void) | null = null;
|
||||
|
||||
function loadPrefs(): Partial<Pick<State, "theme" | "leftWidth" | "aiWidth" | "resultRatio" | "showAi" | "stopOnError" | "execTimeoutMs">> {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_PREFS);
|
||||
if (!raw) return {};
|
||||
const p = JSON.parse(raw) as Record<string, unknown>;
|
||||
return {
|
||||
theme: p.theme === "light" ? "light" : "dark",
|
||||
leftWidth: typeof p.leftWidth === "number" ? Math.min(360, Math.max(200, p.leftWidth)) : undefined,
|
||||
aiWidth: typeof p.aiWidth === "number" ? p.aiWidth : undefined,
|
||||
resultRatio: typeof p.resultRatio === "number" ? p.resultRatio : undefined,
|
||||
showAi: typeof p.showAi === "boolean" ? p.showAi : undefined,
|
||||
stopOnError: typeof p.stopOnError === "boolean" ? p.stopOnError : undefined,
|
||||
execTimeoutMs: typeof p.execTimeoutMs === "number" ? Math.min(3_600_000, Math.max(0, p.execTimeoutMs)) : undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function loadTabs(): { tabs: QueryTab[]; activeTabId: string } | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_TABS);
|
||||
if (!raw) return null;
|
||||
const p = JSON.parse(raw) as { tabs?: QueryTab[]; activeTabId?: string };
|
||||
if (!Array.isArray(p.tabs) || !p.tabs.length) return null;
|
||||
const tabs = p.tabs.map((tab) => {
|
||||
const savedSql = tab.savedSql ?? tab.sql ?? "";
|
||||
const sql = tab.sql ?? "";
|
||||
return { ...tab, sql, savedSql, dirty: sql !== savedSql };
|
||||
});
|
||||
return { tabs, activeTabId: p.activeTabId || tabs[0].id };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadHistory(): QueryHistoryItem[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_HISTORY);
|
||||
if (!raw) return [];
|
||||
const p = JSON.parse(raw);
|
||||
return Array.isArray(p) ? p.slice(0, 50) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function loadGroups(): ConnectionGroup[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_GROUPS);
|
||||
if (!raw) return [{ id: DEFAULT_CONN_GROUP, name: "默认" }];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed) || !parsed.length) return [{ id: DEFAULT_CONN_GROUP, name: "默认" }];
|
||||
const list = parsed.filter((g: ConnectionGroup) => g && g.id && g.name);
|
||||
if (!list.some((g: ConnectionGroup) => g.id === DEFAULT_CONN_GROUP)) {
|
||||
list.unshift({ id: DEFAULT_CONN_GROUP, name: "默认" });
|
||||
}
|
||||
return list;
|
||||
} catch {
|
||||
return [{ id: DEFAULT_CONN_GROUP, name: "默认" }];
|
||||
}
|
||||
}
|
||||
|
||||
function saveGroups(list: ConnectionGroup[]) {
|
||||
localStorage.setItem(LS_GROUPS, JSON.stringify(list));
|
||||
}
|
||||
|
||||
function loadExpanded(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_EXPANDED);
|
||||
if (!raw) return [DEFAULT_CONN_GROUP];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [DEFAULT_CONN_GROUP];
|
||||
} catch {
|
||||
return [DEFAULT_CONN_GROUP];
|
||||
}
|
||||
}
|
||||
|
||||
function loadLs(): SavedConnection[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function defaultTab(): QueryTab {
|
||||
// 新 Tab 默认空白(修 T3),不再预置 SELECT 1 AS ok;
|
||||
return { id: crypto.randomUUID(), title: "查询 1", sql: "", savedSql: "", dirty: false };
|
||||
}
|
||||
|
||||
export const useWorkspaceStore = create<State>((set, get) => ({
|
||||
theme: "dark",
|
||||
view: "workbench",
|
||||
connections: [],
|
||||
connGroups: [{ id: DEFAULT_CONN_GROUP, name: "默认" }],
|
||||
expandedConnGroups: [DEFAULT_CONN_GROUP],
|
||||
activeConnectionId: null,
|
||||
tree: [],
|
||||
treeLoading: false,
|
||||
tabs: [defaultTab()],
|
||||
activeTabId: "",
|
||||
result: null,
|
||||
resultSets: [],
|
||||
activeResultSetId: null,
|
||||
resultMeta: null,
|
||||
schemaView: null,
|
||||
resultTab: "data",
|
||||
explainText: null,
|
||||
bookmarks: [],
|
||||
showBookmarks: false,
|
||||
connState: "ok",
|
||||
sidebarFilter: "",
|
||||
sidebarTypeFilter: "all",
|
||||
running: false,
|
||||
pickerSearch: "",
|
||||
pickerCategory: "全部",
|
||||
pickerDriver: null,
|
||||
pickerMode: "list",
|
||||
form: {},
|
||||
aiMessages: [],
|
||||
aiAsk: "",
|
||||
aiModel: "opsxdb-stub",
|
||||
aiBusy: false,
|
||||
status: "就绪",
|
||||
notifyModal: null,
|
||||
notifyToasts: [],
|
||||
sessions: {},
|
||||
queryHistory: [],
|
||||
leftWidth: 260,
|
||||
aiWidth: 280,
|
||||
resultRatio: 0.38,
|
||||
showAi: true,
|
||||
showHelp: false,
|
||||
showHistory: false,
|
||||
confirm: null,
|
||||
stopOnError: true,
|
||||
execTimeoutMs: 0,
|
||||
focusedRelation: null,
|
||||
ddlRequest: null,
|
||||
viewDataRequest: null,
|
||||
|
||||
set: (key, value) => {
|
||||
set({ [key]: value } as Partial<State>);
|
||||
if (key === "theme" || key === "leftWidth" || key === "aiWidth" || key === "resultRatio" || key === "showAi" || key === "stopOnError" || key === "execTimeoutMs") {
|
||||
get().persistPrefs();
|
||||
}
|
||||
if (key === "tabs" || key === "activeTabId") {
|
||||
const s = get();
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: s.tabs, activeTabId: s.activeTabId }));
|
||||
}
|
||||
},
|
||||
|
||||
notify: (input) => {
|
||||
const parsed = input.raw !== undefined ? formatDbError(input.raw) : null;
|
||||
const kind = input.kind ?? parsed?.kind ?? "info";
|
||||
const mode = input.mode ?? (kind === "error" ? "modal" : "toast");
|
||||
const payload: NotifyPayload = {
|
||||
id: notifyId(),
|
||||
kind,
|
||||
title: input.title ?? parsed?.title ?? (kind === "error" ? "错误" : "提示"),
|
||||
message: input.message || parsed?.message || "",
|
||||
detail: input.detail ?? parsed?.detail,
|
||||
tips: input.tips ?? parsed?.tips,
|
||||
mode,
|
||||
durationMs: input.durationMs,
|
||||
};
|
||||
if (mode === "modal") {
|
||||
set({ notifyModal: payload });
|
||||
} else {
|
||||
set((s) => ({ notifyToasts: [...s.notifyToasts.slice(-4), payload] }));
|
||||
}
|
||||
},
|
||||
|
||||
notifyError: (raw, mode = "modal") => {
|
||||
const parsed = formatDbError(raw);
|
||||
const payload: NotifyPayload = {
|
||||
id: notifyId(),
|
||||
kind: parsed.kind,
|
||||
title: parsed.title,
|
||||
message: parsed.message,
|
||||
detail: parsed.detail,
|
||||
tips: parsed.tips,
|
||||
mode,
|
||||
};
|
||||
if (mode === "modal") set({ notifyModal: payload });
|
||||
else set((s) => ({ notifyToasts: [...s.notifyToasts.slice(-4), payload] }));
|
||||
},
|
||||
|
||||
dismissNotify: (id) => {
|
||||
const s = get();
|
||||
if (s.notifyModal?.id === id) set({ notifyModal: null });
|
||||
set({ notifyToasts: get().notifyToasts.filter((t) => t.id !== id) });
|
||||
},
|
||||
|
||||
loadConnections: () => {
|
||||
const list = loadLs().map((c) => ({
|
||||
...c,
|
||||
groupId: c.groupId || DEFAULT_CONN_GROUP,
|
||||
sortOrder: c.sortOrder ?? 0,
|
||||
}));
|
||||
const prefs = loadPrefs();
|
||||
const savedTabs = loadTabs();
|
||||
const tabs = savedTabs?.tabs ?? get().tabs;
|
||||
const activeTabId = savedTabs?.activeTabId || get().activeTabId || tabs[0]?.id || "";
|
||||
set({
|
||||
connections: list,
|
||||
connGroups: loadGroups(),
|
||||
expandedConnGroups: loadExpanded(),
|
||||
tabs,
|
||||
activeTabId,
|
||||
queryHistory: loadHistory(),
|
||||
...prefs,
|
||||
});
|
||||
},
|
||||
|
||||
saveConnections: (list) => {
|
||||
const encoded = list.map((c) => ({
|
||||
...c,
|
||||
password: encodeSecret(c.password || ""),
|
||||
sshPassword: c.sshPassword ? encodeSecret(c.sshPassword) : c.sshPassword,
|
||||
sshPassphrase: c.sshPassphrase ? encodeSecret(c.sshPassphrase) : c.sshPassphrase,
|
||||
}));
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(encoded));
|
||||
set({ connections: encoded });
|
||||
},
|
||||
|
||||
upsertConnection: (c) => {
|
||||
const next = {
|
||||
...c,
|
||||
groupId: c.groupId || DEFAULT_CONN_GROUP,
|
||||
sortOrder: c.sortOrder ?? Date.now(),
|
||||
password: c.savePassword === false ? "" : c.password,
|
||||
sshPassword: c.savePassword === false ? "" : c.sshPassword,
|
||||
};
|
||||
let list = [...get().connections];
|
||||
// 同名防护:不同 id 同名时自动加序号后缀,避免侧栏出现重名条目
|
||||
const nameTaken = (n: string) => list.some((x) => x.id !== next.id && x.name === n);
|
||||
if (nameTaken(next.name)) {
|
||||
let k = 2;
|
||||
while (nameTaken(`${next.name} ${k}`)) k++;
|
||||
next.name = `${next.name} ${k}`;
|
||||
}
|
||||
if (next.isDefault) {
|
||||
list = list.map((x) => ({ ...x, isDefault: x.id === next.id ? true : false }));
|
||||
}
|
||||
const i = list.findIndex((x) => x.id === next.id);
|
||||
if (i >= 0) list[i] = { ...list[i], ...next };
|
||||
else list.unshift(next);
|
||||
get().saveConnections(list);
|
||||
},
|
||||
|
||||
removeConnection: (id) => {
|
||||
get().saveConnections(get().connections.filter((c) => c.id !== id));
|
||||
dropSchemaCache(id);
|
||||
const sessions = { ...get().sessions };
|
||||
delete sessions[id];
|
||||
if (get().activeConnectionId === id) set({ activeConnectionId: null, tree: [], sessions });
|
||||
else set({ sessions });
|
||||
},
|
||||
|
||||
setDefaultConnection: (id) => {
|
||||
const list = get().connections.map((c) => ({ ...c, isDefault: c.id === id }));
|
||||
get().saveConnections(list);
|
||||
},
|
||||
|
||||
|
||||
addConnGroup: (name) => {
|
||||
const n = name.trim() || "新分组";
|
||||
const id = crypto.randomUUID();
|
||||
const list = [...get().connGroups, { id, name: n }];
|
||||
saveGroups(list);
|
||||
const expanded = Array.from(new Set([...get().expandedConnGroups, id]));
|
||||
localStorage.setItem(LS_EXPANDED, JSON.stringify(expanded));
|
||||
set({ connGroups: list, expandedConnGroups: expanded });
|
||||
return id;
|
||||
},
|
||||
|
||||
/** R2-F5:导入连接时并入分组;与现有 id 冲突或缺失时重新生成,避免串组 */
|
||||
importConnGroups: (groups) => {
|
||||
if (!Array.isArray(groups) || !groups.length) return;
|
||||
const cur = get().connGroups;
|
||||
const ids = new Set(cur.map((g) => g.id));
|
||||
const merged = [...cur];
|
||||
for (const g of groups) {
|
||||
if (!g || typeof g.name !== "string" || !g.name.trim()) continue;
|
||||
if (g.id === DEFAULT_CONN_GROUP) continue;
|
||||
const item = { id: g.id && !ids.has(g.id) ? g.id : crypto.randomUUID(), name: g.name.trim() };
|
||||
ids.add(item.id);
|
||||
merged.push(item);
|
||||
}
|
||||
saveGroups(merged);
|
||||
set({ connGroups: merged });
|
||||
},
|
||||
|
||||
renameConnGroup: (id, name) => {
|
||||
if (id === DEFAULT_CONN_GROUP) return;
|
||||
const n = name.trim();
|
||||
if (!n) return;
|
||||
const list = get().connGroups.map((g) => (g.id === id ? { ...g, name: n } : g));
|
||||
saveGroups(list);
|
||||
set({ connGroups: list });
|
||||
},
|
||||
|
||||
removeConnGroup: (id) => {
|
||||
if (id === DEFAULT_CONN_GROUP) return;
|
||||
const list = get().connGroups.filter((g) => g.id !== id);
|
||||
saveGroups(list);
|
||||
const connections = get().connections.map((c) =>
|
||||
(c.groupId || DEFAULT_CONN_GROUP) === id ? { ...c, groupId: DEFAULT_CONN_GROUP } : c,
|
||||
);
|
||||
get().saveConnections(connections);
|
||||
const expanded = get().expandedConnGroups.filter((x) => x !== id);
|
||||
localStorage.setItem(LS_EXPANDED, JSON.stringify(expanded));
|
||||
set({ connGroups: list, expandedConnGroups: expanded });
|
||||
},
|
||||
|
||||
moveConnection: (id, groupId, index) => {
|
||||
const gid = groupId || DEFAULT_CONN_GROUP;
|
||||
const all = [...get().connections];
|
||||
const moving = all.find((c) => c.id === id);
|
||||
if (!moving) return;
|
||||
const othersInGroup = all
|
||||
.filter((c) => c.id !== id && (c.groupId || DEFAULT_CONN_GROUP) === gid)
|
||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
|
||||
const clamped = Math.max(0, Math.min(index, othersInGroup.length));
|
||||
othersInGroup.splice(clamped, 0, { ...moving, groupId: gid });
|
||||
othersInGroup.forEach((c, i) => {
|
||||
c.sortOrder = i;
|
||||
});
|
||||
const rest = all.filter((c) => c.id !== id && (c.groupId || DEFAULT_CONN_GROUP) !== gid);
|
||||
get().saveConnections([...rest, ...othersInGroup]);
|
||||
if (!get().expandedConnGroups.includes(gid)) {
|
||||
const expanded = [...get().expandedConnGroups, gid];
|
||||
localStorage.setItem(LS_EXPANDED, JSON.stringify(expanded));
|
||||
set({ expandedConnGroups: expanded });
|
||||
}
|
||||
},
|
||||
|
||||
toggleConnGroup: (id) => {
|
||||
const cur = get().expandedConnGroups;
|
||||
const expanded = cur.includes(id) ? cur.filter((x) => x !== id) : [...cur, id];
|
||||
localStorage.setItem(LS_EXPANDED, JSON.stringify(expanded));
|
||||
set({ expandedConnGroups: expanded });
|
||||
},
|
||||
|
||||
openPicker: (driver = null) => {
|
||||
const d = driver ? driverById(driver) : null;
|
||||
set({
|
||||
view: "picker",
|
||||
pickerMode: d ? "drivers" : "drivers",
|
||||
pickerDriver: driver,
|
||||
form: d
|
||||
? {
|
||||
id: crypto.randomUUID(),
|
||||
name: `${d.name} 连接`,
|
||||
driver: d.id,
|
||||
host: d.hints?.host ?? "127.0.0.1",
|
||||
port: d.defaultPort,
|
||||
user: d.hints?.user ?? (d.id === "redis" ? "" : "root"),
|
||||
password: "",
|
||||
database: d.hints?.database ?? "",
|
||||
ssl: false,
|
||||
sslMode: "disable",
|
||||
sslCA: "",
|
||||
sslCert: "",
|
||||
sslKey: "",
|
||||
service: d.id === "oracle" ? (d.hints?.database ?? "ORCL") : "",
|
||||
savePassword: true,
|
||||
keySeparator: d.id === "redis" ? ":" : undefined,
|
||||
redisMode: d.id === "redis" ? "standalone" : undefined,
|
||||
oracleConnType: d.id === "oracle" ? "basic" : undefined,
|
||||
oracleIdKind: d.id === "oracle" ? "service" : undefined,
|
||||
oracleRole: d.id === "oracle" ? "default" : undefined,
|
||||
authMode: d.id === "sqlserver" ? "sql" : undefined,
|
||||
sshEnabled: false,
|
||||
sshPort: 22,
|
||||
sshAuth: "password",
|
||||
}
|
||||
: {},
|
||||
});
|
||||
},
|
||||
|
||||
openConnectionList: () => {
|
||||
set({ view: "picker", pickerMode: "list", pickerDriver: null, form: {} });
|
||||
},
|
||||
|
||||
editConnection: (c) => {
|
||||
set({
|
||||
view: "picker",
|
||||
pickerMode: "drivers",
|
||||
pickerDriver: c.driver,
|
||||
form: {
|
||||
...c,
|
||||
password: decodeSecret(c.password),
|
||||
sshPassword: c.sshPassword ? decodeSecret(c.sshPassword) : "",
|
||||
sshPassphrase: c.sshPassphrase ? decodeSecret(c.sshPassphrase) : "",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
newQueryTab: () => {
|
||||
const n = get().tabs.length + 1;
|
||||
const tab: QueryTab = {
|
||||
id: crypto.randomUUID(),
|
||||
title: `查询 ${n}`,
|
||||
sql: "",
|
||||
savedSql: "",
|
||||
dirty: false,
|
||||
connectionId: get().activeConnectionId ?? undefined,
|
||||
};
|
||||
const tabs = [...get().tabs, tab];
|
||||
set({ tabs, activeTabId: tab.id });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: tab.id }));
|
||||
},
|
||||
|
||||
setActiveTabTitle: (title) => {
|
||||
const s = get();
|
||||
const tabs = s.tabs.map((t) => (t.id === s.activeTabId ? { ...t, title } : t));
|
||||
set({ tabs });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: s.activeTabId }));
|
||||
},
|
||||
|
||||
setTabDatabase: (database) => {
|
||||
const s = get();
|
||||
const tabs = s.tabs.map((t) => (t.id === s.activeTabId ? { ...t, database } : t));
|
||||
set({ tabs });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: s.activeTabId }));
|
||||
},
|
||||
|
||||
closeTab: (id) => {
|
||||
const tabs = get().tabs.filter((t) => t.id !== id);
|
||||
const next = tabs.length ? tabs : [defaultTab()];
|
||||
const activeTabId = get().activeTabId === id ? next[0].id : get().activeTabId;
|
||||
set({ tabs: next, activeTabId });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: next, activeTabId }));
|
||||
},
|
||||
|
||||
/** R2-F6:右键菜单「关闭其他」——只保留指定 Tab */
|
||||
closeOtherTabs: (id) => {
|
||||
const tabs = get().tabs.filter((t) => t.id === id);
|
||||
const next = tabs.length ? tabs : [defaultTab()];
|
||||
const activeTabId = next.some((t) => t.id === get().activeTabId) ? get().activeTabId : next[0].id;
|
||||
set({ tabs: next, activeTabId });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: next, activeTabId }));
|
||||
},
|
||||
|
||||
/** R2-F6:右键菜单「关闭右侧」——保留指定 Tab 及其左侧全部 */
|
||||
closeRightTabs: (id) => {
|
||||
const idx = get().tabs.findIndex((t) => t.id === id);
|
||||
if (idx < 0) return;
|
||||
const next = get().tabs.slice(0, idx + 1);
|
||||
const activeTabId = next.some((t) => t.id === get().activeTabId) ? get().activeTabId : next[next.length - 1].id;
|
||||
set({ tabs: next, activeTabId });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: next, activeTabId }));
|
||||
},
|
||||
|
||||
/** R2-F6:右键菜单「关闭全部」——回落一个空白 Tab(与 closeTab 语义一致) */
|
||||
closeAllTabs: () => {
|
||||
const next = [defaultTab()];
|
||||
set({ tabs: next, activeTabId: next[0].id });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs: next, activeTabId: next[0].id }));
|
||||
},
|
||||
|
||||
setStopOnError: (v) => {
|
||||
set({ stopOnError: v });
|
||||
get().persistPrefs();
|
||||
},
|
||||
|
||||
setExecTimeoutMs: (v) => {
|
||||
set({ execTimeoutMs: Math.min(3_600_000, Math.max(0, v || 0)) });
|
||||
get().persistPrefs();
|
||||
},
|
||||
|
||||
updateActiveSql: (sql) => {
|
||||
const id = get().activeTabId;
|
||||
const tabs = get().tabs.map((t) => {
|
||||
if (t.id !== id) return t;
|
||||
const saved = t.savedSql ?? "";
|
||||
return { ...t, sql, dirty: sql !== saved };
|
||||
});
|
||||
set({ tabs });
|
||||
localStorage.setItem(LS_TABS, JSON.stringify({ tabs, activeTabId: id }));
|
||||
},
|
||||
|
||||
pushAi: (role, content) =>
|
||||
set((s) => ({
|
||||
aiMessages: [
|
||||
...s.aiMessages,
|
||||
{ id: crypto.randomUUID(), role, content, at: new Date().toLocaleTimeString() },
|
||||
],
|
||||
})),
|
||||
|
||||
setSession: (connectionId, sessionId) => {
|
||||
const sessions = { ...get().sessions };
|
||||
if (sessionId) sessions[connectionId] = sessionId;
|
||||
else delete sessions[connectionId];
|
||||
set({ sessions });
|
||||
},
|
||||
|
||||
pushHistory: (item) => {
|
||||
const norm = (s: string) => s.replace(/\s+/g, " ").trim().toLowerCase();
|
||||
const key = norm(item.sql);
|
||||
const entry: QueryHistoryItem = {
|
||||
id: crypto.randomUUID(),
|
||||
at: item.at ?? Date.now(),
|
||||
sql: item.sql,
|
||||
connectionId: item.connectionId,
|
||||
rowCount: item.rowCount,
|
||||
timeMs: item.timeMs,
|
||||
error: item.error,
|
||||
};
|
||||
// 相同语句去重置顶:重复执行只留最新一次,历史即「最近用过的不同语句」
|
||||
const next = [entry, ...get().queryHistory.filter((h) => norm(h.sql) !== key)].slice(0, 200);
|
||||
localStorage.setItem(LS_HISTORY, JSON.stringify(next));
|
||||
set({ queryHistory: next });
|
||||
},
|
||||
|
||||
clearHistory: () => {
|
||||
set({ queryHistory: [] });
|
||||
localStorage.setItem(LS_HISTORY, JSON.stringify([]));
|
||||
},
|
||||
|
||||
|
||||
setResultSets: (items) => {
|
||||
const active = items[0]?.id ?? null;
|
||||
set({
|
||||
resultSets: items,
|
||||
activeResultSetId: active,
|
||||
result: items[0]?.result ?? null,
|
||||
resultTab: "data",
|
||||
});
|
||||
},
|
||||
|
||||
selectResultSet: (id) => {
|
||||
const item = get().resultSets.find((x) => x.id === id);
|
||||
if (!item) return;
|
||||
set({ activeResultSetId: id, result: item.result, resultTab: "data" });
|
||||
},
|
||||
|
||||
updateResultSet: (id, patch) => {
|
||||
const items = get().resultSets.map((x) => (x.id === id ? { ...x, ...patch } : x));
|
||||
const next: Partial<State> = { resultSets: items };
|
||||
if (get().activeResultSetId === id) {
|
||||
const item = items.find((x) => x.id === id);
|
||||
if (item) next.result = item.result;
|
||||
}
|
||||
set(next);
|
||||
},
|
||||
|
||||
loadBookmarks: () => {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_BOOKMARKS);
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) set({ bookmarks: parsed });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
saveBookmark: (name, sql) => {
|
||||
const n = name.trim();
|
||||
if (!n) return;
|
||||
const list = [{ id: crypto.randomUUID(), name: n, sql, at: Date.now() }, ...get().bookmarks].slice(0, 100);
|
||||
localStorage.setItem(LS_BOOKMARKS, JSON.stringify(list));
|
||||
set({ bookmarks: list });
|
||||
},
|
||||
|
||||
renameBookmark: (id, name) => {
|
||||
const n = name.trim();
|
||||
if (!n) return;
|
||||
const list = get().bookmarks.map((b) => (b.id === id ? { ...b, name: n } : b));
|
||||
localStorage.setItem(LS_BOOKMARKS, JSON.stringify(list));
|
||||
set({ bookmarks: list });
|
||||
},
|
||||
|
||||
removeBookmark: (id) => {
|
||||
const list = get().bookmarks.filter((b) => b.id !== id);
|
||||
localStorage.setItem(LS_BOOKMARKS, JSON.stringify(list));
|
||||
set({ bookmarks: list });
|
||||
},
|
||||
|
||||
askConfirm: (req) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
confirmResolver?.(false);
|
||||
confirmResolver = resolve;
|
||||
set({ confirm: req });
|
||||
}),
|
||||
|
||||
resolveConfirm: (ok) => {
|
||||
const r = confirmResolver;
|
||||
confirmResolver = null;
|
||||
set({ confirm: null });
|
||||
r?.(ok);
|
||||
},
|
||||
|
||||
persistPrefs: () => {
|
||||
const s = get();
|
||||
localStorage.setItem(
|
||||
LS_PREFS,
|
||||
JSON.stringify({
|
||||
theme: s.theme,
|
||||
leftWidth: s.leftWidth,
|
||||
aiWidth: s.aiWidth,
|
||||
resultRatio: s.resultRatio,
|
||||
showAi: s.showAi,
|
||||
stopOnError: s.stopOnError,
|
||||
execTimeoutMs: s.execTimeoutMs,
|
||||
}),
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
// init bookmarks
|
||||
try {
|
||||
const _bm = localStorage.getItem(LS_BOOKMARKS);
|
||||
if (_bm) {
|
||||
const parsed = JSON.parse(_bm);
|
||||
if (Array.isArray(parsed)) useWorkspaceStore.setState({ bookmarks: parsed });
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// init activeTabId after create
|
||||
const boot = useWorkspaceStore.getState();
|
||||
if (!boot.activeTabId && boot.tabs[0]) {
|
||||
useWorkspaceStore.setState({ activeTabId: boot.tabs[0].id });
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
});
|
||||