mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-29 16:18:55 +00:00
feat(chart): add chart functionality with ECharts integration and update output view options
This commit is contained in:
+1
-1
@@ -71,7 +71,7 @@ const aiAssistantRef = ref<InstanceType<typeof AiAssistant> | null>(null);
|
||||
const selectedSql = ref("");
|
||||
const cursorPos = ref(0);
|
||||
const formatSqlRequestId = ref(0);
|
||||
const activeOutputView = ref<"result" | "explain">("result");
|
||||
const activeOutputView = ref<"result" | "explain" | "chart">("result");
|
||||
|
||||
const activeTab = computed(() => queryStore.tabs.find((t) => t.id === queryStore.activeTabId));
|
||||
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { use } from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import { LineChart, BarChart, PieChart } from "echarts/charts";
|
||||
import { GridComponent, TooltipComponent, LegendComponent } from "echarts/components";
|
||||
import VChart from "vue-echarts";
|
||||
import { BarChart3 } from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
import { useTheme } from "@/composables/useTheme";
|
||||
|
||||
use([CanvasRenderer, LineChart, BarChart, PieChart, GridComponent, TooltipComponent, LegendComponent]);
|
||||
|
||||
const props = defineProps<{
|
||||
result: QueryResult;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
const { isDark } = useTheme();
|
||||
|
||||
type ChartType = "line" | "bar" | "pie";
|
||||
const chartType = ref<ChartType>("bar");
|
||||
const xColumn = ref("");
|
||||
const yColumns = ref<string[]>([]);
|
||||
|
||||
const numericColumns = computed(() =>
|
||||
props.result.columns.filter((_, idx) => props.result.rows.some((row) => typeof row[idx] === "number")),
|
||||
);
|
||||
|
||||
const allColumns = computed(() => props.result.columns);
|
||||
|
||||
watch(
|
||||
() => props.result,
|
||||
() => {
|
||||
const cols = props.result.columns;
|
||||
const numCols = numericColumns.value;
|
||||
xColumn.value = cols.find((c) => !numCols.includes(c)) || cols[0] || "";
|
||||
yColumns.value = numCols.length > 0 ? [numCols[0]] : [];
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
function toggleYColumn(col: string) {
|
||||
const idx = yColumns.value.indexOf(col);
|
||||
if (idx >= 0) {
|
||||
yColumns.value = yColumns.value.filter((c) => c !== col);
|
||||
} else {
|
||||
yColumns.value = [...yColumns.value, col];
|
||||
}
|
||||
}
|
||||
|
||||
const chartOption = computed(() => {
|
||||
const xIdx = props.result.columns.indexOf(xColumn.value);
|
||||
if (xIdx < 0 || yColumns.value.length === 0) return null;
|
||||
|
||||
const xData = props.result.rows.map((row) => String(row[xIdx] ?? ""));
|
||||
|
||||
if (chartType.value === "pie") {
|
||||
const yIdx = props.result.columns.indexOf(yColumns.value[0]);
|
||||
if (yIdx < 0) return null;
|
||||
return {
|
||||
tooltip: { trigger: "item" },
|
||||
legend: { bottom: 0, textStyle: { color: isDark.value ? "#ccc" : "#333" } },
|
||||
series: [
|
||||
{
|
||||
type: "pie",
|
||||
radius: ["30%", "60%"],
|
||||
data: xData.map((name, i) => ({
|
||||
name,
|
||||
value: Number(props.result.rows[i][yIdx]) || 0,
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const yIndices = yColumns.value.map((c) => props.result.columns.indexOf(c)).filter((i) => i >= 0);
|
||||
|
||||
return {
|
||||
tooltip: { trigger: "axis" },
|
||||
legend: {
|
||||
bottom: 0,
|
||||
textStyle: { color: isDark.value ? "#ccc" : "#333" },
|
||||
},
|
||||
grid: { left: 60, right: 20, top: 20, bottom: 40 },
|
||||
xAxis: {
|
||||
type: "category" as const,
|
||||
data: xData,
|
||||
axisLabel: { color: isDark.value ? "#aaa" : "#666" },
|
||||
},
|
||||
yAxis: {
|
||||
type: "value" as const,
|
||||
axisLabel: { color: isDark.value ? "#aaa" : "#666" },
|
||||
},
|
||||
series: yIndices.map((yIdx) => ({
|
||||
name: props.result.columns[yIdx],
|
||||
type: chartType.value,
|
||||
data: props.result.rows.map((row) => Number(row[yIdx]) || 0),
|
||||
smooth: chartType.value === "line",
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
const hasData = computed(() => props.result.rows.length > 0 && numericColumns.value.length > 0);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full flex flex-col">
|
||||
<div v-if="!hasData" class="flex-1 flex flex-col items-center justify-center gap-2 text-muted-foreground text-sm">
|
||||
<BarChart3 class="h-10 w-10 opacity-30" />
|
||||
<span>{{ t("chart.noNumericData") }}</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="h-9 shrink-0 border-b bg-muted/20 px-3 flex items-center gap-3 text-xs">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-muted-foreground">{{ t("chart.type") }}</span>
|
||||
<div class="flex gap-0.5">
|
||||
<Button
|
||||
v-for="ct in ['bar', 'line', 'pie'] as ChartType[]"
|
||||
:key="ct"
|
||||
size="sm"
|
||||
:variant="chartType === ct ? 'secondary' : 'ghost'"
|
||||
class="h-6 px-2 text-xs"
|
||||
@click="chartType = ct"
|
||||
>
|
||||
{{ t(`chart.${ct}`) }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<span class="h-4 w-px bg-border" />
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-muted-foreground">X</span>
|
||||
<Select :model-value="xColumn" @update:model-value="(v: any) => (xColumn = v)">
|
||||
<SelectTrigger class="h-6 w-auto max-w-40 border-0 bg-transparent px-1 text-xs shadow-none focus:ring-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="col in allColumns" :key="col" :value="col">{{ col }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<span class="h-4 w-px bg-border" />
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-muted-foreground">Y</span>
|
||||
<div class="flex gap-0.5">
|
||||
<Button
|
||||
v-for="col in numericColumns"
|
||||
:key="col"
|
||||
size="sm"
|
||||
:variant="yColumns.includes(col) ? 'secondary' : 'ghost'"
|
||||
class="h-6 px-2 text-xs"
|
||||
@click="toggleYColumn(col)"
|
||||
>
|
||||
{{ col }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 p-2">
|
||||
<VChart v-if="chartOption" :option="chartOption" autoresize class="h-full w-full" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Loader2, Square, Bot, Table2, GitBranch } from "lucide-vue-next";
|
||||
import { Loader2, Square, Bot, Table2, GitBranch, BarChart3 } from "lucide-vue-next";
|
||||
import { Splitpanes, Pane } from "splitpanes";
|
||||
import "splitpanes/dist/splitpanes.css";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -10,6 +10,7 @@ import DataGrid from "@/components/grid/DataGrid.vue";
|
||||
import RedisKeyBrowser from "@/components/redis/RedisKeyBrowser.vue";
|
||||
import MongoDocBrowser from "@/components/mongo/MongoDocBrowser.vue";
|
||||
const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue"));
|
||||
const QueryChart = defineAsyncComponent(() => import("@/components/chart/QueryChart.vue"));
|
||||
import { useQueryStore } from "@/stores/queryStore";
|
||||
import { canCancelQueryExecution, queryExecutionLabelKey } from "@/lib/queryExecutionState";
|
||||
import { databaseDisplayNameForTab } from "@/lib/tabPresentation";
|
||||
@@ -20,14 +21,14 @@ const props = defineProps<{
|
||||
activeTab: QueryTab;
|
||||
activeConnection?: ConnectionConfig;
|
||||
executableSql: string;
|
||||
activeOutputView: "result" | "explain";
|
||||
activeOutputView: "result" | "explain" | "chart";
|
||||
formatSqlRequestId: number;
|
||||
selectedSql: string;
|
||||
cursorPos: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:activeOutputView": [value: "result" | "explain"];
|
||||
"update:activeOutputView": [value: "result" | "explain" | "chart"];
|
||||
fixWithAi: [errorMessage: string];
|
||||
execute: [sqlOverride?: string];
|
||||
cancel: [];
|
||||
@@ -63,6 +64,12 @@ const activeSqlFormatDialect = computed<SqlFormatDialect>(() => {
|
||||
const editorDialect = computed<"mysql" | "postgres">(() =>
|
||||
props.activeConnection?.db_type === "postgres" ? "postgres" : "mysql",
|
||||
);
|
||||
|
||||
const hasNumericData = computed(() => {
|
||||
const r = props.activeTab.result;
|
||||
if (!r || r.rows.length === 0) return false;
|
||||
return r.columns.some((_, idx) => r.rows.some((row) => typeof row[idx] === "number"));
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -132,6 +139,16 @@ const editorDialect = computed<"mysql" | "postgres">(() =>
|
||||
<GitBranch class="h-3.5 w-3.5" />
|
||||
{{ t("explain.title") }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:variant="activeOutputView === 'chart' ? 'secondary' : 'ghost'"
|
||||
class="h-6 px-2 text-xs gap-1"
|
||||
:disabled="!hasNumericData"
|
||||
@click="emit('update:activeOutputView', 'chart')"
|
||||
>
|
||||
<BarChart3 class="h-3.5 w-3.5" />
|
||||
{{ t("chart.title") }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ExplainPlanViewer
|
||||
@@ -144,6 +161,12 @@ const editorDialect = computed<"mysql" | "postgres">(() =>
|
||||
:explain-sql="activeTab.explainSql"
|
||||
/>
|
||||
|
||||
<QueryChart
|
||||
v-else-if="activeOutputView === 'chart' && activeTab.result"
|
||||
class="flex-1 min-h-0"
|
||||
:result="activeTab.result"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<DataGrid
|
||||
v-if="activeTab.result"
|
||||
|
||||
@@ -23,7 +23,7 @@ export function useSqlExecution(deps: {
|
||||
activeTab: ComputedRef<QueryTab | undefined>;
|
||||
activeConnection: ComputedRef<ConnectionConfig | undefined>;
|
||||
executableSql: ComputedRef<string>;
|
||||
activeOutputView: Ref<"result" | "explain">;
|
||||
activeOutputView: Ref<"result" | "explain" | "chart">;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const queryStore = useQueryStore();
|
||||
|
||||
@@ -121,6 +121,14 @@ export default {
|
||||
scrollLeft: "Scroll tabs left",
|
||||
scrollRight: "Scroll tabs right",
|
||||
},
|
||||
chart: {
|
||||
title: "Chart",
|
||||
type: "Type",
|
||||
line: "Line",
|
||||
bar: "Bar",
|
||||
pie: "Pie",
|
||||
noNumericData: "No numeric data available for charting",
|
||||
},
|
||||
grid: {
|
||||
rows: "{count} rows",
|
||||
totalRows: "Total {count} rows",
|
||||
|
||||
@@ -120,6 +120,14 @@ export default {
|
||||
scrollLeft: "向左滚动标签页",
|
||||
scrollRight: "向右滚动标签页",
|
||||
},
|
||||
chart: {
|
||||
title: "图表",
|
||||
type: "类型",
|
||||
line: "折线图",
|
||||
bar: "柱状图",
|
||||
pie: "饼图",
|
||||
noNumericData: "没有可用于图表的数值数据",
|
||||
},
|
||||
grid: {
|
||||
rows: "{count} 行",
|
||||
totalRows: "共 {count} 行",
|
||||
|
||||
Reference in New Issue
Block a user