jcloud/dashboard/src2/pages/devtools/database/DatabaseSQLPlayground.vue
2025-04-12 17:39:38 +08:00

405 lines
11 KiB
Vue

<template>
<Header class="sticky top-0 z-10 bg-white">
<div
class="flex w-full flex-col gap-2 md:flex-row md:items-center md:justify-between"
>
<div class="flex flex-row items-center gap-2">
<!-- 标题 -->
<Breadcrumbs
:items="[
{ label: '开发工具', route: '/sql-playground' }, // Dev tools has no seperate page as its own, so it doesn't need a different route
{ label: 'SQL 实验室', route: '/sql-playground' },
]"
/>
<!-- 操作 -->
<FormControl
class="w-min-[200px] cursor-pointer"
type="select"
:options="[
{
label: '只读&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;',
value: 'read-only',
},
{
label: '读写&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;',
value: 'read-write',
},
]"
size="sm"
variant="outline"
v-model="mode"
/>
</div>
<div class="flex flex-row gap-2">
<LinkControl
class="cursor-pointer"
:options="{ pagetype: 'Site', filters: { status: 'Active' } }"
placeholder="选择一个站点"
v-model="site"
/>
<Button
icon="refresh-ccw"
variant="subtle"
:loading="site && !isAutoCompletionReady"
:disabled="!site"
@click="
() =>
fetchTableSchemas({
reload: true,
})
"
>
<span class="md:hidden">架构</span>
<span class="hidden md:inline">刷新架构</span>
</Button>
</div>
</div>
</Header>
<div class="m-5">
<!-- 主体 -->
<div
v-if="!site"
class="flex h-full min-h-[80vh] w-full items-center justify-center gap-2 text-gray-700"
>
选择一个站点以开始
</div>
<div class="mt-2 flex flex-col" v-else>
<div class="overflow-hidden rounded border">
<SQLCodeEditor
v-model="query"
:schema="sqlSchemaForAutocompletion"
@codeSelected="handleCodeSelected"
@codeUnselected="handleCodeUnselected"
/>
</div>
<div class="mt-2 flex flex-row items-center justify-between">
<div class="flex gap-2">
<Button
iconLeft="table"
@click="toggleTableSchemasDialog"
:disabled="!isAutoCompletionReady"
></Button
>
<Button iconLeft="file-text" @click="toggleLogsDialog">日志</Button>
</div>
<div class="flex gap-2">
<Button
v-if="selectedQuery"
@click="runSelectedSQLQuery"
:loading="$resources.runSQLQuery.loading"
iconLeft="play"
variant="outline"
>
运行选中的查询
</Button>
<Button
@click="() => runSQLQuery()"
:loading="$resources.runSQLQuery.loading"
iconLeft="play"
variant="solid"
>运行查询</Button
>
</div>
</div>
<div
class="mt-4"
v-if="!$resources.runSQLQuery.loading && (data || errorMessage)"
>
<div v-if="errorMessage" class="output-container">
<pre class="mb-4 text-sm" v-if="failedQuery">{{ failedQuery }}</pre>
{{ prettifySQLError(errorMessage) }}<br /><br />
查询执行失败
</div>
<div v-else-if="data.length == 0" class="output-container">
未收到输出
</div>
<div v-else-if="data.length == 1">
<SQLResult :result="data[0]" />
</div>
<div v-else>
<FTabs :tabs="sqlResultTabs" v-model="tabIndex">
<template #tab-panel="{ tab }">
<div class="pt-5">
<SQLResult :result="tab" />
</div>
</template>
</FTabs>
</div>
</div>
</div>
<DatabaseSQLPlaygroundLog
v-if="this.site"
:site="this.site"
v-model="showLogsDialog"
@rerunQuery="rerunQuery"
/>
<DatabaseTableSchemaDialog
v-if="this.site"
:site="this.site"
:tableSchemas="$resources.tableSchemas?.data?.message?.data ?? {}"
v-model="showTableSchemasDialog"
:showSQLActions="true"
@runSQLQuery="runSQLQueryForViewingTable"
/>
</div>
</template>
<style scoped>
.output-container {
@apply rounded border p-4 text-base text-gray-700;
}
</style>
<script>
import { toast } from 'vue-sonner';
import Header from '../../../components/Header.vue';
import { Tabs, Breadcrumbs } from 'jingrow-ui';
import SQLResultTable from '../../../components/devtools/database/ResultTable.vue';
import SQLCodeEditor from '../../../components/devtools/database/SQLCodeEditor.vue';
import { confirmDialog } from '../../../utils/components';
import DatabaseSQLPlaygroundLog from '../../../components/devtools/database/DatabaseSQLPlaygroundLog.vue';
import DatabaseTableSchemaDialog from '../../../components/devtools/database/DatabaseTableSchemaDialog.vue';
import SQLResult from '../../../components/devtools/database/SQLResult.vue';
import LinkControl from '../../../components/LinkControl.vue';
import { getToastErrorMessage } from '../../../utils/toast';
export default {
name: 'DatabaseSQLPlayground',
components: {
Header,
Breadcrumbs,
FTabs: Tabs,
SQLResultTable,
SQLResult,
SQLCodeEditor,
DatabaseSQLPlaygroundLog,
DatabaseTableSchemaDialog,
LinkControl,
},
data() {
return {
site: null,
tabIndex: 0,
query: '',
selectedQuery: null,
commit: false,
execution_successful: null,
data: null,
errorMessage: null,
failedQuery: null,
mode: '只读',
showLogsDialog: false,
showTableSchemasDialog: false,
};
},
mounted() {},
watch: {
query() {
window.localStorage.setItem(
`sql_playground_query_${this.site}`,
this.query,
);
},
site(site_name) {
// 重置状态
this.execution_successful = null;
this.data = null;
this.errorMessage = null;
this.failedQuery = null;
this.mode = '只读';
this.showLogsDialog = false;
this.showTableSchemasDialog = false;
// 恢复查询并获取表结构
this.query =
window.localStorage.getItem(`sql_playground_query_${this.site}`) || '';
this.fetchTableSchemas({
site_name: site_name,
});
},
},
resources: {
runSQLQuery() {
return {
url: 'jcloud.api.client.run_pg_method',
onSuccess: (data) => {
this.execution_successful = data?.message?.success || false;
if (!this.execution_successful) {
this.errorMessage = data?.message?.data ?? '未知错误';
this.failedQuery = data?.message?.failed_query ?? '';
this.data = [];
} else {
this.data = data?.message?.data ?? [];
this.errorMessage = null;
}
this.tabIndex = 0; // 重置结果标签页索引
},
onError: (e) => {
toast.error(getToastErrorMessage(e, '执行SQL查询失败'));
},
auto: false,
};
},
tableSchemas() {
return {
url: 'jcloud.api.client.run_pg_method',
initialData: {},
auto: false,
onSuccess: (data) => {
if (data?.message?.loading) {
setTimeout(this.fetchTableSchemas, 5000);
}
},
};
},
},
computed: {
sqlSchemaForAutocompletion() {
const tableSchemas =
this.$resources.tableSchemas?.data?.message?.data ?? {};
if (!tableSchemas || !Object.keys(tableSchemas).length) return null;
let childrenSchemas = {};
for (const tableName in tableSchemas) {
childrenSchemas[tableName] = {
self: { label: tableName, type: 'table' },
children: tableSchemas[tableName].columns.map((x) => ({
label: x.column,
type: 'column',
detail: x.data_type,
})),
};
}
return {
self: { label: 'SQL 模式', type: 'schema' },
children: childrenSchemas,
};
},
isAutoCompletionReady() {
if (this.$resources.tableSchemas.loading) return false;
if (this.$resources.tableSchemas?.data?.message?.loading) return false;
if (!this.$resources.tableSchemas?.data?.message?.data) return false;
if (this.$resources.tableSchemas?.data?.message?.data == {}) return false;
if (!this.sqlSchemaForAutocompletion) return false;
return true;
},
sqlResultTabs() {
if (!this.data || !this.data.length) return [];
let data = [];
let queryNo = 1;
for (let i = 0; i < this.data.length; i++) {
data.push({
label: `查询 ${queryNo++}`,
...this.data[i],
});
}
return data;
},
},
methods: {
handleCodeSelected(selectedCode) {
this.selectedQuery = selectedCode;
},
handleCodeUnselected() {
this.selectedQuery = null;
},
fetchTableSchemas({ site_name = null, reload = false } = {}) {
if (!site_name) site_name = this.site;
if (!site_name) return;
this.$resources.tableSchemas.submit({
dt: 'Site',
dn: site_name,
method: 'fetch_database_table_schema',
args: {
reload,
},
});
},
runSQLQuery(ignore_validation = false, run_selected_query = false) {
if (!this.query) return;
if (this.mode === 'read-only' || ignore_validation) {
this.$resources.runSQLQuery.submit({
dt: 'Site',
dn: this.site,
method: 'run_sql_query_in_database',
args: {
query: run_selected_query ? this.selectedQuery : this.query,
commit: this.mode === 'read-write',
},
});
return;
}
confirmDialog({
title: '运行 SQL 查询',
message: `
您当前正在使用<strong>读写</strong>模式。您的 SQL 查询所做的所有更改都将提交到数据库。
<br><br>
您确定要运行查询吗?`,
primaryAction: {
label: '运行查询',
variant: 'solid',
onClick: ({ hide }) => {
this.runSQLQuery(true, run_selected_query);
hide();
},
},
});
},
runSelectedSQLQuery() {
if (!this.selectedQuery) {
return;
}
confirmDialog({
title: '验证查询',
message: `
您确定要运行查询吗?
<br>
<pre class="mt-2 max-h-52 overflow-y-auto whitespace-pre-wrap rounded bg-gray-50 px-2 py-1.5 text-sm text-gray-700">${this.selectedQuery}</pre>
`,
primaryAction: {
label: '运行查询',
variant: 'solid',
onClick: ({ hide }) => {
this.runSQLQuery(false, true);
hide();
},
},
});
},
toggleLogsDialog() {
this.showLogsDialog = !this.showLogsDialog;
},
toggleTableSchemasDialog() {
if (!this.isAutoCompletionReady) {
toast.error('表结构仍在加载中,请稍候。');
return;
}
this.showTableSchemasDialog = !this.showTableSchemasDialog;
},
rerunQuery(query) {
this.query = query;
this.showLogsDialog = false;
},
runSQLQueryForViewingTable(query) {
// 设置为只读模式
this.mode = 'read-only';
this.showTableSchemasDialog = false;
this.query = query;
this.runSQLQuery();
},
prettifySQLError(msg) {
if (typeof msg !== 'string') return null;
// 如果错误信息是 (state, message) 格式,尝试解析它
const regex = /\((\d+),\s*['"](.*?)['"]\)/;
const match = msg.match(regex);
if (match) {
const statusCode = match[1];
const errorMessage = match[2];
return `#${statusCode} - ${errorMessage}`;
} else {
return msg;
}
},
},
};
</script>