diff --git a/docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md b/docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md deleted file mode 100644 index ce3665d..0000000 --- a/docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md +++ /dev/null @@ -1,1432 +0,0 @@ -# JVM Connector MVP Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 在 GoNavi 中落地 JVM Connector MVP,首期支持 JMX + Management Endpoint 两种接入模式,覆盖连接测试、能力探测、资源浏览、受控预览/写入、审计记录和 AI 变更计划生成。 - -**Architecture:** 复用 GoNavi 现有的“Redis 式独立能力线”,新增 `internal/jvm` 后端包和一组 JVM 专用前端组件,而不是复用 SQL `Database` 接口。所有写操作统一通过 Guard + Preview + Audit 链路,AI 只生成结构化变更计划,不直接执行。 - -**Tech Stack:** Go 1.24, Wails v2, React 18, TypeScript, Zustand, Ant Design 5, Vitest - ---- - -## File Map - -- Modify: `internal/connection/types.go` - - 为 `ConnectionConfig` 增加 `JVMConfig`、JMX/Endpoint 可选配置,保持现有连接持久化链路可复用。 -- Create: `internal/jvm/types.go` - - JVM 能力、资源、值快照、变更预览、审计记录等 DTO。 -- Create: `internal/jvm/config.go` - - 运行模式归一化、只读/生产保护、模式可用性判断。 -- Create: `internal/jvm/provider.go` - - Provider 接口、注册与按模式分发。 -- Create: `internal/jvm/jmx_provider.go` - - JMX Provider 实现。 -- Create: `internal/jvm/http_provider.go` - - Management Endpoint Provider 实现。 -- Create: `internal/jvm/guard.go` - - 写入前预览、权限保护和风险等级判断。 -- Create: `internal/jvm/audit_store.go` - - JSONL 审计落盘与查询。 -- Create: `internal/jvm/config_test.go` - - JVM 配置归一化和保护规则测试。 -- Create: `internal/app/methods_jvm.go` - - Wails 暴露的 JVM 读写方法。 -- Create: `internal/app/methods_jvm_test.go` - - App 层对 fake provider 的集成测试。 -- Modify: `frontend/src/types.ts` - - 新增 JVM 连接配置、资源模型、TabData 扩展。 -- Create: `frontend/src/utils/jvmConnectionConfig.ts` - - JVM 连接默认值、表单转配置、模式标签和默认端口。 -- Create: `frontend/src/utils/jvmConnectionConfig.test.ts` - - JVM 表单配置转换测试。 -- Create: `frontend/src/utils/jvmRuntimePresentation.ts` - - 模式徽标、审计风险文案、JVM tab 标题构造。 -- Create: `frontend/src/utils/jvmRuntimePresentation.test.ts` - - 展示层纯函数测试。 -- Modify: `frontend/src/components/DatabaseIcons.tsx` - - 增加 JVM 图标映射。 -- Modify: `frontend/src/components/ConnectionModal.tsx` - - 新增 JVM 连接类型与表单。 -- Modify: `frontend/src/components/Sidebar.tsx` - - 新增 JVM 节点、懒加载和资源打开动作。 -- Modify: `frontend/src/components/TabManager.tsx` - - 路由 JVM 新 Tab。 -- Create: `frontend/src/components/JVMOverview.tsx` - - 展示连接能力矩阵与风险提示。 -- Create: `frontend/src/components/JVMResourceBrowser.tsx` - - 资源树、值快照和写入入口。 -- Create: `frontend/src/components/JVMAuditViewer.tsx` - - JVM 审计记录查看器。 -- Create: `frontend/src/components/jvm/JVMModeBadge.tsx` - - 统一渲染 `JMX` / `Endpoint` / `只读` / `可写` 徽标。 -- Create: `frontend/src/components/jvm/JVMChangePreviewModal.tsx` - - 写入预览与确认对话框。 -- Create: `frontend/src/utils/jvmAiPlan.ts` - - 解析和校验 AI 结构化变更计划。 -- Create: `frontend/src/utils/jvmAiPlan.test.ts` - - AI 计划解析测试。 -- Modify: `frontend/src/components/AIChatPanel.tsx` - - 向 JVM tab 注入上下文与推荐 prompt。 -- Modify: `frontend/src/components/ai/AIMessageBubble.tsx` - - 检测 JVM 结构化计划,提供“应用到预览”按钮。 -- Regenerate: `frontend/wailsjs/go/app/App.d.ts`, `frontend/wailsjs/go/app/App.js`, `frontend/wailsjs/go/models.ts` - - 由 Wails 命令生成,不手工编辑。 -- Modify: `docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md` - - 记录计划文件、实施进度和验证证据。 - -## Task 1: 定义 JVM 共享契约与配置归一化 - -**Files:** -- Create: `internal/jvm/types.go` -- Create: `internal/jvm/config.go` -- Create: `internal/jvm/config_test.go` -- Modify: `internal/connection/types.go` -- Create: `frontend/src/utils/jvmConnectionConfig.ts` -- Create: `frontend/src/utils/jvmConnectionConfig.test.ts` -- Modify: `frontend/src/types.ts` - -- [ ] **Step 1: 写后端失败测试,锁定 JVM 模式归一化和默认保护规则** - -```go -package jvm - -import ( - "testing" - - "GoNavi-Wails/internal/connection" -) - -func TestNormalizeConnectionConfigDefaultsToReadOnlyJMX(t *testing.T) { - raw := connection.ConnectionConfig{ - Type: "jvm", - Host: "orders-prod.internal", - Port: 9010, - } - - got, err := NormalizeConnectionConfig(raw) - if err != nil { - t.Fatalf("NormalizeConnectionConfig returned error: %v", err) - } - if !got.JVM.ReadOnly { - t.Fatalf("expected JVM connection to default to readOnly") - } - if got.JVM.PreferredMode != ModeJMX { - t.Fatalf("expected preferred mode %q, got %q", ModeJMX, got.JVM.PreferredMode) - } - if len(got.JVM.AllowedModes) != 1 || got.JVM.AllowedModes[0] != ModeJMX { - t.Fatalf("expected allowed modes [jmx], got %#v", got.JVM.AllowedModes) - } - if got.JVM.JMX.Port != 9010 { - t.Fatalf("expected JMX port to inherit root port 9010, got %d", got.JVM.JMX.Port) - } -} - -func TestNormalizeConnectionConfigFallsBackToFirstAllowedMode(t *testing.T) { - raw := connection.ConnectionConfig{ - Type: "jvm", - Host: "cache-svc.internal", - JVM: connection.JVMConfig{ - AllowedModes: []string{ModeEndpoint, ModeJMX}, - PreferredMode: ModeAgent, - Endpoint: connection.JVMEndpointConfig{ - Enabled: true, - BaseURL: "https://cache-svc.internal/manage/jvm", - }, - }, - } - - got, err := NormalizeConnectionConfig(raw) - if err != nil { - t.Fatalf("NormalizeConnectionConfig returned error: %v", err) - } - if got.JVM.PreferredMode != ModeEndpoint { - t.Fatalf("expected preferred mode %q, got %q", ModeEndpoint, got.JVM.PreferredMode) - } -} -``` - -- [ ] **Step 2: 运行测试,确认 `internal/jvm` 还不存在导致失败** - -Run: `go test ./internal/jvm -run TestNormalizeConnectionConfig -count=1` - -Expected: FAIL,提示 `GoNavi-Wails/internal/jvm` 尚不存在或 `NormalizeConnectionConfig` 未定义。 - -- [ ] **Step 3: 实现后端 JVM 类型与归一化规则** - -```go -package connection - -type JVMJMXConfig struct { - Enabled bool `json:"enabled,omitempty"` - Host string `json:"host,omitempty"` - Port int `json:"port,omitempty"` - Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` - DomainAllowlist []string `json:"domainAllowlist,omitempty"` -} - -type JVMEndpointConfig struct { - Enabled bool `json:"enabled,omitempty"` - BaseURL string `json:"baseUrl,omitempty"` - APIKey string `json:"apiKey,omitempty"` - TimeoutSeconds int `json:"timeoutSeconds,omitempty"` -} - -type JVMConfig struct { - Environment string `json:"environment,omitempty"` - ReadOnly bool `json:"readOnly,omitempty"` - AllowedModes []string `json:"allowedModes,omitempty"` - PreferredMode string `json:"preferredMode,omitempty"` - JMX JVMJMXConfig `json:"jmx,omitempty"` - Endpoint JVMEndpointConfig `json:"endpoint,omitempty"` -} -``` - -```go -package jvm - -import ( - "fmt" - "strings" - - "GoNavi-Wails/internal/connection" -) - -const ( - ModeJMX = "jmx" - ModeEndpoint = "endpoint" - ModeAgent = "agent" - EnvPROD = "prod" -) - -type Capability struct { - Mode string `json:"mode"` - CanBrowse bool `json:"canBrowse"` - CanWrite bool `json:"canWrite"` - CanPreview bool `json:"canPreview"` - Reason string `json:"reason,omitempty"` - DisplayLabel string `json:"displayLabel"` -} - -type ResourceSummary struct { - ID string `json:"id"` - ParentID string `json:"parentId,omitempty"` - Kind string `json:"kind"` - Name string `json:"name"` - Path string `json:"path"` - ProviderMode string `json:"providerMode"` - CanRead bool `json:"canRead"` - CanWrite bool `json:"canWrite"` - HasChildren bool `json:"hasChildren"` - Sensitive bool `json:"sensitive,omitempty"` -} - -type ValueSnapshot struct { - ResourceID string `json:"resourceId"` - Kind string `json:"kind"` - Format string `json:"format"` - Version string `json:"version,omitempty"` - Value interface{} `json:"value"` - Metadata map[string]any `json:"metadata,omitempty"` -} - -type ChangeRequest struct { - ProviderMode string `json:"providerMode"` - ResourceID string `json:"resourceId"` - Action string `json:"action"` - Reason string `json:"reason"` - ExpectedVersion string `json:"expectedVersion,omitempty"` - Payload map[string]any `json:"payload,omitempty"` -} - -type ChangePreview struct { - Allowed bool `json:"allowed"` - RequiresConfirmation bool `json:"requiresConfirmation,omitempty"` - Summary string `json:"summary"` - RiskLevel string `json:"riskLevel"` - BlockingReason string `json:"blockingReason,omitempty"` - Before ValueSnapshot `json:"before"` - After ValueSnapshot `json:"after"` -} - -type ApplyResult struct { - Status string `json:"status"` - Message string `json:"message,omitempty"` - UpdatedValue ValueSnapshot `json:"updatedValue"` -} - -type AuditRecord struct { - Timestamp int64 `json:"timestamp"` - ConnectionID string `json:"connectionId"` - ProviderMode string `json:"providerMode"` - ResourceID string `json:"resourceId"` - Action string `json:"action"` - Reason string `json:"reason"` - Result string `json:"result"` -} - -func NormalizeConnectionConfig(raw connection.ConnectionConfig) (connection.ConnectionConfig, error) { - cfg := raw - if strings.TrimSpace(cfg.Type) != "jvm" { - return connection.ConnectionConfig{}, fmt.Errorf("unexpected connection type: %s", cfg.Type) - } - cfg.Type = "jvm" - cfg.JVM.Environment = strings.ToLower(strings.TrimSpace(cfg.JVM.Environment)) - if cfg.JVM.ReadOnly == false { - cfg.JVM.ReadOnly = true - } - if cfg.JVM.JMX.Port <= 0 { - cfg.JVM.JMX.Port = cfg.Port - } - if len(cfg.JVM.AllowedModes) == 0 { - cfg.JVM.AllowedModes = []string{ModeJMX} - } - cfg.JVM.AllowedModes = normalizeModes(cfg.JVM.AllowedModes) - if cfg.JVM.PreferredMode == "" || !containsMode(cfg.JVM.AllowedModes, cfg.JVM.PreferredMode) { - cfg.JVM.PreferredMode = cfg.JVM.AllowedModes[0] - } - return cfg, nil -} - -func normalizeModes(input []string) []string { - result := make([]string, 0, len(input)) - seen := map[string]struct{}{} - for _, item := range input { - mode := strings.ToLower(strings.TrimSpace(item)) - switch mode { - case ModeJMX, ModeEndpoint, ModeAgent: - default: - continue - } - if _, ok := seen[mode]; ok { - continue - } - seen[mode] = struct{}{} - result = append(result, mode) - } - if len(result) == 0 { - return []string{ModeJMX} - } - return result -} - -func containsMode(items []string, target string) bool { - target = strings.ToLower(strings.TrimSpace(target)) - for _, item := range items { - if strings.ToLower(strings.TrimSpace(item)) == target { - return true - } - } - return false -} -``` - -- [ ] **Step 4: 写前端 JVM 默认值与配置转换的失败测试** - -```ts -import { describe, expect, it } from 'vitest'; -import { buildDefaultJVMConnectionValues, buildJVMConnectionConfig } from './jvmConnectionConfig'; - -describe('jvmConnectionConfig', () => { - it('defaults to readonly jmx mode', () => { - const values = buildDefaultJVMConnectionValues(); - expect(values.type).toBe('jvm'); - expect(values.jvmReadOnly).toBe(true); - expect(values.jvmAllowedModes).toEqual(['jmx']); - expect(values.jvmPreferredMode).toBe('jmx'); - }); - - it('builds nested jvm config payload', () => { - const config = buildJVMConnectionConfig({ - name: 'Orders JVM', - type: 'jvm', - host: 'orders.internal', - port: 9010, - jvmReadOnly: true, - jvmAllowedModes: ['jmx', 'endpoint'], - jvmPreferredMode: 'endpoint', - jvmEnvironment: 'prod', - jvmEndpointEnabled: true, - jvmEndpointBaseUrl: 'https://orders.internal/manage/jvm', - jvmEndpointApiKey: 'token-1', - }); - expect(config.jvm?.preferredMode).toBe('endpoint'); - expect(config.jvm?.endpoint.baseUrl).toBe('https://orders.internal/manage/jvm'); - }); -}); -``` - -- [ ] **Step 5: 实现前端类型与连接工具** - -```ts -export interface JVMJMXConfig { - enabled?: boolean; - host?: string; - port?: number; - username?: string; - password?: string; - domainAllowlist?: string[]; -} - -export interface JVMEndpointConfig { - enabled?: boolean; - baseUrl?: string; - apiKey?: string; - timeoutSeconds?: number; -} - -export interface JVMConfig { - environment?: 'dev' | 'uat' | 'prod'; - readOnly?: boolean; - allowedModes?: Array<'jmx' | 'endpoint' | 'agent'>; - preferredMode?: 'jmx' | 'endpoint' | 'agent'; - jmx?: JVMJMXConfig; - endpoint?: JVMEndpointConfig; -} - -export interface JVMCapability { - mode: 'jmx' | 'endpoint' | 'agent'; - canBrowse: boolean; - canWrite: boolean; - canPreview: boolean; - reason?: string; - displayLabel: string; -} - -export interface JVMResourceSummary { - id: string; - parentId?: string; - kind: string; - name: string; - path: string; - providerMode: 'jmx' | 'endpoint' | 'agent'; - canRead: boolean; - canWrite: boolean; - hasChildren: boolean; - sensitive?: boolean; -} - -export interface JVMValueSnapshot { - resourceId: string; - kind: string; - format: string; - version?: string; - value: any; - metadata?: Record; -} - -export interface JVMChangePreview { - allowed: boolean; - requiresConfirmation?: boolean; - summary: string; - riskLevel: 'low' | 'medium' | 'high'; - blockingReason?: string; - before: JVMValueSnapshot; - after: JVMValueSnapshot; -} -``` - -```ts -import type { ConnectionConfig } from '../types'; - -export const buildDefaultJVMConnectionValues = () => ({ - type: 'jvm', - host: 'localhost', - port: 9010, - jvmReadOnly: true, - jvmAllowedModes: ['jmx'], - jvmPreferredMode: 'jmx', - jvmEnvironment: 'dev', - jvmEndpointEnabled: false, - jvmEndpointBaseUrl: '', - jvmEndpointApiKey: '', -}); - -export const buildJVMConnectionConfig = (values: Record): ConnectionConfig => ({ - type: 'jvm', - host: String(values.host || '').trim(), - port: Number(values.port || 0), - user: '', - password: '', - timeout: Number(values.timeout || 30), - jvm: { - environment: values.jvmEnvironment, - readOnly: Boolean(values.jvmReadOnly), - allowedModes: values.jvmAllowedModes, - preferredMode: values.jvmPreferredMode, - jmx: { - enabled: values.jvmAllowedModes?.includes('jmx'), - host: String(values.jvmJmxHost || values.host || '').trim(), - port: Number(values.jvmJmxPort || values.port || 0), - username: String(values.jvmJmxUsername || '').trim(), - password: String(values.jvmJmxPassword || ''), - }, - endpoint: { - enabled: Boolean(values.jvmEndpointEnabled), - baseUrl: String(values.jvmEndpointBaseUrl || '').trim(), - apiKey: String(values.jvmEndpointApiKey || ''), - timeoutSeconds: Number(values.jvmEndpointTimeoutSeconds || values.timeout || 30), - }, - }, -}); -``` - -- [ ] **Step 6: 运行单测,确认前后端配置契约稳定** - -Run: `go test ./internal/jvm -run TestNormalizeConnectionConfig -count=1` - -Expected: PASS,输出 `ok GoNavi-Wails/internal/jvm` - -Run: `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts` - -Expected: PASS,2 个测试通过。 - -- [ ] **Step 7: 提交配置契约** - -```bash -git add internal/connection/types.go internal/jvm/types.go internal/jvm/config.go internal/jvm/config_test.go frontend/src/types.ts frontend/src/utils/jvmConnectionConfig.ts frontend/src/utils/jvmConnectionConfig.test.ts -git commit -m "feat(jvm): 定义 JVM 连接契约与配置归一化" -``` - -## Task 2: 建立后端 Provider 注册与连接探测 API - -**Files:** -- Create: `internal/jvm/provider.go` -- Create: `internal/jvm/jmx_provider.go` -- Create: `internal/jvm/http_provider.go` -- Create: `internal/app/methods_jvm.go` -- Create: `internal/app/methods_jvm_test.go` -- Regenerate: `frontend/wailsjs/go/app/App.d.ts` -- Regenerate: `frontend/wailsjs/go/app/App.js` -- Regenerate: `frontend/wailsjs/go/models.ts` - -- [ ] **Step 1: 写 App 层失败测试,锁定连接测试与能力探测输出** - -```go -package app - -import ( - "context" - "testing" - - "GoNavi-Wails/internal/connection" - "GoNavi-Wails/internal/jvm" -) - -type fakeJVMProvider struct { - testErr error - probe []jvm.Capability - list []jvm.ResourceSummary - value jvm.ValueSnapshot - apply jvm.ApplyResult -} - -func (f fakeJVMProvider) Mode() string { return jvm.ModeJMX } -func (f fakeJVMProvider) TestConnection(context.Context, connection.ConnectionConfig) error { return f.testErr } -func (f fakeJVMProvider) ProbeCapabilities(context.Context, connection.ConnectionConfig) ([]jvm.Capability, error) { - return f.probe, nil -} -func (f fakeJVMProvider) ListResources(context.Context, connection.ConnectionConfig, string) ([]jvm.ResourceSummary, error) { - return f.list, nil -} -func (f fakeJVMProvider) GetValue(context.Context, connection.ConnectionConfig, string) (jvm.ValueSnapshot, error) { - return f.value, nil -} -func (f fakeJVMProvider) PreviewChange(context.Context, connection.ConnectionConfig, jvm.ChangeRequest) (jvm.ChangePreview, error) { - return jvm.ChangePreview{Allowed: true, Summary: "preview"}, nil -} -func (f fakeJVMProvider) ApplyChange(context.Context, connection.ConnectionConfig, jvm.ChangeRequest) (jvm.ApplyResult, error) { - return f.apply, nil -} - -func swapJVMProviderFactory(factory func(mode string) (jvm.Provider, error)) func() { - prev := newJVMProvider - newJVMProvider = factory - return func() { newJVMProvider = prev } -} - -func TestTestJVMConnectionUsesPreferredProvider(t *testing.T) { - app := NewAppWithSecretStore(nil) - restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { - return fakeJVMProvider{}, nil - }) - defer restore() - - res := app.TestJVMConnection(connection.ConnectionConfig{ - Type: "jvm", - Host: "orders.internal", - JVM: connection.JVMConfig{ - PreferredMode: "jmx", - AllowedModes: []string{"jmx"}, - }, - }) - - if !res.Success { - t.Fatalf("expected success, got %+v", res) - } -} - -func TestJVMProbeCapabilitiesReturnsCapabilityArray(t *testing.T) { - app := NewAppWithSecretStore(nil) - restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { - return fakeJVMProvider{ - probe: []jvm.Capability{{Mode: jvm.ModeJMX, CanBrowse: true, CanWrite: false, CanPreview: false, DisplayLabel: "JMX"}}, - }, nil - }) - defer restore() - - res := app.JVMProbeCapabilities(connection.ConnectionConfig{ - Type: "jvm", - Host: "orders.internal", - JVM: connection.JVMConfig{ - PreferredMode: "jmx", - AllowedModes: []string{"jmx"}, - }, - }) - - if !res.Success { - t.Fatalf("expected success, got %+v", res) - } - items, ok := res.Data.([]jvm.Capability) - if !ok || len(items) != 1 { - t.Fatalf("expected one capability, got %#v", res.Data) - } -} -``` - -- [ ] **Step 2: 运行测试,确认 App 方法尚未定义** - -Run: `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1` - -Expected: FAIL,提示 `TestJVMConnection` 或 `JVMProbeCapabilities` 未定义。 - -- [ ] **Step 3: 实现 Provider 接口、JMX/Endpoint 骨架和 App 方法** - -```go -package jvm - -import ( - "context" - "fmt" - "strings" - - "GoNavi-Wails/internal/connection" -) - -type Provider interface { - Mode() string - TestConnection(ctx context.Context, cfg connection.ConnectionConfig) error - ProbeCapabilities(ctx context.Context, cfg connection.ConnectionConfig) ([]Capability, error) - ListResources(ctx context.Context, cfg connection.ConnectionConfig, parentPath string) ([]ResourceSummary, error) - GetValue(ctx context.Context, cfg connection.ConnectionConfig, resourcePath string) (ValueSnapshot, error) - PreviewChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) - ApplyChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ApplyResult, error) -} - -var providerFactories = map[string]func() Provider{ - ModeJMX: func() Provider { return NewJMXProvider() }, - ModeEndpoint: func() Provider { return NewHTTPProvider() }, -} - -func NewProvider(mode string) (Provider, error) { - normalized := strings.ToLower(strings.TrimSpace(mode)) - factory, ok := providerFactories[normalized] - if !ok { - return nil, fmt.Errorf("unsupported jvm provider mode: %s", mode) - } - return factory(), nil -} - -type JMXProvider struct{} - -func NewJMXProvider() Provider { return &JMXProvider{} } -func (p *JMXProvider) Mode() string { return ModeJMX } -func (p *JMXProvider) TestConnection(ctx context.Context, cfg connection.ConnectionConfig) error { return nil } -func (p *JMXProvider) ProbeCapabilities(ctx context.Context, cfg connection.ConnectionConfig) ([]Capability, error) { - return []Capability{{Mode: ModeJMX, CanBrowse: true, CanWrite: false, CanPreview: false, DisplayLabel: "JMX"}}, nil -} -func (p *JMXProvider) ListResources(ctx context.Context, cfg connection.ConnectionConfig, parentPath string) ([]ResourceSummary, error) { - return []ResourceSummary{}, nil -} -func (p *JMXProvider) GetValue(ctx context.Context, cfg connection.ConnectionConfig, resourcePath string) (ValueSnapshot, error) { - return ValueSnapshot{}, nil -} -func (p *JMXProvider) PreviewChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) { - return ChangePreview{}, nil -} -func (p *JMXProvider) ApplyChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ApplyResult, error) { - return ApplyResult{}, nil -} - -type HTTPProvider struct{} - -func NewHTTPProvider() Provider { return &HTTPProvider{} } -func (p *HTTPProvider) Mode() string { return ModeEndpoint } -func (p *HTTPProvider) TestConnection(ctx context.Context, cfg connection.ConnectionConfig) error { return nil } -func (p *HTTPProvider) ProbeCapabilities(ctx context.Context, cfg connection.ConnectionConfig) ([]Capability, error) { - return []Capability{{Mode: ModeEndpoint, CanBrowse: true, CanWrite: true, CanPreview: true, DisplayLabel: "Endpoint"}}, nil -} -func (p *HTTPProvider) ListResources(ctx context.Context, cfg connection.ConnectionConfig, parentPath string) ([]ResourceSummary, error) { - return []ResourceSummary{}, nil -} -func (p *HTTPProvider) GetValue(ctx context.Context, cfg connection.ConnectionConfig, resourcePath string) (ValueSnapshot, error) { - return ValueSnapshot{}, nil -} -func (p *HTTPProvider) PreviewChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) { - return ChangePreview{}, nil -} -func (p *HTTPProvider) ApplyChange(ctx context.Context, cfg connection.ConnectionConfig, req ChangeRequest) (ApplyResult, error) { - return ApplyResult{}, nil -} -``` - -```go -package app - -import ( - "GoNavi-Wails/internal/connection" - "GoNavi-Wails/internal/jvm" - "path/filepath" - "strings" -) - -var newJVMProvider = jvm.NewProvider - -func (a *App) TestJVMConnection(cfg connection.ConnectionConfig) connection.QueryResult { - normalized, err := jvm.NormalizeConnectionConfig(cfg) - if err != nil { - return connection.QueryResult{Success: false, Message: err.Error()} - } - provider, err := newJVMProvider(normalized.JVM.PreferredMode) - if err != nil { - return connection.QueryResult{Success: false, Message: err.Error()} - } - if err := provider.TestConnection(a.ctx, normalized); err != nil { - return connection.QueryResult{Success: false, Message: err.Error()} - } - return connection.QueryResult{Success: true, Message: "JVM 连接成功"} -} - -func (a *App) JVMProbeCapabilities(cfg connection.ConnectionConfig) connection.QueryResult { - normalized, err := jvm.NormalizeConnectionConfig(cfg) - if err != nil { - return connection.QueryResult{Success: false, Message: err.Error()} - } - items := make([]jvm.Capability, 0, len(normalized.JVM.AllowedModes)) - for _, mode := range normalized.JVM.AllowedModes { - provider, providerErr := newJVMProvider(mode) - if providerErr != nil { - items = append(items, jvm.Capability{Mode: mode, DisplayLabel: strings.ToUpper(mode), Reason: providerErr.Error()}) - continue - } - caps, probeErr := provider.ProbeCapabilities(a.ctx, normalized) - if probeErr != nil { - items = append(items, jvm.Capability{Mode: mode, DisplayLabel: strings.ToUpper(mode), Reason: probeErr.Error()}) - continue - } - items = append(items, caps...) - } - return connection.QueryResult{Success: true, Data: items} -} -``` - -- [ ] **Step 4: 刷新 Wails 绑定** - -Run: `wails build -clean` - -Expected: PASS,命令退出码为 0,同时刷新 `frontend/wailsjs/go/app/App.*` 与 `frontend/wailsjs/go/models.ts`。 - -- [ ] **Step 5: 运行后端测试,确认探测 API 可用** - -Run: `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1` - -Expected: PASS,输出 `ok GoNavi-Wails/internal/app` - -- [ ] **Step 6: 提交 Provider 骨架** - -```bash -git add internal/jvm/provider.go internal/jvm/jmx_provider.go internal/jvm/http_provider.go internal/app/methods_jvm.go internal/app/methods_jvm_test.go frontend/wailsjs/go/app/App.d.ts frontend/wailsjs/go/app/App.js frontend/wailsjs/go/models.ts -git commit -m "feat(jvm): 增加连接测试与能力探测 API" -``` - -## Task 3: 接入 JVM 连接表单与图标 - -**Files:** -- Modify: `frontend/src/components/DatabaseIcons.tsx` -- Modify: `frontend/src/components/ConnectionModal.tsx` -- Create: `frontend/src/utils/jvmRuntimePresentation.ts` -- Create: `frontend/src/utils/jvmRuntimePresentation.test.ts` - -- [ ] **Step 1: 写展示层失败测试,锁定 JVM 模式标签和 tab 标题构造** - -```ts -import { describe, expect, it } from 'vitest'; -import { buildJVMTabTitle, resolveJVMModeMeta } from './jvmRuntimePresentation'; - -describe('jvmRuntimePresentation', () => { - it('renders readable mode meta', () => { - expect(resolveJVMModeMeta('jmx').label).toBe('JMX'); - expect(resolveJVMModeMeta('endpoint').label).toBe('Endpoint'); - }); - - it('builds overview title with provider suffix', () => { - expect(buildJVMTabTitle('Orders JVM', 'overview', 'jmx')).toBe('[Orders JVM] JVM 概览 · JMX'); - }); -}); -``` - -- [ ] **Step 2: 运行测试,确认展示帮助函数尚未实现** - -Run: `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts` - -Expected: FAIL,提示 `buildJVMTabTitle` / `resolveJVMModeMeta` 未定义。 - -- [ ] **Step 3: 实现 JVM 图标和展示帮助函数** - -```ts -export const resolveJVMModeMeta = (mode: string) => { - switch (mode) { - case 'endpoint': - return { label: 'Endpoint', color: 'blue' as const }; - case 'agent': - return { label: 'Agent', color: 'purple' as const }; - default: - return { label: 'JMX', color: 'gold' as const }; - } -}; - -export const buildJVMTabTitle = (connectionName: string, tabKind: 'overview' | 'resource' | 'audit', mode: string) => { - const modeLabel = resolveJVMModeMeta(mode).label; - if (tabKind === 'audit') return `[${connectionName}] JVM 审计 · ${modeLabel}`; - if (tabKind === 'resource') return `[${connectionName}] JVM 资源 · ${modeLabel}`; - return `[${connectionName}] JVM 概览 · ${modeLabel}`; -}; -``` - -```tsx -export const DB_ICON_TYPES = [ - 'mysql', - 'postgres', - 'oracle', - 'redis', - 'mongodb', - 'custom', - 'jvm', -] as const; -``` - -- [ ] **Step 4: 扩展 ConnectionModal,新增 JVM 连接类型与测试连接分发** - -```tsx -{ key: 'jvm', name: 'JVM', icon: } -``` - -```tsx -if (dbType === 'jvm') { - return ( - <> - - - - - - - - - - - 默认只读 - - - - - - ); -} -``` - -```tsx -const requestTest = async () => { - const values = form.getFieldsValue(true); - const config = values.type === 'jvm' - ? buildJVMConnectionConfig(values) - : await buildConfig(values, false); - const result = values.type === 'jvm' - ? await (window as any).go.app.App.TestJVMConnection(config as any) - : values.type === 'redis' - ? await RedisConnect(config as any) - : await TestConnection(config as any); - setTestResult(result.success ? { type: 'success', message: result.message || '连接成功' } : { type: 'error', message: result.message || '连接失败' }); -}; -``` - -- [ ] **Step 5: 运行前端纯函数测试与构建** - -Run: `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts` - -Expected: PASS - -Run: `cd frontend && npm run build` - -Expected: PASS,生成最新 `frontend/dist`。 - -- [ ] **Step 6: 提交连接体验改动** - -```bash -git add frontend/src/components/DatabaseIcons.tsx frontend/src/components/ConnectionModal.tsx frontend/src/utils/jvmRuntimePresentation.ts frontend/src/utils/jvmRuntimePresentation.test.ts -git commit -m "feat(jvm): 新增 JVM 连接表单与展示元数据" -``` - -## Task 4: 打通只读资源浏览与 JVM Tab - -**Files:** -- Modify: `frontend/src/types.ts` -- Modify: `frontend/src/components/Sidebar.tsx` -- Modify: `frontend/src/components/TabManager.tsx` -- Create: `frontend/src/components/JVMOverview.tsx` -- Create: `frontend/src/components/JVMResourceBrowser.tsx` -- Create: `frontend/src/components/jvm/JVMModeBadge.tsx` -- Modify: `internal/app/methods_jvm.go` -- Modify: `internal/app/methods_jvm_test.go` - -- [ ] **Step 1: 写后端失败测试,锁定资源列表和值读取接口** - -```go -func TestJVMListResourcesReturnsTreePayload(t *testing.T) { - app := NewAppWithSecretStore(nil) - restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) { - return fakeJVMProvider{ - list: []jvm.ResourceSummary{ - {ID: "cache:orders", Kind: "cacheNamespace", Name: "orders", Path: "cache/orders", ProviderMode: "jmx", HasChildren: true, CanRead: true}, - }, - }, nil - }) - defer restore() - - res := app.JVMListResources(connection.ConnectionConfig{ - Type: "jvm", - Host: "orders.internal", - JVM: connection.JVMConfig{PreferredMode: "jmx", AllowedModes: []string{"jmx"}}, - }, "") - - if !res.Success { - t.Fatalf("expected success, got %+v", res) - } - items, ok := res.Data.([]jvm.ResourceSummary) - if !ok || len(items) != 1 { - t.Fatalf("expected one resource item, got %#v", res.Data) - } -} -``` - -- [ ] **Step 2: 运行测试,确认资源读取方法尚未实现** - -Run: `go test ./internal/app -run 'TestJVMListResources' -count=1` - -Expected: FAIL,提示 `JVMListResources` 未定义。 - -- [ ] **Step 3: 实现后端读接口并在 Sidebar 中新增 JVM 懒加载节点** - -```go -func (a *App) JVMListResources(cfg connection.ConnectionConfig, parentPath string) connection.QueryResult { - normalized, err := jvm.NormalizeConnectionConfig(cfg) - if err != nil { - return connection.QueryResult{Success: false, Message: err.Error()} - } - provider, err := newJVMProvider(normalized.JVM.PreferredMode) - if err != nil { - return connection.QueryResult{Success: false, Message: err.Error()} - } - items, err := provider.ListResources(a.ctx, normalized, parentPath) - if err != nil { - return connection.QueryResult{Success: false, Message: err.Error()} - } - return connection.QueryResult{Success: true, Data: items} -} - -func (a *App) JVMGetValue(cfg connection.ConnectionConfig, resourcePath string) connection.QueryResult { - normalized, err := jvm.NormalizeConnectionConfig(cfg) - if err != nil { - return connection.QueryResult{Success: false, Message: err.Error()} - } - provider, err := newJVMProvider(normalized.JVM.PreferredMode) - if err != nil { - return connection.QueryResult{Success: false, Message: err.Error()} - } - value, err := provider.GetValue(a.ctx, normalized, resourcePath) - if err != nil { - return connection.QueryResult{Success: false, Message: err.Error()} - } - return connection.QueryResult{Success: true, Data: value} -} -``` - -```tsx -type TreeNode = { - title: string; - key: string; - isLeaf?: boolean; - children?: TreeNode[]; - icon?: React.ReactNode; - dataRef?: any; - type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource'; -}; -``` - -```tsx -if (conn.config.type === 'jvm') { - const modeChildren = (caps as JVMCapability[]).map((cap) => ({ - title: ( - - {cap.displayLabel} - - - ), - key: `${conn.id}-jvm-mode-${cap.mode}`, - type: 'jvm-mode' as const, - dataRef: { ...conn, providerMode: cap.mode }, - isLeaf: false, - })); - setTreeData((origin) => updateTreeData(origin, conn.id, modeChildren)); - return; -} -``` - -- [ ] **Step 4: 新增 JVM 概览与资源浏览 Tab** - -```tsx -if (tab.type === 'jvm-overview') { - content = ; -} else if (tab.type === 'jvm-resource') { - content = ; -} else if (tab.type === 'jvm-audit') { - content = ; -} -``` - -```tsx -export interface TabData { - id: string; - title: string; - type: 'query' | 'table' | 'design' | 'redis-keys' | 'redis-command' | 'redis-monitor' | 'trigger' | 'view-def' | 'routine-def' | 'table-overview' | 'jvm-overview' | 'jvm-resource' | 'jvm-audit'; - connectionId: string; - dbName?: string; - tableName?: string; - providerMode?: 'jmx' | 'endpoint' | 'agent'; - resourcePath?: string; - resourceKind?: string; -} -``` - -- [ ] **Step 5: 运行后端与前端最小回归** - -Run: `go test ./internal/app -run 'TestJVMListResources' -count=1` - -Expected: PASS - -Run: `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts` - -Expected: PASS - -- [ ] **Step 6: 提交只读浏览链路** - -```bash -git add internal/app/methods_jvm.go internal/app/methods_jvm_test.go frontend/src/types.ts frontend/src/components/Sidebar.tsx frontend/src/components/TabManager.tsx frontend/src/components/JVMOverview.tsx frontend/src/components/JVMResourceBrowser.tsx frontend/src/components/jvm/JVMModeBadge.tsx -git commit -m "feat(jvm): 打通 JVM 只读资源浏览" -``` - -## Task 5: 加入写入预览、Guard 和审计记录 - -**Files:** -- Create: `internal/jvm/guard.go` -- Create: `internal/jvm/audit_store.go` -- Modify: `internal/jvm/types.go` -- Modify: `internal/app/methods_jvm.go` -- Modify: `internal/app/methods_jvm_test.go` -- Create: `frontend/src/components/jvm/JVMChangePreviewModal.tsx` -- Create: `frontend/src/components/JVMAuditViewer.tsx` -- Modify: `frontend/src/components/JVMResourceBrowser.tsx` - -- [ ] **Step 1: 写 Guard 失败测试,锁定只读/生产环境拦截** - -```go -func TestPreviewChangeBlocksReadOnlyConnection(t *testing.T) { - cfg := connection.ConnectionConfig{ - Type: "jvm", - Host: "orders.internal", - JVM: connection.JVMConfig{ - ReadOnly: true, - Environment: "prod", - PreferredMode: "endpoint", - AllowedModes: []string{"endpoint"}, - }, - } - - preview, err := jvm.BuildChangePreview(cfg, jvm.ChangeRequest{ - ProviderMode: "endpoint", - ResourceID: "cache/orders/user:1", - Action: "updateValue", - Reason: "修复错误缓存态", - Payload: map[string]any{"status": "ACTIVE"}, - }) - if err != nil { - t.Fatalf("BuildChangePreview returned error: %v", err) - } - if preview.Allowed { - t.Fatalf("expected readonly connection to block write preview") - } - if preview.BlockingReason == "" { - t.Fatalf("expected blocking reason") - } -} -``` - -- [ ] **Step 2: 运行测试,确认 Guard 逻辑尚未存在** - -Run: `go test ./internal/jvm -run TestPreviewChangeBlocksReadOnlyConnection -count=1` - -Expected: FAIL,提示 `BuildChangePreview` 未定义。 - -- [ ] **Step 3: 实现 Guard、预览和审计落盘** - -```go -package jvm - -import ( - "encoding/json" - "os" - "fmt" - "time" - - "GoNavi-Wails/internal/connection" -) - -func BuildChangePreview(cfg connection.ConnectionConfig, req ChangeRequest) (ChangePreview, error) { - normalized, err := NormalizeConnectionConfig(cfg) - if err != nil { - return ChangePreview{}, err - } - preview := ChangePreview{ - Allowed: true, - RiskLevel: "medium", - Summary: fmt.Sprintf("%s -> %s", req.ResourceID, req.Action), - } - if normalized.JVM.ReadOnly { - preview.Allowed = false - preview.RiskLevel = "high" - preview.BlockingReason = "当前连接为只读,禁止写入" - } - if normalized.JVM.Environment == EnvPROD { - preview.RequiresConfirmation = true - } - return preview, nil -} - -type AuditStore struct { - path string -} - -func NewAuditStore(path string) *AuditStore { return &AuditStore{path: path} } - -func (s *AuditStore) Append(record AuditRecord) error { - record.Timestamp = time.Now().UnixMilli() - file, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) - if err != nil { - return err - } - defer file.Close() - return json.NewEncoder(file).Encode(record) -} -``` - -```go -func (a *App) JVMPreviewChange(cfg connection.ConnectionConfig, req jvm.ChangeRequest) connection.QueryResult { - preview, err := jvm.BuildChangePreview(cfg, req) - if err != nil { - return connection.QueryResult{Success: false, Message: err.Error()} - } - return connection.QueryResult{Success: true, Data: preview} -} - -func (a *App) JVMApplyChange(cfg connection.ConnectionConfig, req jvm.ChangeRequest) connection.QueryResult { - preview, err := jvm.BuildChangePreview(cfg, req) - if err != nil { - return connection.QueryResult{Success: false, Message: err.Error()} - } - if !preview.Allowed { - return connection.QueryResult{Success: false, Message: preview.BlockingReason} - } - provider, err := newJVMProvider(req.ProviderMode) - if err != nil { - return connection.QueryResult{Success: false, Message: err.Error()} - } - result, err := provider.ApplyChange(a.ctx, cfg, req) - if err != nil { - return connection.QueryResult{Success: false, Message: err.Error()} - } - _ = jvm.NewAuditStore(filepath.Join(a.configDir, "jvm_audit.jsonl")).Append(jvm.AuditRecord{ - ConnectionID: cfg.ID, - ProviderMode: req.ProviderMode, - ResourceID: req.ResourceID, - Action: req.Action, - Reason: req.Reason, - Result: result.Status, - }) - return connection.QueryResult{Success: true, Data: result} -} -``` - -- [ ] **Step 4: 实现前端预览弹窗与审计页签** - -```tsx -export const JVMChangePreviewModal: React.FC<{ - open: boolean; - preview: JVMChangePreview | null; - onCancel: () => void; - onConfirm: () => Promise; -}> = ({ open, preview, onCancel, onConfirm }) => ( - void onConfirm()} - okText="确认执行" - cancelText="取消" - okButtonProps={{ danger: preview?.riskLevel === 'high' }} - > - - {preview?.summary} - {preview?.riskLevel} - {preview?.blockingReason || '无'} - - - {JSON.stringify(preview?.before?.value ?? {}, null, 2)} - {JSON.stringify(preview?.after?.value ?? {}, null, 2)} - -); -``` - -```tsx -const handleApply = async () => { - const previewRes = await (window as any).go.app.App.JVMPreviewChange(config, draftPlan); - if (!previewRes.success) { - message.error(previewRes.message || '预览失败'); - return; - } - setPreview(previewRes.data); - setPreviewOpen(true); -}; -``` - -- [ ] **Step 5: 跑写入链路单测** - -Run: `go test ./internal/jvm ./internal/app -run 'TestPreviewChangeBlocksReadOnlyConnection|TestJVMApplyChange' -count=1` - -Expected: PASS - -- [ ] **Step 6: 提交预览与审计链路** - -```bash -git add internal/jvm/guard.go internal/jvm/audit_store.go internal/jvm/types.go internal/app/methods_jvm.go internal/app/methods_jvm_test.go frontend/src/components/jvm/JVMChangePreviewModal.tsx frontend/src/components/JVMAuditViewer.tsx frontend/src/components/JVMResourceBrowser.tsx -git commit -m "feat(jvm): 增加 JVM 写入预览与审计" -``` - -## Task 6: 接入 AI 结构化变更计划 - -**Files:** -- Create: `frontend/src/utils/jvmAiPlan.ts` -- Create: `frontend/src/utils/jvmAiPlan.test.ts` -- Modify: `frontend/src/components/AIChatPanel.tsx` -- Modify: `frontend/src/components/ai/AIMessageBubble.tsx` -- Modify: `frontend/src/components/JVMResourceBrowser.tsx` - -- [ ] **Step 1: 写失败测试,锁定 AI 计划 JSON 解析规则** - -```ts -import { describe, expect, it } from 'vitest'; -import { extractJVMChangePlan } from './jvmAiPlan'; - -describe('extractJVMChangePlan', () => { - it('parses fenced json plan', () => { - const message = [ - '建议先预览再执行:', - '```json', - '{"targetType":"cacheEntry","selector":{"namespace":"orders","key":"user:1"},"action":"updateValue","payload":{"format":"json","value":{"status":"ACTIVE"}},"reason":"修复缓存脏值"}', - '```', - ].join('\n'); - - const plan = extractJVMChangePlan(message); - expect(plan?.action).toBe('updateValue'); - expect(plan?.selector.namespace).toBe('orders'); - }); - - it('returns null for malformed plan', () => { - expect(extractJVMChangePlan('```json\n{"action":1}\n```')).toBeNull(); - }); -}); -``` - -- [ ] **Step 2: 运行测试,确认 AI 计划解析器尚未存在** - -Run: `cd frontend && npm test -- src/utils/jvmAiPlan.test.ts` - -Expected: FAIL,提示 `extractJVMChangePlan` 未定义。 - -- [ ] **Step 3: 实现 AI 计划解析器** - -```ts -export type JVMAIChangePlan = { - targetType: 'cacheEntry' | 'managedBean'; - selector: { namespace?: string; key?: string; resourcePath?: string }; - action: 'updateValue' | 'evict' | 'clear'; - payload?: { format: 'json' | 'text'; value: unknown }; - reason: string; -}; - -export const extractJVMChangePlan = (content: string): JVMAIChangePlan | null => { - const match = String(content || '').match(/```json\s*([\s\S]*?)```/i); - if (!match) return null; - try { - const parsed = JSON.parse(match[1]); - if (!parsed || typeof parsed !== 'object') return null; - if (!parsed.targetType || !parsed.selector || !parsed.action || !parsed.reason) return null; - return parsed as JVMAIChangePlan; - } catch { - return null; - } -}; -``` - -- [ ] **Step 4: 在 AI 气泡里识别 JVM 计划并提供“应用到预览”按钮** - -```tsx -const jvmPlan = extractJVMChangePlan(msg.content || ''); - -{jvmPlan && ( - -)} -``` - -```tsx -useEffect(() => { - const handler = (event: Event) => { - const detail = (event as CustomEvent).detail; - if (!detail?.plan) return; - setDraftPlan({ - providerMode: tab.providerMode || 'endpoint', - resourceID: detail.plan.selector.resourcePath || `${detail.plan.selector.namespace}/${detail.plan.selector.key}`, - action: detail.plan.action, - payload: detail.plan.payload?.value ?? {}, - reason: detail.plan.reason, - }); - }; - window.addEventListener('gonavi:jvm-apply-ai-plan', handler as EventListener); - return () => window.removeEventListener('gonavi:jvm-apply-ai-plan', handler as EventListener); -}, [tab.providerMode]); -``` - -- [ ] **Step 5: 跑 AI 计划解析测试** - -Run: `cd frontend && npm test -- src/utils/jvmAiPlan.test.ts` - -Expected: PASS - -- [ ] **Step 6: 提交 AI 集成** - -```bash -git add frontend/src/utils/jvmAiPlan.ts frontend/src/utils/jvmAiPlan.test.ts frontend/src/components/AIChatPanel.tsx frontend/src/components/ai/AIMessageBubble.tsx frontend/src/components/JVMResourceBrowser.tsx -git commit -m "feat(jvm): 支持 AI 生成 JVM 变更计划" -``` - -## Task 7: 全量回归、文档回填与交付检查 - -**Files:** -- Modify: `docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md` -- Regenerate/Verify: `frontend/wailsjs/go/app/App.d.ts` -- Regenerate/Verify: `frontend/wailsjs/go/app/App.js` -- Regenerate/Verify: `frontend/wailsjs/go/models.ts` - -- [ ] **Step 1: 更新需求追踪文档,写入计划路径与实施阶段** - -```md -## 3. 里程碑与进度 -- [x] 阶段 1(需求澄清):完成 -- [x] 阶段 2(影响分析):完成 -- [x] 阶段 3(方案设计):完成 -- [x] 阶段 4(实施计划):完成 -- [ ] 阶段 5(实现与自检): - -## 7. 验证记录 -- 证据(日志/截图/链接): - - `docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md` - - `docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md` -``` - -- [ ] **Step 2: 运行后端全量测试** - -Run: `go test ./...` - -Expected: PASS,全仓 Go 测试通过。 - -- [ ] **Step 3: 运行前端全量测试** - -Run: `cd frontend && npm test` - -Expected: PASS,全量 Vitest 通过。 - -- [ ] **Step 4: 运行前端生产构建** - -Run: `cd frontend && npm run build` - -Expected: PASS,生成最新 `frontend/dist`。 - -- [ ] **Step 5: 运行 Wails 生产构建,确认绑定与嵌入资源完整** - -Run: `wails build -clean` - -Expected: PASS,命令退出码为 0。 - -- [ ] **Step 6: 提交最终计划内实现** - -```bash -git add docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md frontend/wailsjs/go/app/App.d.ts frontend/wailsjs/go/app/App.js frontend/wailsjs/go/models.ts -git commit -m "feat(jvm): 完成 JVM Connector MVP" -``` - -## Self-Review Notes - -- Spec coverage: - - `JMX + Management Endpoint`:Task 2 / Task 4 / Task 5 - - `统一连接入口`:Task 1 / Task 3 - - `资源浏览`:Task 4 - - `受控修改 + 预览 + 审计`:Task 5 - - `AI 生成修改计划`:Task 6 - - `验证与文档回填`:Task 7 -- Placeholder scan: - - 无 `TODO` / `TBD` / “后续补充” 占位语 -- Type consistency: - - 统一使用 `JVMConfig` / `Capability` / `ResourceSummary` / `ChangeRequest` / `ChangePreview` - -## Execution Handoff - -Plan complete and saved to `docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md`. Two execution options: - -**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration - -**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints - -**Which approach?** diff --git a/docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md b/docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md deleted file mode 100644 index 2c2011b..0000000 --- a/docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md +++ /dev/null @@ -1,483 +0,0 @@ -# JVM 缓存可视化编辑设计 - -## 1. 背景 - -当前用户在公司 Java 项目中经常把缓存或运行时状态直接保存在 JVM 内存中。出现数据脏值、缓存穿透、临时纠偏或排障时,通常只有两种方式: - -- 为特定业务临时补管理接口 -- 重启应用并依赖重新初始化 - -这两种方式都存在明显问题: - -- 临时接口会污染业务代码,并带来后续维护和权限风险 -- 重启应用成本高,且不适合用于精确修复单个缓存项 - -GoNavi 现有已具备三类可复用基础: - -- 统一连接与工作台能力:`frontend/src/components/ConnectionModal.tsx`、`frontend/src/components/Sidebar.tsx`、`frontend/src/components/TabManager.tsx` -- 独立运行时能力样板:Redis 通过 `internal/app/methods_redis.go` 和专用前端视图实现,不依赖 SQL `Database` 抽象 -- AI 与日志能力底座:`frontend/src/components/AIChatPanel.tsx`、`frontend/src/components/QueryEditor.tsx`、`frontend/src/components/LogPanel.tsx` - -因此,GoNavi 有条件扩展出 JVM 运行时连接与受控编辑能力,但不能简单把该需求理解为“新数据库驱动”。 - -## 2. 目标 - -- 为 GoNavi 增加统一的 `JVM Connector` 子系统,用于连接和浏览 Java 服务的运行时缓存/管理对象 -- 在同一套 UI 下支持多种接入模式,并根据目标 JVM 能力自动协商或手动切换 -- 提供结构化的缓存浏览、值检查、受控修改、操作预览和审计记录 -- 允许 AI 参与解释、分析和生成修改计划,但不默认开放 AI 自动执行 -- 尽量避免强依赖 `-javaagent` 或运行时动态 attach,适配企业内对生产进程注入普遍敏感的环境 - -## 3. 非目标 - -- 不承诺“任意 JVM 内任意对象均可直接读写” -- 不在首期支持任意 Java 表达式执行、任意反射路径写值或任意 classloader 深度探测 -- 不把 JVM 功能强行塞进现有 SQL `Database` / driver-agent 抽象 -- 不承诺通过 Agent 模式支持所有缓存框架或任意深层对象写入 -- 不绕过目标服务现有认证、鉴权和网络边界 - -## 4. 需求与约束 - -### 4.1 需求清单 - -- 统一配置 JVM 连接 -- 探测当前 JVM 支持的接入模式与可用能力 -- 浏览缓存空间、管理对象和受控操作 -- 查看值快照与元数据 -- 执行受控修改,并提供 before/after 预览 -- 将操作结果写入审计记录 -- 支持 AI 对资源结构和修改方案进行分析 - -### 4.2 已确认约束 - -- 用户倾向通用型产品形态,但目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach -- 企业环境下,稳定性与安全性优先级高于“黑科技式通用能力” -- 一期应优先基于标准协议和业务可控接入面,而不是侵入式 runtime 操作 - -## 5. 现状分析 - -### 5.1 GoNavi 架构启示 - -- `internal/db/database.go` 面向标准化数据源 CRUD,适合 SQL 类资源 -- `internal/app/methods_redis.go` 证明 GoNavi 已支持“独立运行时系统能力线” -- `frontend/src/components/RedisViewer.tsx` 与 `frontend/src/components/RedisCommandEditor.tsx` 提供了树形浏览、结构化值编辑和控制台交互样板 -- `frontend/src/components/AIChatPanel.tsx` 与 `frontend/src/components/ai/AIMessageBubble.tsx` 已具备 AI 交互和危险执行确认能力 - -### 5.2 结论 - -JVM 缓存可视化编辑应当比照 Redis 独立建模,新增 `JVM Connector` 子系统,而不是复用 SQL `Database` 接口。 - -## 6. 方案比较 - -### 方案 A:单一路径通用 Agent - -- 描述:统一要求目标 JVM 通过 `-javaagent` 或运行时 attach 暴露运行时对象访问能力 -- 优点: - - 理论能力上限最高 - - 可覆盖更多自研缓存和深层对象 -- 缺点: - - 与已知企业约束直接冲突 - - 风险最高,部署与安全成本高 - - 与首期产品化目标不匹配 - -### 方案 B:多接入模式 + 能力协商 - -- 描述:统一做 `JVM Connector`,底层同时支持 `JMX`、`Management Endpoint`、`Agent` -- 优点: - - 产品形态统一 - - 能根据目标 JVM 能力降级 - - 可先做低风险路径,后续再扩展高级模式 -- 缺点: - - 不同模式能力不一致,UI 与权限模型更复杂 - -### 方案 C:只做业务侧管理端点 - -- 描述:完全放弃通用接入,只提供官方 Starter/管理端点接入 -- 优点: - - 结构最稳,AI 最容易接入 - - 权限、审计、预览、回滚最好做 -- 缺点: - - 不满足“尽量通用”的产品定位 - - 无法覆盖仅开放 JMX 的存量系统 - -## 7. 选型 - -采用方案 B。当前已落地: - -- `JMX Provider` -- `Management Endpoint Provider` -- `Agent Provider`(高级可选模式,要求目标 Java 服务显式预埋 GoNavi Java Agent) - -## 8. 目标架构 - -### 8.1 总体结构 - -新增统一的 `JVM Connector` 子系统,分为五层: - -- `Connection Layer` - - 新增 `jvm` 连接类型 - - 保存目标地址、认证、允许模式、首选模式、环境标签等配置 -- `Capability Layer` - - 建立连接后探测当前支持的 provider 与能力矩阵 -- `Provider Layer` - - `JMX Provider` - - `Management Endpoint Provider` - - `Agent Provider`(预留) -- `Resource Layer` - - 将不同来源统一映射为结构化资源 -- `Guard Layer` - - 统一负责预览、确认、审计、回读验证、错误归一化 - -### 8.2 设计原则 - -- UI 统一,协议多态 -- 读写分离,修改必须经过 Guard Layer -- provider 不得自行绕过权限与审计链路 -- 能力不足时显式降级,不提供“看似可用、实际不可执行”的假入口 - -## 9. Provider 设计 - -### 9.1 JMX Provider - -- 负责: - - 建立 JMX/RMI 连接 - - 发现 MBean - - 读取属性 - - 调用白名单操作 - - 写入允许修改的白名单属性 -- 适用场景: - - 目标 JVM 已开放 JMX - - 缓存或管理对象已暴露为 MBean -- 特点: - - 低侵入、标准化、可落地 - - key/value 级资源能力通常有限 - -### 9.2 Management Endpoint Provider - -- 负责: - - 调用业务服务暴露的 GoNavi 管理端点或 Starter - - 返回结构化缓存资源、元数据和受控动作 - - 提供修改预览与回滚信息 -- 适用场景: - - 业务方愿意接入轻量 Starter/管理端点 - - 需要更强的 key/value 级浏览与修改能力 -- 特点: - - 最适合产品化和 AI 协同 - - 权限、脱敏、审计、回滚最容易做 - -### 9.3 Agent Provider - -- 负责: - - 在特定环境下通过 GoNavi Java Agent 暴露受控管理端口 - - 提供比 JMX 更贴近缓存资源模型的结构化浏览、预览与写入能力 -- 定位: - - 高级模式 - - 不默认启用 - - 需要目标 Java 服务以 `-javaagent` 方式显式启动 - -## 10. 统一资源模型 - -建议统一抽象以下资源: - -- `runtime` - - 目标 JVM 实例 -- `cacheNamespace` - - 缓存空间,如某个 CacheManager 下的 cacheName -- `cacheEntry` - - 具体缓存项 key/value -- `managedBean` - - 可读写的托管对象或 MBean -- `operation` - - 受控操作,如 `evict`、`put`、`refresh`、`clear` -- `auditRecord` - - 每次读写与 AI 建议的审计记录 - -统一资源模型要求: - -- 每个资源都有稳定 ID、显示名、provider 来源、能力标签、敏感级别 -- 值快照必须区分原始值、展示值和可编辑值 -- 资源定位信息必须可写入审计 - -## 11. AI 协同设计 - -### 11.1 AI 的角色 - -AI 在 JVM 场景中只能作为“受控编排者”,不能作为直接执行者。 - -AI 可以: - -- 解释缓存/Bean 的结构和当前状态 -- 生成筛选条件和定位建议 -- 生成结构化修改计划 -- 生成风险说明和回滚建议 -- 对执行前后结果做对比分析 - -AI 不应默认做: - -- 直接执行 JVM 修改 -- 自由生成任意脚本并直写内存 -- 绕过人工确认直接调用 provider - -### 11.2 AI 输出形态 - -AI 不直接输出脚本,而输出结构化变更计划,例如: - -```json -{ - "targetType": "cacheEntry", - "selector": { - "namespace": "userSessionCache", - "key": "user:1001" - }, - "action": "updateValue", - "payload": { - "format": "json", - "value": { - "status": "ACTIVE" - } - }, - "reason": "修复错误缓存态" -} -``` - -### 11.3 AI 执行链路 - -1. AI 读取结构化上下文 -2. AI 产出结构化变更计划 -3. Guard Layer 校验目标资源、能力和权限 -4. UI 展示修改预览与风险提示 -5. 用户确认 -6. provider 执行 -7. 系统回读验证并写审计 - -### 11.4 一期 AI 边界 - -- 支持 AI 分析资源 -- 支持 AI 生成修改计划 -- 不默认支持 AI 自动执行修改 - -## 12. 页面与交互设计 - -### 12.1 连接层 - -在 `ConnectionModal` 中新增 `JVM` 类型,建议配置: - -- 连接名称 -- 目标地址/端口 -- 认证信息 -- 允许模式列表 -- 首选模式 -- 环境标签(DEV/UAT/PROD) -- 默认权限级别(只读/读写) - -### 12.2 侧边栏 - -展示结构: - -- 连接 -- 模式能力 -- 资源类型 -- `cacheNamespace` / `managedBean` / `operation` - -每个连接或节点显示能力徽标,例如: - -- `JMX` -- `Endpoint` -- `Agent` -- `只读` -- `可写` - -### 12.3 主工作区 Tab - -建议新增以下 Tab 类型: - -- `概览` -- `资源浏览` -- `值检查器` -- `修改预览` -- `AI 助手` -- `审计记录` - -### 12.4 标准操作流 - -1. 用户连接 JVM -2. 系统探测 provider 能力 -3. 用户选择资源并读取快照 -4. 用户手工修改或让 AI 生成计划 -5. 系统生成 before/after 预览 -6. 用户二次确认 -7. provider 执行 -8. 系统回读验证 -9. 写入审计与操作日志 - -## 13. 权限与审计 - -### 13.1 权限模型 - -权限建议分四层: - -- `连接级` - - 决定默认 `readonly` / `readwrite` -- `模式级` - - 决定某 provider 支持哪些动作 -- `资源级` - - 某些资源永远只读 -- `环境级` - - `PROD` 默认强制二次确认,禁用 AI 自动执行 - -### 13.2 审计要求 - -JVM 审计日志不应复用 SQL 日志数据结构,但可以复用现有 LogPanel 样式。 - -建议记录: - -- 连接 ID / 名称 -- provider 类型 -- 资源定位信息 -- 动作类型 -- 修改原因 -- AI 是否参与 -- 执行前摘要 -- 执行后摘要 -- 结果状态 -- 耗时 -- 错误信息 - -建议本地独立落盘为 `jvm_audit.jsonl` 或等价结构,不混入 `sqlLogs`。 - -## 14. 错误处理与兼容性边界 - -### 14.1 错误分层 - -- `连接层失败` - - 认证失败、证书失败、JMX/RMI 不通、端点 401/403 -- `能力层失败` - - 连接成功但不支持列 key、写值或批量操作 -- `执行层失败` - - 资源不存在、值格式非法、provider 拒绝写入 -- `验证层失败` - - 执行返回成功但回读校验不一致 - -所有错误都应显式标明是哪个 provider、哪一层失败,避免泛化为“修改失败”。 - -### 14.2 首期兼容性承诺 - -优先承诺以下边界: - -- Java 8 / 11 / 17 / 21 -- Spring Boot 服务优先 -- JMX 标准 MBean -- Management Endpoint 模式下优先支持: - - Caffeine - - Ehcache - - Guava Cache - - Spring Cache 抽象下可枚举缓存 - - 接入 GoNavi Starter 的自研缓存 -- 值类型首期优先: - - string - - number - - boolean - - JSON object / JSON array - - map / list 的结构化展示 - -### 14.3 首期不承诺 - -- 任意 Java 对象深度反射编辑 -- 无类型信息的二进制对象直接改写 -- 跨 classloader 任意对象定位 -- 生产环境默认开放批量危险写入 - -## 15. MVP 分期 - -### Phase 1:连接与只读探测 - -- JVM 连接类型 -- JMX / Endpoint 能力探测 -- 资源树浏览 -- 值查看 -- 概览页与能力徽标 -- 不开放写入 - -### Phase 2:受控修改与审计 - -- 白名单资源写入 -- before/after 预览 -- 二次确认 -- 审计日志 -- 回读验证 -- 环境级保护策略 - -### Phase 3:AI 协同 - -- AI 解释资源 -- AI 生成修改计划 -- AI 风险分析 -- AI 回滚建议 -- 仍默认不允许 AI 自动执行 - -### Phase 4:高级模式 - -- Agent Provider -- 预埋 Java Agent 的 runtime 资源治理能力 -- 仅在特殊环境启用 - -## 16. 验证策略 - -### 16.1 功能验证 - -- 能连接 JMX 目标 -- 能连接 Endpoint 目标 -- 能列出缓存空间 -- 能查看 key/value -- 能完成受控修改并回读成功 - -### 16.2 兼容性验证 - -- Java 8 / 11 / 17 / 21 -- 本地、容器、K8s 内网场景 -- 开启认证 / 不开启认证 -- 仅 JMX、仅 Endpoint、双模式并存 - -### 16.3 安全验证 - -- 只读连接无法写入 -- `PROD` 环境必须二次确认 -- AI 无法绕过人工确认直接执行 -- 审计日志完整记录修改链路 - -### 16.4 稳定性验证 - -- 目标 JVM 不可达时 UI 不假死 -- 资源树大数量时支持分页或懒加载 -- 回读失败时标识“不确定状态” -- provider 超时、部分失败、降级路径清晰 - -## 17. 风险与缓解 - -### 17.1 风险 - -- 多 provider 模式会带来能力不一致,用户可能误解“所有 JVM 都能随便改” -- JMX 模式的 key/value 级能力可能明显不足 -- 管理端点模式需要业务接入,推广成本高于纯客户端方案 -- 若未来引入 Agent 模式,可能引入新的安全审核和兼容性成本 - -### 17.2 缓解 - -- 在 UI 中显式展示能力矩阵和当前 provider 来源 -- 所有修改都强制经过预览、确认与审计 -- 首期将“通用”定义为“统一入口 + 多模式协商”,而不是“单通道万能能力” -- Agent 仅作为高级扩展位,避免污染 MVP 边界 - -## 18. 最终结论 - -JVM 缓存可视化编辑能力在 GoNavi 中具备落地基础,但必须采用“统一入口、多 provider、能力协商、强 Guard Layer”的产品化方案。 - -推荐结论如下: - -- 新增独立的 `JVM Connector` 子系统 -- 首期支持 `JMX + Management Endpoint` -- `Agent` 作为高级可选模式交付 -- AI 首期支持分析与生成修改计划,不默认开放自动执行 -- 所有修改必须经过预览、确认、审计和回读验证 - -这一路径能够在兼顾企业安全约束的前提下,为用户提供可持续演进的 JVM 运行时缓存治理能力。 diff --git a/docs/需求追踪/需求进度追踪-AI聊天发送快捷键-20260428.md b/docs/需求追踪/需求进度追踪-AI聊天发送快捷键-20260428.md deleted file mode 100644 index ead34b9..0000000 --- a/docs/需求追踪/需求进度追踪-AI聊天发送快捷键-20260428.md +++ /dev/null @@ -1,73 +0,0 @@ -# 需求进度追踪 - AI聊天发送快捷键 - -## 1. 需求摘要 -- 需求名称:AI 聊天发送快捷键 -- 提出日期:2026-04-28 -- 负责人:Claude Code -- 目标:将 AI 聊天发送快捷键纳入工具中心快捷键管理,支持录制自定义 Enter 相关组合键,降低输入法 Enter 上屏时误发送的风险。 -- 非目标:不调整后端 AI 服务配置,不改发送按钮行为,不把 AI 发送快捷键放在 AI 设置弹窗的独立入口。 - -## 2. 范围与验收 -- 范围:工具中心快捷键管理、AI 聊天输入框、本地前端偏好持久化。 -- 验收标准:工具中心出现“AI 聊天发送”快捷键;默认 Enter 发送;可录制 Enter / Cmd+Enter / Ctrl+Enter / Alt+Enter 等 Enter 相关组合;普通字符键不可录制为 AI 发送;Shift+Enter 始终换行;输入法 composing 状态不发送;刷新后快捷键保持;AI 设置弹窗不再出现独立“聊天输入”快捷键入口。 -- 依赖与约束:沿用 Zustand `lite-db-storage` 中的 `shortcutOptions` 持久化;保持现有 AI 后端接口不变。 - -## 3. 里程碑与进度 -- [x] 阶段 1(需求澄清):确认输入法 Enter 上屏导致误发送,需要支持录制自定义快捷键,并复用工具中心快捷键体系。 -- [x] 阶段 2(影响分析):影响工具中心快捷键配置、AIChatPanel、AIChatInput、store 和相关测试。 -- [x] 阶段 3(方案设计):采用共享 `shortcutOptions` action,AI 输入框局部消费,不走全局快捷键执行器。 -- [x] 阶段 4(实施计划):计划已按用户反馈调整为工具中心统一方案。 -- [x] 阶段 5(实现与自检):目标红灯测试已补充,新方案核心实现已完成。 -- [x] 阶段 6(评审与交付):已完成代码审查反馈修复、目标测试、全量测试、构建、diff 检查和浏览器手工验证。 -- [ ] 阶段 7(发布与观察):发布后观察用户输入法场景反馈。 - -## 4. 变更清单 -- 已完成:新增工具中心 AI 发送 action 目标测试;实现 Enter 默认快捷键、Enter 组合录制规则、AI 输入框按 `shortcutOptions` 判定发送;移除 AI 设置独立入口;修复刷新后录制值被启动配置刷新覆盖的问题;限制 AI 发送快捷键只能录制 0 或 1 个修饰键的 Enter 组合;消费 AI 发送快捷键后阻止事件继续冒泡;更新 store、工具函数和输入框提示测试。 -- 进行中:无。 -- 待处理:发布后观察输入法场景反馈。 - -## 5. 风险与阻塞 -- 风险:默认 Enter 发送在少数未标记 composing 的输入法中仍可能误发。 -- 阻塞:无。 -- 缓解措施:用户可在工具中心录制 Cmd+Enter / Ctrl+Enter / Alt+Enter,普通 Enter 不再触发发送;AI 发送录制限制为 Enter 相关组合并保留 Shift+Enter 换行;输入法 composing 状态始终不发送。 - -## 6. 决策记录 -- 决策 1:AI 发送快捷键作为工具中心快捷键 action 持久化,不写入后端 AI provider 配置。 -- 决策 2:`sendAIChatMessage` 仅由 AI 输入框处理,全局快捷键执行器跳过该局部 action。 -- 决策 3:AI 发送快捷键允许默认无修饰键 Enter,但录制时只接受 Enter 相关组合,拒绝普通字符键和含 Shift 的组合。 -- 决策 4:输入法 composing 状态始终不发送。 -- 决策 5:AI 发送快捷键仅允许 Enter / Ctrl+Enter / Cmd+Enter / Alt+Enter,拒绝 Ctrl+Alt+Enter 等多修饰键组合,避免扩大局部快捷键冲突面。 -- 决策 6:AI 输入框命中发送快捷键后同时执行 `preventDefault` 和 `stopPropagation`,避免事件继续冒泡到全局快捷键处理器。 - -## 7. 验证记录 -- 验证项:初版两档下拉方案红灯测试。 -- 结果:已确认旧实现失败。 -- 证据:`aiChatSendShortcut.test.ts` 缺模块失败;`store.test.ts` 新增字段缺失失败;`AIChatInput.notice.test.tsx` placeholder 仍为 Enter 失败。 -- 验证项:工具中心统一方案红灯测试。 -- 结果:已确认旧实现失败。 -- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts` 显示缺少 `sendAIChatMessage` action、`canRecordShortcutForAction` 和自定义 binding 判定失败;`src/store.test.ts` 显示 `shortcutOptions.sendAIChatMessage` 缺失;`src/components/ai/AIChatInput.notice.test.tsx` 显示 placeholder 未渲染 `Meta+Enter 发送`。 -- 验证项:工具中心统一方案目标绿灯测试。 -- 结果:已通过。 -- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts`(6 passed)、`src/components/ai/AIChatInput.notice.test.tsx`(2 passed)、`src/store.test.ts`(10 passed)。 -- 验证项:代码审查反馈红灯测试。 -- 结果:已确认旧实现失败。 -- 证据:多修饰键 Enter 组合被误放行、缺少 `consumeAIChatSendShortcutOnKeyDown`、脏持久化 `sendAIChatMessage: A` 未回退到 Enter。 -- 验证项:代码审查反馈修复后目标测试。 -- 结果:已通过。 -- 证据:`npm --prefix frontend test -- --run src/utils/aiChatSendShortcut.test.ts src/components/ai/AIChatInput.notice.test.tsx src/store.test.ts`(3 files passed,22 tests passed)。 -- 验证项:浏览器手工验证。 -- 结果:已通过。 -- 证据:工具中心录制 `Meta+Enter` 后刷新仍保持;AI 输入框 placeholder 显示 `输入消息... (Meta+Enter 发送,Shift+Enter 换行,/ 快捷命令)`;普通 Enter 和 Shift+Enter 不触发发送;Meta+Enter 触发发送、调用 `preventDefault` 且事件不冒泡。 -- 验证项:前端全量测试。 -- 结果:已通过。 -- 证据:`npm --prefix frontend test -- --run`(88 files passed,421 tests passed)。 -- 验证项:diff 空白检查。 -- 结果:已通过。 -- 证据:`git diff --check` 无输出。 -- 验证项:生产构建。 -- 结果:已通过。 -- 证据:`npm --prefix frontend run build` 通过,仅有既有 dynamic import / chunk size 警告。 - -## 8. 下一步 -- 下一步行动:提交并推送本次改动,发布后观察用户输入法场景反馈。 -- 负责人:Claude Code diff --git a/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md b/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md deleted file mode 100644 index 0aa46fc..0000000 --- a/docs/需求追踪/需求进度追踪-JVM缓存可视化编辑-20260422.md +++ /dev/null @@ -1,246 +0,0 @@ -# 需求进度追踪 - JVM缓存可视化编辑 - -## 1. 需求摘要 -- 需求名称:JVM缓存可视化编辑 -- 提出日期:2026-04-22 -- 负责人:Codex -- 目标:完成 GoNavi 连接 Java JVM、可视化查看并修改 JVM 内缓存/对象值的通用能力交付,降低“改缓存只能写接口或重启应用”的运维与排障成本 -- 非目标:不承诺覆盖所有 Java 框架/所有对象类型,不绕过目标应用现有安全控制,不在首期开放脚本式任意表达式执行 - -## 2. 范围与验收 -- 范围: - - 交付 JVM 共享契约、连接配置、provider 注册、连接测试与能力探测 - - 交付 Endpoint / JMX / Agent 三种接入模式及其资源浏览、读值、预览、执行链路 - - 交付 JVM 资源页、预览弹窗、审计查看、AI 草稿生成与回填能力 - - 交付 Guard、审计、来源标记、真实集成测试与构建验证 -- 验收标准: - - 可以在 GoNavi 中新增 JVM 连接并完成连接测试 - - 可以按资源树浏览 JVM 对象并查看结构化快照 - - 可以对支持写入的资源执行预览和确认写入,且带 Guard 与审计 - - 可以通过 AI 生成结构化修改草稿,但不会跳过人工确认直接执行 - - 可以通过真实 JMX 与真实 HTTP contract 完成端到端验证,并通过前后端构建回归 -- 依赖与约束: - - 需复用 GoNavi 当前 Wails + React + driver-agent 架构 - - 新能力不得破坏现有数据库/Redis 工作流 - - 高风险写操作必须具备明确鉴权、审计与回滚思路 - - JMX 模式要求 GoNavi 运行机器本地可用 `java` 可执行文件 - -## 3. 里程碑与进度 -- [x] 阶段 1(需求澄清):完成 -- [x] 阶段 2(影响分析):完成 -- [x] 阶段 3(方案设计):完成(已形成正式设计文档) -- [x] 阶段 4(实施计划):完成(已形成正式实施计划) -- [x] 阶段 5(实现与自检):完成(Task 1 至 Task 7 已完成,代码与构建回归通过) -- [x] 阶段 6(评审与交付):完成(已完成契约复核、上下文隔离修正、文档回填与交付检查) -- [ ] 阶段 7(发布与观察):未开始 - -## 4. 变更清单 -- 已完成: - - 确认 GoNavi 当前存在统一驱动接口与可选 driver-agent 机制 - - 确认前端已有 Redis 结构化浏览、命令编辑器、Monaco 编辑器、DataGrid 编辑能力可复用 - - 初步判断 JVM 运行时对象编辑不适合直接复用 SQL/Database 抽象,需新增非数据库协议层 - - 用户已确认目标方向为“通用型 JVM 接入” - - 用户已确认升级到完整模式,开始高风险架构评估 - - 用户明确目标 Java 服务大概率不允许 `-javaagent` 或运行时动态 attach - - 已形成 JVM 缓存可视化编辑正式设计文档 - - 已形成 JVM Connector MVP 正式实施计划文档 - - 已完成 Task 1:JVM 共享契约与配置归一化 - - 已完成 Task 2:Provider 注册、连接测试与能力探测 API - - 已完成 Task 3:JVM 连接表单、图标与展示文案接入 - - 已完成 Task 4:只读资源浏览与 JVM Tab - - 已完成 Task 5:写入预览、Guard 和审计记录 - - 已完成 Task 6:AI 结构化变更计划 - - 已完成 Task 7:全量回归、文档回填与交付检查 - - 已完成 JVM AI 计划解析、资源定位解析、AI 计划到当前 JVM 变更草稿的显式映射,避免把 `payload.format/value` 包装层直接透传到现有 JVM 写入契约 - - 已完成 AI 聊天面板 JVM 上下文注入、AI 气泡“应用到 JVM 预览”入口以及 JVM 资源页草稿回填闭环 - - 已完成 JVM AI 计划来源上下文绑定:消息现在绑定生成时的 `tabId + connectionId + providerMode + resourcePath`,避免切换 JVM 页签后误投递到当前激活页 - - 已完成 Endpoint provider 真实 HTTP contract 与补测,支持资源浏览、读值、预览和执行 - - 已完成可手工启动的 Java Endpoint fixture 与真实集成补测,可直接验证 Endpoint 模式端到端行为 - - 已完成 JMX provider 真实 helper 接入与补测,支持 `domain -> mbean -> attribute/operation` 浏览、attribute `set`、operation `invoke` - - 已完成 JMX helper 预编译 runtime jar 内嵌分发,运行时不再依赖仓库源码目录,也不再要求本地 `javac` - - 已完成 JVM 快照动作提示与 payload 模板回填,前端可直接根据 `supportedActions` 生成草稿 - - 已完成 AI 参与来源写入 JVM 审计记录,审计页可区分“手工”与“AI 辅助” - - 已完成 Agent provider、Agent 连接表单与概览展示,支持通过独立 Agent Base URL 接入 GoNavi Java Agent - - 已完成真实 Java Agent fixture 与集成验证,可通过 `-javaagent` 方式真实验证 Agent 模式资源浏览、预览与执行 - - 已完成 JVM 收口优化:Endpoint 能力探测遵循只读配置,概览页能力矩阵补齐模式能力探测与多行错误展示,能力探测失败与风险/结果状态文案统一收口为中文业务语义 -- 待处理: - - 无阻塞性交付项;后续仅保留复杂对象参数、`CompositeData` / `TabularData` 等高级类型写入扩展作为增强项 - -## 5. 风险与阻塞 -- 风险: - - 直接修改 JVM 内对象属于高风险运行时操作,误改可能造成业务状态污染 - - 不同缓存框架(Caffeine/Ehcache/Guava/自研 Map)缺少统一标准协议 - - 若依赖 attach agent 或表达式执行,需严格控制安全边界与可观测性 - - 若目标 JVM 不允许预埋或动态注入 Agent,则“通用型”能力边界会明显收缩 - - 多接入模式会带来能力不一致问题,UI 与权限模型必须显式展示“当前模式支持什么/不支持什么” - - 当前 AI 能力边界仍是“分析 + 生成结构化计划 + 回填预览草稿”,不直接执行 JVM 写入,真实执行仍取决于 Guard、人工确认和 provider 能力 - - 当前 AI 计划若只提供 `namespace + key`,仍更适合 endpoint/cache 风格资源;JMX 复杂 target 仍建议优先使用 `resourcePath` - - JMX helper 已改为内嵌 jar 分发,但操作者机器仍需本地存在可用 `java` - - Agent 模式要求目标 Java 服务显式以 `-javaagent` 方式启动 GoNavi Java Agent,并额外暴露管理端口 - - JMX operation preview 仅做参数/签名校验和预览快照,不预测真实副作用 - - JMX 参数转换当前覆盖基础类型、`ObjectName` 和部分数组;复杂对象写入仍是后续扩展项 - - 历史旧 AI 消息不包含 JVM 来源上下文,若需要应用到预览,需在目标 JVM 资源页重新生成计划 -- 阻塞: - - 当前开发收口阶段无新增阻塞 -- 缓解措施: - - 优先收敛到标准接入面(JMX / Spring Actuator / Java Agent 三选一) - - 首期只支持白名单对象类型与受控写操作 - - 要求变更审计、预览、确认与失败回滚路径 - - 在交付说明中明确“AI 只生成草稿,不直接执行 JVM 写入” - - JMX helper 改为内嵌 runtime jar,默认写入用户缓存目录;必要时允许通过 `GONAVI_JMX_HELPER_CLASSPATH` 覆盖 classpath - - 对复杂参数调用保持白名单和人工确认,不开放脚本式自由执行 - -## 6. 决策记录 -- 决策 1:先做可行性评估与方案设计,不直接进入实现 -- 决策 2:默认优先复用 GoNavi 现有 driver-agent 与前端编辑器能力,避免侵入式重构主流程 -- 决策 3:已按完整模式推进,后续方案将优先评估通用 Agent 路径是否成立 -- 决策 4:由于目标服务大概率不允许 agent/attach,后续推荐方向转为“多接入模式 + 能力协商” -- 决策 5:AI 在 JVM 场景中只负责分析与生成结构化计划,不直接执行运行时写入 -- 决策 6:AI 计划应用入口只回填 JVM 预览草稿,后续仍必须经过 `JVMPreviewChange`、Guard 校验和人工确认 -- 决策 7:当前 MVP 中 `updateValue` 会映射到现有 JVM 变更 contract 的 `put`,且 payload 仅接受 JSON 对象 -- 决策 8:JVM AI 计划必须绑定生成时的 JVM 上下文,只允许投递到匹配的 `tabId + connectionId + providerMode + resourcePath` -- 决策 9:JMX helper 采用 Java 8 兼容的预编译 runtime jar 内嵌分发,运行时只依赖本地 `java` -- 决策 10:Agent 模式按“预埋 GoNavi Java Agent + 独立 Agent Base URL 接入”落地,不在当前版本实现动态 attach - -## 7. 验证记录 -- 验证项: - - GoNavi 驱动代理机制核查 - - GoNavi 现有 Redis/编辑器/UI 复用能力核查 - - JVM Connector 正式设计文档自检 - - JVM Connector 实施计划文档自检 - - Task 1:JVM 共享契约与配置归一化 - - Task 2:Provider 注册、连接测试与能力探测 API - - Task 6:AI 计划解析、资源定位解析、契约映射与页签上下文隔离 - - Task 7:Java Endpoint fixture 真实集成验证 - - Task 7:JMX helper 内嵌分发与运行时缓存验证 - - Task 7:Agent provider 与真实 Java Agent 集成验证 - - Task 7:后端全量测试 - - Task 7:前端全量测试 - - Task 7:前端生产构建 - - Task 7:Wails 生产构建 -- 结果: - - 已确认存在可复用的连接桥接与编辑器基础设施 - - 已完成正式设计文档落盘与自检,未发现占位词和明显范围冲突 - - 已完成正式实施计划落盘与自检,已补齐共享 DTO、provider factory 和审计落盘等关键实现细节 - - 已完成 JVM 连接共享契约、默认只读/默认 JMX 归一化、前端配置收敛与补测 - - Task 1 已完成规格审查与代码质量审查,结论均通过 - - 已完成 JVM Provider 工厂、JMX/Endpoint provider 骨架、App 层连接测试与能力探测 API - - Task 2 已完成规格审查与代码质量审查,结论均通过 - - 已完成 JVM 连接类型卡片、最小表单字段、连接测试分发与展示文案接入 - - Task 3 已完成规格审查与代码质量审查;过程中修复了 JVM 标题文案偏差、模式选项暴露范围、编辑态模式静默降级和 endpoint timeout 失真问题 - - 已完成 JVM 只读资源浏览链路:后端新增 `JVMListResources` / `JVMGetValue`,前端新增 `jvm-overview` / `jvm-resource` tab 与侧边栏 JVM 模式/资源节点 - - Task 4 已完成规格复审;代码质量复审确认真实 provider 浏览能力仍为后续任务范围,另外已修正 JVM 资源 tab 同名问题 - - 已完成 Task 5:后端新增 `JVMPreviewChange` / `JVMApplyChange` / `JVMListAuditRecords`,补齐 Guard、审计 JSONL 落盘与审计读取能力 - - Task 5 已补齐只读拦截、`prod` 环境确认、provider preview 错误透出、审计写入失败显式回传、连接 `allowedModes` 约束和局部快照合并保底 - - 前端已完成 JVM 变更草稿区、预览弹窗、执行确认、审计记录页签与按 provider mode 的审计过滤 - - 已完成 Task 6:AI 计划解析、资源定位解析、`updateValue -> put` 显式映射、JSON 对象 payload 约束和上下文绑定单测 - - 已完成 Task 6:AI 聊天消息与 JVM 来源页签绑定,AI 气泡应用按钮不再依赖点击时的 `activeTabId`,避免跨 JVM 页签误投递 - - 已完成 Task 7:Java Endpoint fixture,可真实验证 `resources / value / preview / apply` 四个 endpoint contract - - `go test ./internal/jvm -run 'TestHTTPProvider' -count=1` 通过 - - 已完成 Task 7:JMX helper 改为预编译 jar 内嵌分发,并补齐 classpath 覆盖与缓存落盘单测 - - `go test ./internal/jvm -run 'TestEnsureJMXHelperRuntime|TestJMXProvider' -count=1` 通过 - - 已完成 Task 7:Agent provider、Java agent fixture 与真实 `-javaagent` 集成测试 - - `go test ./internal/jvm -run 'TestAgentProvider' -count=1` 通过 - - `cd frontend && npm test -- --run src/utils/jvmAiPlan.test.ts` 通过(11 tests) - - `go test ./... -count=1` 通过 - - `cd frontend && npm test -- --run` 通过(61 files,259 tests) - - `cd frontend && npm run build` 通过;构建中存在既有 chunk size / dynamic import 警告,但未阻塞产物生成 - - `wails build -clean` 通过,成功生成 macOS 应用包 - - 已完成 JVM 收口优化:模式能力探测现在按当前 mode 做业务化错误翻译,避免概览页继续回显 `non-JRMP server`、`baseURL is required` 这类原始报错 - - `go test ./internal/jvm -run 'TestHTTPProvider' -count=1` 再次通过(Endpoint 能力探测只读语义回归) - - `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1` 再次通过(能力探测模式透传与中文错误翻译回归) - - `cd frontend && npm test -- --run src/components/JVMResourceBrowser.layout.test.tsx` 通过(JVM 资源页布局回归) - - `cd frontend && npm test -- --run src/utils/jvmResourcePresentation.test.ts` 通过(风险等级、审计结果等本地化展示回归) - - `cd frontend && npm run build` 再次通过 - - `wails build -clean` 再次通过,成功生成最新可验收桌面包 -- 证据(日志/截图/链接): - - `cmd/optional-driver-agent/main.go` - - `internal/db/database.go` - - `frontend/src/components/RedisViewer.tsx` - - `frontend/src/components/RedisCommandEditor.tsx` - - `frontend/src/components/QueryEditor.tsx` - - `docs/superpowers/specs/2026-04-22-jvm-cache-visual-editing-design.md` - - `docs/superpowers/plans/2026-04-22-jvm-connector-mvp.md` - - `internal/connection/types.go` - - `internal/jvm/types.go` - - `internal/jvm/config.go` - - `internal/jvm/config_test.go` - - `frontend/src/types.ts` - - `frontend/src/utils/jvmConnectionConfig.ts` - - `frontend/src/utils/jvmConnectionConfig.test.ts` - - `go test ./internal/jvm -count=1` - - `go test ./...` - - `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts` - - `cd frontend && npm test -- --run` - - `cd frontend && npm run build` - - `internal/jvm/provider.go` - - `internal/jvm/jmx_provider.go` - - `internal/jvm/http_provider.go` - - `internal/jvm/http_provider_test.go` - - `internal/jvm/jmx_helper.go` - - `internal/jvm/jmx_helper_test.go` - - `internal/jvm/provider_contract_test.go` - - `internal/jvm/jmxhelper_assets/jmx-helper-runtime.jar` - - `internal/jvm/jmxhelper_assets/README.md` - - `internal/jvm/testdata/endpointfixture/src/com/gonavi/fixture/EndpointTestServer.java` - - `internal/jvm/testdata/endpointfixture/src/com/gonavi/fixture/MiniJson.java` - - `tools/jmx-helper/src/com/gonavi/jmxhelper/JmxHelperMain.java` - - `internal/app/methods_jvm.go` - - `internal/app/methods_jvm_test.go` - - `frontend/wailsjs/go/app/App.d.ts` - - `frontend/wailsjs/go/app/App.js` - - `frontend/wailsjs/go/models.ts` - - `go test ./internal/app -run 'Test(TestJVMConnection|JVMProbeCapabilities)' -count=1` - - `go test ./internal/jvm ./internal/app -count=1` - - `wails build -clean` - - `frontend/src/components/DatabaseIcons.tsx` - - `frontend/src/components/ConnectionModal.tsx` - - `frontend/src/utils/jvmRuntimePresentation.ts` - - `frontend/src/utils/jvmRuntimePresentation.test.ts` - - `frontend/src/utils/jvmConnectionConfig.ts` - - `frontend/src/utils/jvmConnectionConfig.test.ts` - - `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts` - - `cd frontend && npm test -- src/utils/jvmConnectionConfig.test.ts` - - `cd frontend && npm run build` - - `internal/app/methods_jvm.go` - - `internal/app/methods_jvm_test.go` - - `frontend/src/components/Sidebar.tsx` - - `frontend/src/components/TabManager.tsx` - - `frontend/src/components/JVMOverview.tsx` - - `frontend/src/components/JVMResourceBrowser.tsx` - - `frontend/src/components/jvm/JVMModeBadge.tsx` - - `frontend/src/store.ts` - - `frontend/src/types.ts` - - `go test ./internal/app -run 'TestJVM(ListResources|GetValue)' -count=1` - - `go test ./internal/app -run 'TestJVMProbeCapabilities|TestTestJVMConnection' -count=1` - - `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts` - - `cd frontend && npm run build` - - `internal/jvm/guard.go` - - `internal/jvm/guard_test.go` - - `internal/jvm/audit_store.go` - - `internal/jvm/audit_store_test.go` - - `internal/app/methods_jvm.go` - - `internal/app/methods_jvm_test.go` - - `frontend/src/components/JVMAuditViewer.tsx` - - `frontend/src/components/jvm/JVMChangePreviewModal.tsx` - - `go test ./internal/jvm ./internal/app -run 'TestPreviewChangeBlocksReadOnlyConnection|TestPreviewChangeReturnsProviderPreviewErrorWhenWriteAllowed|TestPreviewChangeMarksProdWritesAsConfirmationRequired|TestPreviewChangeMergesProviderSnapshotsWithoutDroppingDefaults|TestJVMApplyChangeReturnsProviderPayload|TestJVMPreviewChangeRejectsModeOutsideAllowedModes|TestJVMListAuditRecordsReturnsLatestRecords|TestJVMApplyChangeSurfacesAuditWriteFailure' -count=1` - - `go test ./internal/jvm ./internal/app -count=1` - - `cd frontend && npm run build` - - `frontend/src/utils/jvmAiPlan.ts` - - `frontend/src/utils/jvmAiPlan.test.ts` - - `frontend/src/components/AIChatPanel.tsx` - - `frontend/src/components/ai/AIMessageBubble.tsx` - - `frontend/src/components/JVMResourceBrowser.tsx` - - `frontend/src/types.ts` - - `cd frontend && npm test -- --run src/utils/jvmAiPlan.test.ts` - - `go test ./... -count=1` - - `go test ./internal/jvm -run 'TestHTTPProvider' -count=1` - - `go test ./internal/jvm -run 'TestEnsureJMXHelperRuntime|TestJMXProvider' -count=1` - - `cd frontend && npm test -- --run src/components/JVMResourceBrowser.layout.test.tsx` - - `cd frontend && npm test -- --run src/utils/jvmResourcePresentation.test.ts` - - `cd frontend && npm test -- --run` - - `wails build -clean` - -## 8. 下一步 -- 下一步行动:由用户按真实 JVM / endpoint 场景执行验收验证;若验收通过,再决定是否提交、推送或继续扩展高级类型写入 -- 负责人:Codex diff --git a/docs/需求追踪/需求进度追踪-SQL方言适配-20260426.md b/docs/需求追踪/需求进度追踪-SQL方言适配-20260426.md deleted file mode 100644 index 404541b..0000000 --- a/docs/需求追踪/需求进度追踪-SQL方言适配-20260426.md +++ /dev/null @@ -1,24 +0,0 @@ -# SQL 方言适配需求进度追踪 - -## 背景 - -- Oracle 等非 MySQL 数据源在表设计 DDL 预览中可能回落到 MySQL 语法,导致修改字段名、字段属性等操作执行失败。 -- GitHub 相关问题:Refs #402(金仓字段类型/DDL 方言)、Refs #409(Oracle 删除数据 DATE 字面量)。 - -## 范围 - -- 表设计 ALTER TABLE 预览:按 MySQL-family、PostgreSQL-family、Oracle/Dameng、SQL Server、SQLite、DuckDB、ClickHouse、TDengine 分支生成。 -- 新建表 DDL 预览:避免 Oracle/Dameng/SQL Server/SQLite/DuckDB/ClickHouse/TDengine 输出 MySQL 表选项。 -- SQL 自动补全:按当前连接方言解析关键字和函数,避免 Oracle/SQL Server 出现 MySQL-only 提示。 -- 表设计字段类型:按数据源给出候选类型,不再大量回退到 MySQL 通用类型。 -- Oracle/Dameng 数据复制/删除 SQL:DATE/TIMESTAMP 字段使用 Oracle 时间构造函数。 - -## 验证 - -- `npm test -- tableDesignerSchemaSql.test.ts sqlDialect.test.ts dataGridCopyInsert.test.ts` -- `npm run build` - -## 风险与后续 - -- ClickHouse/TDengine 的字段约束、默认值、备注语法差异较大,当前策略是生成有限原生 ALTER,并用中文注释阻止 MySQL 专属子句外溢。 -- SQL Server 删除旧主键约束需要真实约束名,当前预览会提示先在索引页确认。 diff --git a/docs/需求追踪/需求进度追踪-发布脚本测试版号与Mac打包无交互-20260424.md b/docs/需求追踪/需求进度追踪-发布脚本测试版号与Mac打包无交互-20260424.md deleted file mode 100644 index 51d555c..0000000 --- a/docs/需求追踪/需求进度追踪-发布脚本测试版号与Mac打包无交互-20260424.md +++ /dev/null @@ -1,71 +0,0 @@ -# 需求进度追踪 - 发布脚本测试版号与 Mac 打包无交互 - -## 1. 需求摘要 -- 需求名称:发布脚本测试版号与 Mac 打包无交互 -- 提出日期:2026-04-24 -- 负责人:Codex -- 目标: - - `build-release.sh` 不再触发 macOS DMG/Finder 排版交互。 - - `build-release.sh` 与开发态应用内版本号统一使用测试版号来源。 -- 非目标: - - 不调整 GitHub Release 工作流。 - - 不修改正式发布 tag 版本策略。 - -## 2. 范围与验收 -- 范围: - - 发布脚本 `build-release.sh` - - 版本解析逻辑 `internal/app/version.go` - - 共享测试版号文件 -- 验收标准: - - `bash build-release.sh` 的 macOS 打包不再调用 `create-dmg` 或触发 Finder 排版。 - - 本地开发态版本显示与发布脚本默认版本号一致。 - - 保留环境变量覆盖版本号能力。 -- 依赖与约束: - - 维持现有 Windows/Linux 构建逻辑不变。 - -## 3. 里程碑与进度 -- [x] 阶段 1(需求澄清):确认去掉 DMG 排版,统一测试版号来源 -- [x] 阶段 2(影响分析):锁定 `build-release.sh` 与 `internal/app/version.go` -- [x] 阶段 3(方案设计):共享 `version/dev-version.txt`,macOS 改 ZIP 打包 -- [x] 阶段 4(实施计划):先补版本回归测试,再改实现 -- [ ] 阶段 5(实现与自检): -- [ ] 阶段 6(评审与交付): -- [ ] 阶段 7(发布与观察): - -## 4. 变更清单 -- 已完成: - - 新增共享测试版号文件。 - - 新增版本回归测试。 - - 改造发布脚本 macOS 打包为无交互 ZIP。 -- 进行中: - - 自检验证。 -- 待处理: - - 无。 - -## 5. 风险与阻塞 -- 风险: - - 正式发版若未覆盖 `GONAVI_VERSION`,默认会使用测试版号。 -- 阻塞: - - 无。 -- 缓解措施: - - 允许通过 `GONAVI_VERSION` 环境变量显式覆盖。 - -## 6. 决策记录 -- 决策 1:以 `version/dev-version.txt` 作为本地开发/测试共享版本号来源。 -- 决策 2:发布脚本的 macOS 产物改为 ZIP,避免 `create-dmg` 的 Finder 交互。 - -## 7. 验证记录 -- 验证项: - - 版本回归测试 - - 发布脚本语法检查 - - 发布脚本运行输出 -- 结果: - - 进行中 -- 证据(日志/截图/链接): - - 待补充 - -## 8. 下一步 -- 下一步行动: - - 跑通回归测试和脚本验证,确认输出产物与版本号 -- 负责人: - - Codex diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index bed8925..7396e24 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -0295a42fd931778d85157816d79d29e5 \ No newline at end of file +d0464f9da25e9356e61652e638c99ffe \ No newline at end of file diff --git a/frontend/src/components/DataGrid.layout.test.tsx b/frontend/src/components/DataGrid.layout.test.tsx index 64051b3..c033310 100644 --- a/frontend/src/components/DataGrid.layout.test.tsx +++ b/frontend/src/components/DataGrid.layout.test.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it, vi } from 'vitest'; -import DataGrid from './DataGrid'; +import DataGrid, { formatCellDisplayText } from './DataGrid'; vi.mock('../store', () => ({ useStore: (selector: (state: any) => any) => selector({ @@ -83,6 +83,10 @@ describe('DataGrid layout', () => { expect(markup).toContain('当前页查找...'); }); + it('preserves fractional seconds when rendering datetime values', () => { + expect(formatCellDisplayText('2026-05-10T09:12:33.456+08:00')).toBe('2026-05-10 09:12:33.456'); + }); + it('renders a DDL action for table data pages only', () => { const tableMarkup = renderToStaticMarkup( { ); }; -// Normalize common datetime strings to `YYYY-MM-DD HH:mm:ss` for display/editing. +// Normalize common datetime strings to `YYYY-MM-DD HH:mm:ss[.fraction]` for display/editing. // Handles RFC3339 and Go-style datetime text like `2024-05-13 08:32:47 +0800 CST`. // Also keep invalid datetime values like `0000-00-00 00:00:00` unchanged. const normalizeDateTimeString = (val: string) => { @@ -200,16 +205,16 @@ const normalizeDateTimeString = (val: string) => { } const match = val.match( - /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/ + /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/ ); - const normalized = match ? `${match[1]} ${match[2]}` : val; + const normalized = match ? `${match[1]} ${match[2]}${match[3] || ''}` : val; trimSimpleCache(normalizedDateTimeCache, DATE_TIME_CACHE_LIMIT); normalizedDateTimeCache.set(val, normalized); return normalized; }; // --- Helper: Format Value --- -const formatCellDisplayText = (val: any): string => { +export const formatCellDisplayText = (val: any): string => { try { if (val === null) return 'NULL'; if (typeof val === 'object') { @@ -1079,7 +1084,7 @@ const CELL_ELLIPSIS_STYLE: React.CSSProperties = { overflow: 'hidden', textOverf const VIRTUAL_CELL_WRAPPER_STYLE: React.CSSProperties = { margin: -8, padding: '8px 8px 8px 8px' }; const DataGrid: React.FC = ({ - data, columnNames, loading, tableName, exportScope = 'table', resultSql, dbName, connectionId, pkColumns = [], editLocator, readOnly = false, + data, columnNames, loading, tableName, exportScope = 'table', dbName, connectionId, pkColumns = [], editLocator, readOnly = false, onReload, onSort, onPageChange, pagination, onRequestTotalCount, onCancelTotalCount, sortInfoExternal, showFilter, onToggleFilter, exportSqlWithFilter, onApplyFilter, appliedFilterConditions, quickWhereCondition, onApplyQuickWhereCondition, scrollSnapshot, onScrollSnapshotChange @@ -1206,6 +1211,14 @@ const DataGrid: React.FC = ({ setDisplayColumnNames(allOrderedColumnNames.filter(col => !hiddenSet.has(col))); }, [allOrderedColumnNames, localHiddenColumns]); + const displayOutputColumnNames = useMemo( + () => resolveDataGridOutputColumnNames( + displayColumnNames.length > 0 || allOrderedColumnNames.length > 0 ? displayColumnNames : visibleColumnNames, + GONAVI_ROW_KEY, + ), + [displayColumnNames, allOrderedColumnNames, visibleColumnNames] + ); + // Handle Dragging const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), @@ -1510,15 +1523,9 @@ const DataGrid: React.FC = ({ const exportData = async (rows: any[], format: string) => { const hide = message.loading(`正在导出 ${rows.length} 条数据...`, 0); try { - const cleanRows = rows.map((row) => { - const next: Record = {}; - displayColumnNames.forEach((columnName) => { - next[columnName] = row?.[columnName]; - }); - return next; - }); + const cleanRows = pickDataGridOutputRows(rows, displayOutputColumnNames); // Pass tableName (or 'export') as default filename - const res = await ExportData(cleanRows, displayColumnNames, tableName || 'export', format); + const res = await ExportData(cleanRows, displayOutputColumnNames, tableName || 'export', format); if (res.success) { void message.success("导出成功"); } else if (res.message !== "已取消") { @@ -3435,26 +3442,15 @@ const DataGrid: React.FC = ({ const jsonViewText = useMemo(() => { if (viewMode !== 'json') return ''; - const cleanRows = mergedDisplayData.map((row) => { - const next: Record = {}; - visibleColumnNames.forEach((columnName) => { - next[columnName] = row?.[columnName]; - }); - return normalizeValueForJsonView(next); - }); + const cleanRows = pickDataGridOutputRows(mergedDisplayData, displayOutputColumnNames) + .map((row) => normalizeValueForJsonView(row)); return JSON.stringify(cleanRows, null, 2); - }, [viewMode, mergedDisplayData, visibleColumnNames]); + }, [viewMode, mergedDisplayData, displayOutputColumnNames]); const textViewRows = useMemo(() => { if (viewMode !== 'text') return []; - return mergedDisplayData.map((row) => { - const next: Record = {}; - visibleColumnNames.forEach((columnName) => { - next[columnName] = row?.[columnName]; - }); - return next; - }); - }, [viewMode, mergedDisplayData, visibleColumnNames]); + return pickDataGridOutputRows(mergedDisplayData, displayOutputColumnNames); + }, [viewMode, mergedDisplayData, displayOutputColumnNames]); const currentTextRow = useMemo(() => { if (viewMode !== 'text') return null; @@ -3919,7 +3915,7 @@ const DataGrid: React.FC = ({ const copiedRows = buildCopiedRowsForPaste({ rows: mergedDisplayData as Array>, selectedRowKeys, - columnNames: visibleColumnNames, + columnNames: displayOutputColumnNames, rowKeyField: GONAVI_ROW_KEY, rowKeyToString: rowKeyStr, }); @@ -3930,7 +3926,7 @@ const DataGrid: React.FC = ({ setCopiedRowsForPaste(copiedRows); void message.success(`已复制 ${copiedRows.length} 行,可粘贴为新增行`); - }, [selectedRowKeys, mergedDisplayData, visibleColumnNames, rowKeyStr]); + }, [selectedRowKeys, mergedDisplayData, displayOutputColumnNames, rowKeyStr]); const handlePasteCopiedRowsAsNew = useCallback(() => { if (copiedRowsForPaste.length === 0) { @@ -3940,7 +3936,7 @@ const DataGrid: React.FC = ({ const nextRows = buildPastedRowsFromCopiedRows({ rows: copiedRowsForPaste, - columnNames: visibleColumnNames, + columnNames: displayOutputColumnNames, rowKeyField: GONAVI_ROW_KEY, createRowKey: (index) => { pastedRowSequenceRef.current += 1; @@ -3956,7 +3952,7 @@ const DataGrid: React.FC = ({ setAddedRows(prev => [...prev, ...nextRows]); setSelectedRowKeys(nextRows.map(row => row[GONAVI_ROW_KEY])); void message.success(`已粘贴 ${nextRows.length} 行为新增行,请检查后提交事务`); - }, [copiedRowsForPaste, visibleColumnNames]); + }, [copiedRowsForPaste, displayOutputColumnNames]); const handleDeleteSelected = () => { setDeletedRowKeys(prev => { @@ -4050,16 +4046,16 @@ const DataGrid: React.FC = ({ pickRowsForClipboard({ rows: mergedDisplayData as Array>, selectedRowKeys, - columnNames: visibleColumnNames, + columnNames: displayOutputColumnNames, rowKeyField: GONAVI_ROW_KEY, rowKeyToString: rowKeyStr, }) - ), [mergedDisplayData, selectedRowKeys, visibleColumnNames, rowKeyStr]); + ), [mergedDisplayData, selectedRowKeys, displayOutputColumnNames, rowKeyStr]); const getClipboardColumnNames = useCallback((rows: Array>) => { if (rows.length === 0) return []; - return visibleColumnNames.filter((columnName) => columnName !== GONAVI_ROW_KEY); - }, [visibleColumnNames]); + return displayOutputColumnNames; + }, [displayOutputColumnNames]); const handleCopyQueryResultCsv = useCallback(() => { const rows = getClipboardRows(); @@ -4199,7 +4195,7 @@ const DataGrid: React.FC = ({ return null; } const records = getTargets(record); - const orderedCols = visibleColumnNames.filter(c => c !== GONAVI_ROW_KEY); + const orderedCols = displayOutputColumnNames; if (mode === 'insert') { return records.map((row: any) => buildCopyInsertSQL({ dbType, @@ -4248,7 +4244,7 @@ const DataGrid: React.FC = ({ }, [ supportsCopyInsert, getTargets, - visibleColumnNames, + displayOutputColumnNames, dbType, tableName, columnTypeMapByLowerName, @@ -4277,19 +4273,13 @@ const DataGrid: React.FC = ({ const handleCopyJson = useCallback((record: any) => { const records = getTargets(record); - const cleanRecords = records.map((r: any) => { - const next: Record = {}; - visibleColumnNames.forEach((columnName) => { - next[columnName] = r?.[columnName]; - }); - return next; - }); + const cleanRecords = pickDataGridOutputRows(records, displayOutputColumnNames); copyToClipboard(JSON.stringify(cleanRecords, null, 2)); - }, [getTargets, visibleColumnNames, copyToClipboard]); + }, [getTargets, displayOutputColumnNames, copyToClipboard]); const handleCopyCsv = useCallback((record: any) => { const records = getTargets(record); - const orderedCols = visibleColumnNames.filter(c => c !== GONAVI_ROW_KEY); + const orderedCols = displayOutputColumnNames; const header = orderedCols.map(c => `"${c}"`).join(','); const lines = records.map((r: any) => { const values = orderedCols.map(c => { @@ -4302,7 +4292,7 @@ const DataGrid: React.FC = ({ return values.join(','); }); copyToClipboard([header, ...lines].join('\n')); - }, [getTargets, visibleColumnNames, copyToClipboard]); + }, [getTargets, displayOutputColumnNames, copyToClipboard]); const buildConnConfig = useCallback(() => { if (!connectionId) return null; @@ -4362,7 +4352,12 @@ const DataGrid: React.FC = ({ if (!tableName || !pagination) return ''; const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, quickWhereCondition); const whereSQL = buildWhereSQL(dbType, effectiveFilterConditions); - const baseSql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} ${whereSQL}`; + const baseSql = buildDataGridSelectBaseSql({ + dbType, + tableName, + columnNames: displayOutputColumnNames, + whereSql: whereSQL, + }); const orderBySQL = buildOrderBySQL(dbType, sortInfo, pkColumns); const normalizedType = String(dbType || '').trim().toLowerCase(); const hasSortForBuffer = hasExplicitSort(sortInfo); @@ -4372,7 +4367,36 @@ const DataGrid: React.FC = ({ sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024); } return sql; - }, [tableName, pagination, filterConditions, quickWhereCondition, sortInfo, pkColumns]); + }, [tableName, pagination, filterConditions, quickWhereCondition, sortInfo, pkColumns, displayOutputColumnNames]); + + const buildAllRowsSql = useCallback((dbType: string) => { + if (!tableName) return ''; + return buildDataGridSelectBaseSql({ + dbType, + tableName, + columnNames: displayOutputColumnNames, + }); + }, [tableName, displayOutputColumnNames]); + + const buildFilteredAllSql = useCallback((dbType: string) => { + if (!tableName) return ''; + const effectiveFilterConditions = buildEffectiveFilterConditions(filterConditions, quickWhereCondition); + const whereSQL = buildWhereSQL(dbType, effectiveFilterConditions); + if (!whereSQL) return ''; + let sql = buildDataGridSelectBaseSql({ + dbType, + tableName, + columnNames: displayOutputColumnNames, + whereSql: whereSQL, + }); + sql += buildOrderBySQL(dbType, sortInfo, pkColumns); + const normalizedType = String(dbType || '').trim().toLowerCase(); + const hasSortForBuffer = hasExplicitSort(sortInfo); + if (hasSortForBuffer && (normalizedType === 'mysql' || normalizedType === 'mariadb')) { + sql = withSortBufferTuningSQL(normalizedType, sql, 32 * 1024 * 1024); + } + return sql; + }, [tableName, filterConditions, quickWhereCondition, sortInfo, pkColumns, displayOutputColumnNames]); // Context Menu Export const handleExportSelected = useCallback(async (format: string, record: any) => { @@ -4406,9 +4430,14 @@ const DataGrid: React.FC = ({ return; } - const sql = `SELECT * FROM ${quoteQualifiedIdent(dbType, tableName)} WHERE ${pkWhere}`; + const sql = buildDataGridSelectBaseSql({ + dbType, + tableName, + columnNames: displayOutputColumnNames, + whereSql: `WHERE ${pkWhere}`, + }); await exportByQuery(sql, format, tableName || 'export'); - }, [getTargets, isQueryResultExport, connectionId, tableName, hasChanges, exportData, buildConnConfig, buildPkWhereSql, exportByQuery]); + }, [getTargets, isQueryResultExport, connectionId, tableName, hasChanges, exportData, buildConnConfig, buildPkWhereSql, exportByQuery, displayOutputColumnNames]); // Export const handleExport = async (format: string) => { @@ -4423,12 +4452,7 @@ const DataGrid: React.FC = ({ // 查询结果页导出统一按当前结果集(已加载数据)导出,避免再次执行原 SQL 造成大数据导出或长时间阻塞。 if (isQueryResultExport) { - const sql = String(resultSql || '').trim(); - if (!hasChanges && supportsSqlQueryExport && sql) { - await exportByQuery(sql, format, tableName || 'query_result'); - } else { - await exportData(mergedDisplayData, format); - } + await exportData(mergedDisplayData, format); return; } @@ -4440,19 +4464,9 @@ const DataGrid: React.FC = ({ if (!tableName) return; const config = buildConnConfig(); if (!config) return; - const hide = message.loading(`正在导出全部数据...`, 0); - try { - const res = await ExportTable(buildRpcConnectionConfig(config) as any, dbName || '', tableName, format); - if (res.success) { - void message.success("导出成功"); - } else if (res.message !== "已取消") { - void message.error("导出失败: " + res.message); - } - } catch (e: any) { - void message.error("导出失败: " + (e?.message || String(e))); - } finally { - hide(); - } + const sql = buildAllRowsSql(resolveDataSourceType(config)); + if (!sql) return; + await exportByQuery(sql, format, tableName || 'export'); }; const handlePage = async () => { instance.destroy(); @@ -4505,11 +4519,18 @@ const DataGrid: React.FC = ({ void message.error('当前数据源不支持按筛选结果导出'); return; } + const config = buildConnConfig(); + if (!config) return; if (hasChanges) { void message.warning("当前存在未提交修改,筛选结果导出基于数据库已提交数据。"); } - await exportByQuery(filteredExportSql, format, `${tableName || 'export'}_filtered`); + const sql = buildFilteredAllSql(resolveDataSourceType(config)); + if (!sql) { + void message.warning('当前未应用筛选条件'); + return; + } + await exportByQuery(sql, format, `${tableName || 'export'}_filtered`); }; const handleImport = async () => { @@ -4655,7 +4676,7 @@ const DataGrid: React.FC = ({ { key: 'json', label: 'JSON', onClick: handleCopyQueryResultJson }, { key: 'markdown', label: 'Markdown', onClick: handleCopyQueryResultMarkdown }, ]; - const canCopyQueryResult = isQueryResultExport && mergedDisplayData.length > 0 && visibleColumnNames.length > 0; + const canCopyQueryResult = isQueryResultExport && mergedDisplayData.length > 0 && displayOutputColumnNames.length > 0; const columnInfoSettingContent = (
@@ -6139,7 +6160,7 @@ const DataGrid: React.FC = ({ )}
- {currentTextRow ? displayColumnNames.map((col) => ( + {currentTextRow ? displayOutputColumnNames.map((col) => (
{col} : diff --git a/frontend/src/components/dataGridClipboardExport.test.ts b/frontend/src/components/dataGridClipboardExport.test.ts index 86cb080..216ed2e 100644 --- a/frontend/src/components/dataGridClipboardExport.test.ts +++ b/frontend/src/components/dataGridClipboardExport.test.ts @@ -37,4 +37,19 @@ describe('dataGridClipboardExport', () => { expect(rows).toEqual([{ total: 2 }]); }); + + it('keeps copied row fields in the provided display column order', () => { + const rows = pickRowsForClipboard({ + rows: [ + { __gonavi_row_key__: 'row-1', id: 1, name: 'alpha', hidden_note: 'A' }, + ], + selectedRowKeys: [], + columnNames: ['name', 'id'], + rowKeyField: '__gonavi_row_key__', + }); + + expect(Object.keys(rows[0])).toEqual(['name', 'id']); + expect(buildClipboardCsv(rows, ['name', 'id'])).toBe('"name","id"\n"alpha","1"'); + expect(buildClipboardJson(rows)).toBe('[\n {\n "name": "alpha",\n "id": 1\n }\n]'); + }); }); diff --git a/frontend/src/components/dataGridCopyInsert.test.ts b/frontend/src/components/dataGridCopyInsert.test.ts index 0c5e44b..534b8dc 100644 --- a/frontend/src/components/dataGridCopyInsert.test.ts +++ b/frontend/src/components/dataGridCopyInsert.test.ts @@ -46,6 +46,38 @@ describe('buildCopyInsertSQL', () => { ); }); + it('preserves fractional seconds for MySQL datetime precision columns', () => { + const sql = buildCopyInsertSQL({ + dbType: 'mysql', + tableName: 'events', + orderedCols: ['created_at'], + record: { + created_at: '2026-05-10T09:12:33.456+08:00', + }, + columnTypesByLowerName: { + created_at: 'datetime(3)', + }, + }); + + expect(sql).toBe( + "INSERT INTO `events` (`created_at`) VALUES ('2026-05-10 09:12:33.456');", + ); + }); + + it('uses ordered columns for copy-as-insert output', () => { + const sql = buildCopyInsertSQL({ + dbType: 'mysql', + tableName: 'users', + orderedCols: ['name', 'id'], + record: { + id: 7, + name: 'Ada', + }, + }); + + expect(sql).toBe("INSERT INTO `users` (`name`, `id`) VALUES ('Ada', '7');"); + }); + it('keeps RFC3339-looking text unchanged for non-temporal columns', () => { const sql = buildCopyInsertSQL({ dbType: 'postgres', diff --git a/frontend/src/components/dataGridCopyInsert.ts b/frontend/src/components/dataGridCopyInsert.ts index e1a7178..2287b45 100644 --- a/frontend/src/components/dataGridCopyInsert.ts +++ b/frontend/src/components/dataGridCopyInsert.ts @@ -51,9 +51,9 @@ const normalizeDateTimeString = (val: string): string => { } const match = val.match( - /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/ + /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(\.\d+)?(?:\s*(?:Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/ ); - return match ? `${match[1]} ${match[2]}` : val; + return match ? `${match[1]} ${match[2]}${match[3] || ''}` : val; }; const normalizeTimezoneAwareDateTimeString = (val: string): string => { @@ -66,13 +66,14 @@ const normalizeTimezoneAwareDateTimeString = (val: string): string => { } const match = val.match( - /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(?:\.\d+)?(?:\s*(Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/ + /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}:\d{2})(\.\d+)?(?:\s*(Z|[+-]\d{2}:?\d{2})(?:\s+[A-Za-z_\/+-]+)?)?$/ ); if (!match) { return val; } - const suffix = match[3] || ''; - return `${match[1]} ${match[2]}${suffix}`; + const fractional = match[3] || ''; + const suffix = match[4] || ''; + return `${match[1]} ${match[2]}${fractional}${suffix}`; }; const isTemporalColumnType = (columnType?: string): boolean => { @@ -165,22 +166,36 @@ const toNormalizedLiteralText = (value: any, columnType?: string): string => { return String(value); }; +const hasFractionalSeconds = (value: string): boolean => /\d{2}:\d{2}:\d{2}\.\d+/.test(value); + +const stripFractionalSeconds = (value: string): string => ( + value.replace(/(\d{2}:\d{2}:\d{2})\.\d+/, '$1') +); + const formatOracleTemporalLiteral = (value: any, columnType?: string): string | null => { if (!isTemporalColumnType(columnType)) { return null; } const normalized = toNormalizedLiteralText(value, columnType); - const escaped = escapeLiteral(normalized); - if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) { + const rawType = String(columnType || '').toLowerCase(); + const isTimestamp = rawType.includes('timestamp'); + const oracleValue = isTimestamp ? normalized : stripFractionalSeconds(normalized); + const escaped = escapeLiteral(oracleValue); + if (/^\d{4}-\d{2}-\d{2}$/.test(oracleValue)) { return `TO_DATE('${escaped}', 'YYYY-MM-DD')`; } - if (isTimezoneAwareColumnType(columnType) && /[+-]\d{2}:?\d{2}$/.test(normalized)) { - const compactOffset = normalized.replace(/([+-]\d{2}):(\d{2})$/, '$1:$2'); - return `TO_TIMESTAMP_TZ('${escapeLiteral(compactOffset)}', 'YYYY-MM-DD HH24:MI:SSTZH:TZM')`; + if (isTimezoneAwareColumnType(columnType) && /[+-]\d{2}:?\d{2}$/.test(oracleValue)) { + const compactOffset = oracleValue.replace(/([+-]\d{2}):(\d{2})$/, '$1:$2'); + const temporalFormat = hasFractionalSeconds(oracleValue) + ? 'YYYY-MM-DD HH24:MI:SS.FFTZH:TZM' + : 'YYYY-MM-DD HH24:MI:SSTZH:TZM'; + return `TO_TIMESTAMP_TZ('${escapeLiteral(compactOffset)}', '${temporalFormat}')`; } - const rawType = String(columnType || '').toLowerCase(); - if (rawType.includes('timestamp')) { - return `TO_TIMESTAMP('${escaped}', 'YYYY-MM-DD HH24:MI:SS')`; + if (isTimestamp) { + const temporalFormat = hasFractionalSeconds(oracleValue) + ? 'YYYY-MM-DD HH24:MI:SS.FF' + : 'YYYY-MM-DD HH24:MI:SS'; + return `TO_TIMESTAMP('${escaped}', '${temporalFormat}')`; } return `TO_DATE('${escaped}', 'YYYY-MM-DD HH24:MI:SS')`; }; diff --git a/frontend/src/components/dataGridOutput.test.ts b/frontend/src/components/dataGridOutput.test.ts new file mode 100644 index 0000000..e7d65c6 --- /dev/null +++ b/frontend/src/components/dataGridOutput.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildDataGridSelectBaseSql, + pickDataGridOutputRows, + resolveDataGridOutputColumnNames, +} from './dataGridOutput'; + +const rowKeyField = '__gonavi_row_key__'; + +describe('dataGridOutput helpers', () => { + it('resolves exportable columns in display order without the internal row key', () => { + expect(resolveDataGridOutputColumnNames(['name', rowKeyField, 'id'], rowKeyField)).toEqual(['name', 'id']); + }); + + it('keeps exact column names when resolving output order', () => { + expect(resolveDataGridOutputColumnNames([' full name ', 'id'], rowKeyField)).toEqual([' full name ', 'id']); + }); + + it('picks row values in display column order', () => { + const rows = pickDataGridOutputRows([ + { [rowKeyField]: 'row-1', id: 1, name: 'alpha', hidden_note: 'A' }, + ], ['name', 'id']); + + expect(Object.keys(rows[0])).toEqual(['name', 'id']); + expect(rows[0]).toEqual({ name: 'alpha', id: 1 }); + }); + + it('builds table SELECT SQL with explicit display columns', () => { + expect(buildDataGridSelectBaseSql({ + dbType: 'mysql', + tableName: 'users', + columnNames: ['name', 'id'], + whereSql: "WHERE `id` = '7'", + })).toBe("SELECT `name`, `id` FROM `users` WHERE `id` = '7'"); + }); +}); diff --git a/frontend/src/components/dataGridOutput.ts b/frontend/src/components/dataGridOutput.ts new file mode 100644 index 0000000..318a68f --- /dev/null +++ b/frontend/src/components/dataGridOutput.ts @@ -0,0 +1,41 @@ +import { quoteIdentPart, quoteQualifiedIdent } from '../utils/sql'; + +export const resolveDataGridOutputColumnNames = ( + displayColumnNames: string[], + rowKeyField: string, +): string[] => ( + (displayColumnNames || []) + .map((columnName) => String(columnName ?? '')) + .filter((columnName) => columnName && columnName !== rowKeyField) +); + +export const pickDataGridOutputRows = ( + rows: Array>, + columnNames: string[], +): Array> => ( + (rows || []).map((row) => { + const next: Record = {}; + (columnNames || []).forEach((columnName) => { + next[columnName] = row?.[columnName]; + }); + return next; + }) +); + +export const buildDataGridSelectBaseSql = ({ + dbType, + tableName, + columnNames, + whereSql = '', +}: { + dbType: string; + tableName: string; + columnNames: string[]; + whereSql?: string; +}): string => { + const selectList = columnNames.length > 0 + ? columnNames.map((columnName) => quoteIdentPart(dbType, columnName)).join(', ') + : '*'; + const wherePart = String(whereSql || '').trim(); + return `SELECT ${selectList} FROM ${quoteQualifiedIdent(dbType, tableName)}${wherePart ? ` ${wherePart}` : ''}`; +}; diff --git a/frontend/src/components/dataGridRowClipboard.test.ts b/frontend/src/components/dataGridRowClipboard.test.ts index 232f4ff..bc4d13a 100644 --- a/frontend/src/components/dataGridRowClipboard.test.ts +++ b/frontend/src/components/dataGridRowClipboard.test.ts @@ -22,6 +22,20 @@ describe('dataGridRowClipboard', () => { ]); }); + it('copies row fields in display column order', () => { + const copiedRows = buildCopiedRowsForPaste({ + rows: [ + { [rowKeyField]: 'row-1', id: 1, name: 'alpha', hidden_note: 'A' }, + ], + selectedRowKeys: ['row-1'], + columnNames: ['name', 'id'], + rowKeyField, + }); + + expect(Object.keys(copiedRows[0])).toEqual(['name', 'id']); + expect(copiedRows[0]).toEqual({ name: 'alpha', id: 1 }); + }); + it('builds pasted rows as new rows with fresh internal keys', () => { const pastedRows = buildPastedRowsFromCopiedRows({ rows: [