diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 0a3c8755..db151a22 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -83b4b7d166ed9172beba2d6236382c0b \ No newline at end of file +84c1262b11d52116d262ce60a8ec5676 \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css index 35c8ed09..7e2927e1 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -403,6 +403,19 @@ body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-check max-height: calc(100vh - 72px); display: flex; flex-direction: column; + padding: 0 !important; + overflow: hidden; + border-radius: 16px !important; + background: var(--gn-bg-panel, var(--ant-color-bg-elevated)) !important; + border: 1px solid var(--gn-br-2, var(--ant-color-border-secondary)) !important; + box-shadow: var(--gn-shadow-lg, var(--ant-box-shadow-secondary)) !important; +} + +.connection-modal-wrap .ant-modal-header { + margin: 0 !important; + padding: 0 !important; + border-bottom: 1px solid var(--gn-br-1, rgba(16, 24, 40, 0.06)); + background: var(--gn-bg-chrome, var(--ant-color-fill-quaternary)) !important; } .connection-modal-wrap .ant-modal-body { @@ -412,6 +425,168 @@ body[data-theme='light'] .redis-viewer-workbench .ant-radio-button-wrapper-check .connection-modal-wrap .ant-modal-footer { flex-shrink: 0; + margin: 0 !important; + padding: 8px 12px !important; + border-top: 1px solid var(--gn-br-1, rgba(16, 24, 40, 0.06)); + background: var(--gn-bg-chrome, var(--ant-color-fill-quaternary)) !important; +} + +/* v2-theme.css 后加载且包含通用 Modal !important,这里仅提高连接弹窗作用域优先级。 */ +body[data-ui-version='v2'] .connection-modal-wrap .ant-modal .ant-modal-content { + border: 1px solid var(--gn-br-2) !important; + border-radius: 16px !important; +} + +body[data-ui-version='v2'] .connection-modal-wrap .ant-modal .ant-modal-header { + margin: 0 !important; + padding: 0 !important; + border-bottom: 1px solid var(--gn-br-1) !important; + background: var(--gn-bg-chrome) !important; +} + +body[data-ui-version='v2'] .connection-modal-wrap .ant-modal .ant-modal-body { + padding: 0 !important; +} + +body[data-ui-version='v2'] .connection-modal-wrap .ant-modal .ant-modal-footer { + margin: 0 !important; + padding: 8px 12px !important; + border-top: 1px solid var(--gn-br-1) !important; + background: var(--gn-bg-chrome) !important; +} + +.connection-modal-picker-wrap .ant-modal-footer { + display: none; +} + +/* A · Studio 标题栏:左右列等宽,步骤始终保持几何居中。 */ +.gn-conn-studio-header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; + column-gap: 12px; + width: 100%; + padding: 12px 16px; + box-sizing: border-box; +} + +.connection-modal-wrap.connection-modal-form .gn-conn-studio-header { + column-gap: 8px; + padding: 8px 12px; +} + +.gn-conn-studio-header-left { + justify-self: start; + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.gn-conn-studio-type-badge { + width: 36px; + height: 36px; + border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 36px; + overflow: hidden; + box-sizing: border-box; + color: #fff; + background: var(--gn-accent, #1677ff); + font-size: 22px; + line-height: 1; +} + +.connection-modal-wrap.connection-modal-form .gn-conn-studio-type-badge { + width: 28px; + height: 28px; + flex-basis: 28px; + border-radius: 7px; + font-size: 11px; +} + +.gn-conn-studio-heading { + min-width: 0; +} + +.gn-conn-studio-title { + overflow: hidden; + color: var(--gn-fg-1, var(--ant-color-text)); + font-size: 14.5px; + font-weight: 700; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.connection-modal-wrap.connection-modal-form .gn-conn-studio-title { + font-size: 13.5px; +} + +.gn-conn-studio-subtitle { + margin-top: 2px; + overflow: hidden; + color: var(--gn-fg-4, var(--ant-color-text-secondary)); + font-size: 11.5px; + font-weight: 400; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gn-conn-studio-steps { + grid-column: 2; + justify-self: center; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + color: var(--gn-fg-5, var(--ant-color-text-quaternary)); + font-size: 11.5px; + font-weight: 400; + line-height: 1; + white-space: nowrap; +} + +.gn-conn-studio-steps [data-active='true'] { + color: var(--gn-accent, var(--ant-color-primary)); + font-weight: 700; +} + +.gn-conn-studio-steps [data-done='true'] { + color: var(--gn-fg-3, var(--ant-color-text-secondary)); +} + +.gn-conn-studio-step-arrow { + color: var(--gn-fg-5, var(--ant-color-text-quaternary)); + font-size: 9px; +} + +.gn-conn-studio-close { + grid-column: 3; + justify-self: end; + display: inline-grid; + place-items: center; + width: 28px; + height: 28px; + padding: 0; + border: 1px solid transparent; + border-radius: 7px; + background: transparent; + color: var(--gn-fg-4, var(--ant-color-text-secondary)); + cursor: pointer; +} + +.gn-conn-studio-close:hover { + background: var(--gn-bg-hover, var(--ant-color-fill-tertiary)); + color: var(--gn-fg-1, var(--ant-color-text)); +} + +.gn-conn-studio-close:focus-visible { + outline: 2px solid color-mix(in srgb, var(--gn-accent, #1677ff) 55%, transparent); + outline-offset: 1px; } /* 连接配置的表单分组改用「小标题 + 细分割线」代替卡片(见 ConnectionModal.tsx 的 @@ -464,8 +639,6 @@ body[data-theme='dark'] .gn-connection-type-group:hover:not([aria-pressed='true' grid-template-columns: minmax(200px, 24%) minmax(0, 1fr); flex: 1; min-height: 0; - border: 1px solid var(--gn-br-2, rgba(16, 24, 40, 0.08)); - border-radius: 12px; overflow: hidden; background: var(--gn-bg-panel, transparent); } @@ -488,8 +661,10 @@ body[data-theme='dark'] .gn-connection-type-group:hover:not([aria-pressed='true' background: var(--gn-bg-input, var(--gn-bg-panel-2, rgba(16, 24, 40, 0.04))); border: 1px solid var(--gn-br-2, rgba(16, 24, 40, 0.1)); border-radius: 10px; - padding: 2px 8px 2px 10px; + padding: 7px 8px 7px 10px; min-width: 0; + box-sizing: border-box; + overflow: hidden; } .gn-conn-picker-search-ico { @@ -501,7 +676,8 @@ body[data-theme='dark'] .gn-connection-type-group:hover:not([aria-pressed='true' .gn-conn-picker-search-input { flex: 1 1 auto; min-width: 0; - padding: 4px 0 !important; + width: 0; + padding: 0 !important; } .gn-conn-picker-search-input.ant-input-affix-wrapper, @@ -510,6 +686,20 @@ body[data-theme='dark'] .gn-connection-type-group:hover:not([aria-pressed='true' box-shadow: none !important; } +.gn-conn-picker-search-shortcut { + flex-shrink: 0; + padding: 2px 5px; + border: 1px solid var(--gn-br-2, rgba(16, 24, 40, 0.1)); + border-radius: 4px; + background: var(--gn-bg-panel-2, rgba(16, 24, 40, 0.03)); + color: var(--gn-fg-5, rgba(0, 0, 0, 0.4)); + font-family: var(--mono, ui-monospace, SFMono-Regular, Consolas, monospace); + font-size: 10px; + font-weight: 400; + line-height: 1.2; + white-space: nowrap; +} + .gn-conn-picker-sec-label { font-size: 10.5px; letter-spacing: 0.06em; @@ -660,7 +850,7 @@ body[data-theme='dark'] .gn-connection-type-group:hover:not([aria-pressed='true' width: 100%; } -@media (min-width: 1100px) { +@media (min-width: 1200px) { .connection-modal-wrap .gn-conn-picker-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); } @@ -674,7 +864,7 @@ body[data-theme='dark'] .gn-connection-type-group:hover:not([aria-pressed='true' color: inherit; background: var(--gn-bg-panel-2, rgba(16, 24, 40, 0.03)); border: 1px solid var(--gn-br-2, rgba(16, 24, 40, 0.1)); - border-radius: 12px; + border-radius: var(--radius, 8px); padding: 12px; transition: border-color 0.15s, transform 0.15s, background 0.15s; min-width: 0; @@ -732,6 +922,23 @@ body[data-theme='dark'] .gn-connection-type-group:hover:not([aria-pressed='true' line-height: 1.25; } +.gn-conn-type-card-tags { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 10px; +} + +.gn-conn-type-card-tag { + padding: 2px 7px; + border-radius: 999px; + background: var(--gn-bg-chrome, rgba(16, 24, 40, 0.03)); + color: var(--gn-fg-4, rgba(0, 0, 0, 0.45)); + font-size: 10.5px; + font-weight: 400; + line-height: 1.2; +} + .gn-conn-picker-empty { padding: 18px 10px; color: var(--gn-fg-4, rgba(0, 0, 0, 0.45)); @@ -743,16 +950,18 @@ body[data-theme='dark'] .gn-connection-type-group:hover:not([aria-pressed='true' display: flex; flex-direction: column; min-height: 0; - height: 100%; + flex: 0 0 auto; + max-height: min(720px, calc(100vh - 132px)); } .gn-conn-form-nav { display: flex; flex-wrap: wrap; gap: 2px; - padding: 0 2px; - margin: 0 0 10px; + padding: 6px 12px 0; + margin: 0; border-bottom: 1px solid var(--gn-br-1, rgba(16, 24, 40, 0.08)); + background: var(--gn-bg-chrome, var(--ant-color-fill-quaternary)); flex: 0 0 auto; } @@ -762,10 +971,10 @@ body[data-theme='dark'] .gn-connection-type-group:hover:not([aria-pressed='true' background: transparent; text-align: center; font: inherit; - font-size: 12.5px; + font-size: 12px; font-weight: 600; color: var(--gn-fg-4, rgba(0, 0, 0, 0.45)); - padding: 7px 14px 8px; + padding: 7px 12px 8px; border-radius: 8px 8px 0 0; cursor: pointer; display: inline-flex; @@ -789,8 +998,326 @@ body[data-theme='dark'] .gn-connection-type-group:hover:not([aria-pressed='true' .gn-conn-form-main { flex: 1 1 auto; min-height: 0; + width: 100%; + max-width: none; overflow: auto; - padding: 2px 0 4px; + padding: 10px 2% 12px; + box-sizing: border-box; +} + +/* URI 命令按钮:仅当前操作组使用绿色交互态,不影响弹窗底栏。 */ +.gn-conn-uri-actions .ant-space-item > .ant-btn { + transition: background-color 0.14s ease, border-color 0.14s ease, color 0.14s ease, transform 0.1s ease, box-shadow 0.14s ease; +} + +.gn-conn-uri-actions .ant-space-item > .ant-btn:hover, +.gn-conn-uri-actions .ant-space-item > .ant-btn:focus, +.gn-conn-uri-actions .ant-space-item > .ant-btn:focus-visible { + border-color: var(--gn-accent, #15803d) !important; + background: var(--gn-accent, #15803d) !important; + color: #fff !important; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--gn-accent, #15803d) 22%, transparent); +} + +.gn-conn-uri-actions .ant-space-item > .ant-btn:active { + border-color: var(--gn-accent, #15803d) !important; + background: var(--gn-accent, #15803d) !important; + color: #fff !important; + transform: scale(0.98); +} + +/* URI 提示关闭按钮仅在悬浮或键盘聚焦时显示。 */ +.gn-conn-uri-block .ant-alert-close-icon { + opacity: 0; + pointer-events: none; + transition: opacity 0.14s ease; +} + +.gn-conn-uri-block .ant-alert:hover .ant-alert-close-icon, +.gn-conn-uri-block .ant-alert:focus-within .ant-alert-close-icon { + opacity: 1; + pointer-events: auto; +} + +/* Studio 测试反馈:与 Demo 的 test-banner 保持同一信息密度。 */ +.gn-conn-studio-test-banner { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; + padding: 4px 8px; + border: 1px solid var(--gn-br-2, rgba(16, 24, 40, 0.1)); + border-radius: 6px; + font-size: 12px; + line-height: 1.35; +} + +.gn-conn-studio-test-banner > span:nth-child(2) { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gn-conn-studio-test-banner[data-status='success'] { + border-color: color-mix(in srgb, var(--gn-accent, #1677ff) 35%, transparent); + background: var(--gn-accent-soft, rgba(22, 119, 255, 0.1)); + color: var(--gn-accent, #1677ff); +} + +.gn-conn-studio-test-banner[data-status='error'] { + border-color: color-mix(in srgb, var(--gn-danger, #ff4d4f) 35%, transparent); + background: color-mix(in srgb, var(--gn-danger, #ff4d4f) 12%, transparent); + color: var(--gn-danger, #ff4d4f); +} + +.gn-conn-studio-test-banner .ant-btn-link { + height: auto; + padding: 0 2px; + color: currentColor; + font-size: 11.5px; +} + +/* Studio 第二步底栏。 */ +.gn-conn-studio-foot { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + width: 100%; +} + +.gn-conn-studio-foot-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; + color: var(--gn-fg-4, var(--ant-color-text-secondary)); + font-size: 12px; +} + +.gn-conn-studio-foot-right { + flex: 0 0 auto; +} + +.gn-conn-studio-button.ant-btn { + height: 28px; + padding: 0 10px; + border-radius: 7px; + font-size: 12px; + font-weight: 600; + box-shadow: none; +} + +.gn-conn-studio-foot-status { + display: inline-flex; + align-items: center; + gap: 5px; + flex: 0 0 auto; + white-space: nowrap; +} + +.gn-conn-studio-foot-status[data-status='success'] { + color: var(--gn-accent, #1677ff); +} + +.gn-conn-studio-foot-status[data-status='error'], +.gn-conn-studio-foot-error { + color: var(--gn-danger, #ff4d4f); +} + +.gn-conn-studio-foot-error { + min-width: 0; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gn-conn-studio-details-button.ant-btn { + height: 24px; + padding: 0 8px; + border-radius: 6px; + border-color: color-mix(in srgb, var(--gn-danger, #ff4d4f) 35%, transparent); + background: color-mix(in srgb, var(--gn-danger, #ff4d4f) 10%, transparent); + color: var(--gn-danger, #ff4d4f); + font-size: 11.5px; +} + +/* 外观页:对应 Demo 的 icon-grid / color-row / appear-preview。 */ +.gn-conn-appearance { + display: grid; + gap: 14px; +} + +.gn-conn-appearance-label { + margin: 0 0 6px; + color: var(--gn-fg-5, var(--ant-color-text-quaternary)); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.gn-conn-appearance-label span { + margin-left: 6px; + color: var(--gn-fg-4, var(--ant-color-text-secondary)); + font-weight: 500; + letter-spacing: 0; + text-transform: none; +} + +.gn-conn-appearance-icon-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(36px, 1fr)); + gap: 6px; + max-height: 148px; + overflow: auto; + padding: 2px; +} + +.gn-conn-appearance-icon { + appearance: none; + display: grid; + place-items: center; + width: 100%; + aspect-ratio: 1; + padding: 4px; + border: 1.5px solid var(--gn-br-2, rgba(16, 24, 40, 0.1)); + border-radius: 8px; + background: var(--gn-bg-input, var(--ant-color-bg-container)); + color: inherit; + cursor: pointer; +} + +.gn-conn-appearance-icon:hover { + border-color: var(--gn-br-3, rgba(16, 24, 40, 0.18)); + background: var(--gn-bg-hover, var(--ant-color-fill-tertiary)); +} + +.gn-conn-appearance-icon[data-active='true'] { + border-color: var(--gn-accent, #1677ff); + box-shadow: 0 0 0 2px var(--gn-accent-soft, rgba(22, 119, 255, 0.12)); +} + +.gn-conn-appearance-colors { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.gn-conn-appearance-color, +.gn-conn-appearance-custom-color { + width: 22px; + height: 22px; + flex: 0 0 22px; + padding: 0; + border: 2px solid transparent; + border-radius: 50%; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12); + cursor: pointer; +} + +.gn-conn-appearance-color[data-active='true'] { + border-color: var(--gn-fg-1, var(--ant-color-text)); + box-shadow: 0 0 0 2px var(--gn-bg-panel, var(--ant-color-bg-container)), 0 0 0 3.5px var(--gn-fg-1, var(--ant-color-text)); +} + +.gn-conn-appearance-custom-color { + overflow: hidden; + border: 1px solid var(--gn-br-2, rgba(16, 24, 40, 0.1)); + background: var(--gn-bg-input, var(--ant-color-bg-container)); +} + +.gn-conn-appearance-custom-color::-webkit-color-swatch-wrapper { + padding: 1px; +} + +.gn-conn-appearance-custom-color::-webkit-color-swatch { + border: 0; + border-radius: 50%; +} + +.gn-conn-appearance-preview { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 10px; + border: 1px solid var(--gn-br-2, rgba(16, 24, 40, 0.1)); + border-radius: 8px; + background: var(--gn-bg-panel-2, rgba(16, 24, 40, 0.03)); +} + +.gn-conn-appearance-preview-main { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.gn-conn-appearance-preview-icon { + display: grid; + place-items: center; + width: 28px; + height: 28px; + flex: 0 0 28px; + overflow: hidden; + border: 1.5px solid var(--gn-br-2, rgba(16, 24, 40, 0.1)); + border-radius: 7px; + background: var(--gn-bg-input, var(--ant-color-bg-container)); +} + +.gn-conn-appearance-preview-copy { + min-width: 0; +} + +.gn-conn-appearance-preview-name { + overflow: hidden; + color: var(--gn-fg-1, var(--ant-color-text)); + font-size: 13px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gn-conn-appearance-preview-meta { + margin-top: 1px; + color: var(--gn-fg-5, var(--ant-color-text-quaternary)); + font-size: 11px; +} + +.gn-conn-appearance-reset.ant-btn { + margin-left: auto; +} + +@media (max-width: 700px) { + .gn-conn-studio-header { + grid-template-columns: minmax(0, 1fr) auto; + } + + .gn-conn-studio-steps { + display: none; + } + + .gn-conn-studio-close { + grid-column: 2; + } + + .gn-conn-picker-body { + grid-template-columns: minmax(155px, 36%) minmax(0, 1fr); + } + + .gn-conn-picker-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .gn-conn-studio-foot-error, + .gn-conn-studio-details-button { + display: none; + } } /* URI block in basic tab — flat, no heavy card chrome */ @@ -853,8 +1380,34 @@ body[data-theme='dark'] .gn-connection-type-group:hover:not([aria-pressed='true' } .connection-modal-wrap.connection-modal-form .ant-modal-body { - padding-left: 12px !important; - padding-right: 12px !important; + padding-left: 0 !important; + padding-right: 0 !important; +} + +/* 垂直拖拽后,表单内容区消费新增高度,底栏保持固定。 */ +.connection-modal-wrap.connection-modal-form .gn-resizable-draggable-modal[data-has-resized-height='true'] .ant-modal-content { + height: var(--gn-modal-resized-height); + max-height: var(--gn-modal-resized-height); +} + +.connection-modal-wrap.connection-modal-form .gn-resizable-draggable-modal[data-has-resized-height='true'] .ant-modal-body { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-height: 0; + max-height: none !important; + overflow: hidden !important; +} + +.connection-modal-wrap.connection-modal-form .gn-resizable-draggable-modal[data-has-resized-height='true'] .gn-conn-studio-form, +.connection-modal-wrap.connection-modal-form .gn-resizable-draggable-modal[data-has-resized-height='true'] .gn-conn-form-layout { + display: flex; + flex: 1 1 auto; + flex-direction: column; + width: 100%; + height: 100%; + min-height: 0; + max-height: none; } /* demo .f-row */ diff --git a/frontend/src/components/ConnectionModal.i18n.test.tsx b/frontend/src/components/ConnectionModal.i18n.test.tsx index 8e95f25e..6d46d4b1 100644 --- a/frontend/src/components/ConnectionModal.i18n.test.tsx +++ b/frontend/src/components/ConnectionModal.i18n.test.tsx @@ -193,9 +193,9 @@ vi.mock("@ant-design/icons", () => { CloudOutlined: Icon, CheckCircleFilled: Icon, CloseCircleFilled: Icon, - LinkOutlined: Icon, - EditOutlined: Icon, - AppstoreOutlined: Icon, + ArrowLeftOutlined: Icon, + CloseOutlined: Icon, + PlusOutlined: Icon, BgColorsOutlined: Icon, ApiOutlined: Icon, ClusterOutlined: Icon, @@ -382,6 +382,8 @@ describe("ConnectionModal i18n", () => { vi.stubGlobal("window", { setTimeout, clearTimeout, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), }); storeState.theme = "light"; storeState.languagePreference = "zh-CN"; @@ -504,7 +506,8 @@ describe("ConnectionModal i18n", () => { expect(textContent(renderer!.toJSON())).toContain("选择数据源类型"); expect(textContent(renderer!.toJSON())).toContain("选择数据源"); - expect(textContent(renderer!.toJSON())).toContain("取消"); + expect(textContent(renderer!.toJSON())).toContain("1 选类型"); + expect(textContent(renderer!.toJSON())).toContain("SSL"); await act(async () => { findClickableCard(renderer!, "MySQL").props.onClick(); @@ -519,7 +522,9 @@ describe("ConnectionModal i18n", () => { renderer!.update(); }); - expect(textContent(renderer!.toJSON())).toContain("新建 MySQL 连接"); + expect(textContent(renderer!.toJSON())).toContain("MySQL 连接"); + expect(textContent(renderer!.toJSON())).toContain("2 参数"); + expect(textContent(renderer!.toJSON())).toContain("未测试"); expect(textContent(renderer!.toJSON())).toContain("名称"); expect(textContent(renderer!.toJSON())).toContain("测试连接"); expect(textContent(renderer!.toJSON())).toContain("保存"); @@ -552,13 +557,15 @@ describe("ConnectionModal i18n", () => { expect(textContent(renderer!.toJSON())).toContain("Select connection type"); expect(textContent(renderer!.toJSON())).toContain("Click to configure"); expect(textContent(renderer!.toJSON())).toContain("Categories"); - expect(textContent(renderer!.toJSON())).toContain("Cancel"); + expect(textContent(renderer!.toJSON())).toContain("1 Choose type"); await act(async () => { findClickableCard(renderer!, "MySQL").props.onClick(); }); - expect(textContent(renderer!.toJSON())).toContain("New MySQL connection"); + expect(textContent(renderer!.toJSON())).toContain("MySQL connection"); + expect(textContent(renderer!.toJSON())).toContain("2 Parameters"); + expect(textContent(renderer!.toJSON())).toContain("Not tested"); expect(textContent(renderer!.toJSON())).toContain("Name"); expect(textContent(renderer!.toJSON())).toContain("Test connection"); expect(textContent(renderer!.toJSON())).toContain("Save"); @@ -630,7 +637,7 @@ describe("ConnectionModal i18n", () => { findClickableCard(renderer!, sourceLabel).props.onClick(); }); - // Demo:生产保护默认展开;折叠后选项文案应消失 + // A · Studio:生产保护默认展开,折叠后选项文案隐藏。 let pageText = textContent(renderer!.toJSON()); expect(pageText).toContain(expectations[0]); expect(pageText).toContain(expectations[2]); @@ -647,22 +654,8 @@ describe("ConnectionModal i18n", () => { pageText = textContent(renderer!.toJSON()); expect(pageText).toContain(expectations[0]); - expect(pageText).toContain( - language === "zh-CN" ? "未限制" : "No restrictions", - ); expect(pageText).not.toContain(expectations[2]); expect(pageText).not.toContain(expectations[3]); - - await act(async () => { - protectionToggle.props.onClick({ stopPropagation: vi.fn() }); - }); - - pageText = textContent(renderer!.toJSON()); - expectations - .filter((expected) => expected !== expectations[1]) - .forEach((expected) => { - expect(pageText).toContain(expected); - }); expect(pageText).not.toContain("connection.modal.section.undefined.title"); expect(pageText).not.toContain("connection.modal.section.undefined.description"); }, @@ -1096,6 +1089,15 @@ describe("ConnectionModal i18n", () => { message: "backend raw error: /tmp/app.db", }); const { default: ConnectionModal } = await import("./ConnectionModal"); + let dismissUriFeedback: (() => void) | undefined; + const uriFeedbackTimer = vi.fn((callback: () => void, delay: number) => { + if (delay === 4000) dismissUriFeedback = callback; + return 1; + }); + Object.assign(window, { + setTimeout: uriFeedbackTimer, + clearTimeout: vi.fn(), + }); let renderer: ReactTestRenderer; await act(async () => { @@ -1115,6 +1117,12 @@ describe("ConnectionModal i18n", () => { }); expect(textContent(renderer!.toJSON())).toContain("URI generated."); + expect(uriFeedbackTimer).toHaveBeenCalledWith(expect.any(Function), 4000); + + await act(async () => { + dismissUriFeedback?.(); + }); + expect(textContent(renderer!.toJSON())).not.toContain("URI generated."); await act(async () => { findButton(renderer!, "Back").props.onClick(); @@ -1132,6 +1140,45 @@ describe("ConnectionModal i18n", () => { ); }); + it("automatically dismisses URI warning feedback after four seconds", async () => { + storeState.appearance.uiVersion = "legacy"; + setCurrentLanguage("en-US"); + const { default: ConnectionModal } = await import("./ConnectionModal"); + let dismissUriFeedback: (() => void) | undefined; + const uriFeedbackTimer = vi.fn((callback: () => void, delay: number) => { + if (delay === 4000) dismissUriFeedback = callback; + return 1; + }); + Object.assign(window, { + setTimeout: uriFeedbackTimer, + clearTimeout: vi.fn(), + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await act(async () => { + findClickableCard(renderer!, "MySQL").props.onClick(); + }); + await act(async () => { + expandUriSection(renderer!); + }); + const parseUriButton = findButton(renderer!, "Parse from URI"); + expect(parseUriButton, textContent(renderer!.toJSON())).toBeDefined(); + await act(async () => { + parseUriButton.props.onClick(); + }); + + expect(textContent(renderer!.toJSON())).toContain("Enter a URI first"); + expect(uriFeedbackTimer).toHaveBeenCalledWith(expect.any(Function), 4000); + + await act(async () => { + dismissUriFeedback?.(); + }); + expect(textContent(renderer!.toJSON())).not.toContain("Enter a URI first"); + }); + it("retranslates test failure feedback while preserving raw detail when language changes in-place", async () => { storeState.appearance.uiVersion = "legacy"; setCurrentLanguage("zh-CN"); diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index fa157b12..806843e4 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -20,9 +20,9 @@ import { FileTextOutlined, CheckCircleFilled, CloseCircleFilled, - LinkOutlined, - EditOutlined, - AppstoreOutlined, + ArrowLeftOutlined, + CloseOutlined, + PlusOutlined, ApiOutlined, ClusterOutlined, CodeOutlined, @@ -170,7 +170,8 @@ const PRIMARY_USERNAME_OPTIONAL_TYPES = new Set([ /** Step1 选型网格需要较宽;Step2 密排表单对齐 Demo ~600,避免右侧空洞或输入框被拉超长 */ const CONNECTION_MODAL_WIDTH_STEP1 = 960; const CONNECTION_MODAL_WIDTH_STEP2 = 600; -const CONNECTION_MODAL_BODY_HEIGHT = 620; +// A · Studio 第一步整体高度约 560px,扣除头部后主体固定为 500px。 +const CONNECTION_MODAL_BODY_HEIGHT = 500; const REDIS_DEFAULT_DATABASE_COUNT = 16; const CLICKHOUSE_PROTOCOL_OPTIONS: Array<{ value: ClickHouseProtocolChoice; @@ -2440,6 +2441,25 @@ const ConnectionModal: React.FC<{ setDbTypeQuery(""); }; + useEffect(() => { + if (!open || step !== 1 || typeof window === "undefined") return; + // Studio 选型页快捷键处理器,仅在第一步挂载。 + const focusStudioSearch = (event: KeyboardEvent) => { + if (!(event.metaKey || event.ctrlKey) || event.key.toLowerCase() !== "k") { + return; + } + // 当前弹窗内的数据源搜索框。 + const searchInput = document.querySelector( + '.connection-modal-wrap [data-connection-type-search="true"]', + ); + if (!searchInput) return; + event.preventDefault(); + searchInput.focus(); + }; + window.addEventListener("keydown", focusStudioSearch); + return () => window.removeEventListener("keydown", focusStudioSearch); + }, [open, step]); + const dbTypes = getAllConnectionTypeCatalogItems(); const recentConnectionChips = useMemo(() => { @@ -2511,8 +2531,12 @@ const ConnectionModal: React.FC<{ onChange={(event) => handleDbTypeQueryChange(event.target.value)} placeholder={t("connection.modal.step1.search.placeholder")} className="gn-conn-picker-search-input" + data-connection-type-search="true" {...noAutoCapInputProps} /> + + {isMacLikePlatform() ? "Cmd K" : "Ctrl K"} + {recentConnectionChips.length > 0 ? ( @@ -2588,6 +2612,15 @@ const ConnectionModal: React.FC<{
{visibleDbTypeItems.map((item) => { const color = getDbDefaultColor(item.key); + // 卡片所属的数据源分类展示标签。 + const categoryLabel = localizedDbTypeGroups.find((group) => + group.items.some((groupItem) => groupItem.key === item.key), + )?.label; + // Studio 卡片底部展示标签,不参与连接逻辑。 + const studioTags = [ + categoryLabel, + supportsSSLForType(item.key) ? "SSL" : null, + ].filter((tag): tag is string => Boolean(tag)); return (
+ {studioTags.length > 0 ? ( + + ) : null} ); })} @@ -2696,6 +2738,7 @@ const ConnectionModal: React.FC<{ renderJvmSectionHeader, renderStoredSecretControls, resolvedUriFeedbackMessage, + resolvedTestResultMessage, rocketmqTopology, selectingCertificateField, selectingDbFile, @@ -2735,11 +2778,7 @@ const ConnectionModal: React.FC<{ const getFooter = () => { if (step === 1) { - return [ - , - ]; + return null; } const isTestSuccess = testResult?.type === "success"; const hasTestError = !!testResult && !isTestSuccess; @@ -2754,74 +2793,40 @@ const ConnectionModal: React.FC<{ driverStatusChecking || !!unsupportedJvmModeMessage; return ( -
-
+
+
{!initialValues && ( - )} - {testResult ? ( - + + {testResult ? ( + <> {isTestSuccess ? : } - - {isTestSuccess - ? t("connection.status.success") - : t("connection.status.failure")} - - - ) : null} + + {isTestSuccess + ? t("connection.status.success") + : t("connection.status.failure")} + + + ) : ( + t("connection.modal.studio.test.idle") + )} + {hasTestError && ( {testFailureSummary} @@ -2830,35 +2835,30 @@ const ConnectionModal: React.FC<{ )}
- + - +
+ ); }; const isFormStep = step !== 1; // Step2 对齐 demo `.frame-form { height: fit-content }`,避免固定 620 撑出大片空白 const modalBodyStyle = { - padding: isFormStep ? "10px 12px 12px" : "12px 24px 18px", + padding: 0, height: isFormStep ? "auto" : CONNECTION_MODAL_BODY_HEIGHT, - maxHeight: isFormStep ? "min(720px, calc(100vh - 160px))" : undefined, + maxHeight: isFormStep ? "min(720px, calc(100vh - 132px))" : undefined, minHeight: isFormStep ? 0 : CONNECTION_MODAL_BODY_HEIGHT, - overflowY: "auto" as const, + overflowY: "hidden" as const, overflowX: "hidden" as const, }; return ( <> {step === 1 ? renderStep1() : renderStep2()} diff --git a/frontend/src/components/connectionModal/ConnectionModalStep2.tsx b/frontend/src/components/connectionModal/ConnectionModalStep2.tsx index 18099082..cb141195 100644 --- a/frontend/src/components/connectionModal/ConnectionModalStep2.tsx +++ b/frontend/src/components/connectionModal/ConnectionModalStep2.tsx @@ -15,7 +15,9 @@ import { } from "antd"; import { ApiOutlined, + CheckCircleFilled, ClusterOutlined, + CloseCircleFilled, CodeOutlined, DatabaseOutlined, FileTextOutlined, @@ -89,6 +91,9 @@ const PRIMARY_USERNAME_OPTIONAL_TYPES = new Set([ "rabbitmq", ]); +// URI 操作反馈统一保留 4 秒,便于用户读取后自动回收空间。 +const URI_FEEDBACK_AUTO_DISMISS_MS = 4000; + type ConnectionModalStep2Props = Record; const ConnectionModalStep2: React.FC = (props) => { @@ -158,6 +163,7 @@ const ConnectionModalStep2: React.FC = (props) => { renderConfigSectionCard, renderJvmSectionHeader, renderStoredSecretControls, + resolvedTestResultMessage, resolvedUriFeedbackMessage, rocketmqTopology, selectingCertificateField, @@ -194,7 +200,7 @@ const ConnectionModalStep2: React.FC = (props) => { useSSL, } = props; - // Demo:进入配置后生产保护默认展开,便于看到限制项 + // A · Studio 默认展开生产保护,保留用户按需折叠的交互。 const [readOnlyProtectionExpanded, setReadOnlyProtectionExpanded] = React.useState(true); @@ -202,6 +208,14 @@ const ConnectionModalStep2: React.FC = (props) => { setReadOnlyProtectionExpanded(true); }, [dbType]); + React.useEffect(() => { + if (!uriFeedback) return undefined; + const dismissTimer = window.setTimeout(() => { + setUriFeedback(null); + }, URI_FEEDBACK_AUTO_DISMISS_MS); + return () => window.clearTimeout(dismissTimer); + }, [setUriFeedback, uriFeedback]); + const renderStep2 = () => { const showConnectionReadOnlyField = supportsConnectionReadOnlyMode({ type: dbType, @@ -238,8 +252,13 @@ const ConnectionModalStep2: React.FC = (props) => { placeholder={getUriPlaceholder(dbType)} /> - -
-
-
- {getDbIcon(effectiveIconType, effectiveIconColor, 24)} -
-
+
+
+
+ {getDbIcon(effectiveIconType, effectiveIconColor, 18)} +
+
+
{form.getFieldValue("name") || t("connection.modal.appearance.previewName")}
-
+
{t("connection.modal.appearance.preview")}
@@ -2984,7 +2929,7 @@ const ConnectionModalStep2: React.FC = (props) => { + ) : null} +
+ ) : null} + {currentSectionContent} +
); })()} diff --git a/shared/i18n/de-DE.json b/shared/i18n/de-DE.json index 228ade01..22012a0b 100644 --- a/shared/i18n/de-DE.json +++ b/shared/i18n/de-DE.json @@ -8776,6 +8776,16 @@ "connection_modal.step1.categories": "Kategorien", "connection_modal.step1.clickToConfigure": "Zum Konfigurieren klicken", "connection_modal.step1.recent.empty": "Keine letzten Verbindungen", + "connection_modal.studio.step.chooseType": "Typ wählen", + "connection_modal.studio.step.configure": "Konfigurieren", + "connection_modal.studio.step.testSave": "Testen & speichern", + "connection_modal.studio.step.type": "Typ", + "connection_modal.studio.step.params": "Parameter", + "connection_modal.studio.step.save": "Speichern", + "connection_modal.studio.title.edit": "{{type}}-Verbindung bearbeiten", + "connection_modal.studio.title.connection": "{{type}}-Verbindung", + "connection_modal.studio.progress": "Fortschritt der Verbindungseinrichtung", + "connection_modal.studio.test.idle": "Nicht getestet", "connection_modal.config.advanced.title": "Erweitert", "connection_modal.config.advanced.description": "Verbindungsparameter und Typ-Extras", "connection_modal.config.advanced.empty": "Keine erweiterten Optionen für diesen Typ", diff --git a/shared/i18n/en-US.json b/shared/i18n/en-US.json index 50296e6b..71d52e0c 100644 --- a/shared/i18n/en-US.json +++ b/shared/i18n/en-US.json @@ -8776,6 +8776,16 @@ "connection_modal.step1.categories": "Categories", "connection_modal.step1.clickToConfigure": "Click to configure", "connection_modal.step1.recent.empty": "No recent connections", + "connection_modal.studio.step.chooseType": "Choose type", + "connection_modal.studio.step.configure": "Configure", + "connection_modal.studio.step.testSave": "Test & save", + "connection_modal.studio.step.type": "Type", + "connection_modal.studio.step.params": "Parameters", + "connection_modal.studio.step.save": "Save", + "connection_modal.studio.title.edit": "Edit {{type}} connection", + "connection_modal.studio.title.connection": "{{type}} connection", + "connection_modal.studio.progress": "Connection setup progress", + "connection_modal.studio.test.idle": "Not tested", "connection_modal.config.advanced.title": "Advanced", "connection_modal.config.advanced.description": "Connection parameters and type extras", "connection_modal.config.advanced.empty": "No advanced options for this type", diff --git a/shared/i18n/ja-JP.json b/shared/i18n/ja-JP.json index bcd4ebf0..343f67d8 100644 --- a/shared/i18n/ja-JP.json +++ b/shared/i18n/ja-JP.json @@ -8776,6 +8776,16 @@ "connection_modal.step1.categories": "カテゴリ", "connection_modal.step1.clickToConfigure": "クリックして設定", "connection_modal.step1.recent.empty": "最近の接続なし", + "connection_modal.studio.step.chooseType": "種類を選択", + "connection_modal.studio.step.configure": "パラメータ", + "connection_modal.studio.step.testSave": "テストと保存", + "connection_modal.studio.step.type": "種類", + "connection_modal.studio.step.params": "パラメータ", + "connection_modal.studio.step.save": "保存", + "connection_modal.studio.title.edit": "{{type}} 接続を編集", + "connection_modal.studio.title.connection": "{{type}} 接続", + "connection_modal.studio.progress": "接続作成の進行状況", + "connection_modal.studio.test.idle": "未テスト", "connection_modal.config.advanced.title": "詳細", "connection_modal.config.advanced.description": "接続パラメータとタイプ別拡張", "connection_modal.config.advanced.empty": "このタイプに詳細設定はありません", diff --git a/shared/i18n/ru-RU.json b/shared/i18n/ru-RU.json index c7bf55e8..0237814d 100644 --- a/shared/i18n/ru-RU.json +++ b/shared/i18n/ru-RU.json @@ -8776,6 +8776,16 @@ "connection_modal.step1.categories": "Категории", "connection_modal.step1.clickToConfigure": "Нажмите для настройки", "connection_modal.step1.recent.empty": "Нет недавних подключений", + "connection_modal.studio.step.chooseType": "Выбрать тип", + "connection_modal.studio.step.configure": "Параметры", + "connection_modal.studio.step.testSave": "Тест и сохранение", + "connection_modal.studio.step.type": "Тип", + "connection_modal.studio.step.params": "Параметры", + "connection_modal.studio.step.save": "Сохранить", + "connection_modal.studio.title.edit": "Изменить подключение {{type}}", + "connection_modal.studio.title.connection": "Подключение {{type}}", + "connection_modal.studio.progress": "Ход настройки подключения", + "connection_modal.studio.test.idle": "Не проверено", "connection_modal.config.advanced.title": "Дополнительно", "connection_modal.config.advanced.description": "Параметры подключения и расширения", "connection_modal.config.advanced.empty": "Нет доп. параметров для этого типа", diff --git a/shared/i18n/zh-CN.json b/shared/i18n/zh-CN.json index 88e531bd..0f967559 100644 --- a/shared/i18n/zh-CN.json +++ b/shared/i18n/zh-CN.json @@ -8776,6 +8776,16 @@ "connection_modal.step1.categories": "分类", "connection_modal.step1.clickToConfigure": "单击进入配置表单", "connection_modal.step1.recent.empty": "暂无连接记录", + "connection_modal.studio.step.chooseType": "选类型", + "connection_modal.studio.step.configure": "配参数", + "connection_modal.studio.step.testSave": "测试保存", + "connection_modal.studio.step.type": "类型", + "connection_modal.studio.step.params": "参数", + "connection_modal.studio.step.save": "保存", + "connection_modal.studio.title.edit": "编辑 {{type}} 连接", + "connection_modal.studio.title.connection": "{{type}} 连接", + "connection_modal.studio.progress": "连接创建进度", + "connection_modal.studio.test.idle": "未测试", "connection_modal.config.advanced.title": "高级", "connection_modal.config.advanced.description": "连接参数与类型扩展项", "connection_modal.config.advanced.empty": "当前类型暂无额外高级参数", diff --git a/shared/i18n/zh-TW.json b/shared/i18n/zh-TW.json index 3e693056..46c05d99 100644 --- a/shared/i18n/zh-TW.json +++ b/shared/i18n/zh-TW.json @@ -8776,6 +8776,16 @@ "connection_modal.step1.categories": "分類", "connection_modal.step1.clickToConfigure": "按一下進入設定表單", "connection_modal.step1.recent.empty": "尚無連線紀錄", + "connection_modal.studio.step.chooseType": "選類型", + "connection_modal.studio.step.configure": "設參數", + "connection_modal.studio.step.testSave": "測試儲存", + "connection_modal.studio.step.type": "類型", + "connection_modal.studio.step.params": "參數", + "connection_modal.studio.step.save": "儲存", + "connection_modal.studio.title.edit": "編輯 {{type}} 連線", + "connection_modal.studio.title.connection": "{{type}} 連線", + "connection_modal.studio.progress": "連線建立進度", + "connection_modal.studio.test.idle": "未測試", "connection_modal.config.advanced.title": "進階", "connection_modal.config.advanced.description": "連線參數與類型擴充項", "connection_modal.config.advanced.empty": "目前類型沒有額外進階參數",