405 lines
11 KiB
Vue
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: '只读 ',
|
|
value: 'read-only',
|
|
},
|
|
{
|
|
label: '读写 ',
|
|
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> |