diff --git a/.github/workflows/sync-main-to-dev.yml b/.github/workflows/sync-main-to-dev.yml deleted file mode 100644 index 18f047a..0000000 --- a/.github/workflows/sync-main-to-dev.yml +++ /dev/null @@ -1,180 +0,0 @@ -name: main 回灌 dev - -on: - push: - branches: - - main - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -concurrency: - group: sync-main-to-dev - cancel-in-progress: true - -jobs: - sync-main-to-dev: - name: 执行回灌同步 - runs-on: ubuntu-latest - steps: - - name: 检出代码 - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: 检查是否需要同步 - id: diff_check - shell: bash - run: | - set -euo pipefail - echo "开始检查 main 与 dev 的分支差异..." - git fetch origin main dev - ahead_count="$(git rev-list --count origin/dev..origin/main)" - echo "ahead_count=${ahead_count}" >> "$GITHUB_OUTPUT" - if [ "${ahead_count}" -eq 0 ]; then - echo "无需同步,dev 已包含 main 的最新提交。" - echo "has_changes=false" >> "$GITHUB_OUTPUT" - else - echo "检测到 ${ahead_count} 个待同步提交,准备创建或复用同步 PR。" - echo "has_changes=true" >> "$GITHUB_OUTPUT" - fi - - - name: 创建或复用同步 PR - id: sync_pr - if: steps.diff_check.outputs.has_changes == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - echo "permission_blocked=false" >> "$GITHUB_OUTPUT" - existing_number="$(gh pr list --base dev --head main --state open --json number --jq '.[0].number // empty')" - - if [ -n "${existing_number}" ]; then - pr_number="${existing_number}" - pr_url="$(gh pr view "${pr_number}" --json url --jq '.url')" - echo "复用已有同步 PR:#${pr_number}" - echo "created=false" >> "$GITHUB_OUTPUT" - else - body_file="$(mktemp)" - error_file="$(mktemp)" - { - echo "## 自动回灌:\`main -> dev\`" - echo - echo "- 触发条件:\`main\` 分支出现新提交(含贡献者直接合并到 \`main\` 的 PR)" - echo "- 目标:让 \`dev\` 持续吸收 \`main\` 的更新,避免发布前集中冲突" - echo - echo "### 合并建议" - echo "- 无冲突:直接合并该 PR(建议 \`Merge commit\`)" - echo "- 有冲突:在该 PR 内解决冲突后再合并" - } > "${body_file}" - - if pr_url="$(gh pr create \ - --base dev \ - --head main \ - --title "🔁 chore(sync): 回灌 main 到 dev" \ - --body-file "${body_file}" 2>"${error_file}")"; then - pr_number="${pr_url##*/}" - echo "已创建同步 PR:#${pr_number}" - echo "created=true" >> "$GITHUB_OUTPUT" - else - error_message="$(tr '\n' ' ' < "${error_file}")" - if printf '%s' "${error_message}" | grep -Fq "GitHub Actions is not permitted to create or approve pull requests"; then - echo "::warning::仓库未开启“Allow GitHub Actions to create and approve pull requests”,已跳过自动创建同步 PR。" - echo "permission_blocked=true" >> "$GITHUB_OUTPUT" - echo "created=false" >> "$GITHUB_OUTPUT" - echo "pr_number=" >> "$GITHUB_OUTPUT" - echo "pr_url=" >> "$GITHUB_OUTPUT" - exit 0 - fi - echo "::error::创建同步 PR 失败:${error_message}" - exit 1 - fi - fi - - echo "pr_number=${pr_number}" >> "$GITHUB_OUTPUT" - echo "pr_url=${pr_url}" >> "$GITHUB_OUTPUT" - - - name: 检查合并状态 - id: merge_state - if: steps.diff_check.outputs.has_changes == 'true' && steps.sync_pr.outputs.permission_blocked != 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - pr_number="${{ steps.sync_pr.outputs.pr_number }}" - mergeable="UNKNOWN" - merge_state_status="UNKNOWN" - - for attempt in 1 2 3 4 5 6; do - mergeable="$(gh pr view "${pr_number}" --json mergeable --jq '.mergeable')" - merge_state_status="$(gh pr view "${pr_number}" --json mergeStateStatus --jq '.mergeStateStatus')" - echo "第 ${attempt} 次检查 PR #${pr_number} 合并状态:mergeable=${mergeable}, mergeStateStatus=${merge_state_status}" - if [ "${mergeable}" != "UNKNOWN" ]; then - break - fi - if [ "${attempt}" -lt 6 ]; then - echo "GitHub 仍在计算可合并状态,3 秒后重试..." - sleep 3 - fi - done - - if [ "${mergeable}" = "UNKNOWN" ]; then - echo "::warning::PR 合并状态仍在计算中,本次未开启自动合并,可稍后重跑 workflow 或手动开启。" - echo "merge_state_pending=true" >> "$GITHUB_OUTPUT" - else - echo "merge_state_pending=false" >> "$GITHUB_OUTPUT" - fi - echo "mergeable=${mergeable}" >> "$GITHUB_OUTPUT" - echo "merge_state_status=${merge_state_status}" >> "$GITHUB_OUTPUT" - - - name: 可合并时开启自动合并 - id: auto_merge - if: steps.diff_check.outputs.has_changes == 'true' && steps.sync_pr.outputs.permission_blocked != 'true' && steps.merge_state.outputs.mergeable == 'MERGEABLE' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - set -euo pipefail - pr_number="${{ steps.sync_pr.outputs.pr_number }}" - if gh pr merge "${pr_number}" --merge --auto; then - echo "已为 PR #${pr_number} 开启自动合并。" - echo "result=enabled" >> "$GITHUB_OUTPUT" - else - echo "::warning::自动合并开启失败,请手动处理并合并该 PR。" - echo "result=failed" >> "$GITHUB_OUTPUT" - fi - - - name: 写入执行摘要 - if: always() - shell: bash - run: | - { - echo "## main 回灌 dev 执行结果" - if [ "${{ steps.diff_check.outputs.has_changes }}" != "true" ]; then - echo "- 状态:无需同步(dev 已包含 main 最新提交)" - exit 0 - fi - if [ "${{ steps.sync_pr.outputs.permission_blocked }}" = "true" ]; then - echo "- 状态:已跳过自动创建同步 PR" - echo "- 原因:仓库未开启 GitHub Actions 创建与审批 Pull Request 权限" - echo "- 处理:前往 Settings -> Actions -> General -> Workflow permissions,开启 Allow GitHub Actions to create and approve pull requests" - echo "- 兜底:由维护者手动执行 main 到 dev 合并,或开启该设置后重新运行 workflow" - exit 0 - fi - echo "- PR:${{ steps.sync_pr.outputs.pr_url }}" - echo "- 可合并状态:${{ steps.merge_state.outputs.mergeable }}" - echo "- 合并状态详情:${{ steps.merge_state.outputs.merge_state_status }}" - if [ "${{ steps.merge_state.outputs.mergeable }}" = "CONFLICTING" ]; then - echo "- 结论:检测到冲突,需要手动处理后合并" - elif [ "${{ steps.merge_state.outputs.merge_state_pending }}" = "true" ]; then - echo "- 结论:GitHub 仍在计算合并状态,本次未开启自动合并;可稍后重跑 workflow 或手动开启 auto-merge" - elif [ "${{ steps.auto_merge.outputs.result }}" = "enabled" ]; then - echo "- 结论:已启用自动合并(满足保护规则后将自动入 dev)" - else - echo "- 结论:PR 已创建/复用,请按分支策略人工合并" - fi - } >> "$GITHUB_STEP_SUMMARY" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b89e554..162357f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,14 +79,8 @@ Because external pull requests are merged directly into `main`, maintainers must ### 1. Sync `main` -> `dev` (required) -This repository provides automatic sync via GitHub Actions workflow: - -- `.github/workflows/sync-main-to-dev.yml` -- Trigger: every push to `main` -- Behavior: create/reuse a PR from `main` to `dev`; if mergeable, it tries to enable auto-merge -- Prerequisite: in `Settings -> Actions -> General -> Workflow permissions`, enable `Allow GitHub Actions to create and approve pull requests`; otherwise the workflow will skip PR creation and only emit a warning summary - -Manual fallback (when conflicts or automation is unavailable): +The automatic GitHub Actions sync workflow has been removed. +Maintainers should sync `main` back to `dev` manually when needed: ```bash git checkout dev diff --git a/CONTRIBUTING.zh-CN.md b/CONTRIBUTING.zh-CN.md index 3e79997..a2d3983 100644 --- a/CONTRIBUTING.zh-CN.md +++ b/CONTRIBUTING.zh-CN.md @@ -79,14 +79,8 @@ feature/* / fix/* -> dev -> release/* -> main -> tag(vX.Y.Z) ### 1. main → dev 同步(必做) -仓库已提供 GitHub Actions 自动同步机制: - -- `.github/workflows/sync-main-to-dev.yml` -- 触发时机:每次 `main` 分支有新的 push -- 行为:自动创建或复用 `main` 到 `dev` 的同步 PR;若可合并,则尝试开启自动合并 -- 前置条件:需在 `Settings -> Actions -> General -> Workflow permissions` 中开启 `Allow GitHub Actions to create and approve pull requests`,否则 workflow 只会输出告警摘要并跳过建 PR - -当出现冲突,或自动化暂不可用时,使用以下手动兜底方式: +仓库已移除 GitHub Actions 自动回灌 workflow。 +当前统一采用手动方式将 `main` 同步回 `dev`: ```bash git checkout dev diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index a7661c0..0f8f4fe 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -d0f9366af59a6367ad3c7e2d4185ead4 \ No newline at end of file +5b8157374dae5f9340e31b2d0bd2c00e \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index be49c41..3aa2f01 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { Layout, Button, ConfigProvider, theme, Dropdown, MenuProps, message, Modal, Spin, Slider, Progress, Switch, Input, InputNumber, Select } from 'antd'; import zhCN from 'antd/locale/zh_CN'; -import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined } from '@ant-design/icons'; +import { PlusOutlined, ConsoleSqlOutlined, UploadOutlined, DownloadOutlined, CloudDownloadOutlined, BugOutlined, ToolOutlined, GlobalOutlined, InfoCircleOutlined, GithubOutlined, SkinOutlined, CheckOutlined, MinusOutlined, BorderOutlined, CloseOutlined, SettingOutlined, LinkOutlined, BgColorsOutlined, AppstoreOutlined } from '@ant-design/icons'; import { BrowserOpenURL, Environment, EventsOn, Quit, WindowFullscreen, WindowGetSize, WindowIsFullscreen, WindowIsMaximised, WindowMaximise, WindowMinimise, WindowSetSize, WindowToggleMaximise } from '../wailsjs/runtime'; import Sidebar from './components/Sidebar'; import TabManager from './components/TabManager'; @@ -11,7 +11,7 @@ import DriverManagerModal from './components/DriverManagerModal'; import LogPanel from './components/LogPanel'; import { useStore } from './store'; import { SavedConnection } from './types'; -import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform } from './utils/appearance'; +import { blurToFilter, normalizeBlurForPlatform, normalizeOpacityForPlatform, isWindowsPlatform, resolveAppearanceValues } from './utils/appearance'; import { SHORTCUT_ACTION_META, SHORTCUT_ACTION_ORDER, @@ -78,11 +78,11 @@ function App() { const tokenControlHeightLG = Math.max(30, Math.round(40 * effectiveUiScale)); const appComponentSize: 'small' | 'middle' | 'large' = effectiveUiScale <= 0.92 ? 'small' : (effectiveUiScale >= 1.12 ? 'large' : 'middle'); const titleBarHeight = Math.max(28, Math.round(32 * effectiveUiScale)); - const toolbarHeight = Math.max(32, Math.round(36 * effectiveUiScale)); const titleBarButtonWidth = Math.max(40, Math.round(46 * effectiveUiScale)); const floatingLogButtonHeight = Math.max(30, Math.round(34 * effectiveUiScale)); - const effectiveOpacity = normalizeOpacityForPlatform(appearance.opacity); - const effectiveBlur = normalizeBlurForPlatform(appearance.blur); + const resolvedAppearance = resolveAppearanceValues(appearance); + const effectiveOpacity = normalizeOpacityForPlatform(resolvedAppearance.opacity); + const effectiveBlur = normalizeBlurForPlatform(resolvedAppearance.blur); const blurFilter = blurToFilter(effectiveBlur); const windowCornerRadius = 14; const [runtimePlatform, setRuntimePlatform] = useState(''); @@ -93,8 +93,8 @@ function App() { // 同步 macOS 窗口透明度:opacity=1.0 且 blur=0 时关闭 NSVisualEffectView, // 避免 GPU 持续计算窗口背后的模糊合成 useEffect(() => { - void SetWindowTranslucency(appearance.opacity, appearance.blur).catch(() => undefined); - }, [appearance.opacity, appearance.blur]); + void SetWindowTranslucency(resolvedAppearance.opacity, resolvedAppearance.blur).catch(() => undefined); + }, [resolvedAppearance.blur, resolvedAppearance.opacity]); useEffect(() => { let cancelled = false; @@ -370,6 +370,141 @@ function App() { const floatingLogButtonShadow = darkMode ? '0 8px 22px rgba(0,0,0,0.38)' : '0 8px 20px rgba(0,0,0,0.16)'; + + const isOpaqueUtilityMode = resolvedAppearance.opacity >= 0.999 && resolvedAppearance.blur <= 0; + const utilityButtonBgAlpha = darkMode + ? Math.max(0.28, Math.min(0.76, effectiveOpacity * 0.72)) + : Math.max(0.52, Math.min(0.92, effectiveOpacity * 0.9)); + const utilityButtonBgColor = isOpaqueUtilityMode + ? 'transparent' + : (darkMode + ? `rgba(20, 26, 38, ${utilityButtonBgAlpha})` + : `rgba(255, 255, 255, ${utilityButtonBgAlpha})`); + const utilityButtonBorderColor = isOpaqueUtilityMode + ? (darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(16,24,40,0.10)') + : (darkMode + ? `rgba(255,255,255,${Math.max(0.08, Math.min(0.18, effectiveOpacity * 0.16))})` + : `rgba(16,24,40,${Math.max(0.06, Math.min(0.14, effectiveOpacity * 0.12))})`); + const utilityButtonShadow = isOpaqueUtilityMode + ? 'none' + : (darkMode + ? `0 8px 18px rgba(0,0,0,${Math.max(0.10, Math.min(0.22, effectiveOpacity * 0.24))})` + : `0 8px 18px rgba(15,23,42,${Math.max(0.04, Math.min(0.12, effectiveOpacity * 0.12))})`); + const utilityButtonStyle = useMemo(() => ({ + height: Math.max(30, Math.round(32 * effectiveUiScale)), + width: '100%', + paddingInline: Math.max(10, Math.round(12 * effectiveUiScale)), + borderRadius: 10, + border: `1px solid ${utilityButtonBorderColor}`, + background: utilityButtonBgColor, + color: darkMode ? 'rgba(255,255,255,0.94)' : '#162033', + boxShadow: utilityButtonShadow, + backdropFilter: isOpaqueUtilityMode ? 'none' : blurFilter, + WebkitBackdropFilter: isOpaqueUtilityMode ? 'none' : blurFilter, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + }), [blurFilter, darkMode, effectiveUiScale, isOpaqueUtilityMode, utilityButtonBgColor, utilityButtonBorderColor, utilityButtonShadow]); + const utilityDropdownShellStyle = useMemo(() => ({ + borderRadius: 14, + padding: 6, + background: darkMode ? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)' : 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)', + border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)', + boxShadow: darkMode ? '0 20px 48px rgba(0,0,0,0.32)' : '0 16px 36px rgba(15,23,42,0.12)', + backdropFilter: darkMode ? 'blur(16px)' : 'none', + overflow: 'hidden', + }), [darkMode]); + + const sidebarQuickActionBaseStyle = useMemo(() => ({ + height: Math.max(34, Math.round(36 * effectiveUiScale)), + borderRadius: 12, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + paddingInline: Math.max(12, Math.round(14 * effectiveUiScale)), + fontWeight: 700, + boxShadow: darkMode ? '0 8px 18px rgba(0,0,0,0.16)' : '0 8px 16px rgba(15,23,42,0.08)', + backdropFilter: blurFilter, + WebkitBackdropFilter: blurFilter, + minWidth: 0, + whiteSpace: 'nowrap', + }), [blurFilter, darkMode, effectiveUiScale]); + const sidebarQueryActionStyle = useMemo(() => ({ + ...sidebarQuickActionBaseStyle, + flex: '1 1 0', + border: `1px solid ${darkMode ? 'rgba(255,255,255,0.12)' : 'rgba(16,24,40,0.10)'}`, + background: darkMode ? `rgba(255,255,255,0.05)` : 'rgba(255,255,255,0.88)', + color: darkMode ? 'rgba(255,255,255,0.92)' : '#162033', + }), [darkMode, sidebarQuickActionBaseStyle]); + const sidebarCreateConnectionActionStyle = useMemo(() => ({ + ...sidebarQuickActionBaseStyle, + flex: '1 1 0', + border: 'none', + background: 'linear-gradient(135deg, rgba(255,214,102,0.96) 0%, rgba(240,183,39,0.92) 100%)', + color: '#2a1f00', + }), [sidebarQuickActionBaseStyle]); + + const utilityMenuTheme = useMemo(() => ({ + components: { + Menu: { + popupBg: 'transparent', + darkPopupBg: 'transparent', + itemBg: 'transparent', + darkItemBg: 'transparent', + subMenuItemBg: 'transparent', + itemColor: darkMode ? 'rgba(255,255,255,0.88)' : '#162033', + itemHoverColor: darkMode ? '#fff7d6' : '#0f172a', + itemHoverBg: darkMode ? 'rgba(255,214,102,0.10)' : 'rgba(24,144,255,0.08)', + itemSelectedColor: darkMode ? '#ffd666' : '#1677ff', + itemSelectedBg: darkMode ? 'rgba(255,214,102,0.14)' : 'rgba(24,144,255,0.12)', + itemBorderRadius: 10, + itemMarginBlock: 4, + itemMarginInline: 0, + itemPaddingInline: 12, + itemHeight: 40, + groupTitleColor: darkMode ? 'rgba(255,255,255,0.48)' : 'rgba(16,24,40,0.48)', + }, + }, + }), [darkMode]); + const renderUtilityDropdown = (menu: React.ReactNode) => ( + +
+ {menu} +
+
+ ); + const utilityModalShellStyle = useMemo(() => ({ + background: darkMode ? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)' : 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)', + border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)', + boxShadow: darkMode ? '0 24px 56px rgba(0,0,0,0.32)' : '0 18px 42px rgba(15,23,42,0.12)', + backdropFilter: darkMode ? 'blur(18px)' : 'none', + }), [darkMode]); + const utilityPanelStyle = useMemo(() => ({ + padding: 16, + borderRadius: 14, + border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)', + background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.84)', + }), [darkMode]); + const utilityMutedTextStyle = useMemo(() => ({ + color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', + fontSize: 12, + lineHeight: 1.6, + }), [darkMode]); + const renderUtilityModalTitle = (icon: React.ReactNode, title: string, description: string) => ( +
+
+ {icon} +
+
+
{title}
+
{description}
+
+
+ ); + + const sidebarHorizontalPadding = 10; const addTab = useStore(state => state.addTab); const activeContext = useStore(state => state.activeContext); @@ -786,37 +921,18 @@ function App() { label: '驱动管理', icon: , onClick: () => setIsDriverModalOpen(true) - } - ]; - - const themeMenu: MenuProps['items'] = [ - { - key: 'light', - label: '亮色主题', - icon: themeMode === 'light' ? : undefined, - onClick: () => setTheme('light') - }, - { - key: 'dark', - label: '暗色主题', - icon: themeMode === 'dark' ? : undefined, - onClick: () => setTheme('dark') }, { type: 'divider' }, - { - key: 'settings', - label: '外观设置...', - icon: , - onClick: () => setIsAppearanceModalOpen(true) - }, { key: 'shortcut-settings', - label: '快捷键管理...', + label: '快捷键管理', icon: , onClick: () => setIsShortcutModalOpen(true) } ]; + const [isThemeModalOpen, setIsThemeModalOpen] = useState(false); + const [themeModalSection, setThemeModalSection] = useState<'theme' | 'appearance'>('theme'); const [isAppearanceModalOpen, setIsAppearanceModalOpen] = useState(false); const [isShortcutModalOpen, setIsShortcutModalOpen] = useState(false); const [capturingShortcutAction, setCapturingShortcutAction] = useState(null); @@ -1190,8 +1306,6 @@ function App() { }, components: { Layout: { - colorBgBody: 'transparent', - colorBgHeader: 'transparent', bodyBg: 'transparent', headerBg: 'transparent', siderBg: 'transparent', @@ -1272,28 +1386,6 @@ function App() { -
- - - - - - - - -
-
- -
- + + + + +
+
+
+
+ + +
-
@@ -1370,8 +1475,8 @@ function App() { title="拖动调整宽度" /> - -
+ +
{isLogPanelOpen && ( @@ -1399,9 +1504,10 @@ function App() { onOpenGlobalProxySettings={() => setIsProxyModalOpen(true)} /> , '关于 GoNavi', '查看版本信息、仓库地址、更新状态与下载入口。')} open={isAboutOpen} onCancel={() => setIsAboutOpen(false)} + styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }} footer={[ canShowProgressEntry ? ( @@ -1421,150 +1527,274 @@ function App() {
) : ( -
-
版本:{aboutInfo?.version || '未知'}
-
作者:{aboutInfo?.author || '未知'}
- {aboutInfo?.communityUrl ? ( - - ) : null} -
更新状态:{aboutUpdateStatus || '未检查'}
-
- - {aboutInfo?.repoUrl ? ( - { e.preventDefault(); if (aboutInfo?.repoUrl) BrowserOpenURL(aboutInfo.repoUrl); }} href={aboutInfo.repoUrl}> - {aboutInfo.repoUrl} - - ) : '未知'} +
+
+
+
+
版本
+
{aboutInfo?.version || '未知'}
+
+
+
作者
+
{aboutInfo?.author || '未知'}
+
+
+
更新状态
+
{aboutUpdateStatus || '未检查'}
+
+ {aboutInfo?.communityUrl ? ( + + ) : null} +
+
+
- - -
)} setIsAppearanceModalOpen(false)} + title={renderUtilityModalTitle( + themeModalSection === 'theme' ? : , + themeModalSection === 'theme' ? '主题设置' : '外观设置', + themeModalSection === 'theme' + ? '切换亮暗主题,保持整体视觉风格统一。' + : '统一调整缩放、字体、透明度与模糊效果。' + )} + open={isThemeModalOpen} + onCancel={() => { setIsThemeModalOpen(false); setThemeModalSection('theme'); }} footer={null} - width={460} + width={820} + styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8, height: 620, overflow: 'hidden' }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }} > -
-
-
界面缩放 (UI Scale)
-
- setUiScale(Number(v))} - style={{ flex: 1 }} - /> - {Math.round(effectiveUiScale * 100)}% -
-
- * 建议小屏设备设置为 85%-95% +
+
+
设置导航
+
+ {[ + { key: 'theme', title: '主题模式', description: '亮色与暗色切换', icon: }, + { key: 'appearance', title: '外观参数', description: '缩放、字体与透明度', icon: }, + ].map((item) => { + const active = themeModalSection === item.key; + return ( + + ); + })}
-
-
基础字体大小 (Font Size)
-
- setFontSize(Number(v))} - style={{ flex: 1 }} - /> - {effectiveFontSize}px -
-
-
-
背景不透明度 (Opacity)
-
- setAppearance({ opacity: v })} - style={{ flex: 1 }} - /> - {Math.round((appearance.opacity ?? 1.0) * 100)}% -
-
-
-
高斯模糊 (Blur)
- {isWindowsPlatform() ? ( -
- Windows 使用系统 Acrylic 效果,模糊程度由系统控制 +
+ {themeModalSection === 'theme' ? ( +
+
+
主题模式
+
+ {[ + { key: 'light', label: '亮色主题', description: '适合明亮环境,层次更轻。' }, + { key: 'dark', label: '暗色主题', description: '适合低光环境,视觉更沉稳。' }, + ].map((item) => { + const active = themeMode === item.key; + return ( + + ); + })} +
+
) : ( - <> -
- setAppearance({ blur: v })} - style={{ flex: 1 }} - /> - {appearance.blur}px +
+
+
界面缩放 (UI Scale)
+
+ setUiScale(Number(v))} + style={{ flex: 1 }} + /> + {Math.round(effectiveUiScale * 100)}% +
+
+ * 建议小屏设备设置为 85%-95% +
-
- * 仅控制应用内覆盖层的模糊效果 +
+
基础字体大小 (Font Size)
+
+ setFontSize(Number(v))} + style={{ flex: 1 }} + /> + {effectiveFontSize}px +
- +
+
透明与模糊效果
+
+
+
启用透明与模糊
+
关闭后保留当前阈值,重新开启时直接恢复之前的设置。
+
+ setAppearance({ enabled: checked })} /> +
+
+
+
背景不透明度 (Opacity)
+
+ setAppearance({ opacity: v })} + style={{ flex: 1 }} + /> + {Math.round((appearance.opacity ?? 1.0) * 100)}% +
+
+
+
高斯模糊 (Blur)
+ {isWindowsPlatform() ? ( +
+ Windows 使用系统 Acrylic 效果,模糊程度由系统控制 +
+ ) : ( + <> +
+ setAppearance({ blur: v })} + style={{ flex: 1 }} + /> + {appearance.blur}px +
+
+ * 仅控制应用内覆盖层的模糊效果 +
+ + )} +
+
+
+
+
启动窗口
+
+ 启动时全屏 + setStartupFullscreen(checked)} /> +
+
+ * 修改后下次启动生效 +
+
+
+ +
+
)}
-
-
启动窗口
-
- 启动时全屏 - setStartupFullscreen(checked)} /> -
-
- * 修改后下次启动生效 -
-
-
- -
, '快捷键管理', '统一查看、录制与启停常用快捷键,保持操作习惯一致。')} open={isShortcutModalOpen} onCancel={() => { setIsShortcutModalOpen(false); setCapturingShortcutAction(null); }} - width={720} + width={760} + styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }} footer={[ - - - - {uriFeedback && ( - setUriFeedback(null)} - style={{ marginBottom: 12 }} - /> - )} - {currentDriverUnavailableReason && ( - - {currentDriverUnavailableReason} - - - )} - /> - )} - - {isCustom ? ( - <> - - - - - - - - ) : ( - <> -
- - - - {isFileDb && ( - - - - )} - {!isFileDb && ( - Number(value) > 0)]} - style={{ width: 100 }} - > - - - )} -
+ + + - {(dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase') && ( - - - - )} + {!isCustom && ( + <> + + + + + + + + + {uriFeedback && ( + setUriFeedback(null)} + style={{ marginBottom: 16 }} + /> + )} + + )} - {dbType === 'oracle' && ( - - - - )} + {isCustom ? ( + <> + + + + + + + + ) : ( + <> +
+ + + + {isFileDb ? ( + + + + ) : ( + Number(value) > 0)]} + style={{ marginBottom: 0 }} + > + + + )} +
- {(dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') && ( - <> - - - -
- - - - - - -
- - )} - - )} + {(dbType === 'postgres' || dbType === 'kingbase' || dbType === 'highgo' || dbType === 'vastbase') && ( + + + + )} - {dbType === 'mongodb' && ( - <> - - 使用 SRV 记录(mongodb+srv) - - - - - )} - {mongoSrv && ( - - )} - - - -
- - - - - - -
- - - 发现后可校验当前副本集状态 - - {mongoMembers.length > 0 && ( - `${record.host}-${record.state}`} - dataSource={mongoMembers} - style={{ marginBottom: 12 }} - columns={[ - { - title: '成员', - dataIndex: 'host', - width: '48%', - render: (value: string, record: MongoMemberInfo) => ( - - {value} - {record.isSelf ? 当前 : null} - - ), - }, - { - title: '状态', - dataIndex: 'state', - width: '32%', - render: (value: string) => { - const state = String(value || '').toUpperCase(); - let color: string = 'default'; - if (state === 'PRIMARY') color = 'success'; - else if (state === 'SECONDARY' || state === 'PASSIVE') color = 'blue'; - else if (state === 'ARBITER') color = 'purple'; - else if (state === 'DOWN' || state === 'REMOVED' || state === 'UNKNOWN') color = 'error'; - return {state || 'UNKNOWN'}; - }, - }, - { - title: '健康', - dataIndex: 'healthy', - width: '20%', - render: (value: boolean) => ( - {value ? '正常' : '异常'} - ), - }, - ]} - /> - )} - - )} - - - - - + + )} - {/* Redis specific: password only, no username */} - {isRedis && ( - <> - - - - )} - - - - - - - - )} + {(dbType === 'mysql' || dbType === 'mariadb' || dbType === 'diros' || dbType === 'sphinx') && ( + <> + + + +
+ + + + + + +
+ + )} + + )} - {/* Non-Redis, non-SQLite: username and password */} - {!isFileDb && !isRedis && ( -
- - - - - - - {dbType === 'mongodb' && ( - - + + + 使用 SRV(mongodb+srv) + + {mongoSrv && useSSH && ( + + )} + {mongoTopology === 'replica' && ( + <> + + + + + + +
+ + + + + + + {mongoMembers.length > 0 && ( +
record.host} + pagination={false} + dataSource={mongoMembers} + style={{ marginBottom: 12 }} + columns={[ + { title: 'Host', dataIndex: 'host', width: '48%' }, + { + title: '角色', + dataIndex: 'role', + width: '32%', + render: (value: string, record: MongoMemberInfo) => ( + {value || 'UNKNOWN'} + ), + }, + { + title: '健康', + dataIndex: 'healthy', + width: '20%', + render: (value: boolean) => ( + {value ? '正常' : '异常'} + ), + }, + ]} + /> + )} + + )} +
+ + + + + + + {redisTopology === 'cluster' && ( + + + {redisDbList.map(db => db{db})} + + + + )} - {!isFileDb && !isRedis && ( - - - - )} + {!isFileDb && !isRedis && ( +
+ + + + + + + {dbType === 'mongodb' && ( + + - - {dbType === 'dameng' && ( - <> - - - - - - - - )} - - {sslHintText} - -
- )} - - )} + {dbType === 'mongodb' && ( + + 保存密码 + + )} - - - 使用 SSH 隧道 (SSH Tunnel) - + {!isFileDb && !isRedis && ( + + + + )} + + )} +
+ ); - {useSSH && ( -
-
- - - - - - -
-
- - - - - - -
- - - - - - - - -
- )} + const networkSecuritySection = !isFileDb ? (() => { + const networkItems: Array<{ + key: 'ssl' | 'ssh' | 'proxy' | 'httpTunnel'; + title: string; + description: string; + enabled: boolean; + }> = [ + ...(isSSLType ? [{ key: 'ssl' as const, title: 'SSL/TLS', description: '加密与证书校验', enabled: useSSL }] : []), + { key: 'ssh', title: 'SSH 隧道', description: '跳板机 / 堡垒机转发', enabled: useSSH }, + { key: 'proxy', title: '代理', description: 'SOCKS5 / HTTP CONNECT', enabled: useProxy }, + { key: 'httpTunnel', title: 'HTTP 隧道', description: '独立 HTTP CONNECT 路由', enabled: useHttpTunnel }, + ]; + const resolvedNetworkConfig = networkItems.some((item) => item.key === activeNetworkConfig) + ? activeNetworkConfig + : networkItems[0]?.key || 'ssh'; + const renderNetworkPanel = () => { + if (resolvedNetworkConfig === 'ssl') { + return ( +
+
SSL/TLS
+
为连接链路增加加密与证书校验控制,适合生产或跨网络访问场景。
+ {!useSSL ? ( +
+ 左侧勾选“SSL/TLS”后,可在这里配置模式、证书与校验策略。 +
+ ) : ( +
+ + + + + + + + )} + {sslHintText} +
+ )} +
+ ); + } + if (resolvedNetworkConfig === 'ssh') { + return ( +
+
SSH 隧道
+
通过跳板机或堡垒机转发数据库连接,适合内网或受限网络环境。
+ {!useSSH ? ( +
+ 左侧勾选“SSH 隧道”后,可在这里填写主机、端口、用户名、密码和私钥路径。 +
+ ) : ( +
+
+ + + + + + +
+
+ + + + + + +
+ + + + + + + + +
+ )} +
+ ); + } + if (resolvedNetworkConfig === 'proxy') { + return ( +
+
代理
+
适合借助本地代理软件或中间网关转发数据库流量。
+ {!useProxy ? ( +
+ 左侧勾选“代理”后,可在这里选择代理类型并填写主机、端口与认证信息。 +
+ ) : ( +
+ + + +
+ + + + + + +
+
+ )} +
+ ); + } + return ( +
+
HTTP 隧道
+
与代理模式互斥,适合单独指定一条 HTTP CONNECT 隧道路由。
+ {!useHttpTunnel ? ( +
+ 左侧勾选“HTTP 隧道”后,可在这里填写隧道目标与认证信息。 +
+ ) : ( +
+
+ + + + + + +
+
+ + + + + + +
+ 与“使用代理”互斥,启用后将通过 HTTP CONNECT 建立独立隧道。 +
+ )} +
+ ); + }; - - - 使用代理 (SOCKS5 / HTTP CONNECT) - + return ( +
+
网络与安全
+
上方稳定列出所有连接方式,下方固定展示当前方式的配置详情,避免启用后页面重新排布,同时给详情区留出足够宽度。
+
+ {networkItems.map((item) => { + const active = item.key === resolvedNetworkConfig; + const activeColor = darkMode ? '#ffd666' : '#1677ff'; + return ( +
setActiveNetworkConfig(item.key)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setActiveNetworkConfig(item.key); + } + }} + style={{ + ...getConnectionOptionCardStyle(item.enabled), + borderColor: active + ? (darkMode ? 'rgba(255,214,102,0.46)' : 'rgba(24,144,255,0.36)') + : 'transparent', + background: active + ? (darkMode ? 'linear-gradient(180deg, rgba(255,214,102,0.14) 0%, rgba(255,214,102,0.08) 100%)' : 'linear-gradient(180deg, rgba(24,144,255,0.12) 0%, rgba(24,144,255,0.06) 100%)') + : getConnectionOptionCardStyle(item.enabled).background, + boxShadow: active + ? (darkMode ? '0 0 0 1px rgba(255,214,102,0.18) inset, 0 12px 26px rgba(0,0,0,0.16)' : '0 0 0 1px rgba(24,144,255,0.14) inset, 0 12px 22px rgba(24,144,255,0.10)') + : 'none', + cursor: 'pointer', + outline: 'none', + }} + > +
+
+
+ + + +
+
+ {item.title} +
+ {active && ( + + 当前编辑 + + )} + + {item.enabled ? '已启用' : '未启用'} + +
+
+
+ {item.description} +
+
+
+
+
+ ); + })} +
+
+ {renderNetworkPanel()} +
+
+
高级连接
+ + + +
+
+ ); + })() : null; - {useProxy && ( -
-
- - - - - - -
-
- - - - - - -
-
- )} + return ( + { + if (testResult) { + setTestResult(null); + setTestErrorLogOpen(false); + } + if (changed.uri !== undefined || changed.type !== undefined) { + setUriFeedback(null); + } + if (changed.useSSL !== undefined) { + setUseSSL(changed.useSSL); + if (changed.useSSL) setActiveNetworkConfig('ssl'); + } + if (changed.useSSH !== undefined) { + setUseSSH(changed.useSSH); + if (changed.useSSH) setActiveNetworkConfig('ssh'); + } + if (changed.useProxy !== undefined) { + const enabledProxy = !!changed.useProxy; + setUseProxy(enabledProxy); + if (enabledProxy) setActiveNetworkConfig('proxy'); + if (enabledProxy && form.getFieldValue('useHttpTunnel')) { + form.setFieldValue('useHttpTunnel', false); + setUseHttpTunnel(false); + } + } + if (changed.proxyType !== undefined) { + const nextType = String(changed.proxyType || 'socks5').toLowerCase(); + if (nextType === 'http') { + const currentPort = Number(form.getFieldValue('proxyPort') || 0); + if (!currentPort || currentPort === 1080) { + form.setFieldValue('proxyPort', 8080); + } + } else { + const currentPort = Number(form.getFieldValue('proxyPort') || 0); + if (!currentPort || currentPort === 8080) { + form.setFieldValue('proxyPort', 1080); + } + } + } + if (changed.useHttpTunnel !== undefined) { + const enabledHttpTunnel = !!changed.useHttpTunnel; + setUseHttpTunnel(enabledHttpTunnel); + if (enabledHttpTunnel) setActiveNetworkConfig('httpTunnel'); + if (enabledHttpTunnel && form.getFieldValue('useProxy')) { + form.setFieldValue('useProxy', false); + setUseProxy(false); + } + if (enabledHttpTunnel) { + const currentPort = Number(form.getFieldValue('httpTunnelPort') || 0); + if (!currentPort || currentPort <= 0) { + form.setFieldValue('httpTunnelPort', 8080); + } + } + } + if (changed.type !== undefined) setDbType(changed.type); + if (changed.redisTopology !== undefined) { + const supportedDbs = Array.from({ length: 16 }, (_, i) => i); + setRedisDbList(supportedDbs); + const selectedDbsRaw = form.getFieldValue('includeRedisDatabases'); + const selectedDbs = Array.isArray(selectedDbsRaw) ? selectedDbsRaw.map((entry: any) => Number(entry)) : []; + const validDbs = selectedDbs + .filter((entry: number) => Number.isFinite(entry)) + .map((entry: number) => Math.trunc(entry)) + .filter((entry: number) => supportedDbs.includes(entry)); + form.setFieldValue('includeRedisDatabases', validDbs.length > 0 ? validDbs : undefined); + } + if ( + changed.type !== undefined + || changed.host !== undefined + || changed.port !== undefined + || changed.mongoHosts !== undefined + || changed.mongoTopology !== undefined + || changed.mongoSrv !== undefined + ) { + setMongoMembers([]); + } + }} + > + + {currentDriverUnavailableReason && ( + + {currentDriverUnavailableReason} + + + )} + /> + )} + {(() => { + const sectionItems: Array<{ key: 'basic' | 'network'; title: string; description: string; icon: React.ReactNode }> = [ + { key: 'basic', title: '基础信息', description: '名称、地址、认证、URI 与数据库范围', icon: }, + ...(!isCustom && !isFileDb ? [{ key: 'network' as const, title: '网络与安全', description: 'SSL、SSH、代理与高级连接', icon: }] : []), + ]; + const resolvedSection = sectionItems.some((item) => item.key === activeConfigSection) + ? activeConfigSection + : sectionItems[0]?.key || 'basic'; + const currentSectionContent = resolvedSection === 'basic' + ? baseInfoSection + : networkSecuritySection; - - - 使用 HTTP 隧道(独立代理) - + if (sectionItems.length <= 1) { + return currentSectionContent; + } - {useHttpTunnel && ( -
-
- - - - - - -
-
- - - - - - -
- - 与“使用代理”互斥,启用后将通过 HTTP CONNECT 建立独立隧道。 - -
- )} - - - - - - - ) - }]} - /> - - )} - - )} - - - ); + return ( +
+
+
配置分区
+
+ {sectionItems.map((item) => { + const active = item.key === resolvedSection; + return ( + + ); + })} +
+
+
+ {currentSectionContent} +
+
+ ); + })()} + + ); + }; const getFooter = () => { if (step === 1) { @@ -2333,7 +2534,7 @@ const ConnectionModal: React.FC<{ const hasTestError = !!testResult && !isTestSuccess; const operationBlocked = !!currentDriverUnavailableReason || driverStatusChecking; return ( -
+
{!initialValues && } {testResult ? ( @@ -2386,18 +2587,21 @@ const ConnectionModal: React.FC<{ }; const getTitle = () => { - if (step === 1) return "选择数据源类型"; + if (step === 1) { + return renderConnectionModalTitle(, '选择数据源类型', '按数据库、中间件或文件类型快速进入对应的连接配置流程。'); + } const typeName = dbTypes.find(t => t.key === dbType)?.name || dbType; - return initialValues ? "编辑连接" : `新建 ${typeName} 连接`; + return initialValues + ? renderConnectionModalTitle(, '编辑连接', `调整 ${typeName} 连接的参数、认证方式与网络选项。`) + : renderConnectionModalTitle(, `新建 ${typeName} 连接`, '填写连接参数、测试连通性,并保存到连接树中。'); }; - const modalBodyStyle = step === 1 - ? { padding: '16px 24px', overflow: 'hidden' as const, minHeight: STEP1_MODAL_MIN_BODY_HEIGHT } - : { - padding: '16px 24px', - overflowY: 'auto' as const, - overflowX: 'hidden' as const, - }; + const modalBodyStyle = { + padding: '12px 24px 18px', + height: CONNECTION_MODAL_BODY_HEIGHT, + overflowY: 'auto' as const, + overflowX: 'hidden' as const, + }; return ( <> @@ -2408,22 +2612,33 @@ const ConnectionModal: React.FC<{ footer={getFooter()} centered wrapClassName="connection-modal-wrap" - width={step === 1 ? STEP1_MODAL_WIDTH : STEP2_MODAL_WIDTH} + width={CONNECTION_MODAL_WIDTH} zIndex={10001} destroyOnHidden maskClosable={false} - styles={{ body: modalBodyStyle }} + styles={{ + content: modalShellStyle, + header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, + body: modalBodyStyle, + footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } + }} > {step === 1 ? renderStep1() : renderStep2()} , '测试连接失败原因', '查看本次测试连接的完整错误上下文,便于快速定位配置问题。')} open={testErrorLogOpen} onCancel={() => setTestErrorLogOpen(false)} centered width={760} zIndex={10002} destroyOnHidden + styles={{ + content: modalShellStyle, + header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, + body: { paddingTop: 8 }, + footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } + }} footer={[ , ]} diff --git a/frontend/src/components/DataGrid.tsx b/frontend/src/components/DataGrid.tsx index 10c8b87..f23caa0 100644 --- a/frontend/src/components/DataGrid.tsx +++ b/frontend/src/components/DataGrid.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef, useContext, useMemo, useCallback } import { createPortal } from 'react-dom'; import { Table, message, Input, Button, Dropdown, MenuProps, Form, Pagination, Select, Modal, Checkbox, Segmented, Tooltip, Popover } from 'antd'; import type { SortOrder } from 'antd/es/table/interface'; -import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined } from '@ant-design/icons'; +import { ReloadOutlined, ImportOutlined, ExportOutlined, DownOutlined, PlusOutlined, DeleteOutlined, SaveOutlined, UndoOutlined, FilterOutlined, CloseOutlined, ConsoleSqlOutlined, FileTextOutlined, CopyOutlined, ClearOutlined, EditOutlined, VerticalAlignBottomOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons'; import Editor from '@monaco-editor/react'; import { ImportData, ExportTable, ExportData, ExportQuery, ApplyChanges, DBGetColumns } from '../../wailsjs/go/app/App'; import ImportPreviewModal from './ImportPreviewModal'; @@ -11,7 +11,7 @@ import type { ColumnDefinition } from '../types'; import { v4 as uuidv4 } from 'uuid'; import 'react-resizable/css/styles.css'; import { buildOrderBySQL, buildWhereSQL, escapeLiteral, quoteIdentPart, quoteQualifiedIdent, withSortBufferTuningSQL, type FilterCondition } from '../utils/sql'; -import { isMacLikePlatform, normalizeOpacityForPlatform } from '../utils/appearance'; +import { isMacLikePlatform, normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities'; // --- Error Boundary --- @@ -639,7 +639,8 @@ const DataGrid: React.FC = ({ const setQueryOptions = useStore(state => state.setQueryOptions); const isMacLike = useMemo(() => isMacLikePlatform(), []); const darkMode = theme === 'dark'; - const opacity = normalizeOpacityForPlatform(appearance.opacity); + const resolvedAppearance = resolveAppearanceValues(appearance); + const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity); const canModifyData = !readOnly && !!tableName; const showColumnComment = queryOptions?.showColumnComment !== false; const showColumnType = queryOptions?.showColumnType !== false; @@ -706,6 +707,33 @@ const DataGrid: React.FC = ({ const toolbarDividerColor = darkMode ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.10)'; const columnMetaHintColor = darkMode ? darkHighlightTextColor : lightMetaHintColor; const columnMetaTooltipColor = darkMode ? darkHighlightTextColor : lightMetaTooltipColor; + const paginationPageSizeOptions = ['100', '200', '500', '1000']; + const paginationGlassMode = opacity < 0.999 || resolvedAppearance.blur > 0; + const paginationShellBg = darkMode + ? `linear-gradient(135deg, rgba(17,22,34,${paginationGlassMode ? Math.max(0.22, opacity * 0.38) : 0.82}) 0%, rgba(10,14,24,${paginationGlassMode ? Math.max(0.28, opacity * 0.46) : 0.9}) 100%)` + : `linear-gradient(135deg, rgba(255,255,255,${paginationGlassMode ? Math.max(0.24, opacity * 0.36) : 0.96}) 0%, rgba(246,248,252,${paginationGlassMode ? Math.max(0.32, opacity * 0.44) : 0.99}) 100%)`; + const paginationShellBorderColor = darkMode + ? `rgba(255,255,255,${paginationGlassMode ? 0.10 : 0.08})` + : `rgba(16,24,40,${paginationGlassMode ? 0.08 : 0.08})`; + const paginationShellShadow = darkMode + ? `0 16px 34px rgba(0,0,0,${paginationGlassMode ? 0.10 : 0.22})` + : `0 14px 30px rgba(15,23,42,${paginationGlassMode ? 0.03 : 0.08})`; + const paginationChipBg = darkMode + ? `rgba(255,255,255,${paginationGlassMode ? Math.max(0.02, opacity * 0.035) : 0.04})` + : `rgba(255,255,255,${paginationGlassMode ? Math.max(0.18, opacity * 0.26) : 0.86})`; + const paginationChipBorderColor = darkMode + ? `rgba(255,255,255,${paginationGlassMode ? 0.10 : 0.08})` + : `rgba(16,24,40,${paginationGlassMode ? 0.10 : 0.08})`; + const paginationHoverBg = darkMode + ? `rgba(255,255,255,${paginationGlassMode ? Math.max(0.04, opacity * 0.06) : 0.07})` + : `rgba(255,255,255,${paginationGlassMode ? Math.max(0.24, opacity * 0.34) : 0.96})`; + const paginationPrimaryTextColor = darkMode ? '#f5f7ff' : '#162033'; + const paginationSecondaryTextColor = darkMode ? 'rgba(255,255,255,0.54)' : 'rgba(16,24,40,0.56)'; + const paginationAccentBg = darkMode ? 'rgba(255,214,102,0.14)' : 'rgba(24,144,255,0.10)'; + const paginationAccentBorderColor = darkMode ? 'rgba(255,214,102,0.38)' : 'rgba(24,144,255,0.22)'; + const paginationActiveItemBg = darkMode ? 'rgba(255,214,102,0.18)' : 'rgba(24,144,255,0.12)'; + const paginationActiveItemBorderColor = darkMode ? 'rgba(255,214,102,0.46)' : 'rgba(24,144,255,0.28)'; + const paginationActiveItemTextColor = darkMode ? '#fff7d6' : '#0958d9'; const [form] = Form.useForm(); const [modal, contextHolder] = Modal.useModal(); @@ -2970,6 +2998,49 @@ const DataGrid: React.FC = ({ }; }, [viewMode, tableScrollX, mergedDisplayData.length, syncExternalScrollFromTargets, pickHorizontalScrollTargets]); + const paginationSummaryText = useMemo(() => { + if (!pagination) return ''; + const total = Number.isFinite(pagination.total) ? pagination.total : 0; + const rangeStart = Math.max(0, (pagination.current - 1) * pagination.pageSize + (total > 0 ? 1 : 0)); + const hasValidRange = total > 0 && rangeStart > 0; + const rangeEnd = hasValidRange ? Math.min(total, rangeStart + pagination.pageSize - 1) : 0; + const currentCount = hasValidRange ? Math.max(0, rangeEnd - rangeStart + 1) : 0; + + if (pagination.totalKnown === false) { + if (isDuckDBConnection) { + if (pagination.totalCountLoading) return `当前 ${currentCount} 条 / 正在统计精确总数…`; + if (pagination.totalApprox && Number.isFinite(total) && total > 0) return `当前 ${currentCount} 条 / 约 ${total} 条`; + if (pagination.totalCountCancelled) return `当前 ${currentCount} 条 / 已取消统计`; + return `当前 ${currentCount} 条 / 总数未统计`; + } + return `当前 ${currentCount} 条 / 正在统计总数…`; + } + + if (isDuckDBConnection && (!Number.isFinite(total) || total <= 0)) { + return '当前 0 条 / 共 0 条'; + } + + return `当前 ${currentCount} 条 / 共 ${total} 条`; + }, [pagination, isDuckDBConnection]); + + const paginationPageText = useMemo(() => { + if (!pagination) return ''; + const total = Number.isFinite(pagination.total) ? pagination.total : 0; + const canShowTotalPages = pagination.totalKnown !== false || (isDuckDBConnection && pagination.totalApprox && total > 0); + if (!canShowTotalPages || total <= 0) return `第 ${pagination.current} 页`; + const totalPages = Math.max(1, Math.ceil(total / Math.max(1, pagination.pageSize))); + return `第 ${pagination.current} / ${totalPages} 页`; + }, [pagination, isDuckDBConnection]); + + const handlePageSizeChange = useCallback((value: string) => { + if (!pagination || !onPageChange) return; + const nextSize = Number(value); + if (!Number.isFinite(nextSize) || nextSize <= 0) return; + const firstRowIndex = Math.max(0, (pagination.current - 1) * pagination.pageSize); + const nextPage = Math.floor(firstRowIndex / nextSize) + 1; + onPageChange(nextPage, nextSize); + }, [pagination, onPageChange]); + return (
{/* Toolbar + Filter Panel */} @@ -3697,33 +3768,41 @@ const DataGrid: React.FC = ({
{pagination && ( -
- { - const hasValidRange = Array.isArray(range) && range[0] > 0 && range[1] >= range[0]; - const currentCount = hasValidRange ? Math.max(0, range[1] - range[0] + 1) : 0; - if (pagination.totalKnown === false) { - if (isDuckDBConnection) { - if (pagination.totalCountLoading) return `当前 ${currentCount} 条 / 正在统计精确总数...`; - if (pagination.totalApprox && Number.isFinite(total) && total > 0) return `当前 ${currentCount} 条 / 约 ${total} 条`; - if (pagination.totalCountCancelled) return `当前 ${currentCount} 条 / 已取消统计`; - return `当前 ${currentCount} 条 / 总数未统计`; +
+
+
+ 结果集 + {paginationSummaryText} +
+
{paginationPageText}
+ { + if (type === 'prev') { + return ; } - return `当前 ${currentCount} 条 / 正在统计总数...`; - } - if (isDuckDBConnection && (!Number.isFinite(total) || total <= 0)) { - return '当前 0 条 / 共 0 条'; - } - return `当前 ${currentCount} 条 / 共 ${total} 条`; - }} - showSizeChanger - pageSizeOptions={['100', '200', '500', '1000']} - onChange={onPageChange} - size="small" - /> + if (type === 'next') { + return ; + } + return originalElement; + }} + /> +
+
+ {sqlLogs.length === 0 ? ( +
+ 暂无 SQL 执行日志} + /> +
+ ) : ( +
+ )} ); diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx index 62eab17..d5ebc0f 100644 --- a/frontend/src/components/RedisViewer.tsx +++ b/frontend/src/components/RedisViewer.tsx @@ -5,7 +5,7 @@ import { useStore } from '../store'; import { RedisKeyInfo, RedisValue, StreamEntry } from '../types'; import Editor from '@monaco-editor/react'; import type { DataNode } from 'antd/es/tree'; -import { normalizeOpacityForPlatform } from '../utils/appearance'; +import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; const { Search } = Input; @@ -399,7 +399,8 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => { const theme = useStore(state => state.theme); const appearance = useStore(state => state.appearance); const darkMode = theme === 'dark'; - const opacity = normalizeOpacityForPlatform(appearance.opacity); + const resolvedAppearance = resolveAppearanceValues(appearance); + const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity); const connection = connections.find(c => c.id === connectionId); const keyAccentColor = darkMode ? '#ffd666' : '#1677ff'; const jsonAccentColor = darkMode ? '#f6c453' : '#1890ff'; diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 2fa3fdd..3a31be4 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -27,12 +27,15 @@ import { Tree, message, Dropdown, MenuProps, Input, Button, Modal, Form, Badge, DisconnectOutlined, CloudOutlined, CheckSquareOutlined, - CodeOutlined + CodeOutlined, + TagOutlined, + CheckOutlined, + FilterOutlined } from '@ant-design/icons'; import { useStore } from '../store'; import { SavedConnection } from '../types'; import { DBGetDatabases, DBGetTables, DBQuery, DBShowCreateTable, ExportTable, OpenSQLFile, CreateDatabase, RenameDatabase, DropDatabase, RenameTable, DropTable, DropView, DropFunction, RenameView } from '../../wailsjs/go/app/App'; - import { normalizeOpacityForPlatform } from '../utils/appearance'; + import { normalizeOpacityForPlatform, resolveAppearanceValues } from '../utils/appearance'; const { Search } = Input; @@ -73,6 +76,15 @@ const SEARCH_SCOPE_LABEL_MAP: Record = SEARCH_SCOPE_OPTIONS return acc; }, {} as Record); + +const SEARCH_SCOPE_ICON_MAP: Record = { + smart: , + object: , + database: , + host: , + tag: , +}; + const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> = ({ onEditConnection }) => { const connections = useStore(state => state.connections); const savedQueries = useStore(state => state.savedQueries); @@ -95,7 +107,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> const recordTableAccess = useStore(state => state.recordTableAccess); const setTableSortPreference = useStore(state => state.setTableSortPreference); const darkMode = theme === 'dark'; - const opacity = normalizeOpacityForPlatform(appearance.opacity); + const resolvedAppearance = resolveAppearanceValues(appearance); + const opacity = normalizeOpacityForPlatform(resolvedAppearance.opacity); const [treeData, setTreeData] = useState([]); // Background Helper (Duplicate logic for now, ideally shared) @@ -108,6 +121,44 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> return `rgba(${r}, ${g}, ${b}, ${opacity})`; }; const bgMain = getBg('#141414'); + const modalPanelStyle = useMemo(() => ({ + background: darkMode + ? 'linear-gradient(180deg, rgba(20,26,38,0.96) 0%, rgba(13,17,26,0.98) 100%)' + : 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)', + border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)', + boxShadow: darkMode ? '0 20px 48px rgba(0,0,0,0.38)' : '0 18px 42px rgba(15,23,42,0.12)', + backdropFilter: darkMode ? 'blur(18px)' : 'none', + }), [darkMode]); + const modalSectionStyle = useMemo(() => ({ + padding: 14, + borderRadius: 14, + border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)', + background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.84)', + }), [darkMode]); + const modalScrollSectionStyle = useMemo(() => ({ + maxHeight: 400, + overflow: 'auto' as const, + border: darkMode ? '1px solid rgba(255,255,255,0.08)' : '1px solid rgba(16,24,40,0.08)', + borderRadius: 14, + padding: 12, + background: darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.8)', + }), [darkMode]); + const modalHintTextStyle = useMemo(() => ({ + color: darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)', + fontSize: 12, + lineHeight: 1.6, + }), [darkMode]); + const renderSidebarModalTitle = (icon: React.ReactNode, title: string, description: string) => ( +
+
+ {icon} +
+
+
{title}
+
{description}
+
+
+ ); const [searchValue, setSearchValue] = useState(''); const [searchScopes, setSearchScopes] = useState(['smart']); const [isSearchScopePopoverOpen, setIsSearchScopePopoverOpen] = useState(false); @@ -2471,32 +2522,100 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> const searchScopePopoverContent = useMemo(() => { const smartSelected = searchScopes.includes('smart'); const scopedOptions = SEARCH_SCOPE_OPTIONS.filter((option) => option.value !== 'smart'); + const borderColor = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(16,24,40,0.08)'; + const mutedTextColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)'; + const titleColor = darkMode ? 'rgba(255,255,255,0.92)' : '#162033'; + const panelBg = darkMode + ? 'linear-gradient(180deg, rgba(17,24,39,0.96) 0%, rgba(10,15,26,0.98) 100%)' + : 'linear-gradient(180deg, rgba(255,255,255,0.98) 0%, rgba(246,248,252,0.98) 100%)'; + const smartBg = smartSelected + ? (darkMode ? 'linear-gradient(135deg, rgba(255,214,102,0.22) 0%, rgba(255,179,71,0.16) 100%)' : 'linear-gradient(135deg, rgba(255,214,102,0.26) 0%, rgba(255,244,204,0.92) 100%)') + : (darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.72)'); + const smartBorder = smartSelected + ? (darkMode ? 'rgba(255,214,102,0.42)' : 'rgba(245,176,65,0.34)') + : borderColor; + const getOptionCardStyle = (checked: boolean) => ({ + display: 'flex', + alignItems: 'center' as const, + justifyContent: 'space-between' as const, + gap: 12, + padding: '10px 12px', + borderRadius: 12, + border: `1px solid ${checked ? (darkMode ? 'rgba(118,169,250,0.44)' : 'rgba(24,144,255,0.32)') : borderColor}`, + background: checked + ? (darkMode ? 'rgba(64,124,255,0.18)' : 'rgba(24,144,255,0.08)') + : (darkMode ? 'rgba(255,255,255,0.03)' : 'rgba(255,255,255,0.76)'), + transition: 'all 120ms ease', + }); return ( -
-
搜索范围
- setSearchScopeChecked('smart', e.target.checked)} - > - 智能(推荐) - -
- {scopedOptions.map((option) => ( - setSearchScopeChecked(option.value, e.target.checked)} - > - {option.label} - - ))} +
+
+
+
搜索范围
+
“智能”自动匹配最可能的命中项;手动模式支持按维度组合筛选。
+
+
+ +
-
- 智能与其他项互斥;其他项支持多选。 + + + +
+ +
+
手动范围
+
支持多选组合
+
+ +
+ {scopedOptions.map((option) => { + const checked = searchScopes.includes(option.value); + return ( + + ); + })} +
+ +
+ 智能与其他项互斥。若你明确知道要搜的是对象、库、Host 或标签,建议切到手动范围以减少噪音结果。
); - }, [searchScopes]); + }, [darkMode, searchScopes]); const parseHostOnlyToken = (value: unknown): string[] => { const raw = String(value || '').trim(); @@ -3301,14 +3420,14 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> return (
-
- +
+
void }> placement="bottomRight" open={isSearchScopePopoverOpen} onOpenChange={setIsSearchScopePopoverOpen} + styles={{ body: { padding: 0, borderRadius: 18, overflow: 'hidden' } }} > - - +
{/* Toolbar */} -
+
} > -
+
+
先选择连接与数据库,再决定导出范围和目标对象。
{batchTables.length > 0 && ( -
+
void }> {batchTables.length > 0 && ( <> -
+
-
+
setCheckedTableKeys(values as string[])} @@ -3704,10 +3884,12 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }> , "批量操作库", "按数据库批量导出结构,或生成结构加数据的备份。")} open={isBatchDbModalOpen} onCancel={() => setIsBatchDbModalOpen(false)} - width={600} + width={640} + centered + styles={{ content: modalPanelStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 10 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 12 } }} footer={[ ]} > -
- +
+ +
连接选定后会加载当前连接下可批量导出的数据库列表。
{batchDatabases.length > 0 && ( <> -
+
-
+
setCheckedDbKeys(values as string[])} diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 9a3c9f9..4a36b31 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -2491,7 +2491,7 @@ END;`; okText="应用" cancelText="取消" width={640} - destroyOnClose + destroyOnHidden > void; setTheme: (theme: 'light' | 'dark') => void; - setAppearance: (appearance: Partial<{ opacity: number; blur: number }>) => void; + setAppearance: (appearance: Partial<{ enabled: boolean; opacity: number; blur: number }>) => void; setUiScale: (scale: number) => void; setFontSize: (size: number) => void; setStartupFullscreen: (enabled: boolean) => void; @@ -522,13 +522,14 @@ const sanitizeTableSortPreference = (value: unknown): Record | undefined, + appearance: Partial<{ enabled: boolean; opacity: number; blur: number }> | undefined, version: number -): { opacity: number; blur: number } => { +): { enabled: boolean; opacity: number; blur: number } => { if (!appearance || typeof appearance !== 'object') { return { ...DEFAULT_APPEARANCE }; } const nextAppearance = { + enabled: typeof appearance.enabled === 'boolean' ? appearance.enabled : DEFAULT_APPEARANCE.enabled, opacity: typeof appearance.opacity === 'number' ? appearance.opacity : DEFAULT_APPEARANCE.opacity, blur: typeof appearance.blur === 'number' ? appearance.blur : DEFAULT_APPEARANCE.blur, }; diff --git a/frontend/src/utils/appearance.ts b/frontend/src/utils/appearance.ts index 10d48b5..77c5aaa 100644 --- a/frontend/src/utils/appearance.ts +++ b/frontend/src/utils/appearance.ts @@ -10,6 +10,22 @@ const WINDOWS_BLUR_FACTOR = 1.00; const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); +export interface AppearanceSettingsLike { + enabled?: boolean; + opacity?: number; + blur?: number; +} + +export const resolveAppearanceValues = (appearance: AppearanceSettingsLike | undefined): { opacity: number; blur: number } => { + if (!appearance || appearance.enabled !== false) { + return { + opacity: appearance?.opacity ?? DEFAULT_OPACITY, + blur: appearance?.blur ?? 0, + }; + } + return { opacity: DEFAULT_OPACITY, blur: 0 }; +}; + export const isMacLikePlatform = (): boolean => { if (typeof navigator === 'undefined') { return false;